Files
arah2/bot/test/guidance-envelope.test.mjs
T
araharah-executor 0cfa0bde38 test(06-03): re-pin GE4/GE7 to the live service + behavioral envelope suite + PG1 storage list (RED)
- GE1/GE2/GE3 UNCHANGED in meaning (TTL 7200, 4+6 field freeze, inline snapshot)
- GE4 re-pinned: types-only token ban -> the module must EXPORT mint/get/deleteGuidanceSession
- GE7 re-pinned: total consumer ban -> named-imports-only of the service surface
- GE5/GE6 stand (exactly one kv namespace; no Env guidance field)
- GS1-GS5 behavioral: TTL'd mint, roundtrip, 7-class defensive null read,
  UUID key-guard without KV touch (V5), active delete (D-14 server half)
- PG1 storageModules now covers kv/guidance-sessions.ts (same-line TTL law)
2026-10-24 13:02:28 +08:00

334 lines
15 KiB
JavaScript

#!/usr/bin/env node
/*
* 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 (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] (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 (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.
*
* 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";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const here = path.dirname(fileURLToPath(import.meta.url)); // bot/test
const srcDir = path.join(here, "..", "src"); // bot/src
const MODULE = path.join(srcDir, "kv", "guidance-sessions.ts");
const CONFIG = path.join(srcDir, "config.ts");
const WRANGLER = path.join(here, "..", "wrangler.jsonc");
/* Comments stripped first — banned-token scans read CODE, not prose
* (routing/test/contract.test.mjs C4 / web no-storage pattern). */
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping)
}
function tsFilesUnder(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...tsFilesUnder(p));
else if (entry.name.endsWith(".ts")) out.push(p);
}
return out;
}
/* ————— GE1 — the ~2h TTL constant exists as a documented value ————— */
test("GE1 guidance TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (~2h, D-06)", async () => {
const mod = await import("../src/kv/guidance-sessions.ts");
assert.equal(
mod.GUIDANCE_SESSION_TTL_SECONDS,
7200,
"the ~2h TTL must exist as the documented constant awaiting its Phase 6 consumer",
);
});
/* ————— GE2 — the field freeze (SessionClaim law) ————— */
/** Field names of `interface <name> { ... }` — lines shaped `^\s{2}\w+:`. */
function interfaceFields(source, name) {
const decl = source.indexOf(`interface ${name} {`);
assert.notEqual(decl, -1, `interface ${name} not found in the contract module`);
const bodyStart = source.indexOf("{", decl) + 1;
const bodyEnd = source.indexOf("\n}", bodyStart);
assert.notEqual(bodyEnd, -1, `interface ${name} body never closes`);
return source
.slice(bodyStart, bodyEnd)
.split("\n")
.map((line) => line.match(/^\s{2}(\w+):/))
.filter(Boolean)
.map((m) => m[1]);
}
test("GE2 field freeze — envelope has EXACTLY [id, chat_id, route_snapshot, created_at]; snapshot EXACTLY its six", () => {
const source = readFileSync(MODULE, "utf8");
assert.deepEqual(
interfaceFields(source, "GuidanceSessionEnvelope"),
["id", "chat_id", "route_snapshot", "created_at"],
"the envelope grew/shrank a field — a 5th property means drift (the SessionClaim law)",
);
assert.deepEqual(
interfaceFields(source, "GuidanceRouteSnapshot"),
["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"],
"the route snapshot surface drifted (GUID-05/06 scoped fields)",
);
});
/* ————— GE3 — the snapshot is INLINE (D-07: one key, one TTL) ————— */
test("GE3 inline snapshot — route_snapshot is typed inline; zero snapshot-ref tokens in bot/src", () => {
const source = readFileSync(MODULE, "utf8");
assert.ok(
source.includes("route_snapshot: GuidanceRouteSnapshot"),
"route_snapshot must be typed INLINE (GuidanceRouteSnapshot), never a string/number reference",
);
for (const file of tsFilesUnder(srcDir)) {
assert.doesNotMatch(
readFileSync(file, "utf8"),
/snapshotRef|snapshot_ref/,
`${file} — snapshot-by-reference token found (D-07: one key, one TTL, nothing reconstructable after expiry)`,
);
}
});
/* ————— GE4 — RE-PINNED (06-03): the service is LIVE beneath the frozen shape ————— */
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) ————— */
test("GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace: chat_sessions", () => {
const wrangler = JSON.parse(stripComments(readFileSync(WRANGLER, "utf8")));
const namespaces = wrangler.kv_namespaces;
assert.equal(Array.isArray(namespaces), true, "kv_namespaces must remain an array");
assert.equal(
namespaces.length,
1,
"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 ————— */
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 — the service is handed the EXISTING chat_sessions binding; no new Env surface",
);
});
/* ————— GE7 — RE-PINNED (06-03): consumer imports constrained to the service surface ————— */
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"));
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:<id> 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:<id>");
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);
});