fix(gateway): close session and resume boundaries

This commit is contained in:
2026-07-31 17:56:20 +00:00
parent 8f162f1db9
commit a112dced68
4 changed files with 68 additions and 17 deletions
+2 -2
View File
@@ -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) {
-11
View File
@@ -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({
+47 -4
View File
@@ -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<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
const createdRuntimeActions: Array<Record<string, unknown>> = [];
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);
@@ -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();