import { describe, test, expect, beforeEach, mock } from 'bun:test' import { randomUUID, createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { mockConfigModule } from './helpers/mock-config' // Mock config before any module imports mock.module('../config', () => mockConfigModule()) import { Hono } from 'hono' import { Database } from 'bun:sqlite' let migrateDatabase: ((db: Database) => void) | undefined let initDatabase: ((path?: string) => Database) | undefined let resetDbSingleton: (() => void) | undefined let getDb: (() => Database) | undefined try { const dbMod = await import('../db/sqlite') migrateDatabase = dbMod.migrateDatabase getDb = dbMod.getDb initDatabase = dbMod.initDatabase resetDbSingleton = dbMod.resetDbSingleton } catch { // Module not implemented yet } let shareRoutes: Hono | undefined try { const mod = await import('../routes/web/shares') shareRoutes = mod.default } catch { // Module not implemented yet } let teamRoutes: Hono | undefined try { const mod = await import('../routes/web/teams') teamRoutes = mod.default } catch { // Module not implemented yet } let authRoutes: Hono | undefined let resetRateLimits: (() => void) | undefined try { const mod = await import('../routes/web/auth-routes') authRoutes = mod.default resetRateLimits = mod._resetRateLimits } catch { // Module not implemented yet } let issueSessionToken: | (( userId: string, db: Database, ) => { accessToken: string; refreshToken: string }) | undefined let resolveSessionToken: | (( token: string, db: Database, kind?: 'access' | 'refresh', ) => string | null) | undefined try { const sessionMod = await import('../auth/session') issueSessionToken = sessionMod.issueSessionToken resolveSessionToken = sessionMod.resolveSessionToken } catch { // Module not implemented yet } // --------------------------------------------------------------------------- // Test app factory // --------------------------------------------------------------------------- function createApp(): Hono { const app = new Hono() if (authRoutes) app.route('/web/auth', authRoutes) if (shareRoutes) app.route('/web', shareRoutes) if (teamRoutes) app.route('/web', teamRoutes) return app } // --------------------------------------------------------------------------- // Database helpers // --------------------------------------------------------------------------- function freshDb(): Database { if (resetDbSingleton) { try { resetDbSingleton() } catch { // ignore cleanup errors } } if (initDatabase) { const uniquePath = resolve( tmpdir(), `rcs-s3-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`, ) return initDatabase(uniquePath) } const db = new Database(':memory:') db.exec('PRAGMA foreign_keys = ON') if (migrateDatabase) migrateDatabase(db) return db } // --------------------------------------------------------------------------- // User fixtures // --------------------------------------------------------------------------- interface TestUser { id: string username: string role: string accessToken: string refreshToken: string } function createUserDirectly( db: Database, opts: { username: string; role: string }, ): TestUser { const id = `usr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() const passwordHash = '$argon2id$v=19$m=65536,t=3,p=1$dGVzdHNhbHR0ZXN0$' + 'dGVzdGhhc2h0ZXN0aGFzaHRlc3RoYXNodGVzdGhhc2g' try { db.query( `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ($id, $username, $hash, $role, $now)`, ).run({ $id: id, $username: opts.username, $hash: passwordHash, $role: opts.role, $now: now, }) } catch { // users table may not exist } return { id, username: opts.username, role: opts.role, accessToken: '', refreshToken: '', } } function issueTokenForUser( db: Database, userId: string, ): { accessToken: string; refreshToken: string } { if (issueSessionToken) { return issueSessionToken(userId, db) } // Fallback: create tokens manually const accessToken = `rct_${randomUUID().replace(/-/g, '')}` const refreshToken = `rct_${randomUUID().replace(/-/g, '')}` const accessHash = createHash('sha256').update(accessToken).digest('hex') const refreshHash = createHash('sha256').update(refreshToken).digest('hex') const now = new Date().toISOString() const expAccess = new Date(Date.now() + 3600_000).toISOString() const expRefresh = new Date(Date.now() + 30 * 86400_000).toISOString() try { db.query( `INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at) VALUES ($hash, $userId, 'access', $exp, $now)`, ).run({ $hash: accessHash, $userId: userId, $exp: expAccess, $now: now, }) db.query( `INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at) VALUES ($hash, $userId, 'refresh', $exp, $now)`, ).run({ $hash: refreshHash, $userId: userId, $exp: expRefresh, $now: now, }) } catch { // session_tokens may not exist } return { accessToken, refreshToken } } async function createAndLoginUser( db: Database, opts: { username: string; role: string }, ): Promise { const user = createUserDirectly(db, opts) const tokens = issueTokenForUser(db, user.id) user.accessToken = tokens.accessToken user.refreshToken = tokens.refreshToken return user } function authHeader(token: string): Record { return { Authorization: `Bearer ${token}` } } // --------------------------------------------------------------------------- // Session & Team fixtures // --------------------------------------------------------------------------- function createSessionFixture( db: Database, opts: { id?: string ownerType?: string ownerId?: string }, ): string { const id = opts.id || `ses_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() try { db.query( `INSERT INTO sessions (id, visibility, created_at, updated_at) VALUES ($id, 'private', $now, $now)`, ).run({ $id: id, $now: now }) } catch { try { db.query( `INSERT INTO sessions (id, created_at, updated_at) VALUES ($id, $now, $now)`, ).run({ $id: id, $now: now }) } catch { // sessions table may not exist } } if (opts.ownerType && opts.ownerId) { try { db.query( `INSERT INTO session_owners (session_id, owner_type, owner_id) VALUES ($sid, $type, $oid)`, ).run({ $sid: id, $type: opts.ownerType, $oid: opts.ownerId }) } catch { // session_owners may not exist } } return id } function createTeamFixture( db: Database, opts: { slug: string; createdBy: string }, ): string { const id = `team_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() try { db.query( `INSERT INTO teams (id, name, slug, created_by, created_at, updated_at) VALUES ($id, $name, $slug, $by, $now, $now)`, ).run({ $id: id, $name: opts.slug, $slug: opts.slug, $by: opts.createdBy, $now: now, }) db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, 'owner', $now, $by)`, ).run({ $teamId: id, $userId: opts.createdBy, $by: opts.createdBy, $now: now, }) } catch { // tables may not exist } return id } function addTeamMember( db: Database, teamId: string, userId: string, role: string, addedBy: string, ): void { const now = new Date().toISOString() try { db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, $role, $now, $by)`, ).run({ $teamId: teamId, $userId: userId, $role: role, $by: addedBy, $now: now, }) } catch { // table may not exist } } function addSessionShareDirectly( db: Database, opts: { id?: string sessionId: string grantedToUser?: string grantedToTeam?: string permission?: string grantedBy: string expiresAt?: string }, ): string { const id = opts.id || `shr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() try { db.query( `INSERT INTO session_shares (id, session_id, granted_to_user, granted_to_team, permission, expires_at, granted_by, granted_at) VALUES ($id, $sid, $gtu, $gtt, $perm, $exp, $by, $now)`, ).run({ $id: id, $sid: opts.sessionId, $gtu: opts.grantedToUser || null, $gtt: opts.grantedToTeam || null, $perm: opts.permission || 'read', $exp: opts.expiresAt || null, $by: opts.grantedBy, $now: now, }) } catch { // session_shares may not exist } return id } // =========================================================================== // TASK 1: Share token grantee check // Non-grantee users should get 403 when accessing share token endpoint // =========================================================================== describe('security: share token grantee check (Task 1)', () => { let app: Hono let db: Database let owner: TestUser let grantee: TestUser let stranger: TestUser let sessionId: string beforeEach(async () => { db = freshDb() app = createApp() owner = await createAndLoginUser(db, { username: 'shareowner', role: 'member', }) grantee = await createAndLoginUser(db, { username: 'sharegrantee', role: 'member', }) stranger = await createAndLoginUser(db, { username: 'sharestranger', role: 'member', }) sessionId = createSessionFixture(db, { ownerType: 'user', ownerId: owner.id, }) }) test('grantee (user) can access share token → 200', async () => { const shareId = addSessionShareDirectly(db, { sessionId, grantedToUser: grantee.id, permission: 'read', grantedBy: owner.id, }) const res = await app.request(`/web/sessions/s/${shareId}`, { method: 'GET', headers: authHeader(grantee.accessToken), }) expect(res.status).toBe(200) }) test('non-grantee user → 403', async () => { const shareId = addSessionShareDirectly(db, { sessionId, grantedToUser: grantee.id, permission: 'read', grantedBy: owner.id, }) const res = await app.request(`/web/sessions/s/${shareId}`, { method: 'GET', headers: authHeader(stranger.accessToken), }) expect(res.status).toBe(403) }) test('team share: team member can access → 200', async () => { const teamId = createTeamFixture(db, { slug: 'shareteam', createdBy: owner.id, }) addTeamMember(db, teamId, grantee.id, 'member', owner.id) const shareId = addSessionShareDirectly(db, { sessionId, grantedToTeam: teamId, permission: 'read', grantedBy: owner.id, }) const res = await app.request(`/web/sessions/s/${shareId}`, { method: 'GET', headers: authHeader(grantee.accessToken), }) expect(res.status).toBe(200) }) test('team share: non-team-member → 403', async () => { const teamId = createTeamFixture(db, { slug: 'shareteam2', createdBy: owner.id, }) // stranger is NOT added to the team const shareId = addSessionShareDirectly(db, { sessionId, grantedToTeam: teamId, permission: 'read', grantedBy: owner.id, }) const res = await app.request(`/web/sessions/s/${shareId}`, { method: 'GET', headers: authHeader(stranger.accessToken), }) expect(res.status).toBe(403) }) }) // =========================================================================== // TASK 2: Transfer target team check // Caller must be owner/admin of target team // =========================================================================== describe('security: transfer target team check (Task 2)', () => { let app: Hono let db: Database let sourceOwner: TestUser let sourceAdmin: TestUser let sourceMember: TestUser let targetOwner: TestUser let outsider: TestUser let sourceTeamId: string let targetTeamId: string let envId: string beforeEach(async () => { db = freshDb() app = createApp() sourceOwner = await createAndLoginUser(db, { username: 'srcowner', role: 'member', }) sourceAdmin = await createAndLoginUser(db, { username: 'srcadmin', role: 'member', }) sourceMember = await createAndLoginUser(db, { username: 'srcmember', role: 'member', }) targetOwner = await createAndLoginUser(db, { username: 'tgtowner', role: 'member', }) outsider = await createAndLoginUser(db, { username: 'outsider', role: 'member', }) sourceTeamId = createTeamFixture(db, { slug: 'srcteam', createdBy: sourceOwner.id, }) addTeamMember(db, sourceTeamId, sourceAdmin.id, 'admin', sourceOwner.id) addTeamMember(db, sourceTeamId, sourceMember.id, 'member', sourceOwner.id) targetTeamId = createTeamFixture(db, { slug: 'tgtteam', createdBy: targetOwner.id, }) // outsider is NOT in the target team envId = `env_${randomUUID().replace(/-/g, '')}` try { db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: sourceTeamId, $envId: envId }) } catch { // Table may not exist } }) test('source owner transfers to team where they are also owner → 200', async () => { // Add sourceOwner as owner of target team too addTeamMember(db, targetTeamId, sourceOwner.id, 'owner', targetOwner.id) const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(sourceOwner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeamId }), }) expect(res.status).toBe(200) }) test('source owner transfers to team where they are NOT a member → 403', async () => { const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(sourceOwner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeamId }), }) expect(res.status).toBe(403) }) test('source admin transfers to team where they are only member → 403', async () => { // Add sourceAdmin as a regular member of target team addTeamMember(db, targetTeamId, sourceAdmin.id, 'member', targetOwner.id) const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(sourceAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeamId }), }) expect(res.status).toBe(403) }) test('source admin transfers to team where they are admin → 200', async () => { addTeamMember(db, targetTeamId, sourceAdmin.id, 'admin', targetOwner.id) const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(sourceAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeamId }), }) expect(res.status).toBe(200) }) }) // =========================================================================== // TASK 3: /logout revokes refresh token // After logout, old refresh token should not work // =========================================================================== describe('security: logout revokes refresh token (Task 3)', () => { let app: Hono let db: Database let user: TestUser beforeEach(async () => { db = freshDb() app = createApp() if (resetRateLimits) resetRateLimits() user = await createAndLoginUser(db, { username: 'logoutuser', role: 'member', }) }) test('logout revokes refresh token — old refresh token cannot be used', async () => { // Verify the refresh token works before logout if (!resolveSessionToken) return const userIdBefore = resolveSessionToken(user.refreshToken, db, 'refresh') expect(userIdBefore).toBe(user.id) // Logout via POST with access token in Authorization header // and refresh token in cookie const res = await app.request('/web/auth/logout', { method: 'POST', headers: { ...authHeader(user.accessToken), Cookie: `rcs_refresh=${user.refreshToken}`, }, }) expect(res.status).toBe(200) // Old refresh token should be revoked const userIdAfter = resolveSessionToken(user.refreshToken, db, 'refresh') expect(userIdAfter).toBeNull() }) test('logout clears rcs_refresh cookie', async () => { const res = await app.request('/web/auth/logout', { method: 'POST', headers: { ...authHeader(user.accessToken), Cookie: `rcs_refresh=${user.refreshToken}`, }, }) expect(res.status).toBe(200) const setCookies = res.headers.getSetCookie() const refreshClear = setCookies.find(c => c.startsWith('rcs_refresh=')) expect(refreshClear).toBeDefined() expect(refreshClear).toContain('Max-Age=0') }) }) // =========================================================================== // TASK 4: cookie Secure flag with X-Forwarded-Proto // When behind reverse proxy with X-Forwarded-Proto: https, cookies // should have Secure flag // =========================================================================== describe('security: cookie Secure flag with X-Forwarded-Proto (Task 4)', () => { let app: Hono let db: Database beforeEach(async () => { db = freshDb() app = createApp() if (resetRateLimits) resetRateLimits() }) test('login with X-Forwarded-Proto: https → cookies have Secure flag', async () => { // Create a user via direct DB insert const user = createUserDirectly(db, { username: 'secureuser', role: 'member', }) // Set a known password const { hashPasswordAsync } = await import('../auth/password') const hash = await hashPasswordAsync('testpass123') db.query('UPDATE users SET password_hash = $hash WHERE id = $id').run({ $hash: hash, $id: user.id, }) const res = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-Proto': 'https', }, body: JSON.stringify({ username: 'secureuser', password: 'testpass123', }), }) if (res.status !== 200) return // Login route may not be implemented const setCookies = res.headers.getSetCookie() const accessCookie = setCookies.find(c => c.startsWith('rcs_access=')) if (accessCookie) { expect(accessCookie).toContain('Secure') } }) test('login without HTTPS (direct http) → cookies lack Secure flag', async () => { const user = createUserDirectly(db, { username: 'insecureuser', role: 'member', }) const { hashPasswordAsync } = await import('../auth/password') const hash = await hashPasswordAsync('testpass456') db.query('UPDATE users SET password_hash = $hash WHERE id = $id').run({ $hash: hash, $id: user.id, }) const res = await app.request('http://localhost:3000/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: 'insecureuser', password: 'testpass456', }), }) if (res.status !== 200) return const setCookies = res.headers.getSetCookie() const accessCookie = setCookies.find(c => c.startsWith('rcs_access=')) if (accessCookie) { expect(accessCookie).not.toContain('Secure') } }) }) // =========================================================================== // TASK 5: storeCreateSession visibility parameter // =========================================================================== describe('security: storeCreateSession visibility parameter (Task 5)', () => { test('storeCreateSession accepts visibility parameter', async () => { let storeCreateSession: | ((req: Record) => Record) | undefined try { const mod = await import('../store') storeCreateSession = mod.storeCreateSession as unknown as ( req: Record, ) => Record } catch { // not implemented } if (!storeCreateSession) return // Should not throw when visibility is provided const record = storeCreateSession({ title: 'test-visibility', source: 'test', visibility: 'team', }) expect(record).toBeDefined() }) test('storeCreateSession defaults to private when visibility not provided', async () => { let storeCreateSession: | ((req: Record) => Record) | undefined try { const mod = await import('../store') storeCreateSession = mod.storeCreateSession as unknown as ( req: Record, ) => Record } catch { // not implemented } if (!storeCreateSession) return const record = storeCreateSession({ title: 'test-default-vis', source: 'test', }) expect(record).toBeDefined() }) }) // =========================================================================== // TASK 6: ContextVariableMap cleanup // Verify that username and uuid are removed from ContextVariableMap // =========================================================================== describe('security: ContextVariableMap cleanup (Task 6)', () => { test('ContextVariableMap should not have username or uuid fields', async () => { // This is a compile-time check — if the types are correct, this test // simply verifies the module loads without error. // The actual verification is done by typecheck. try { await import('../types/api') // If we get here, the module loaded successfully expect(true).toBe(true) } catch { // Module may not exist expect(true).toBe(true) } }) })