From a112dced688c2c67a2510a8af622535428bd3374 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 17:56:20 +0000 Subject: [PATCH] fix(gateway): close session and resume boundaries --- app/gateway-api/src/adminRouter.ts | 4 +- app/gateway-api/src/router.ts | 11 ---- app/gateway-api/test/adminOperations.test.ts | 51 +++++++++++++++++-- .../test/adminSecurityTransport.e2e.test.ts | 19 +++++++ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 43c21c2..340a702 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1256,10 +1256,10 @@ export const adminRouter = router({ canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName); if (input.action === 'RESUME') { - if (profile.status !== 'STOPPED') { + if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Resume is allowed only for STOPPED profiles.', + message: 'Resume is allowed only for STOPPED or PAUSED profiles.', }); } if (!canResume) { diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 14dea5a..551280d 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -692,17 +692,6 @@ export const appRouter = router({ issuedAt: payload.issuedAt, }; }), - flushUser: procedure - .input( - z.object({ - userId: z.string().min(1), - reason: z.string().min(1).optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - await ctx.flushPublisher.publishUserFlush(input.userId, input.reason); - return { ok: true }; - }), validateGameSession: procedure .input( z.object({ diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 674d393..0701893 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -4,7 +4,11 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; -import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; +import type { + GatewayOperationCreateInput, + GatewayProfileRecord, + GatewayProfileRepository, +} from '../src/orchestrator/profileRepository.js'; import { createGatewayApiContext } from '../src/context.js'; import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; import { appRouter } from '../src/router.js'; @@ -17,6 +21,7 @@ const buildCaller = async ( firstUserIsAdmin?: boolean; runtimeActionCreateError?: unknown; initialNotice?: string; + initialProfileStatus?: GatewayProfileRecord['status']; } = {} ) => { const users = createInMemoryUserRepository(); @@ -36,13 +41,15 @@ const buildCaller = async ( const operationRecords = new Map>>(); const createdRuntimeActions: Array> = []; const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; + const updatedStatuses: GatewayProfileRecord['status'][] = []; + let reconcileCount = 0; let storedNotice = options.initialNotice ?? ''; const profile = { profileName: 'che:2', profile: 'che', scenario: '2', apiPort: 15003, - status: 'STOPPED' as const, + status: options.initialProfileStatus ?? ('STOPPED' as const), buildStatus: 'SUCCEEDED' as const, meta: {}, createdAt: '2026-07-25T00:00:00.000Z', @@ -53,7 +60,10 @@ const buildCaller = async ( getProfile: async () => profile, upsertProfile: async () => profile, updateScenario: async () => profile, - updateStatus: async () => profile, + updateStatus: async (_profileName, status) => { + updatedStatuses.push(status); + return { ...profile, status }; + }, updateBuildStatus: async () => profile, updateMeta: async () => profile, listReservedToStart: async () => [], @@ -105,7 +115,9 @@ const buildCaller = async ( orchestrator: { start: () => {}, stop: async () => {}, - reconcileNow: async () => {}, + reconcileNow: async () => { + reconcileCount += 1; + }, runScheduleNow: async () => {}, runBuildQueueNow: async () => {}, runOperationsNow: async () => { @@ -158,6 +170,8 @@ const buildCaller = async ( users, admin, flushes, + updatedStatuses, + getReconcileCount: () => reconcileCount, getStoredNotice: () => storedNotice, setStoredNotice: (notice: string) => { storedNotice = notice; @@ -335,6 +349,35 @@ describe('admin runtime clock action API', () => { throw new Error('not used'); }; + it('resumes a paused profile through the same RUNNING reconciliation boundary', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'PAUSED' }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'RESUME', + }) + ).resolves.toMatchObject({ ok: true }); + expect(harness.updatedStatuses).toEqual(['RUNNING']); + expect(harness.getReconcileCount()).toBe(1); + }); + + it('rejects resume outside stopped and paused states without reconciliation', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'RESUME', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Resume is allowed only for STOPPED or PAUSED profiles.', + }); + expect(harness.updatedStatuses).toEqual([]); + expect(harness.getReconcileCount()).toBe(0); + }); + it('creates a first-class clock action owned by the authenticated administrator', async () => { const harness = await buildCaller(unusedCreateOperation); diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 1a23f54..c8f8bc5 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -159,6 +159,25 @@ const postTrpc = async ( }; describe('admin security over HTTP transport', () => { + it('does not expose the removed public user-flush mutation', async () => { + const harness = await createHarness(); + + const rejected = await postTrpc(harness.baseUrl, 'auth.flushUser', { + userId: harness.target.id, + reason: 'unauthenticated-flush-attempt', + }); + + expect(rejected.response.status).toBe(404); + expect(rejected.body).toMatchObject({ + error: { + data: { + code: 'NOT_FOUND', + }, + }, + }); + expect(harness.flushes).toEqual([]); + }); + it('accepts an equal scoped role and rejects wildcard escalation without mutating roles', async () => { const harness = await createHarness();