import { describe, test, expect, beforeEach, mock } from 'bun:test' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { mockConfigModule } from './helpers/mock-config' mock.module('../config', () => mockConfigModule({ pollTimeout: 1 })) import { Hono } from 'hono' import { storeReset, storeCreateSession, storeBindSession, storeListSessionsByOwnerUuid, } from '../store' import { initDatabase, resetDbSingleton } from '../db/sqlite' import { issueToken } from '../auth/token' import { SYSTEM_USER_ID } from '../auth/constants' import { resolveOwnedWebSessionId } from '../services/session' import webSessions from '../routes/web/sessions' function createApp() { const app = new Hono() app.route('/web', webSessions) return app } function freshDb() { try { resetDbSingleton() } catch { // ignore } const uniquePath = `/tmp/rcs-low-fixes-${randomUUID().replace(/-/g, '').slice(0, 8)}.db` return initDatabase(uniquePath) } // Auth helper: issues a session token (non-admin) for the given user. function userAuth(user: string): string { return `Bearer ${issueToken(user).token}` } // ============================================================================= // H1: resolveBindUserId isAdmin check // ============================================================================= describe('H1: resolveBindUserId requires isAdmin for X-User-Id', () => { let app: Hono beforeEach(() => { freshDb() storeReset() app = createApp() }) test('admin API key + X-User-Id creates session bound to X-User-Id user', async () => { const res = await app.request('/web/sessions', { method: 'POST', headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json', 'X-User-Id': 'admin-chosen-user', }, body: JSON.stringify({ title: 'Admin Bound' }), }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.id).toMatch(/^session_/) // Verify the session is bound to the X-User-Id user const sessions = storeListSessionsByOwnerUuid('admin-chosen-user') expect(sessions.length).toBeGreaterThanOrEqual(1) expect(sessions.some(s => s.id === body.id)).toBe(true) }) test('non-admin user token + X-User-Id ignores X-User-Id, binds to token user', async () => { const res = await app.request('/web/sessions', { method: 'POST', headers: { Authorization: userAuth('real-user'), 'Content-Type': 'application/json', 'X-User-Id': 'impersonated-user', }, body: JSON.stringify({ title: 'Impersonation Attempt' }), }) expect(res.status).toBe(200) const body = (await res.json()) as Record // Session should be bound to the real token user, NOT the impersonated user const realSessions = storeListSessionsByOwnerUuid('real-user') expect(realSessions.some(s => s.id === body.id)).toBe(true) const impersonated = storeListSessionsByOwnerUuid('impersonated-user') expect(impersonated.some(s => s.id === body.id)).toBe(false) }) test('admin API key without X-User-Id creates orphan session (not bound)', async () => { const res = await app.request('/web/sessions', { method: 'POST', headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Orphan Session' }), }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.id).toMatch(/^session_/) // No user should own this session const systemSessions = storeListSessionsByOwnerUuid(SYSTEM_USER_ID) expect(systemSessions.some(s => s.id === body.id)).toBe(false) }) test('resolveBindUserId source includes isAdmin check', () => { const source = readFileSync( resolve(import.meta.dir, '../routes/web/sessions.ts'), 'utf8', ) // The resolveBindUserId function should check isAdmin when userId is SYSTEM_USER_ID const fnMatch = source.match(/function\s+resolveBindUserId[\s\S]*?^}/m) expect(fnMatch).not.toBeNull() const fnBody = fnMatch![0] // Must reference isAdmin expect(fnBody).toContain('isAdmin') }) }) // ============================================================================= // M2: resolveOwnedWebSessionId removes redundant storeIsSessionOwner fallback // ============================================================================= describe('M2: resolveOwnedWebSessionId removes redundant fallback', () => { beforeEach(() => { freshDb() storeReset() }) test('returns session ID for owner (main logic works)', () => { const session = storeCreateSession({}) storeBindSession(session.id, 'owner-user') const result = resolveOwnedWebSessionId(session.id, 'owner-user') expect(result).toBe(session.id) }) test('returns null for non-owner', () => { const session = storeCreateSession({}) storeBindSession(session.id, 'other-user') const result = resolveOwnedWebSessionId(session.id, 'not-owner') expect(result).toBeNull() }) test('source does not use storeIsSessionOwner as fallback', () => { const source = readFileSync( resolve(import.meta.dir, '../services/session.ts'), 'utf8', ) // The resolveOwnedWebSessionId function should NOT have a catch block // that calls storeIsSessionOwner (redundant DB query) const fnMatch = source.match( /export\s+function\s+resolveOwnedWebSessionId[\s\S]*?^}/m, ) expect(fnMatch).not.toBeNull() const fnBody = fnMatch![0] // Should not reference storeIsSessionOwner in the catch block const catchMatch = fnBody.match(/catch\s*\{[\s\S]*?\}/) if (catchMatch) { expect(catchMatch[0]).not.toContain('storeIsSessionOwner') } }) test('session.ts does not import storeIsSessionOwner', () => { const source = readFileSync( resolve(import.meta.dir, '../services/session.ts'), 'utf8', ) // storeIsSessionOwner should be removed from imports expect(source).not.toMatch(/import\s*\{[^}]*storeIsSessionOwner[^}]*\}/) }) })