tests: more tests for session and auth (#3174)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-05-27 12:32:15 -07:00
committed by GitHub
parent a7bdac16da
commit eba194936e
2 changed files with 325 additions and 0 deletions
@@ -1180,6 +1180,108 @@ describe('AuthService (integration)', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
// ── Revocation flow ────────────────────────────────────────
it('revokeSession on a worker session — authenticate returns reauth.session_revoked', async () => {
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const { token, session } =
await authService.createWorkerSessionToken(user, workerName);
const sessionUuid = (session as { uuid: string }).uuid;
await authService.revokeSession(sessionUuid);
const result = await authService.authenticate(token);
expect(result.actor).toBeUndefined();
expect(result.reauth).toEqual({
reason: 'session_revoked',
auth_id: user.uuid,
});
});
it('createWorkerSessionToken after revoke mints a new session uuid (composite cache invalidates)', async () => {
// Pre-fix, the worker composite cache could short-circuit
// back to the revoked row. Verify cache invalidation runs on
// revoke so the re-create produces a fresh row.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await authService.createWorkerSessionToken(
user,
workerName,
);
const firstUuid = (first.session as { uuid: string }).uuid;
await authService.revokeSession(firstUuid);
const second = await authService.createWorkerSessionToken(
user,
workerName,
);
const secondUuid = (second.session as { uuid: string }).uuid;
expect(secondUuid).not.toBe(firstUuid);
// The new JWT authenticates; the old one does not.
const oldResult = await authService.authenticate(first.token);
const newResult = await authService.authenticate(second.token);
expect(oldResult.actor).toBeUndefined();
expect(newResult.actor?.user.uuid).toBe(user.uuid);
});
it('createWorkerAppToken after revoke mints a new session uuid', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const firstJwt = await authService.createWorkerAppToken(
actor,
appUid,
workerName,
);
const firstDecoded = server.services.token.verify(
'auth',
firstJwt,
) as { session_uid: string };
await authService.revokeSession(firstDecoded.session_uid);
const secondJwt = await authService.createWorkerAppToken(
actor,
appUid,
workerName,
);
const secondDecoded = server.services.token.verify(
'auth',
secondJwt,
) as { session_uid: string };
expect(secondDecoded.session_uid).not.toBe(
firstDecoded.session_uid,
);
});
it('removeSessionByToken on a worker token soft-revokes the row', async () => {
// The logout / signout path lands here. Worker JWTs carry
// type='session' so the same code path applies; verify it
// flips revoked_at and authenticate stops resolving the actor.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const { token, session } =
await authService.createWorkerSessionToken(user, workerName);
const sessionUuid = (session as { uuid: string }).uuid;
await authService.removeSessionByToken(token);
const result = await authService.authenticate(token);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');
// Row still present, just soft-revoked.
const rows = (await server.clients.db.read(
'SELECT `revoked_at` FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[sessionUuid],
)) as Array<{ revoked_at: number | null }>;
expect(rows[0]?.revoked_at).not.toBeNull();
});
});
describe('appUidFromOrigin', () => {
@@ -24,6 +24,7 @@ import { PuterServer } from '../../server.ts';
import {
APP_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
WORKER_WINDOW_SECONDS,
} from './SessionStore.js';
describe('SessionStore', () => {
@@ -359,6 +360,228 @@ describe('SessionStore', () => {
});
});
describe('getOrCreateWorker', () => {
it('creates a kind="worker" row tagged meta.worker / meta.worker_name', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const row = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
auth_id: user.uuid,
});
expect(row).toBeTruthy();
expect(row.kind).toBe('worker');
expect(row.app_uid).toBe(appUid);
expect(row.user_id).toBe(user.id);
expect(row.parent_session_id).toBeNull();
expect(row.meta.worker).toBe(true);
expect(row.meta.worker_name).toBe(workerName);
// Sliding window seeded for worker — bound matches the live
// WORKER_WINDOW_SECONDS constant so a future bump to the window
// doesn't silently fail this assertion.
const now = Math.floor(Date.now() / 1000);
expect(row.expires_at).toBeGreaterThan(now);
expect(row.expires_at).toBeLessThanOrEqual(
now + WORKER_WINDOW_SECONDS + 5,
);
const raw = await rawRow(row.uuid);
expect(raw.kind).toBe('worker');
expect(raw.app_uid).toBe(appUid);
});
it('is idempotent on (user, app, worker_name)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(a.uuid).toBe(b.uuid);
});
it('is idempotent on (user, worker_name) when app_uid is null (user-scoped)', async () => {
// The partial unique index uses IFNULL(app_uid, '') so two
// user-scoped (app_uid=null) workers with the same worker_name
// still dedupe. Without IFNULL, SQLite would treat the NULLs as
// distinct per the SQL standard and let duplicates through.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
expect(a.uuid).toBe(b.uuid);
expect(a.app_uid).toBeNull();
});
it('mints distinct rows for different worker_names under the same (user, app)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-a`,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-b`,
});
expect(a.uuid).not.toBe(b.uuid);
});
it('does NOT collide with an interactive kind="app" row for the same (user, app)', async () => {
// Worker rows are intentionally carved out of the
// idx_sessions_user_app_active index (which is WHERE kind='app').
// An app session and a worker session for the same (user, app)
// must coexist.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const appRow = await target.getOrCreateApp(user.id, appUid);
const workerRow = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}`,
});
expect(appRow.uuid).not.toBe(workerRow.uuid);
expect(appRow.kind).toBe('app');
expect(workerRow.kind).toBe('worker');
});
it('converges to a single row under concurrent racers', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const results = await Promise.all(
Array.from({ length: 10 }, () =>
target.getOrCreateWorker(user.id, { appUid, workerName }),
),
);
const uuids = new Set(results.map((r: { uuid: string }) => r.uuid));
expect(uuids.size).toBe(1);
// And only one row ever made it into the table.
const rows = await server.clients.db.read(
"SELECT `uuid` FROM `sessions` WHERE `user_id` = ? AND `kind` = 'worker' AND `app_uid` = ?",
[user.id, appUid],
);
expect(rows).toHaveLength(1);
});
it('returns null when called with falsy inputs', async () => {
expect(
await target.getOrCreateWorker(null, { workerName: 'wk' }),
).toBeNull();
expect(await target.getOrCreateWorker(1, {})).toBeNull();
expect(
await target.getOrCreateWorker(1, { workerName: '' }),
).toBeNull();
});
it('mints a new row after the previous one was revoked', async () => {
// After revoke, the partial-unique index has no active row
// for (user_id, app_uid, worker_name), so the next call mints
// a fresh uuid instead of being short-circuited by the
// composite cache or by re-SELECTing the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.removeByUuid(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
expect(second.kind).toBe('worker');
});
it('revokeCascade invalidates the worker composite cache', async () => {
// Mirrors the app cascade-invalidation test. First call primes
// the worker composite cache; cascading revoke must drop it so
// the next call doesn't serve the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.revokeCascade(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
});
});
describe('error propagation (no silent swallow)', () => {
// INSERT-IGNORE used to mask every constraint violation, not just
// the partial-unique-index conflict the `getOrCreate*` paths rely
// on for idempotency. These tests pin the post-fix behavior: real
// schema errors throw, the unique-key conflict path still no-ops.
it('create() with an unsupported kind throws (CHECK constraint surfaces)', async () => {
// The `sessions.kind` column carries a CHECK constraint
// restricting it to the known set. A bogus kind must throw
// rather than be silently coerced or swallowed.
const user = await makeUser();
await expect(
target.create(user.id, { kind: 'not-a-real-kind' }),
).rejects.toThrow();
});
it('create() accepts kind="worker" (regression: post-migration the CHECK allows it)', async () => {
// Sanity check that migration 0056 actually relaxed the CHECK.
// Pre-migration this threw `CHECK constraint failed`.
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'worker',
meta: { worker: true, worker_name: 'direct' },
});
expect(session.kind).toBe('worker');
});
it('create() with a duplicate (user_id, app_uid) on kind="app" throws (UNIQUE surfaces)', async () => {
// `create()` runs with ignoreConflict=false, so the partial
// unique index `idx_sessions_user_app_active` fires loudly.
// This is the path that pre-fix `INSERT IGNORE` was silently
// collapsing — verify it now throws as expected.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
await target.create(user.id, { kind: 'app', app_uid: appUid });
await expect(
target.create(user.id, { kind: 'app', app_uid: appUid }),
).rejects.toThrow();
});
it('getOrCreateApp swallows the partial-unique conflict (idempotent get-or-create)', async () => {
// Counterpart to the previous test. The same INSERT path
// routed through getOrCreateApp (ignoreConflict=true) must
// still no-op the unique-key conflict and return the existing
// row instead of throwing.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const first = await target.getOrCreateApp(user.id, appUid);
await expect(
target.getOrCreateApp(user.id, appUid),
).resolves.toMatchObject({ uuid: first.uuid });
});
});
describe('touch slides expires_at per kind', () => {
it('extends expires_at on web sessions', async () => {
const user = await makeUser();