#!/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 * 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 — 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: 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 invariant)"); assert.equal(liveSessions[0], `session:${token2}`); }); /* ————— 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); });