199 lines
8.0 KiB
JavaScript
199 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* sessions.test.mjs — TTL-only KV session layer (D-06, D-07, D-08).
|
|
* -----------------------------------------------------------------
|
|
* Runs against a ~20-line in-memory KVNamespace fake (D-12: zero deps):
|
|
* - put() asserts a finite expirationTtl is present on every write
|
|
* - ttlLog records {key, expirationTtl} for every write
|
|
* Deletion/expiry semantics are modeled by key absence (real KV TTL).
|
|
*
|
|
* Cases:
|
|
* SM1 mint: claim record holds EXACTLY the D-08 key set (no drift)
|
|
* SM2 mint: every put carries expirationTtl === SESSION_TTL_SECONDS (900)
|
|
* SR1 rotate: second mint for same chat removes the old session key
|
|
* SR2 rotate: pointer targets the new token; exactly one live record
|
|
* SR3 the honest KV limit (WR-03): interleaved mints racing the same
|
|
* pointer can orphan a loser record for at most SESSION_TTL_SECONDS;
|
|
* the next sequential mint sweeps the pointer's predecessor
|
|
* SG1 getClaimByToken round-trips (language_code "ms" and null)
|
|
* SG2 unknown token → null (the "expired" rejection class, D-14)
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { test } from "./harness.mjs";
|
|
|
|
let mod; // lazy TS import (Node 24 type stripping)
|
|
let SESSION_TTL_SECONDS;
|
|
|
|
function makeKV() {
|
|
const map = new Map();
|
|
const ttlLog = [];
|
|
return {
|
|
async get(key) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function claim(chatId, languageCode, issuedAt = 1700000000) {
|
|
return {
|
|
user_id: 42,
|
|
chat_id: chatId,
|
|
issued_at: issuedAt,
|
|
language_code: languageCode,
|
|
};
|
|
}
|
|
|
|
/* ————— SM1/SM2 — mint shape + TTL-by-construction ————— */
|
|
|
|
test("SM1 mint: session record contains EXACTLY the D-08 key set {user_id, chat_id, issued_at, language_code}", async () => {
|
|
mod = await import("../src/kv/sessions.ts");
|
|
SESSION_TTL_SECONDS = mod.SESSION_TTL_SECONDS;
|
|
const kv = makeKV();
|
|
const token = await mod.mintSession(kv, claim(101, "ms"));
|
|
assert.match(token, /^[0-9a-f-]{36}$/, "token must be an opaque UUID");
|
|
const record = JSON.parse(kv.map.get(`session:${token}`));
|
|
assert.deepEqual(
|
|
Object.keys(record).sort(),
|
|
["chat_id", "issued_at", "language_code", "user_id"],
|
|
"claim is exactly D-08 — a 5th property is storage drift",
|
|
);
|
|
assert.equal(record.user_id, 42);
|
|
assert.equal(record.chat_id, 101);
|
|
assert.equal(record.language_code, "ms");
|
|
});
|
|
|
|
test("SM2 mint: every put carries expirationTtl === 900 (PRIV-02 by construction)", async () => {
|
|
const kv = makeKV();
|
|
await mod.mintSession(kv, claim(102, "en"));
|
|
assert.equal(kv.ttlLog.length, 2, "mintSession writes exactly two keys");
|
|
for (const entry of kv.ttlLog) {
|
|
assert.strictEqual(entry.expirationTtl, SESSION_TTL_SECONDS);
|
|
assert.strictEqual(entry.expirationTtl, 900, "D-13 pairing — 15-min envelope");
|
|
}
|
|
});
|
|
|
|
/* ————— SR1/SR2/SR3 — rotate-on-new (T-02-02 replay close) ————— */
|
|
|
|
test("SR1 rotate: minting again for the same chat removes the old session key", async () => {
|
|
const kv = makeKV();
|
|
const token1 = await mod.mintSession(kv, claim(103, "ms"));
|
|
const token2 = await mod.mintSession(kv, claim(103, "ms", 1700000050));
|
|
assert.notEqual(token1, token2);
|
|
assert.equal(
|
|
kv.map.has(`session:${token1}`),
|
|
false,
|
|
"old token record must be gone (D-07 rotate-on-new)",
|
|
);
|
|
});
|
|
|
|
test("SR2 rotate (sequential): pointer targets the new token; exactly one live session record", async () => {
|
|
const kv = makeKV();
|
|
const token1 = await mod.mintSession(kv, claim(104, "en"));
|
|
const token2 = await mod.mintSession(kv, claim(104, "en"));
|
|
const pointer = await mod.getTokenForChat(kv, 104);
|
|
assert.equal(pointer, token2, "chat pointer must point at the newest token");
|
|
const liveSessions = [...kv.map.keys()].filter((k) => k.startsWith("session:"));
|
|
assert.equal(
|
|
liveSessions.length,
|
|
1,
|
|
"one live token per chat (D-07) — sequential shape; see sessions.ts header for the WR-03 eventual-consistency caveat",
|
|
);
|
|
assert.equal(liveSessions[0], `session:${token2}`);
|
|
});
|
|
|
|
/** SR3 emulation of the KV race the review confirmed (no transactions,
|
|
* eventual consistency): both racing mints read the SAME pre-write
|
|
* pointer, so neither sees the other's writes. B's pointer read is parked
|
|
* mid-flight — it snapshots `chat:<id>` before any write — while A's
|
|
* entire mint lands; when B resumes, its stale view makes it blind to
|
|
* A's token, exactly the stale-replica shape under KV. */
|
|
test("SR3 (WR-03, documented KV limit): interleaved mints can orphan a loser record for ≤ SESSION_TTL_SECONDS; the next sequential mint sweeps the pointer's predecessor", async () => {
|
|
const kv = makeKV();
|
|
const token1 = await mod.mintSession(kv, claim(108, "en"));
|
|
assert.equal(await mod.getTokenForChat(kv, 108), token1);
|
|
|
|
let releaseB;
|
|
const gate = new Promise((resolve) => (releaseB = resolve));
|
|
const origGet = kv.get.bind(kv);
|
|
let pointerReads = 1; // token1's mint consumed read #1 of chat:108
|
|
kv.get = async (key) => {
|
|
if (key === "chat:108" && (pointerReads += 1) === 3) {
|
|
// B's read: snapshot the pre-write pointer (token1), then park until
|
|
// A's entire mint has landed — a replica serving stale data.
|
|
const stale = await origGet(key);
|
|
await gate;
|
|
return stale;
|
|
}
|
|
return origGet(key);
|
|
};
|
|
|
|
const promiseA = mod.mintSession(kv, claim(108, "en", 1700000050)); // A: read #2, unparked
|
|
const promiseB = mod.mintSession(kv, claim(108, "en", 1700000060)); // B: read #3, parked
|
|
const tokenA = await promiseA; // A's mint lands fully while B is parked
|
|
releaseB(); // B's stale snapshot resolves — it saw token1, never tokenA
|
|
const tokenB = await promiseB;
|
|
|
|
// last pointer write wins
|
|
assert.equal(await mod.getTokenForChat(kv, 108), tokenB);
|
|
// THE HONEST LIMIT: A lost the pointer-write race, so its record is
|
|
// unreferenced yet live — bounded by the 900 s TTL, nothing more.
|
|
assert.equal(
|
|
kv.map.has(`session:${tokenA}`),
|
|
true,
|
|
"losers of a pointer-write race survive until TTL (WR-03 corrected claim — NOT structurally closed)",
|
|
);
|
|
assert.equal(kv.map.has(`session:${tokenB}`), true, "the pointer-named token is live");
|
|
|
|
// next sequential mint sweeps the pointer's predecessor (reconcile at
|
|
// next use — the shape that keeps the chat converging)
|
|
const tokenC = await mod.mintSession(kv, claim(108, "en", 1700000070));
|
|
assert.equal(await mod.getTokenForChat(kv, 108), tokenC);
|
|
assert.equal(
|
|
kv.map.has(`session:${tokenB}`),
|
|
false,
|
|
"the pointer's predecessor is swept at the next mint",
|
|
);
|
|
assert.equal(
|
|
kv.map.has(`session:${tokenA}`),
|
|
true,
|
|
"an unreferenced orphan is invisible to the pointer sweep — TTL (900 s) is the only bound (see sessions.ts header)",
|
|
);
|
|
});
|
|
|
|
/* ————— SG1/SG2 — claim reads (expired class, D-14) ————— */
|
|
|
|
test("SG1 getClaimByToken round-trips language_code 'ms' and null", async () => {
|
|
const kv = makeKV();
|
|
const tokenMs = await mod.mintSession(kv, claim(105, "ms"));
|
|
const claimMs = await mod.getClaimByToken(kv, tokenMs);
|
|
assert.ok(claimMs);
|
|
assert.equal(claimMs.language_code, "ms");
|
|
assert.equal(claimMs.user_id, 42);
|
|
|
|
const kv2 = makeKV();
|
|
const tokenNull = await mod.mintSession(kv2, claim(106, null));
|
|
const claimNull = await mod.getClaimByToken(kv2, tokenNull);
|
|
assert.ok(claimNull);
|
|
assert.strictEqual(claimNull.language_code, null);
|
|
});
|
|
|
|
test("SG2 unknown token → null (callers treat as the expired rejection class)", async () => {
|
|
const kv = makeKV();
|
|
await mod.mintSession(kv, claim(107, "ms"));
|
|
assert.strictEqual(await mod.getClaimByToken(kv, "no-such-token"), null);
|
|
assert.strictEqual(await mod.getTokenForChat(kv, 999999), null);
|
|
});
|