fix(gateway): close session and resume boundaries
This commit is contained in:
@@ -1256,10 +1256,10 @@ export const adminRouter = router({
|
|||||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||||
|
|
||||||
if (input.action === 'RESUME') {
|
if (input.action === 'RESUME') {
|
||||||
if (profile.status !== 'STOPPED') {
|
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Resume is allowed only for STOPPED profiles.',
|
message: 'Resume is allowed only for STOPPED or PAUSED profiles.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!canResume) {
|
if (!canResume) {
|
||||||
|
|||||||
@@ -692,17 +692,6 @@ export const appRouter = router({
|
|||||||
issuedAt: payload.issuedAt,
|
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
|
validateGameSession: procedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
|||||||
|
|
||||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.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 { createGatewayApiContext } from '../src/context.js';
|
||||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
@@ -17,6 +21,7 @@ const buildCaller = async (
|
|||||||
firstUserIsAdmin?: boolean;
|
firstUserIsAdmin?: boolean;
|
||||||
runtimeActionCreateError?: unknown;
|
runtimeActionCreateError?: unknown;
|
||||||
initialNotice?: string;
|
initialNotice?: string;
|
||||||
|
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const users = createInMemoryUserRepository();
|
const users = createInMemoryUserRepository();
|
||||||
@@ -36,13 +41,15 @@ const buildCaller = async (
|
|||||||
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
||||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||||
|
const updatedStatuses: GatewayProfileRecord['status'][] = [];
|
||||||
|
let reconcileCount = 0;
|
||||||
let storedNotice = options.initialNotice ?? '';
|
let storedNotice = options.initialNotice ?? '';
|
||||||
const profile = {
|
const profile = {
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
scenario: '2',
|
scenario: '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'STOPPED' as const,
|
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||||
buildStatus: 'SUCCEEDED' as const,
|
buildStatus: 'SUCCEEDED' as const,
|
||||||
meta: {},
|
meta: {},
|
||||||
createdAt: '2026-07-25T00:00:00.000Z',
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
@@ -53,7 +60,10 @@ const buildCaller = async (
|
|||||||
getProfile: async () => profile,
|
getProfile: async () => profile,
|
||||||
upsertProfile: async () => profile,
|
upsertProfile: async () => profile,
|
||||||
updateScenario: async () => profile,
|
updateScenario: async () => profile,
|
||||||
updateStatus: async () => profile,
|
updateStatus: async (_profileName, status) => {
|
||||||
|
updatedStatuses.push(status);
|
||||||
|
return { ...profile, status };
|
||||||
|
},
|
||||||
updateBuildStatus: async () => profile,
|
updateBuildStatus: async () => profile,
|
||||||
updateMeta: async () => profile,
|
updateMeta: async () => profile,
|
||||||
listReservedToStart: async () => [],
|
listReservedToStart: async () => [],
|
||||||
@@ -105,7 +115,9 @@ const buildCaller = async (
|
|||||||
orchestrator: {
|
orchestrator: {
|
||||||
start: () => {},
|
start: () => {},
|
||||||
stop: async () => {},
|
stop: async () => {},
|
||||||
reconcileNow: async () => {},
|
reconcileNow: async () => {
|
||||||
|
reconcileCount += 1;
|
||||||
|
},
|
||||||
runScheduleNow: async () => {},
|
runScheduleNow: async () => {},
|
||||||
runBuildQueueNow: async () => {},
|
runBuildQueueNow: async () => {},
|
||||||
runOperationsNow: async () => {
|
runOperationsNow: async () => {
|
||||||
@@ -158,6 +170,8 @@ const buildCaller = async (
|
|||||||
users,
|
users,
|
||||||
admin,
|
admin,
|
||||||
flushes,
|
flushes,
|
||||||
|
updatedStatuses,
|
||||||
|
getReconcileCount: () => reconcileCount,
|
||||||
getStoredNotice: () => storedNotice,
|
getStoredNotice: () => storedNotice,
|
||||||
setStoredNotice: (notice: string) => {
|
setStoredNotice: (notice: string) => {
|
||||||
storedNotice = notice;
|
storedNotice = notice;
|
||||||
@@ -335,6 +349,35 @@ describe('admin runtime clock action API', () => {
|
|||||||
throw new Error('not used');
|
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 () => {
|
it('creates a first-class clock action owned by the authenticated administrator', async () => {
|
||||||
const harness = await buildCaller(unusedCreateOperation);
|
const harness = await buildCaller(unusedCreateOperation);
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,25 @@ const postTrpc = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('admin security over HTTP transport', () => {
|
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 () => {
|
it('accepts an equal scoped role and rejects wildcard escalation without mutating roles', async () => {
|
||||||
const harness = await createHarness();
|
const harness = await createHarness();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user