import { describe, test, expect, beforeEach, mock } from 'bun:test' import { createHash, randomUUID } 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' // Dynamic imports — Phase 2 modules may not exist yet (TDD) 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 authRoutes: Hono | undefined try { const mod = await import('../routes/web/auth-routes') authRoutes = 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 environmentRoutes: Hono | undefined try { const mod = await import('../routes/web/environments') environmentRoutes = mod.default } catch { // Module not implemented yet — fallback to Phase 1 module try { const mod = await import('../routes/web/environments') environmentRoutes = mod.default } catch { // Still not available } } let v1EnvironmentRoutes: Hono | undefined try { const mod = await import('../routes/v1/environments') v1EnvironmentRoutes = mod.default } catch { // Module not available } // --------------------------------------------------------------------------- // Test app factory // --------------------------------------------------------------------------- function createApp(): Hono { const app = new Hono() if (authRoutes) app.route('/web/auth', authRoutes) if (teamRoutes) app.route('/web', teamRoutes) if (environmentRoutes) app.route('/web', environmentRoutes) if (v1EnvironmentRoutes) app.route('/v1/environments', v1EnvironmentRoutes) return app } // --------------------------------------------------------------------------- // Database helpers // --------------------------------------------------------------------------- function freshDb(): Database { // Close and clean up any previous singleton if (resetDbSingleton) { try { resetDbSingleton() } catch { // ignore cleanup errors } } // Use a unique temp file per test to avoid WAL lock contention. // The route handlers internally call getDb() (the singleton), // so we must use initDatabase() to set up the singleton. if (initDatabase) { const uniquePath = resolve( tmpdir(), `rcs-test-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`, ) return initDatabase(uniquePath) } // Fallback for when db module isn't available const db = new Database(':memory:') db.exec('PRAGMA foreign_keys = ON') if (migrateDatabase) migrateDatabase(db) return db } function clearAllTables(db: Database): void { db.exec('PRAGMA foreign_keys = OFF') const tables = [ 'session_shares', 'session_owners', 'team_environments', 'team_members', 'teams', 'invitations', 'session_tokens', 'users', ] for (const t of tables) { try { db.exec(`DELETE FROM ${t}`) } catch { // table may not exist } } db.exec('PRAGMA foreign_keys = ON') } // --------------------------------------------------------------------------- // User fixtures // --------------------------------------------------------------------------- interface TestUser { id: string username: string role: string accessToken: string } function createUserDirectly( db: Database, opts: { username: string; role: string }, ): TestUser { const id = `usr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() // Use a pre-computed hash for speed (argon2id is slow in tests). // We insert directly — the auth-routes /login verifies via verifyPassword. const passwordHash = '$argon2id$v=19$m=65536,t=3,p=1$dGVzdHNhbHR0ZXN0$' + 'dGVzdGhhc2h0ZXN0aGFzaHRlc3RoYXNodGVzdGhhc2g' 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, }) return { id, username: opts.username, role: opts.role, accessToken: '' } } function issueTokenForUser(db: Database, userId: string): string { // Import dynamically to avoid hard dependency try { // Use the session module to issue tokens const { issueSessionToken } = require('../auth/session') as { issueSessionToken: ( userId: string, db: Database, ) => { accessToken: string; refreshToken: string } } const { accessToken } = issueSessionToken(userId, db) return accessToken } catch { // Fallback: insert a token manually const token = `rct_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(token).digest('hex') const now = new Date().toISOString() const expiresAt = new Date(Date.now() + 3600_000).toISOString() db.query( `INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at) VALUES ($hash, $userId, 'access', $exp, $now)`, ).run({ $hash: tokenHash, $userId: userId, $exp: expiresAt, $now: now }) return token } } async function setupAdmin(app: Hono): Promise { const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) let body: Record = {} try { body = (await res.json()) as Record } catch { // Response may not be valid JSON } const db = getDb!() const adminRow = db .query("SELECT id FROM users WHERE username = 'admin'") .get() as { id: string } | null const adminId = adminRow?.id || `usr_admin_${randomUUID().replace(/-/g, '').slice(0, 8)}` // Ensure admin has a valid access token const accessToken = body.accessToken || body.access_token || issueTokenForUser(db, adminId) return { id: adminId, username: 'admin', role: 'admin', accessToken, } } async function createAndLoginUser( db: Database, app: Hono, opts: { username: string; role: string }, ): Promise { const user = createUserDirectly(db, opts) user.accessToken = issueTokenForUser(db, user.id) return user } // --------------------------------------------------------------------------- // Team fixtures // --------------------------------------------------------------------------- function createTeamDirectly( db: Database, opts: { name: string; slug: string; createdBy: string }, ): { id: string; name: string; slug: string } { const id = `team_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() db.query( `INSERT INTO teams (id, name, slug, created_by, created_at, updated_at) VALUES ($id, $name, $slug, $createdBy, $now, $now)`, ).run({ $id: id, $name: opts.name, $slug: opts.slug, $createdBy: opts.createdBy, $now: now, }) // Add creator as owner db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, 'owner', $now, $addedBy)`, ).run({ $teamId: id, $userId: opts.createdBy, $addedBy: opts.createdBy, $now: now, }) return { id, name: opts.name, slug: opts.slug } } function addTeamMember( db: Database, opts: { teamId: string; userId: string; role: string; addedBy: string }, ): void { const now = new Date().toISOString() db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, $role, $now, $addedBy)`, ).run({ $teamId: opts.teamId, $userId: opts.userId, $role: opts.role, $addedBy: opts.addedBy, $now: now, }) } function authHeader(token: string): Record { return { Authorization: `Bearer ${token}` } } // =========================================================================== // TESTS // =========================================================================== describe('POST /web/teams', () => { let app: Hono let db: Database let admin: TestUser let member: TestUser let guest: TestUser beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) guest = await createAndLoginUser(db, app, { username: 'guest1', role: 'guest', }) }) test('system admin creates team → 201 + team info', async () => { const res = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Engineering', slug: 'engineering' }), }) expect(res.status).toBe(201) const body = (await res.json()) as Record expect(body.name).toBe('Engineering') expect(body.slug).toBe('engineering') expect(body.id).toBeDefined() }) test('member user creates team → 201 + auto-becomes owner', async () => { const res = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(member.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'My Team', slug: 'my-team' }), }) expect(res.status).toBe(201) const body = (await res.json()) as Record expect(body.id).toBeDefined() // Verify the member is an owner in team_members const membership = db .query( `SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId`, ) .get({ $teamId: body.id as string, $userId: member.id }) as { role: string } | null expect(membership).not.toBeNull() expect(membership?.role).toBe('owner') }) test('guest user creates team → 403', async () => { const res = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(guest.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Guest Team', slug: 'guest-team' }), }) expect(res.status).toBe(403) }) test('unauthenticated → 401', async () => { const res = await app.request('/web/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Anon Team', slug: 'anon-team' }), }) expect(res.status).toBe(401) }) test('duplicate slug → 409', async () => { // Create first team await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'First', slug: 'dupe-slug' }), }) // Try creating with same slug const res = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Second', slug: 'dupe-slug' }), }) expect(res.status).toBe(409) }) test('invalid slug formats → 400', async () => { const invalidSlugs = [ 'UpperCase', // uppercase not allowed 'has spaces', // spaces not allowed 'sp@cial!', // special characters 'ab', // too short (<3) 'a'.repeat(33), // too long (>32) ] for (const slug of invalidSlugs) { const res = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Test', slug }), }) expect(res.status).toBe(400) } }) test('missing name or slug → 400', async () => { // Missing slug const res1 = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Only Name' }), }) expect(res1.status).toBe(400) // Missing name const res2 = await app.request('/web/teams', { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ slug: 'only-slug' }), }) expect(res2.status).toBe(400) }) }) describe('GET /web/teams', () => { let app: Hono let db: Database let admin: TestUser let member: TestUser let guest: TestUser beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) guest = await createAndLoginUser(db, app, { username: 'guest1', role: 'guest', }) // Create two teams: member belongs to one const team1 = createTeamDirectly(db, { name: 'Team Alpha', slug: 'alpha', createdBy: admin.id, }) const team2 = createTeamDirectly(db, { name: 'Team Beta', slug: 'beta', createdBy: member.id, }) // member is owner of team2 (auto-added by createTeamDirectly) // member is NOT in team1 }) test('system admin → returns all teams', async () => { const res = await app.request('/web/teams', { headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Array> expect(body.length).toBeGreaterThanOrEqual(2) }) test('member user → returns only teams they belong to', async () => { const res = await app.request('/web/teams', { headers: authHeader(member.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Array> // member is in team2 (beta) but not team1 (alpha) expect(body.length).toBe(1) expect(body[0].slug).toBe('beta') }) test('guest → returns empty list', async () => { const res = await app.request('/web/teams', { headers: authHeader(guest.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Array> expect(body.length).toBe(0) }) test('unauthenticated → 401', async () => { const res = await app.request('/web/teams') expect(res.status).toBe(401) }) }) describe('GET /web/teams/:id', () => { let app: Hono let db: Database let admin: TestUser let member: TestUser let outsider: TestUser let team: { id: string; name: string; slug: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) outsider = await createAndLoginUser(db, app, { username: 'outsider', role: 'member', }) team = createTeamDirectly(db, { name: 'My Team', slug: 'my-team', createdBy: member.id, }) }) test('team member → 200', async () => { const res = await app.request(`/web/teams/${team.id}`, { headers: authHeader(member.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.id).toBe(team.id) expect(body.name).toBe('My Team') }) test('non-team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}`, { headers: authHeader(outsider.accessToken), }) expect(res.status).toBe(403) }) test('system admin → 200 (any team)', async () => { const res = await app.request(`/web/teams/${team.id}`, { headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) test('non-existent team → 404', async () => { const res = await app.request('/web/teams/team_nonexistent', { headers: authHeader(member.accessToken), }) expect(res.status).toBe(404) }) }) describe('PATCH /web/teams/:id', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Orig Name', slug: 'orig-slug', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner → 200', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'PATCH', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Updated Name' }), }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.name).toBe('Updated Name') }) test('team admin → 403 (only owner can modify team info)', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'PATCH', headers: { ...authHeader(teamAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Admin Change' }), }) expect(res.status).toBe(403) }) test('team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'PATCH', headers: { ...authHeader(teamMember.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Member Change' }), }) expect(res.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'PATCH', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Admin Override' }), }) expect(res.status).toBe(200) }) test('modify slug to existing → 409', async () => { // Create another team with slug "taken-slug" createTeamDirectly(db, { name: 'Other', slug: 'taken-slug', createdBy: admin.id, }) const res = await app.request(`/web/teams/${team.id}`, { method: 'PATCH', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ slug: 'taken-slug' }), }) expect(res.status).toBe(409) }) }) describe('DELETE /web/teams/:id', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Delete Me', slug: 'delete-me', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner → 200 + sets deleted_at', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) expect(res.status).toBe(200) // Verify soft delete in DB const row = db.query('SELECT deleted_at FROM teams WHERE id = $id').get({ $id: team.id, }) as { deleted_at: string | null } | null expect(row?.deleted_at).not.toBeNull() }) test('team admin → 403', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(teamAdmin.accessToken), }) expect(res.status).toBe(403) }) test('team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(teamMember.accessToken), }) expect(res.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) test('deleted team → GET /web/teams/:id returns 404', async () => { await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) const res = await app.request(`/web/teams/${team.id}`, { headers: authHeader(owner.accessToken), }) expect(res.status).toBe(404) }) test('deleted team → not in GET /web/teams list', async () => { await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) const res = await app.request('/web/teams', { headers: authHeader(owner.accessToken), }) const body = (await res.json()) as Array> const found = body.find(t => t.id === team.id) expect(found).toBeUndefined() }) test('team with un-transferred environments → 400', async () => { // Create an environment linked to this team const envId = `env_${randomUUID().replace(/-/g, '')}` try { db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: team.id, $envId: envId }) } catch { // Table may not exist yet — test will still fail as expected } const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) // Should be 400 because there are environments that need to be transferred first expect(res.status).toBe(400) }) }) // =========================================================================== // Team member management // =========================================================================== describe('GET /web/teams/:id/members', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let member: TestUser let outsider: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) outsider = await createAndLoginUser(db, app, { username: 'outsider', role: 'member', }) team = createTeamDirectly(db, { name: 'Members Team', slug: 'members-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: member.id, role: 'member', addedBy: owner.id, }) }) test('team member → 200 + member list', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { headers: authHeader(member.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Array> expect(body.length).toBeGreaterThanOrEqual(2) const userIds = body.map(m => m.user_id || m.userId) expect(userIds).toContain(owner.id) expect(userIds).toContain(member.id) }) test('non-team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { headers: authHeader(outsider.accessToken), }) expect(res.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) }) describe('POST /web/teams/:id/members', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let newUser: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) newUser = await createAndLoginUser(db, app, { username: 'newbie', role: 'member', }) team = createTeamDirectly(db, { name: 'Add Team', slug: 'add-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner/admin → 201', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: newUser.id, role: 'member' }), }) expect(res.status).toBe(201) }) test('team member → 403', async () => { const anotherUser = await createAndLoginUser(db, app, { username: 'another', role: 'member', }) const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(teamMember.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: anotherUser.id, role: 'member' }), }) expect(res.status).toBe(403) }) test('system admin → 201', async () => { const anotherUser = await createAndLoginUser(db, app, { username: 'admin-add', role: 'member', }) const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: anotherUser.id, role: 'member' }), }) expect(res.status).toBe(201) }) test('add already-existing member → 409', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: teamMember.id, role: 'member' }), }) expect(res.status).toBe(409) }) test('add non-existent user → 404', async () => { const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: 'usr_nonexistent', role: 'member' }), }) expect(res.status).toBe(404) }) }) describe('PATCH /web/teams/:id/members/:userId', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Role Team', slug: 'role-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner → 200 (can change any member role)', async () => { const res = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'PATCH', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'admin' }), }, ) expect(res.status).toBe(200) }) test('team admin → can change member but not owner/admin → 403 for admin', async () => { // Admin can change member const res1 = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'PATCH', headers: { ...authHeader(teamAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'admin' }), }, ) // This might be allowed or not depending on implementation — admin changing member role // The plan says: "team admin → can change member, cannot change owner/admin" // Changing a member to admin should be OK expect([200, 403]).toContain(res1.status) // Admin cannot change owner role const res2 = await app.request( `/web/teams/${team.id}/members/${owner.id}`, { method: 'PATCH', headers: { ...authHeader(teamAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'member' }), }, ) expect(res2.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'PATCH', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'admin' }), }, ) expect(res.status).toBe(200) }) test('owner self-demotes to member → 400 (avoid unmanaged team)', async () => { const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, { method: 'PATCH', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'member' }), }) expect(res.status).toBe(400) }) test('last owner demotes → 400', async () => { // owner is the only owner. Try to demote them. const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, { method: 'PATCH', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'admin' }), }) // Demoting from owner to admin means no more owner — should be 400 expect(res.status).toBe(400) }) }) describe('DELETE /web/teams/:id/members/:userId', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Remove Team', slug: 'remove-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner/admin → 200 (cannot remove self)', async () => { // Owner removes member const res = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }, ) expect(res.status).toBe(200) }) test('team member → can only remove self', async () => { // Member removes themselves const res1 = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'DELETE', headers: authHeader(teamMember.accessToken), }, ) expect(res1.status).toBe(200) // Member tries to remove another member (should fail) const anotherMember = await createAndLoginUser(db, app, { username: 'another', role: 'member', }) addTeamMember(db, { teamId: team.id, userId: anotherMember.id, role: 'member', addedBy: owner.id, }) // teamMember already removed, but let's test with a fresh member const freshMember = await createAndLoginUser(db, app, { username: 'fresh', role: 'member', }) addTeamMember(db, { teamId: team.id, userId: freshMember.id, role: 'member', addedBy: owner.id, }) const res2 = await app.request( `/web/teams/${team.id}/members/${anotherMember.id}`, { method: 'DELETE', headers: authHeader(freshMember.accessToken), }, ) expect(res2.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request( `/web/teams/${team.id}/members/${teamMember.id}`, { method: 'DELETE', headers: authHeader(admin.accessToken), }, ) expect(res.status).toBe(200) }) test('remove last owner → 400', async () => { // owner is the only owner; try to remove them const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) expect(res.status).toBe(400) }) }) // =========================================================================== // Team environment ownership // =========================================================================== describe('POST /v1/environments/bridge — register with team', () => { let app: Hono let db: Database let admin: TestUser let member: TestUser let outsider: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) outsider = await createAndLoginUser(db, app, { username: 'outsider', role: 'member', }) team = createTeamDirectly(db, { name: 'Env Team', slug: 'env-team', createdBy: member.id, }) }) test('with X-Team-Id header → environment belongs to that team', async () => { const res = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...authHeader(member.accessToken), 'X-Team-Id': team.id, 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: 'test-secret', machineName: 'dev-machine', directory: '/home/user/project', }), }) // This endpoint may be in a different route; accept 200/201 expect([200, 201]).toContain(res.status) }) test('without X-Team-Id → belongs to user personal space', async () => { const res = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...authHeader(member.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: 'test-secret', machineName: 'dev-machine', directory: '/home/user/project', }), }) expect([200, 201]).toContain(res.status) }) test('invalid team_id → 400', async () => { const res = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...authHeader(member.accessToken), 'X-Team-Id': 'team_nonexistent', 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: 'test-secret', machineName: 'dev-machine', }), }) expect(res.status).toBe(400) }) test('user not a team member → 403', async () => { const res = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...authHeader(outsider.accessToken), 'X-Team-Id': team.id, 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: 'test-secret', machineName: 'dev-machine', }), }) expect(res.status).toBe(403) }) }) describe('GET /web/teams/:id/environments', () => { let app: Hono let db: Database let admin: TestUser let member: TestUser let outsider: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) outsider = await createAndLoginUser(db, app, { username: 'outsider', role: 'member', }) team = createTeamDirectly(db, { name: 'Env Team', slug: 'env-team', createdBy: member.id, }) // Add an environment to the team const envId = `env_${randomUUID().replace(/-/g, '')}` try { db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: team.id, $envId: envId }) } catch { // Table may not exist } }) test('team member → 200 + environment list', async () => { const res = await app.request(`/web/teams/${team.id}/environments`, { headers: authHeader(member.accessToken), }) expect(res.status).toBe(200) const body = (await res.json()) as Array> expect(body.length).toBeGreaterThanOrEqual(1) }) test('non-team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}/environments`, { headers: authHeader(outsider.accessToken), }) expect(res.status).toBe(403) }) }) describe('POST /web/environments/:id/transfer', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let sourceTeam: { id: string } let targetTeam: { id: string } let envId: string beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) sourceTeam = createTeamDirectly(db, { name: 'Source', slug: 'source', createdBy: owner.id, }) targetTeam = createTeamDirectly(db, { name: 'Target', slug: 'target', createdBy: owner.id, }) addTeamMember(db, { teamId: sourceTeam.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: sourceTeam.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) envId = `env_${randomUUID().replace(/-/g, '')}` try { db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: sourceTeam.id, $envId: envId }) } catch { // Table may not exist } }) test('team owner → 200', async () => { const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) expect(res.status).toBe(200) }) test('team admin → 200', async () => { // teamAdmin must also be admin/owner in the target team (security fix) addTeamMember(db, { teamId: targetTeam.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(teamAdmin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) expect(res.status).toBe(200) }) test('team member → 403', async () => { const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(teamMember.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) expect(res.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) expect(res.status).toBe(200) }) test('transfer to non-existent team → 404', async () => { const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: 'team_nonexistent' }), }) expect(res.status).toBe(404) }) test('after transfer → source team cannot see the environment', async () => { await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) const res = await app.request(`/web/teams/${sourceTeam.id}/environments`, { headers: authHeader(owner.accessToken), }) if (res.status === 200) { const body = (await res.json()) as Array> const found = body.find(e => (e.id || e.environment_id) === envId) expect(found).toBeUndefined() } }) test('after transfer → target team can see the environment', async () => { await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) const res = await app.request(`/web/teams/${targetTeam.id}/environments`, { headers: authHeader(owner.accessToken), }) if (res.status === 200) { const body = (await res.json()) as Array> const found = body.find(e => (e.id || e.environment_id) === envId) expect(found).toBeDefined() } }) test('after transfer → historical sessions under the environment transfer too', async () => { // This is a semantic test — the implementation should ensure session_owners // records are updated to reflect the new team ownership. // We verify by checking that sessions in the transferred environment // are visible to members of the target team. const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }) expect([200, 201]).toContain(res.status) // Detailed session visibility is tested in session-visibility.test.ts }) test('personal env (no source team) → target team admin can claim → 200', async () => { // Use a fresh env id that's not in any team const personalEnvId = `env_${randomUUID().replace(/-/g, '')}` const res = await app.request( `/web/environments/${personalEnvId}/transfer`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }, ) expect(res.status).toBe(200) // Verify the link exists const linkRow = db .query( 'SELECT team_id FROM team_environments WHERE environment_id = $envId', ) .get({ $envId: personalEnvId }) as { team_id: string } | null expect(linkRow?.team_id).toBe(targetTeam.id) }) test('personal env → target team member (non-admin) → 403', async () => { const personalEnvId = `env_${randomUUID().replace(/-/g, '')}` // teamMember is only a 'member' of sourceTeam, not targetTeam at all const res = await app.request( `/web/environments/${personalEnvId}/transfer`, { method: 'POST', headers: { ...authHeader(teamMember.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: targetTeam.id }), }, ) expect(res.status).toBe(403) }) }) describe('DELETE /web/teams/:id/environments/:envId', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamMember: TestUser let team: { id: string } let envId: string beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner2', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember2', role: 'member', }) team = createTeamDirectly(db, { name: 'Team2', slug: 'team2', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) envId = `env_${randomUUID().replace(/-/g, '')}` db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: team.id, $envId: envId }) }) test('team owner → 200, link removed', async () => { const res = await app.request( `/web/teams/${team.id}/environments/${envId}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }, ) expect(res.status).toBe(200) const row = db .query( 'SELECT team_id FROM team_environments WHERE environment_id = $envId', ) .get({ $envId: envId }) expect(row).toBeNull() }) test('team member (non-admin) → 403', async () => { const res = await app.request( `/web/teams/${team.id}/environments/${envId}`, { method: 'DELETE', headers: authHeader(teamMember.accessToken), }, ) expect(res.status).toBe(403) }) test('system admin → 200', async () => { const res = await app.request( `/web/teams/${team.id}/environments/${envId}`, { method: 'DELETE', headers: authHeader(admin.accessToken), }, ) expect(res.status).toBe(200) }) }) // =========================================================================== // Team invitations (Phase 2) // =========================================================================== describe('POST /web/teams/:id/invitations', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamAdmin: TestUser let teamMember: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamAdmin = await createAndLoginUser(db, app, { username: 'tadmin', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Invite Team', slug: 'invite-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamAdmin.id, role: 'admin', addedBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) }) test('team owner/admin → 201 + returns invitation token', async () => { const res = await app.request(`/web/teams/${team.id}/invitations`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'member' }), }) expect(res.status).toBe(201) const body = (await res.json()) as Record expect(body.token || body.inviteToken || body.invitationToken).toBeDefined() }) test('team member → 403', async () => { const res = await app.request(`/web/teams/${team.id}/invitations`, { method: 'POST', headers: { ...authHeader(teamMember.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'member' }), }) expect(res.status).toBe(403) }) test('system admin → 201', async () => { const res = await app.request(`/web/teams/${team.id}/invitations`, { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'member' }), }) expect(res.status).toBe(201) }) test('specify role, expiry, and max_uses', async () => { const res = await app.request(`/web/teams/${team.id}/invitations`, { method: 'POST', headers: { ...authHeader(owner.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'admin', expiresIn: 86400, maxUses: 5, }), }) expect(res.status).toBe(201) const body = (await res.json()) as Record expect(body.token || body.inviteToken).toBeDefined() // Verify invitation was stored with correct params if (body.token || body.inviteToken) { const token = (body.token || body.inviteToken) as string const tokenHash = createHash('sha256').update(token).digest('hex') const row = db .query( 'SELECT role, max_uses, team_id FROM invitations WHERE token_hash = $hash', ) .get({ $hash: tokenHash }) as { role: string max_uses: number team_id: string } | null expect(row?.role).toBe('admin') expect(row?.max_uses).toBe(5) expect(row?.team_id).toBe(team.id) } }) }) describe('POST /web/auth/join (Phase 2 team association)', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let team: { id: string } let inviteToken: string beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) team = createTeamDirectly(db, { name: 'Join Team', slug: 'join-team', createdBy: owner.id, }) // Create invitation linked to team inviteToken = `inv_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(inviteToken).digest('hex') const futureExpiry = new Date(Date.now() + 86400_000).toISOString() const now = new Date().toISOString() try { db.query( `INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by) VALUES ($hash, $teamId, 'member', $exp, 5, 0, $createdBy)`, ).run({ $hash: tokenHash, $teamId: team.id, $exp: futureExpiry, $createdBy: owner.id, }) } catch { // Invitations table may not have team_id column yet } }) test('valid invitation → creates user + joins team with correct role', async () => { const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'joiner', password: 'joiner-password-123', }), }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.accessToken || body.access_token).toBeDefined() // Verify user was added to team const userRow = db .query("SELECT id FROM users WHERE username = 'joiner'") .get() as { id: string } | null if (userRow) { const membership = db .query( 'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: team.id, $userId: userRow.id }) as { role: string } | null expect(membership?.role).toBe('member') } }) test('expired invitation → 400', async () => { const expiredToken = `inv_expired_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(expiredToken).digest('hex') const pastExpiry = new Date(Date.now() - 86400_000).toISOString() try { db.query( `INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by) VALUES ($hash, $teamId, 'member', $exp, 1, 0, $createdBy)`, ).run({ $hash: tokenHash, $teamId: team.id, $exp: pastExpiry, $createdBy: owner.id, }) } catch { // Table may not have team_id } const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: expiredToken, username: 'expiredjoiner', password: 'expired-password-123', }), }) expect(res.status).toBe(400) }) test('max_uses reached → 400', async () => { const usedToken = `inv_used_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(usedToken).digest('hex') const futureExpiry = new Date(Date.now() + 86400_000).toISOString() const now = new Date().toISOString() try { db.query( `INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by, consumed_at) VALUES ($hash, $teamId, 'member', $exp, 1, 1, $createdBy, $now)`, ).run({ $hash: tokenHash, $teamId: team.id, $exp: futureExpiry, $createdBy: owner.id, $now: now, }) } catch { // Table schema may differ } const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: usedToken, username: 'usedjoiner', password: 'used-password-123', }), }) expect(res.status).toBe(400) }) test('invalid invitation → 400', async () => { const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: 'inv_totally_invalid', username: 'invalidjoiner', password: 'invalid-password-123', }), }) expect(res.status).toBe(400) }) }) describe('DELETE /web/teams/:id/invitations/:token', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let teamMember: TestUser let team: { id: string } let activeToken: string beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) teamMember = await createAndLoginUser(db, app, { username: 'tmember', role: 'member', }) team = createTeamDirectly(db, { name: 'Revoke Team', slug: 'revoke-team', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: teamMember.id, role: 'member', addedBy: owner.id, }) // Create an active invitation activeToken = `inv_active_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(activeToken).digest('hex') const futureExpiry = new Date(Date.now() + 86400_000).toISOString() try { db.query( `INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by) VALUES ($hash, $teamId, 'member', $exp, 5, 0, $createdBy)`, ).run({ $hash: tokenHash, $teamId: team.id, $exp: futureExpiry, $createdBy: owner.id, }) } catch { // Table schema may differ } }) test('team owner/admin → 200', async () => { const res = await app.request( `/web/teams/${team.id}/invitations/${activeToken}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }, ) expect(res.status).toBe(200) }) test('team member → 403', async () => { const res = await app.request( `/web/teams/${team.id}/invitations/${activeToken}`, { method: 'DELETE', headers: authHeader(teamMember.accessToken), }, ) expect(res.status).toBe(403) }) test('revoked invitation → /web/auth/join returns 400', async () => { // Revoke the invitation await app.request(`/web/teams/${team.id}/invitations/${activeToken}`, { method: 'DELETE', headers: authHeader(owner.accessToken), }) // Try to join with the revoked token const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: activeToken, username: 'revokedjoiner', password: 'revoked-password-123', }), }) expect(res.status).toBe(400) }) }) // =========================================================================== // System admin global permissions // =========================================================================== describe('system admin global permissions', () => { let app: Hono let db: Database let admin: TestUser let owner: TestUser let member: TestUser let team: { id: string } beforeEach(async () => { db = freshDb() app = createApp() admin = await setupAdmin(app) owner = await createAndLoginUser(db, app, { username: 'owner1', role: 'member', }) member = await createAndLoginUser(db, app, { username: 'member1', role: 'member', }) team = createTeamDirectly(db, { name: 'Admin Test', slug: 'admin-test', createdBy: owner.id, }) addTeamMember(db, { teamId: team.id, userId: member.id, role: 'member', addedBy: owner.id, }) // admin is NOT in team_members — should still have access }) test('admin not in team_members but can manage all teams', async () => { // Verify admin is not in team_members const membership = db .query( 'SELECT * FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: team.id, $userId: admin.id }) expect(membership).toBeNull() // Admin can still GET team details const res = await app.request(`/web/teams/${team.id}`, { headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) test('admin can view all team members, environments, sessions', async () => { // Members const membersRes = await app.request(`/web/teams/${team.id}/members`, { headers: authHeader(admin.accessToken), }) expect(membersRes.status).toBe(200) // Environments const envsRes = await app.request(`/web/teams/${team.id}/environments`, { headers: authHeader(admin.accessToken), }) expect(envsRes.status).toBe(200) }) test('admin can force-add members to any team', async () => { const newMember = await createAndLoginUser(db, app, { username: 'forced', role: 'member', }) const res = await app.request(`/web/teams/${team.id}/members`, { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: newMember.id, role: 'member' }), }) expect(res.status).toBe(201) }) test('admin can force-remove members from any team', async () => { const res = await app.request( `/web/teams/${team.id}/members/${member.id}`, { method: 'DELETE', headers: authHeader(admin.accessToken), }, ) expect(res.status).toBe(200) }) test('admin can delete any team', async () => { const res = await app.request(`/web/teams/${team.id}`, { method: 'DELETE', headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) test('admin can transfer environments across teams', async () => { const otherTeam = createTeamDirectly(db, { name: 'Other', slug: 'other', createdBy: owner.id, }) const envId = `env_${randomUUID().replace(/-/g, '')}` try { db.query( `INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: team.id, $envId: envId }) } catch { // Table may not exist } const res = await app.request(`/web/environments/${envId}/transfer`, { method: 'POST', headers: { ...authHeader(admin.accessToken), 'Content-Type': 'application/json', }, body: JSON.stringify({ toTeamId: otherTeam.id }), }) expect(res.status).toBe(200) }) test('admin can view all orphan sessions', async () => { // This is a visibility test — admin should see sessions with no owners // Tested in detail in session-visibility.test.ts // Here we just verify the admin can access the session list endpoint const res = await app.request('/web/teams', { headers: authHeader(admin.accessToken), }) expect(res.status).toBe(200) }) })