import { describe, it, expect } from 'vitest' import { SESSION_COOKIE, SESSION_TTL_MS, mintSession, resolveSession, readSessionCookie, } from '../../skills/data-portal/template/functions/api/_lib/session' import { ownerPrefix, authorizeKey, } from '../../skills/data-portal/template/functions/api/_lib/authorize' import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types' // Models the real resolveSession query: sessions JOIN people, requiring the // session's pcCheck to still match substr(people.hash, 1, 16). A fake that // skipped the join would let the revocation tests below pass vacuously. function fakeDb( seed: Array<{ sessionId: string; ownerId: string; pcCheck: string; expiresAt: number }> = [], people: Array<{ ownerId: string; hash: string }> = [{ ownerId: 'alice', hash: 'HASH-v1-padded00' }], ) { const rows = [...seed] const peopleRows = [...people] const db: D1Database = { prepare(query: string) { let bound: unknown[] = [] const stmt = { bind(...v: unknown[]) { bound = v return stmt }, async run() { if (/INSERT INTO sessions/i.test(query)) { const [sessionId, ownerId, pcCheck, expiresAt] = bound as [string, string, string, number] rows.push({ sessionId, ownerId, pcCheck, expiresAt }) } if (/DELETE FROM sessions/i.test(query)) { const [ownerId, nowMs] = bound as [string, number] for (let i = rows.length - 1; i >= 0; i--) { if (rows[i].ownerId === ownerId && rows[i].expiresAt <= nowMs) rows.splice(i, 1) } } return { meta: { changes: 1 } } }, async all() { return { results: [] } }, async first() { const [sessionId, nowMs] = bound as [string, number] const hit = rows.find((r) => r.sessionId === sessionId && r.expiresAt > nowMs) if (!hit) return null as T | null const person = peopleRows.find((p) => p.ownerId === hit.ownerId) if (!person) return null as T | null // unenrolled -> the JOIN drops it if (person.hash.slice(0, 16) !== hit.pcCheck) return null as T | null // rotated return { ownerId: hit.ownerId } as T }, } return stmt }, } return { db, rows, peopleRows } } const PC = 'HASH-v1-padded00'.slice(0, 16) describe('sessions', () => { it('mints a session resolvable to its owner', async () => { const { db } = fakeDb() await mintSession(db, 'alice', 'sess-1', 1_000_000, PC) expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] }) }) it('does not resolve an expired session', async () => { const { db } = fakeDb() await mintSession(db, 'alice', 'sess-1', 1_000_000, PC) expect(await resolveSession(db, 'sess-1', 1_000_000 + SESSION_TTL_MS + 1)).toBeNull() }) it('does not resolve an unknown session', async () => { const { db } = fakeDb() expect(await resolveSession(db, 'nope', 1_000_000)).toBeNull() }) it('prunes that owner expired rows on mint, so sessions do not grow forever', async () => { const { db, rows } = fakeDb([ { sessionId: 'old', ownerId: 'alice', pcCheck: PC, expiresAt: 1 }, ]) await mintSession(db, 'alice', 'sess-1', 1_000_000, PC) expect(rows.map((r) => r.sessionId)).toEqual(['sess-1']) }) }) describe('session revocation — the property the design claims', () => { it('stops resolving once the passcode is rotated', async () => { // The whole reason a leaked passcode has a remedy. Rotation changes // people.hash, so pcCheck no longer matches and the live session dies — // by construction, not by remembering to delete anything. const { db, peopleRows } = fakeDb() await mintSession(db, 'alice', 'sess-1', 1_000_000, PC) expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] }) peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull() }) it('stops resolving once the person is unenrolled', async () => { const { db, peopleRows } = fakeDb() await mintSession(db, 'alice', 'sess-1', 1_000_000, PC) peopleRows.length = 0 expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull() }) }) describe('readSessionCookie', () => { it('reads the portal session cookie', () => { expect(readSessionCookie('__Host-__portal_session=abc123')).toBe('abc123') }) it('reads it from among other cookies', () => { expect(readSessionCookie('foo=1; __Host-__portal_session=abc123; bar=2')).toBe('abc123') }) it('is null when absent', () => { expect(readSessionCookie('foo=1')).toBeNull() }) it('is null when the header is absent', () => { expect(readSessionCookie(null)).toBeNull() }) it('does not match a lookalike cookie name', () => { expect(readSessionCookie('x__Host-__portal_session=abc123')).toBeNull() }) it('uses the __Host- prefix, which forbids a Domain attribute', () => { // Without the prefix, a cookie set on a sibling subdomain with // Domain=.example.com reaches this host and can shadow the real session, // so a victim uploads into the attacker's prefix. The prefix is the only // thing browsers enforce that against, and we already meet its // preconditions (Secure, Path=/, no Domain). expect(SESSION_COOKIE.startsWith('__Host-')).toBe(true) }) it('keeps scanning past a valueless duplicate rather than returning null', () => { // An attacker-planted empty cookie sorted first must not blank the real // session — that is a persistent logout denial-of-service. expect(readSessionCookie('__Host-__portal_session=; __Host-__portal_session=real')).toBe('real') }) }) describe('authorizeKey — the isolation rule', () => { it('allows a key under the owner prefix', () => { expect(authorizeKey('alice', 'alice/invoice.pdf')).toBe(true) }) it('denies another owner prefix', () => { expect(authorizeKey('alice', 'bob/invoice.pdf')).toBe(false) }) it('denies a traversal escape', () => { expect(authorizeKey('alice', 'alice/../bob/invoice.pdf')).toBe(false) }) it('denies a prefix that merely starts with the owner id', () => { // 'alice' must not authorize 'alice-evil/...': a startsWith check on the // bare id would pass this. The separator is part of the rule. expect(authorizeKey('alice', 'alice-evil/invoice.pdf')).toBe(false) }) it('denies a bare key with no prefix', () => { expect(authorizeKey('alice', 'invoice.pdf')).toBe(false) }) it('denies an absolute-looking key', () => { expect(authorizeKey('alice', '/alice/invoice.pdf')).toBe(false) }) it('denies an empty key', () => { expect(authorizeKey('alice', '')).toBe(false) }) // authorizeKey's correctness silently depends on ownerId containing no // separator. If two people were ever enrolled as 'alice' and 'alice/2024', // then 'alice/2024/x.pdf' starts with 'alice/' and alice reads the other // person's files. Enrolment is an agent writing rows, so the invariant // cannot rest on the skill's prose — enforce it at the gate. it('denies when the ownerId itself contains a separator', () => { expect(authorizeKey('alice/2024', 'alice/2024/invoice.pdf')).toBe(false) }) it('denies an ownerId outside [a-z0-9-], failing closed', () => { for (const bad of ['alice/2024', 'Alice', 'alice..', 'alice bob', '../alice', 'alice%2f']) { expect(authorizeKey(bad, `${bad}/invoice.pdf`)).toBe(false) } }) it('still allows a well-formed ownerId', () => { expect(authorizeKey('alice-smith-2', 'alice-smith-2/invoice.pdf')).toBe(true) }) }) describe('ownerPrefix', () => { it('is the owner id plus a separator', () => { expect(ownerPrefix('alice')).toBe('alice/') }) })