Files
arah2/bot/test/sessions.test.mjs
T
araharah-executor e9aec5833b feat(02-02): TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06/D-07/D-08/D-14)
- sessions.ts: mintSession (crypto.randomUUID opaque token, T-02-05), rotate-on-new deletes prior session record via chat pointer (D-07), both puts carry expirationTtl: SESSION_TTL_SECONDS on the call line (PRIV-02)
- SESSION_TTL_SECONDS = 900 pairs the session envelope with the D-13 freshness window
- SessionClaim is exactly D-08: user_id, chat_id, issued_at, language_code — nothing else serializes to storage
- sessions.test.mjs: in-memory KV fake asserting finite expirationTtl on every put; D-08 key-set equality; rotate removes old token; expired-token → null (D-14 class)
- privacy.test.mjs: PG1 same-line expirationTtl source gate (auto-tightens to webhook.ts when plan 03 adds it) + PG2 no persistence-shaped exports
- 18/18 PASS across initdata + sessions + privacy suites; full plan GREEN
2026-10-22 11:43:20 +08:00

133 lines
5.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
* 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);
});