브라우저의 Gateway·Game tRPC 클라이언트를 POST JSON 전송으로 통일하고 서버의 query method override를 허용한다. GET mutation 거부 계약과 production Chromium 요청 형태를 회귀 테스트로 고정한다.
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import fastify, { type FastifyRequest } from 'fastify';
|
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
|
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',
|
|
instanceKey: 'default',
|
|
currentScenario: null,
|
|
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,
|
|
updateCurrentScenario: 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,
|
|
listOperationLogs: async () => [],
|
|
appendOperationLog: async (operationId, input) => ({
|
|
cursor: '1',
|
|
operationId,
|
|
createdAt: '2026-08-11T00:00:00.000Z',
|
|
...input,
|
|
}),
|
|
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<ReturnType<typeof fastify>>();
|
|
|
|
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,
|
|
...trpcJsonBodyHttpServerOptions,
|
|
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 query input from a POST JSON body but still rejects a mutation sent as GET', async () => {
|
|
const harness = await createHarness();
|
|
|
|
const query = await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken);
|
|
expect(query.response.status).toBe(200);
|
|
expect(query.body).toMatchObject({
|
|
result: {
|
|
data: {
|
|
id: harness.admin.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
const mutationInput = encodeURIComponent(
|
|
JSON.stringify({ json: { sessionToken: harness.adminSessionToken } })
|
|
);
|
|
const mutation = await fetch(`${harness.baseUrl}/trpc/auth.logout?input=${mutationInput}`);
|
|
expect(mutation.status).toBe(405);
|
|
expect(await mutation.json()).toMatchObject({
|
|
error: {
|
|
data: {
|
|
code: 'METHOD_NOT_SUPPORTED',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
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();
|
|
|
|
const allowed = await postTrpc(
|
|
harness.baseUrl,
|
|
'admin.users.updateRoles',
|
|
{
|
|
userId: harness.target.id,
|
|
roles: ['admin.survey.open:che:default'],
|
|
mode: 'grant',
|
|
reason: 'HTTP 권한 부여 테스트',
|
|
},
|
|
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',
|
|
reason: 'HTTP 권한 상승 거부 테스트',
|
|
},
|
|
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',
|
|
reason: '미인증 권한 변경 거부 테스트',
|
|
});
|
|
|
|
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 an unauthenticated special-access grant at the HTTP header boundary', async () => {
|
|
const harness = await createHarness();
|
|
|
|
const rejected = await postTrpc(harness.baseUrl, 'admin.users.grantSpecialAccess', {
|
|
userId: harness.target.id,
|
|
kind: 'TESTER',
|
|
profiles: ['che'],
|
|
allowsGeneralCreation: true,
|
|
expiresAt: null,
|
|
reason: '미인증 특수 접근 부여 거부 테스트',
|
|
});
|
|
|
|
expect(rejected.response.status).toBe(401);
|
|
expect(rejected.body).toMatchObject({
|
|
error: {
|
|
data: {
|
|
code: 'UNAUTHORIZED',
|
|
},
|
|
},
|
|
});
|
|
expect(await harness.users.listSpecialAccessGrants(harness.target.id)).toEqual([]);
|
|
});
|
|
|
|
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',
|
|
reason: '자기 권한 상승 거부 테스트',
|
|
},
|
|
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',
|
|
reason: '범위 밖 권한 제거 거부 테스트',
|
|
},
|
|
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',
|
|
reason: '최고 관리자 권한 부여 테스트',
|
|
},
|
|
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',
|
|
},
|
|
reason: 'HTTP 제재 적용 테스트',
|
|
},
|
|
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',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|