diff --git a/bot/test/guidance-envelope.test.mjs b/bot/test/guidance-envelope.test.mjs index e909d65..b7688a2 100644 --- a/bot/test/guidance-envelope.test.mjs +++ b/bot/test/guidance-envelope.test.mjs @@ -1,43 +1,48 @@ #!/usr/bin/env node /* - * guidance-envelope.test.mjs — GE1–GE7 types-only gates for the frozen - * guidance_sessions contract (D-06/D-07). - * --------------------------------------------------------------------- - * The envelope is a TYPES-ONLY frozen contract this phase (D-06): code - * types + the ~2h TTL constant + the key-scheme doc. NO KV namespace, NO - * service, NO runtime writes — Phase 6 builds the backgrounded turn-card - * service on the frozen shape. These gates pin that boundary MECHANICALLY: + * guidance-envelope.test.mjs — the guidance_sessions contract gates (D-06/D-07). + * ------------------------------------------------------------------------------ + * The envelope SHAPE is the Phase 5 frozen contract: GE1/GE2/GE3 stay UNCHANGED + * in meaning (TTL 7200; exactly 4 envelope fields + 6 snapshot fields; inline + * snapshot). Phase 6 plan 06-03 takes the service LIVE, so the boundary gates + * re-pin WITH the change that invalidates them (the gate re-pin law): * - * GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (the - * ~2h law; every future write carries it) + * GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (UNCHANGED; + * every write carries it) * GE2 field freeze — GuidanceSessionEnvelope has EXACTLY * [id, chat_id, route_snapshot, created_at] and * GuidanceRouteSnapshot has EXACTLY * [destinationId, destinationName, distanceM, - * durationS, geometry, startedAtSec] (the + * durationS, geometry, startedAtSec] (UNCHANGED; the + * structural scan fails on any 5th property — the * SessionClaim "a 5th property means drift" law) * GE3 inline snapshot — route_snapshot is TYPED INLINE * (GuidanceRouteSnapshot, not a reference) and * the tokens snapshotRef / snapshot_ref appear - * NOWHERE in bot/src (D-07: one key, one TTL) - * GE4 types-only — comments stripped, the module contains zero - * behavior tokens (no KV binding type, no - * put/get/delete, no function export, no import) - * GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv - * namespace (chat_sessions) - * GE6 no Env field — config.ts gains no guidance field this phase - * GE7 no premature — no bot/src module imports the contract; only - * consumer the test does (Phase 6 builds the consumer) + * NOWHERE in bot/src (UNCHANGED; D-07: one key, + * one TTL) + * GE4 the service — RE-PINNED (06-03; was the types-only behavior- + * token ban): the module exports mint/get/delete + * GuidanceSession + the TTL constant — the service + * owns every write + * GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace + * (chat_sessions): the service reuses it; R2 later + * is a different binding class, never a 2nd KV + * GE6 no Env field — config.ts gains no guidance field: the service is + * handed the EXISTING chat_sessions binding + * GE7 consumer shape — RE-PINNED (06-03; was the total consumer ban): + * bot/src MAY import the service, but every import + * must be a NAMED import of the service surface + * (mint/get/delete, the TTL constant, the two + * types) — no default/namespace/side-effect + * end-run. Vacuous until the first consumer lands + * (this plan's webhook task); teeth grow with the + * consumer tree. * - * RED-shape note: GE1–GE4 fail while the module is absent. GE5–GE7 are - * negative-space boundary guards over files that must NOT change — they - * are green by construction and redden only on drift (the 05-01 C4/C5/C6 - * zero-laws precedent). - * - * Phase 6 note: when the turn-card service legitimately adds the kv - * namespace binding, the Env field, and the consumer, updating GE4–GE7 is - * THAT plan's responsibility — these gates redden until then (T-05-08: - * the contract cannot acquire a runtime shadow this phase). + * The behavioral block pins the SERVICE LAWS against a Map-fake KV + * (sessions.test.mjs style): TTL'd mint, defensive null reads (the + * location-pin law — corrupt never reaches the cron), the UUID key-guard + * (V5: no arbitrary key probing), and active delete (D-14's server half). */ import { strict as assert } from "node:assert"; import { readdirSync, readFileSync } from "node:fs"; @@ -128,25 +133,30 @@ test("GE3 inline snapshot — route_snapshot is typed inline; zero snapshot-ref } }); -/* ————— GE4 — the module ships NO behavior (T-05-08) ————— */ +/* ————— GE4 — RE-PINNED (06-03): the service is LIVE beneath the frozen shape ————— */ -test("GE4 types-only — comments stripped, zero behavior tokens in guidance-sessions.ts", () => { - const code = stripComments(readFileSync(MODULE, "utf8")); - const banned = [ - "KvNamespace", - ".put(", - ".get(", - ".delete(", - "export function", - "export async function", - "import ", - ]; - for (const token of banned) { - assert.ok( - !code.includes(token), - `types-only violation: behavior token "${token}" in guidance-sessions.ts (T-05-08: premature persistence path)`, - ); - } +test("GE4 the service — the frozen contract exports mint/get/delete + the TTL constant (re-pinned 06-03)", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + assert.equal( + typeof mod.mintGuidanceSession, + "function", + "mintGuidanceSession must exist — the envelope service is LIVE (06-03; was types-only)", + ); + assert.equal( + typeof mod.getGuidanceSession, + "function", + "getGuidanceSession must exist — the cron's defensively-validated read", + ); + assert.equal( + typeof mod.deleteGuidanceSession, + "function", + "deleteGuidanceSession must exist — D-14's server half (active trip-end deletion)", + ); + assert.equal( + mod.GUIDANCE_SESSION_TTL_SECONDS, + 7200, + "the TTL constant survives the service — every write still carries it", + ); }); /* ————— GE5 — exactly one kv namespace (chat_sessions) ————— */ @@ -158,31 +168,166 @@ test("GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace: assert.equal( namespaces.length, 1, - "a second kv namespace appeared — the guidance envelope is TYPES-ONLY this phase (D-06)", + "a second kv namespace appeared — the guidance service reuses chat_sessions (R2 is a different binding class)", ); assert.equal(namespaces[0].binding, "chat_sessions", "the single namespace must remain chat_sessions"); }); -/* ————— GE6 — the Env contract gains nothing this phase ————— */ +/* ————— GE6 — the Env contract gains nothing ————— */ test("GE6 no Env field — config.ts contains no guidance token", () => { const config = readFileSync(CONFIG, "utf8"); assert.ok( !config.includes("guidance"), - "config.ts gained a guidance field (D-06: Env gains NOTHING this phase — Phase 6's plan owns that change)", + "config.ts gained a guidance field — the service is handed the EXISTING chat_sessions binding; no new Env surface", ); }); -/* ————— GE7 — no premature consumer ————— */ +/* ————— GE7 — RE-PINNED (06-03): consumer imports constrained to the service surface ————— */ -test("GE7 no premature consumer — zero bot/src imports of guidance-sessions (only the test imports it)", () => { - const IMPORT_RE = /\bimport\b[^;\n"']*["'][^"']*guidance-sessions[^"']*["']/; +test("GE7 consumer shape — every bot/src import of guidance-sessions is a NAMED import of the service surface", () => { + const ALLOWED = new Set([ + "mintGuidanceSession", + "getGuidanceSession", + "deleteGuidanceSession", + "GUIDANCE_SESSION_TTL_SECONDS", + "GuidanceSessionEnvelope", + "GuidanceRouteSnapshot", + ]); + const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g; for (const file of tsFilesUnder(srcDir)) { const code = stripComments(readFileSync(file, "utf8")); - assert.doesNotMatch( - code, - IMPORT_RE, - `${file} imports the guidance contract (D-06: no consumer until Phase 6 builds the service)`, + const named = [...code.matchAll(NAMED_RE)]; + const specifiers = code.match(/["'][^"']*guidance-sessions[^"']*["']/g) ?? []; + assert.equal( + specifiers.length, + named.length, + `${file} — a default/namespace/side-effect import of guidance-sessions is banned; import NAMED service surface only`, ); + for (const [, names] of named) { + for (const raw of names.split(",")) { + const name = raw.trim().replace(/^type\s+/, ""); + if (name === "") continue; + assert.ok( + ALLOWED.has(name), + `${file} imports "${name}" — only the service surface may cross (mint/get/delete, the TTL constant, the two types)`, + ); + } + } } }); + +/* ————— behavioral — the service laws (Map-fake KV, sessions.test.mjs style) ————— */ + +function makeKV() { + const map = new Map(); + const ttlLog = []; + let getCalls = 0; + return { + async get(key) { + getCalls += 1; + return map.has(key) ? map.get(key) : null; + }, + async put(key, value, opts) { + assert.ok( + opts && Number.isFinite(opts.expirationTtl) && opts.expirationTtl > 0, + `put(${key}) must carry a finite positive expirationTtl`, + ); + ttlLog.push({ key, expirationTtl: opts.expirationTtl }); + map.set(key, value); + }, + async delete(key) { + map.delete(key); + }, + map, + ttlLog, + get getCalls() { + return getCalls; + }, + }; +} + +const SNAPSHOT = { + destinationId: "suria-klcc", + destinationName: "Suria KLCC", + distanceM: 8432, + durationS: 900, + geometry: "oqv_Egt~~`Ewj@_q@", // polyline6, two KL points (routing/src/polyline.ts codec) + startedAtSec: 1792817000, +}; + +test("GS1 mint: uuid id, created_at stamp, EXACTLY ONE put at guidance: with expirationTtl 7200, envelope exactly 4 fields", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + const kv = makeKV(); + const envelope = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); + assert.match(envelope.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id (T-02-05 law)"); + assert.deepEqual( + Object.keys(envelope).sort(), + ["chat_id", "created_at", "id", "route_snapshot"], + "the minted envelope is exactly the frozen 4-field shape (GE2's runtime twin)", + ); + assert.equal(envelope.chat_id, 101, "chat_id comes from the caller's CLAIM, never a body"); + assert.equal(envelope.created_at, 1792817600, "created_at is the injected now, seconds-since-epoch"); + assert.deepEqual(envelope.route_snapshot, SNAPSHOT, "the snapshot is stored verbatim, INLINE"); + assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write"); + assert.equal(kv.ttlLog[0].key, `guidance:${envelope.id}`, "key scheme: guidance:"); + assert.equal(kv.ttlLog[0].expirationTtl, mod.GUIDANCE_SESSION_TTL_SECONDS, "every write carries the TTL constant (GE1)"); +}); + +test("GS2 get: a well-formed record round-trips the minted envelope", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + const kv = makeKV(); + const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); + assert.deepEqual(await mod.getGuidanceSession(kv, minted.id), minted); +}); + +test("GS3 defensive read: missing key, malformed JSON, a 5th envelope field, a missing field, wrong-typed chat_id, non-finite distanceM, negative durationS — ALL null (corrupt never reaches the cron)", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + const kv = makeKV(); + assert.equal( + await mod.getGuidanceSession(kv, "3f2a2c3e-1b4d-4e5a-9c8d-000000000001"), + null, + "missing key → null", + ); + const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); + const key = `guidance:${minted.id}`; + const base = JSON.parse(kv.map.get(key)); + const tamper = (mutate) => kv.map.set(key, JSON.stringify(mutate(base))); + tamper((r) => ({ ...r, extra: 1 })); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a 5th envelope field → null"); + tamper(({ created_at: _dropped, ...rest }) => rest); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a missing field → null"); + tamper((r) => ({ ...r, created_at: 1792817600, chat_id: "101" })); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "wrong-typed chat_id → null"); + tamper((r) => ({ ...r, chat_id: 101, route_snapshot: { ...r.route_snapshot, distanceM: "far" } })); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "non-finite distanceM → null"); + tamper((r) => ({ ...r, route_snapshot: { ...r.route_snapshot, distanceM: 8432, durationS: -5 } })); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "negative durationS → null"); + kv.map.set(key, "{not json"); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "malformed JSON → null"); +}); + +test("GS4 key-guard: ids failing the UUID shape read as null WITHOUT touching KV (V5: no arbitrary key probing)", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + const kv = makeKV(); + const badIds = [ + "", + "../../session:abc", + "3F2A2C3E-1B4D-4E5A-9C8D-000000000001", // uppercase — the guard is lowercase-exact + "3f2a2c3e-1b4d-4e5a-9c8d-00000000000", // one hex short + "3f2a2c3e-1b4d-4e5a-9c8d-00000000000g", // one hex over + ]; + for (const id of badIds) { + assert.equal(await mod.getGuidanceSession(kv, id), null, `guard rejects ${JSON.stringify(id)}`); + } + assert.equal(kv.getCalls, 0, "KV was never consulted for a malformed id"); +}); + +test("GS5 delete: the key is actively removed (D-14's server half) — a get after reads null", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + const kv = makeKV(); + const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); + await mod.deleteGuidanceSession(kv, minted.id); + assert.equal(kv.map.has(`guidance:${minted.id}`), false, "the record is gone, not merely expired"); + assert.equal(await mod.getGuidanceSession(kv, minted.id), null); +}); diff --git a/bot/test/privacy.test.mjs b/bot/test/privacy.test.mjs index 2dc83d5..44c1519 100644 --- a/bot/test/privacy.test.mjs +++ b/bot/test/privacy.test.mjs @@ -22,7 +22,7 @@ import { test } from "./harness.mjs"; const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); -const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"] +const storageModules = ["kv/sessions.ts", "kv/location-pin.ts", "kv/guidance-sessions.ts"] .map((rel) => path.join(SRC, ...rel.split("/"))); const webhookModule = path.join(SRC, "webhook.ts"); if (existsSync(webhookModule)) storageModules.push(webhookModule);