diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts new file mode 100644 index 0000000..1a23f54 --- /dev/null +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -0,0 +1,320 @@ +import fastify, { type FastifyRequest } from 'fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { GatewayPrismaClient } from '@sammo-ts/infra'; + +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; +import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; +import { createGatewayApiContext } from '../src/context.js'; +import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; +import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; +import { appRouter } from '../src/router.js'; + +const profile = { + profileName: 'che:default', + profile: 'che', + scenario: 'default', + apiPort: 15003, + status: 'RUNNING' as const, + buildStatus: 'SUCCEEDED' as const, + meta: {}, + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z', +}; + +const profiles: GatewayProfileRepository = { + listProfiles: async () => [profile], + getProfile: async (profileName) => (profileName === profile.profileName ? profile : null), + upsertProfile: async () => profile, + updateScenario: async () => profile, + updateStatus: async () => profile, + updateBuildStatus: async () => profile, + updateMeta: async () => profile, + listReservedToStart: async () => [], + findQueuedBuild: async () => null, + updateLastError: async () => {}, + updateWorkspaceUsage: async () => {}, + clearWorkspaceUsage: async () => {}, + listOperations: async () => [], + getOperation: async () => null, + createOperation: async () => { + throw new Error('not used'); + }, + claimNextOperation: async () => null, + completeOperation: async () => { + throw new Error('not used'); + }, + requeueOperation: async () => { + throw new Error('not used'); + }, + cancelOperation: async () => false, + retryOperation: async () => null, +}; + +const openApps = new Set>(); + +afterEach(async () => { + await Promise.allSettled(Array.from(openApps, (app) => app.close())); + openApps.clear(); +}); + +const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.survey.open:che:default']) => { + const users = createInMemoryUserRepository(); + const admin = await users.createUser({ + username: 'scoped-admin', + password: 'secretpass', + displayName: 'Scoped Admin', + }); + await users.updateRoles(admin.id, adminRoles); + const target = await users.createUser({ + username: 'target-user', + password: 'secretpass', + displayName: 'Target', + }); + const sessions = new InMemoryGatewaySessionService({ + sessionTtlSeconds: 600, + gameSessionTtlSeconds: 600, + }); + const adminSession = await sessions.createSession({ ...admin, roles: adminRoles }); + const targetSession = await sessions.createSession(target); + const flushes: Array<{ userId: string; reason?: string }> = []; + const app = fastify({ logger: false }); + await app.register(fastifyTRPCPlugin, { + prefix: '/trpc', + trpcOptions: { + router: appRouter, + createContext: ({ req }: { req: FastifyRequest }) => + createGatewayApiContext({ + users, + sessions, + flushPublisher: { + publishUserFlush: async (userId, reason) => { + flushes.push({ userId, reason }); + }, + }, + gameTokenSecret: 'transport-e2e-secret', + gameSessionTtlSeconds: 600, + kakaoClient: {} as never, + oauthSessions: {} as never, + publicBaseUrl: 'http://127.0.0.1', + adminLocalAccountEnabled: false, + localRegistrationEnabled: true, + localAccountGraceDays: 7, + passwordEnvelope: createPasswordEnvelopeService(), + profiles, + orchestrator: { + start: () => {}, + stop: async () => {}, + reconcileNow: async () => {}, + runScheduleNow: async () => {}, + runBuildQueueNow: async () => {}, + runOperationsNow: async () => {}, + cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), + listRuntimeStates: async () => [], + }, + profileStatus: new InMemoryProfileStatusService(), + requestHeaders: req.headers, + prisma: { + appUser: { + findFirst: async () => ({ id: 'bootstrap-user' }), + }, + } as unknown as GatewayPrismaClient, + }), + }, + }); + const baseUrl = await app.listen({ host: '127.0.0.1', port: 0 }); + openApps.add(app); + return { + app, + baseUrl, + users, + admin, + target, + adminSessionToken: adminSession.sessionToken, + targetSessionToken: targetSession.sessionToken, + flushes, + }; +}; + +const postTrpc = async ( + baseUrl: string, + procedure: string, + input: unknown, + sessionToken?: string +): Promise<{ response: Response; body: unknown }> => { + const response = await fetch(`${baseUrl}/trpc/${procedure}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(sessionToken ? { 'x-session-token': sessionToken } : {}), + }, + body: JSON.stringify(input), + }); + return { + response, + body: (await response.json()) as unknown, + }; +}; + +describe('admin security over HTTP transport', () => { + it('accepts an equal scoped role and rejects wildcard escalation without mutating roles', async () => { + const harness = await createHarness(); + + const allowed = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: ['admin.survey.open:che:default'], + mode: 'grant', + }, + harness.adminSessionToken + ); + if (allowed.response.status !== 200) { + throw new Error(`allowed role request failed: ${JSON.stringify(allowed.body)}`); + } + expect(allowed.body).toMatchObject({ + result: { + data: { + roles: ['user', 'admin.survey.open:che:default'], + }, + }, + }); + + const rejected = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: ['admin.survey.open:*'], + mode: 'grant', + }, + harness.adminSessionToken + ); + expect(rejected.response.status).toBe(403); + expect(rejected.body).toMatchObject({ + error: { + data: { + code: 'FORBIDDEN', + }, + }, + }); + expect((await harness.users.findById(harness.target.id))?.roles).toEqual([ + 'user', + 'admin.survey.open:che:default', + ]); + }); + + it('rejects an unauthenticated role change at the HTTP header boundary', async () => { + const harness = await createHarness(); + + const rejected = await postTrpc(harness.baseUrl, 'admin.users.updateRoles', { + userId: harness.target.id, + roles: ['admin.survey.open:che:default'], + mode: 'grant', + }); + + expect(rejected.response.status).toBe(401); + expect(rejected.body).toMatchObject({ + error: { + data: { + code: 'UNAUTHORIZED', + }, + }, + }); + expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user']); + }); + + it('rejects self-escalation and set-mode removal outside a scoped administrator role', async () => { + const harness = await createHarness(); + + const selfEscalation = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.admin.id, + roles: ['admin.survey.open:*'], + mode: 'grant', + }, + harness.adminSessionToken + ); + expect(selfEscalation.response.status).toBe(403); + expect((await harness.users.findByUsername('scoped-admin'))?.roles).toEqual([ + 'user', + 'admin.users.manage', + 'admin.survey.open:che:default', + ]); + + await harness.users.updateRoles(harness.target.id, ['user', 'admin.survey.open:*']); + const outOfScopeRemoval = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: ['user'], + mode: 'set', + }, + harness.adminSessionToken + ); + expect(outOfScopeRemoval.response.status).toBe(403); + expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user', 'admin.survey.open:*']); + }); + + it('allows a superuser to grant a root role over HTTP', async () => { + const harness = await createHarness(['user', 'superuser']); + + const granted = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: ['superuser'], + mode: 'grant', + }, + harness.adminSessionToken + ); + + expect(granted.response.status).toBe(200); + expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user', 'superuser']); + }); + + it('blocks game-session issuance after an HTTP sanction update while retaining the gateway session', async () => { + const harness = await createHarness(); + + const updated = await postTrpc( + harness.baseUrl, + 'admin.users.updateSanctions', + { + userId: harness.target.id, + patch: { + suspendedUntil: '2099-01-01T00:00:00.000Z', + }, + }, + harness.adminSessionToken + ); + if (updated.response.status !== 200) { + throw new Error(`sanction request failed: ${JSON.stringify(updated.body)}`); + } + expect(harness.flushes).toEqual([ + { + userId: harness.target.id, + reason: 'admin-sanctions-updated', + }, + ]); + + const blocked = await postTrpc(harness.baseUrl, 'auth.issueGameSession', { + sessionToken: harness.targetSessionToken, + profile: profile.profileName, + }); + expect(blocked.response.status).toBe(403); + expect(blocked.body).toMatchObject({ + error: { + data: { + code: 'FORBIDDEN', + }, + }, + }); + }); +});