test(06-03): guidance endpoint suite GW1-GW11 — 401/413/400 classes, claim-owned chat (T-06-02 injection probe), own-vs-foreign GET/DELETE, 405 (RED)
- offline undici Request/Response pattern; claims minted through the real /api/session path (mintSession) against the Map-fake KV - GW8 green-by-construction (the catch-all already 404s absent ids); ownership teeth live in GW7/GW10
This commit is contained in:
@@ -202,3 +202,236 @@ test("WK5 unarmed WEBHOOK_SECRET ('') → webhook rejects CLOSED with a constant
|
|||||||
assert.equal(await res2.text(), "Server misconfigured");
|
assert.equal(await res2.text(), "Server misconfigured");
|
||||||
assert.ok(env.chat_sessions === undefined, "the guard fired before any storage or bot wiring was needed");
|
assert.ok(env.chat_sessions === undefined, "the guard fired before any storage or bot wiring was needed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ————————————————————————————————————————————————————————————————————————
|
||||||
|
* /api/guidance — the Phase 6 envelope endpoints (06-03, GUID-05/06, D-14).
|
||||||
|
*
|
||||||
|
* GW1 POST without Bearer → 401 {error:"unauthorized"}; zero KV writes;
|
||||||
|
* log is the category ONLY
|
||||||
|
* GW2 POST with a non-conformant Bearer token → 401 (the charset gate)
|
||||||
|
* GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes;
|
||||||
|
* lying Content-Length backstop → 413 via the post-read check
|
||||||
|
* GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at
|
||||||
|
* guidance:<id>; a chat_id IN THE BODY is IGNORED (T-06-02 injection
|
||||||
|
* probe — the CLAIM's chat_id is stored); extra body fields dropped
|
||||||
|
* GW5 malformed snapshot classes → 400 {error:"malformed"} + category-
|
||||||
|
* only log; zero writes (non-numeric distanceM, negative durationS,
|
||||||
|
* non-integer startedAtSec, off-slug destinationId, empty/
|
||||||
|
* oversized-free destinationName, empty/undecodable geometry,
|
||||||
|
* broken JSON, null/array body)
|
||||||
|
* GW6 GET own id → 200 envelope JSON
|
||||||
|
* GW7 GET a foreign chat's id → 404 (no existence oracle) and the
|
||||||
|
* record SURVIVES (T-06-02b)
|
||||||
|
* GW8 GET absent / malformed id → 404
|
||||||
|
* GW9 DELETE own id → 200 {deleted:true}; the record is GONE (D-14)
|
||||||
|
* GW10 DELETE a foreign chat's id → 404 and the record SURVIVES
|
||||||
|
* (cross-chat deletion impossible, T-06-02b)
|
||||||
|
* GW11 PUT /api/guidance → 405
|
||||||
|
* ———————————————————————————————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
const { mintSession } = await import("../src/kv/sessions.ts");
|
||||||
|
const { mintGuidanceSession } = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
|
||||||
|
/** polyline6, two KL points — the routing workspace's storage format (05-01),
|
||||||
|
* generated with routing/src/polyline.ts encodePolyline6 (round-trip holds). */
|
||||||
|
const KL_TWO_POINTS = "oqv_Egt~~`Ewj@_q@";
|
||||||
|
|
||||||
|
const VALID_BODY = {
|
||||||
|
destinationId: "suria-klcc",
|
||||||
|
destinationName: "Suria KLCC",
|
||||||
|
distanceM: 8432,
|
||||||
|
durationS: 900,
|
||||||
|
geometry: KL_TWO_POINTS,
|
||||||
|
startedAtSec: 1792817000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A memory-held claim the honest way: through the real mint (offline KV). */
|
||||||
|
async function bearerFor(kv, chatId = 101) {
|
||||||
|
return mintSession(kv, {
|
||||||
|
user_id: 42,
|
||||||
|
chat_id: chatId,
|
||||||
|
issued_at: Math.floor(Date.now() / 1000),
|
||||||
|
language_code: "en",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function guidanceReq(method, { body, token, id, headers } = {}) {
|
||||||
|
const url = id === undefined ? "https://host/api/guidance" : `https://host/api/guidance?id=${encodeURIComponent(id)}`;
|
||||||
|
return new Request(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(headers ?? {}),
|
||||||
|
},
|
||||||
|
...(method === "POST" && body !== undefined
|
||||||
|
? { body: typeof body === "string" ? body : JSON.stringify(body) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureLogs(fn) {
|
||||||
|
const seen = [];
|
||||||
|
const orig = console.log;
|
||||||
|
console.log = (...args) => seen.push(args.map((a) => String(a)).join(" "));
|
||||||
|
try {
|
||||||
|
return { result: await fn(), seen };
|
||||||
|
} finally {
|
||||||
|
console.log = orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedEnvelope(kv, chatId) {
|
||||||
|
const token = await bearerFor(kv, chatId);
|
||||||
|
const envelope = await mintGuidanceSession(kv, chatId, VALID_BODY, 1792817600);
|
||||||
|
return { token, id: envelope.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GW1 POST /api/guidance without Bearer → 401 {error:'unauthorized'}; zero KV writes; log is the category ONLY", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: VALID_BODY }), makeEnv(kv), {}));
|
||||||
|
const res = await result;
|
||||||
|
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
||||||
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
||||||
|
assert.equal(kv.ttlLog.length, 0, "an unauthorized caller writes nothing");
|
||||||
|
assert.deepEqual(seen, ["[guidance] rejected: unauthorized"], "category ONLY — no token, no body fragment");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW2 POST with a non-conformant Bearer token (charset gate) → 401", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const res = await handler(guidanceReq("POST", { body: VALID_BODY, token: "bad_token!!" }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
||||||
|
assert.equal(kv.ttlLog.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes; category-only log; lying Content-Length backstop → 413", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const token = await bearerFor(kv, 101);
|
||||||
|
const overCap = JSON.stringify({ ...VALID_BODY, destinationName: "x".repeat(9000) });
|
||||||
|
assert.ok(overCap.length > 8192, "fixture must exceed the cap");
|
||||||
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: overCap, token }), makeEnv(kv), {}));
|
||||||
|
const res = await result;
|
||||||
|
assert.equal(res.status, 413, "the pre-read cap refuses to buffer");
|
||||||
|
assert.deepEqual(await res.json(), { error: "malformed" });
|
||||||
|
assert.equal(kv.ttlLog.length, 0);
|
||||||
|
assert.deepEqual(seen, ["[guidance] rejected: malformed"], "category ONLY");
|
||||||
|
|
||||||
|
// lying header: declares 100, ships 9000+ → the post-read check closes the blind spot
|
||||||
|
const kv2 = makeKV();
|
||||||
|
const token2 = await bearerFor(kv2, 101);
|
||||||
|
const lying = await handler(
|
||||||
|
guidanceReq("POST", { body: overCap, token: token2, headers: { "content-length": "100" } }),
|
||||||
|
makeEnv(kv2),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(lying.status, 413, "the post-read length check must backstop a lying header");
|
||||||
|
assert.equal(kv2.ttlLog.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:<id>; body chat_id 999 IGNORED (T-06-02); extra fields dropped", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const token = await bearerFor(kv, 101);
|
||||||
|
const res = await handler(
|
||||||
|
guidanceReq("POST", { body: { ...VALID_BODY, chat_id: 999, extra: "junk" }, token }),
|
||||||
|
makeEnv(kv),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 201, `expected 201, got ${res.status}: ${await res.clone().text()}`);
|
||||||
|
const { id } = await res.json();
|
||||||
|
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id");
|
||||||
|
assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write");
|
||||||
|
assert.equal(kv.ttlLog[0].key, `guidance:${id}`);
|
||||||
|
assert.equal(kv.ttlLog[0].expirationTtl, 7200, "TTL-only (GE1)");
|
||||||
|
const stored = JSON.parse(kv.map.get(`guidance:${id}`));
|
||||||
|
assert.equal(stored.chat_id, 101, "identity comes from the CLAIM — the body's chat_id 999 is structurally ignored (T-06-02)");
|
||||||
|
assert.deepEqual(Object.keys(stored).sort(), ["chat_id", "created_at", "id", "route_snapshot"], "frozen 4-field envelope");
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(stored.route_snapshot).sort(),
|
||||||
|
["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"],
|
||||||
|
"exactly the six snapshot fields — extra body properties never reach storage",
|
||||||
|
);
|
||||||
|
assert.equal(stored.route_snapshot.destinationName, "Suria KLCC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW5 malformed snapshot classes → 400 {error:'malformed'} + category-only log; zero KV writes", async () => {
|
||||||
|
const badBodies = [
|
||||||
|
["broken JSON", "{not json"],
|
||||||
|
["null body", "null"],
|
||||||
|
["array body", "[1,2]"],
|
||||||
|
["non-numeric distanceM", { ...VALID_BODY, distanceM: "far" }],
|
||||||
|
["negative durationS", { ...VALID_BODY, durationS: -5 }],
|
||||||
|
["non-integer startedAtSec", { ...VALID_BODY, startedAtSec: 1.5 }],
|
||||||
|
["off-slug destinationId", { ...VALID_BODY, destinationId: "Suria KLCC" }],
|
||||||
|
["empty destinationName", { ...VALID_BODY, destinationName: "" }],
|
||||||
|
["empty geometry (decodes to 0 points)", { ...VALID_BODY, geometry: "" }],
|
||||||
|
["undecodable geometry (truncated)", { ...VALID_BODY, geometry: "x" }],
|
||||||
|
];
|
||||||
|
for (const [label, body] of badBodies) {
|
||||||
|
const kv = makeKV();
|
||||||
|
const token = await bearerFor(kv, 101);
|
||||||
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body, token }), makeEnv(kv), {}));
|
||||||
|
const res = await result;
|
||||||
|
assert.equal(res.status, 400, `${label} must be a 400, got ${res.status}`);
|
||||||
|
assert.deepEqual(await res.json(), { error: "malformed" }, `${label} carries the category only`);
|
||||||
|
assert.deepEqual(seen, ["[guidance] rejected: malformed"], `${label} logs the category ONLY`);
|
||||||
|
assert.equal(kv.ttlLog.length, 0, `${label} writes nothing`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW6 GET own id → 200 with the envelope JSON", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { token, id } = await seedEnvelope(kv, 101);
|
||||||
|
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||||
|
const envelope = await res.json();
|
||||||
|
assert.equal(envelope.id, id);
|
||||||
|
assert.equal(envelope.chat_id, 101);
|
||||||
|
assert.deepEqual(envelope.route_snapshot, VALID_BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW7 GET a foreign chat's id → 404 (no existence oracle) and the record SURVIVES (T-06-02b)", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { id } = await seedEnvelope(kv, 101);
|
||||||
|
const mallory = await bearerFor(kv, 202);
|
||||||
|
const res = await handler(guidanceReq("GET", { token: mallory, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.deepEqual(await res.json(), { error: "not_found" }, "foreign and absent are indistinguishable");
|
||||||
|
assert.ok(kv.map.has(`guidance:${id}`), "a foreign GET never deletes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW8 GET absent / malformed id → 404", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { token } = await seedEnvelope(kv, 101);
|
||||||
|
for (const id of ["3f2a2c3e-1b4d-4e5a-9c8d-0000000000ff", "garbage", ""]) {
|
||||||
|
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 404, `id ${JSON.stringify(id)} must 404`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW9 DELETE own id → 200 {deleted:true}; the KV record is GONE (D-14's server half)", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { token, id } = await seedEnvelope(kv, 101);
|
||||||
|
const res = await handler(guidanceReq("DELETE", { token, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||||
|
assert.deepEqual(await res.json(), { deleted: true });
|
||||||
|
assert.equal(kv.map.has(`guidance:${id}`), false, "actively removed, not merely expired");
|
||||||
|
const again = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(again.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW10 DELETE a foreign chat's id → 404 and the record SURVIVES (cross-chat deletion impossible, T-06-02b)", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const { id } = await seedEnvelope(kv, 101);
|
||||||
|
const mallory = await bearerFor(kv, 202);
|
||||||
|
const res = await handler(guidanceReq("DELETE", { token: mallory, id }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.deepEqual(await res.json(), { error: "not_found" });
|
||||||
|
assert.ok(kv.map.has(`guidance:${id}`), "a foreign DELETE never deletes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GW11 PUT /api/guidance → 405", async () => {
|
||||||
|
const kv = makeKV();
|
||||||
|
const token = await bearerFor(kv, 101);
|
||||||
|
const res = await handler(guidanceReq("PUT", { token }), makeEnv(kv), {});
|
||||||
|
assert.equal(res.status, 405);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user