#!/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:` 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); });