morphit/apps/indexer/scripts/chat-fast-notification-smoke.ts

212 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* chat-fast-notification-smoke (cp471) — the fast head-block notification path
* must be a SAFE SUBSET of the durable path's admission, or it becomes a
* notification-spam vector. This guard pins every safety property structurally
* (comment-stripped, so a comment can't satisfy an assertion), so a future
* refactor that removes a gate, drops the dedup, or unshares the order check
* fails loudly.
*
* Properties pinned:
* • dedup: enqueueChatPush INSERTs source_trx_id with ON CONFLICT DO NOTHING;
* BOTH paths pass the on-chain trx id — one notification, fast when the
* tailer wins.
* • block gate: the tailer drops a blocked sender BEFORE it emits or
* fast-notifies (block-check < emit < maybeFastNotify).
* • stranger gate: maybeFastNotify returns without enqueuing unless there is
* a prior exchange OR an order-response bypass (a first-contact stranger is
* never fast-notified).
* • order validity: maybeFastNotify returns on a tag that names no real owned
* order (which the durable path rejects).
* • no divergence: order validity lives ONCE in chatGates.checkChatOrder;
* both chat.ts and the tailer call it.
* • self-chat guard in the shared enqueue.
* • badge: the SW pokes every tab (CHAT_PUSH) + sets the OS app-badge; the
* page treats CHAT_PUSH as a chat-activity ping (fire()).
* • migration v43 present in migrations.ts AND schema.sql.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); // apps/indexer
const WEB = join(ROOT, '..', 'web');
/** Collapse whitespace so source assertions survive prettier reflowing a call
* across lines. A formatting-sensitive regex is a false-negative generator:
* it fails while the wiring it guards is perfectly intact, which trains us to
* ignore the guard. Match on structure, not layout. */
const flat = (src: string): string => src.replace(/\s+/g, ' ');
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${String((err as Error)?.message ?? err)}`);
}
}
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(msg);
}
function stripComments(s: string): string {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
function read(abs: string): string {
return stripComments(readFileSync(abs, 'utf8'));
}
const chat = read(join(ROOT, 'src/indexer/handlers/chat.ts'));
const tailer = read(join(ROOT, 'src/indexer/headTailer.ts'));
const gates = read(join(ROOT, 'src/indexer/chatGates.ts'));
const enqueue = read(join(ROOT, 'src/indexer/chatPushEnqueue.ts'));
const migrations = read(join(ROOT, 'src/db/migrations.ts'));
const schema = readFileSync(join(ROOT, 'src/db/schema.sql'), 'utf8'); // SQL comments kept
const sw = read(join(WEB, 'src/service-worker.ts'));
const globalStream = read(join(WEB, 'src/lib/chat/globalChatActivityStream.ts'));
// ── Dedup ────────────────────────────────────────────────────────────
scenario('enqueueChatPush INSERTs source_trx_id with ON CONFLICT DO NOTHING', () => {
assert(/INSERT INTO push_pending/.test(enqueue), 'enqueue does not INSERT push_pending');
assert(enqueue.includes('source_trx_id'), 'enqueue INSERT omits source_trx_id (the dedup key)');
assert(
/ON CONFLICT \(account, source_trx_id\)[\s\S]*DO NOTHING/.test(enqueue),
'enqueue INSERT lacks ON CONFLICT (account, source_trx_id) … DO NOTHING'
);
});
scenario('durable chat.ts passes ctx.trxId as the dedup key', () => {
assert(chat.includes('enqueueChatPush(client'), 'chat.ts does not call the shared enqueue');
assert(chat.includes('sourceTrxId: ctx.trxId'), 'chat.ts does not pass ctx.trxId to enqueue');
});
scenario('fast tailer passes the block trx id as the dedup key', () => {
assert(tailer.includes('enqueueChatPush(this.db'), 'tailer does not call the shared enqueue');
assert(tailer.includes('block.transaction_ids[ti]'), 'tailer does not read the on-chain trx id');
assert(tailer.includes('sourceTrxId: trxId'), 'tailer does not pass the trx id to enqueue');
});
// ── Block gate: blocked sender never emitted or fast-notified ─────────
scenario('tailer block-check precedes emit precedes maybeFastNotify', () => {
const block = tailer.indexOf('this.recipientBlockedSender(located.recipient');
const emit = tailer.indexOf('chatEventBus.emitFast(');
const notify = tailer.indexOf('this.maybeFastNotify(located');
assert(block !== -1, 'no recipientBlockedSender call in scanBlock');
assert(emit !== -1, 'no emitFast call');
assert(notify !== -1, 'no maybeFastNotify call');
assert(block < emit, 'block-check does not precede emit');
assert(emit < notify, 'emit does not precede maybeFastNotify (a blocked/continued op could fast-notify)');
});
scenario('tailer drops a blocked sender with continue', () => {
assert(/if \(blocked\) \{[\s\S]*?continue;/.test(tailer), 'tailer does not continue on blocked');
});
// ── Stranger gate: no fast push for a first-contact stranger ─────────
scenario('the fast path acts only on recipient-reply OR order-response bypass', () => {
// v1.5.5: the gate moved out of maybeFastNotify into `fastNotifyAllowed`,
// because its answer now drives TWO things — the fast push AND whether the
// event may be REPLAYED into a chatroom opened moments later. Evaluating
// "is this sender established?" twice is precisely how two call sites
// silently drift apart. Same rule, one evaluation, shared answer.
assert(
/return recipientReplied \|\| orderResponseBypass;/.test(tailer),
'fastNotifyAllowed lacks the (recipientReplied || orderResponseBypass) safe gate'
);
assert(tailer.includes('recipientHasReplied(this.db'), 'the fast gate does not check recipient-reply');
// …and the gate must actually be APPLIED, not merely defined: both the push
// and replayability hang off the one answer.
assert(
/const fastAllowed = await this\.fastNotifyAllowed\(/.test(tailer),
'the fast gate is never evaluated in scanBlock'
);
assert(
/if \(fastAllowed && trxId !== undefined\) \{[\s\S]{0,120}?await this\.maybeFastNotify\(/.test(tailer),
'the fast push is not gated on fastAllowed — a first-contact stranger could fast-notify'
);
assert(
/replayable: fastAllowed/.test(tailer),
'snapshot replay is not gated on fastAllowed — an ungated message could be replayed into a fresh chatroom even though the durable path may reject it (a ghost that vanishes on reload)'
);
});
scenario('the fast gate denies a bogus order tag (durable would reject)', () => {
assert(tailer.includes('if (!oc.found) return false'), 'the fast gate does not deny an invalid order tag');
});
// ── No divergence: order validity has ONE implementation ─────────────
scenario('checkChatOrder is the single order-validity impl (query lives in chatGates)', () => {
assert(/FROM orders/.test(gates) && gates.includes('account IN ($2, $4)'), 'chatGates lacks the order query');
assert(chat.includes('checkChatOrder(client'), 'chat.ts does not delegate to checkChatOrder');
assert(tailer.includes('checkChatOrder(this.db'), 'tailer does not use the shared checkChatOrder');
assert(!chat.includes('account IN ($2, $4)'), 'chat.ts still has an inline order query (divergence risk)');
});
scenario('recipientHasReplied is DIRECTIONAL (recipient→sender) + uses the pair index', () => {
assert(
/LEAST\(sender, recipient\)[\s\S]*GREATEST\(sender, recipient\)[\s\S]*AND sender = \$1/.test(gates),
'recipientHasReplied is not directional / does not use the pair index'
);
// guard against regressing to a bidirectional (one-way-spammable) check
assert(!/hasPriorExchange/.test(gates), 'bidirectional hasPriorExchange resurfaced');
assert(!/hasPriorExchange/.test(tailer), 'tailer still references bidirectional hasPriorExchange');
});
// ── Self-chat guard ──────────────────────────────────────────────────
scenario('enqueueChatPush refuses self-chat', () => {
assert(
enqueue.includes('if (params.recipient === params.sender) return'),
'enqueue lacks the self-chat guard'
);
});
// ── Badge: SW pokes tabs + sets OS badge; page treats it as a ping ───
scenario('service worker pokes every tab with CHAT_PUSH on push', () => {
assert(sw.includes("addEventListener('push'"), 'no SW push handler');
assert(sw.includes('matchAll('), 'SW does not enumerate tabs');
// Whitespace-insensitive: the call is prettier-formatted across several
// lines, so a single-line regex here was a FALSE NEGATIVE (the wiring was
// present and working). Flatten before matching — cp471.
assert(
/postMessage\(\s*\{\s*type: 'CHAT_PUSH'/.test(flat(sw)),
'SW does not postMessage CHAT_PUSH'
);
});
scenario('service worker sets the OS app-badge on push', () => {
assert(sw.includes('setAppBadge'), 'SW does not set the OS app-badge');
});
scenario('page treats a CHAT_PUSH message as a chat-activity ping (fire)', () => {
assert(globalStream.includes("data.type === 'CHAT_PUSH'"), 'page does not listen for CHAT_PUSH');
// The assertion is "fire() happens INSIDE the CHAT_PUSH branch", not "fire()
// is within N characters of it". The original 40-char window was a FALSE
// NEGATIVE the moment the branch legitimately grew: v1.5.0 added the
// archived-thread fast-restore between the guard and the fire() call. Match
// on the flattened source with a window wide enough to span the branch body
// but far too narrow to reach any unrelated fire() elsewhere in the file.
assert(
/data\.type === 'CHAT_PUSH'[\s\S]{0,600}?fire\(\)/.test(flat(globalStream)),
'page does not fire() on CHAT_PUSH'
);
assert(globalStream.includes("serviceWorker") && globalStream.includes("'message'"), 'page does not bind the SW message channel');
});
// ── Migration v43 present in BOTH migrations.ts and schema.sql ────────
scenario('migration v43 adds source_trx_id + partial unique index (migrations.ts)', () => {
assert(/version:\s*43/.test(migrations), 'migrations.ts has no version 43');
assert(migrations.includes('push_pending') && migrations.includes('source_trx_id'), 'v43 does not touch push_pending.source_trx_id');
assert(
migrations.includes('push_pending_account_source_trx_uidx'),
'v43 lacks the partial unique index'
);
});
scenario('schema.sql has the idempotent source_trx_id block', () => {
assert(schema.includes('push_pending_account_source_trx_uidx'), 'schema.sql lacks the source_trx_id unique index');
assert(/ADD COLUMN IF NOT EXISTS source_trx_id/.test(schema), 'schema.sql lacks the idempotent column add');
});
if (failures === 0) {
console.log(`✓ all ${scenarios} chat-fast-notification scenarios passed`);
process.exit(0);
} else {
console.log(`${failures} of ${scenarios} chat-fast-notification checks FAILED`);
process.exit(1);
}