refactor: tRPC 조회 입력을 JSON 본문으로 전송
브라우저의 Gateway·Game tRPC 클라이언트를 POST JSON 전송으로 통일하고 서버의 query method override를 허용한다. GET mutation 거부 계약과 production Chromium 요청 형태를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -4,7 +4,11 @@ import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
trpcJsonBodyHttpServerOptions,
|
||||
type RealtimeViewerIdentity,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
@@ -200,6 +204,7 @@ export const createGameApiServer = async () => {
|
||||
prefix: config.trpcPath,
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
|
||||
@@ -167,6 +167,26 @@ integration('game API security over HTTP transport', () => {
|
||||
restoreEnv();
|
||||
}, 30_000);
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
method: 'POST',
|
||||
input: null,
|
||||
accessToken,
|
||||
});
|
||||
|
||||
expect(general.response.status).toBe(200);
|
||||
expect(general.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
general: {
|
||||
id: generalId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'global suspension',
|
||||
|
||||
@@ -131,11 +131,15 @@ const generals = [
|
||||
];
|
||||
|
||||
const parseSort = (route: Route): number => {
|
||||
const raw = new URL(route.request().url()).searchParams.get('input');
|
||||
if (!raw) return 9;
|
||||
try {
|
||||
const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } };
|
||||
return input[0]?.sort ?? input.json?.sort ?? 9;
|
||||
const request = route.request();
|
||||
const queryInput = new URL(request.url()).searchParams.get('input');
|
||||
const input = (request.postData()
|
||||
? request.postDataJSON()
|
||||
: queryInput
|
||||
? JSON.parse(queryInput)
|
||||
: {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } };
|
||||
return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9;
|
||||
} catch {
|
||||
return 9;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ type NavigationFixture = {
|
||||
boardAccessKind: string | null;
|
||||
}>;
|
||||
dashboardRequests?: DashboardBundleInput[];
|
||||
trpcRequests?: Array<{
|
||||
operations: string[];
|
||||
method: string;
|
||||
url: string;
|
||||
body: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
type JsonPatchOperation = {
|
||||
@@ -79,9 +85,13 @@ type DashboardBundleInput = {
|
||||
};
|
||||
|
||||
const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
||||
const input = new URL(route.request().url()).searchParams.get('input');
|
||||
if (!input) return {};
|
||||
const parsed = JSON.parse(input) as Record<string, unknown>;
|
||||
const request = route.request();
|
||||
const queryInput = new URL(request.url()).searchParams.get('input');
|
||||
const parsed = (request.postData()
|
||||
? request.postDataJSON()
|
||||
: queryInput
|
||||
? JSON.parse(queryInput)
|
||||
: {}) as Record<string, unknown>;
|
||||
const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput };
|
||||
return entry.json ?? (entry as DashboardBundleInput);
|
||||
};
|
||||
@@ -361,6 +371,12 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
});
|
||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||
const operations = operationNames(route);
|
||||
(state.trpcRequests ??= []).push({
|
||||
operations,
|
||||
method: route.request().method(),
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
state.operations.push(...operations);
|
||||
if (
|
||||
operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) &&
|
||||
@@ -2706,6 +2722,14 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
boardAccessKind: 'unchanged',
|
||||
});
|
||||
expect(realtimeBundle?.bytes).toBeLessThan(1_000);
|
||||
const dashboardTransport = state.trpcRequests?.find(
|
||||
({ operations, body }) =>
|
||||
operations.includes('dashboard.getContextBundleDelta') && JSON.stringify(body).includes('knownSource')
|
||||
);
|
||||
expect(dashboardTransport).toMatchObject({ method: 'POST' });
|
||||
expect(new URL(dashboardTransport?.url ?? '').searchParams.has('input')).toBe(false);
|
||||
expect(dashboardTransport?.body).toBeTruthy();
|
||||
expect(state.trpcRequests?.every(({ method }) => method === 'POST')).toBe(true);
|
||||
expect(
|
||||
state.dashboardRequests?.find(
|
||||
(request) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
|
||||
@@ -13,6 +14,7 @@ export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers() {
|
||||
const token = getSessionToken();
|
||||
return token ? { 'x-session-token': token } : {};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/game-api';
|
||||
|
||||
@@ -13,6 +14,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers() {
|
||||
const token = getGameToken();
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
|
||||
@@ -4,6 +4,7 @@ import fastifyStatic from '@fastify/static';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import {
|
||||
createGatewayPostgresConnector,
|
||||
createRedisConnector,
|
||||
@@ -108,6 +109,7 @@ export const createGatewayApiServer = async () => {
|
||||
prefix: config.trpcPath,
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: ({ req }: { req: FastifyRequest }) =>
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -94,6 +95,7 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: ({ req }: { req: FastifyRequest }) =>
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
@@ -168,6 +170,33 @@ const postTrpc = async (
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const operationNames = (route: Route): string[] => {
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
const requests: Array<{ operation: string; body: unknown }> = [];
|
||||
let deleteAfter: string | null = null;
|
||||
let graceUntil: string | null = null;
|
||||
let specialGrants: Array<Record<string, unknown>> = [];
|
||||
@@ -31,7 +31,7 @@ const installFixture = async (page: Page) => {
|
||||
const operations = operationNames(route);
|
||||
const body = route.request().postDataJSON() as unknown;
|
||||
const results = operations.map((operation) => {
|
||||
if (route.request().method() === 'POST') mutations.push({ operation, body });
|
||||
requests.push({ operation, body });
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
id: 'admin-user',
|
||||
@@ -202,11 +202,11 @@ const installFixture = async (page: Page) => {
|
||||
body: JSON.stringify(isBatch ? results : results[0]),
|
||||
});
|
||||
});
|
||||
return mutations;
|
||||
return requests;
|
||||
};
|
||||
|
||||
test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => {
|
||||
const mutations = await installFixture(page);
|
||||
const requests = await installFixture(page);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto('admin/users');
|
||||
await expect(page.getByRole('region', { name: '계정 목록' })).toBeVisible();
|
||||
@@ -247,9 +247,9 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await page.getByLabel('탈퇴 전 보존 일수').fill('30');
|
||||
await deletionButton.click();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible();
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
expect(requests.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(requests.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
|
||||
expect(requests.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => {
|
||||
|
||||
@@ -9,10 +9,16 @@ const operationNames = (route: Route): string[] => {
|
||||
};
|
||||
|
||||
const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
const requests: Array<{ method: string; url: string; body: unknown }> = [];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
requests.push({
|
||||
method: route.request().method(),
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
@@ -107,10 +113,11 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
),
|
||||
});
|
||||
});
|
||||
return requests;
|
||||
};
|
||||
|
||||
test('bootstrap superuser can navigate the administrator workspace from the lobby', async ({ page }, testInfo) => {
|
||||
await installGatewayFixture(page, ['superuser']);
|
||||
const requests = await installGatewayFixture(page, ['superuser']);
|
||||
|
||||
await page.goto('lobby');
|
||||
const adminLink = page.getByRole('link', { name: '관리자 페이지' });
|
||||
@@ -144,6 +151,10 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
|
||||
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
expect(requests.every(({ method }) => method === 'POST')).toBe(true);
|
||||
expect(requests.every(({ url }) => !new URL(url).searchParams.has('input'))).toBe(true);
|
||||
expect(requests.some(({ body }) => JSON.stringify(body).includes('"limit":30'))).toBe(true);
|
||||
});
|
||||
|
||||
test('desktop administrator sidebar follows the navbar away and then sticks to the viewport top', async ({
|
||||
|
||||
@@ -180,9 +180,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1);
|
||||
}
|
||||
const results = names.map((name) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
state.requestBodies.push({ operation: name, body });
|
||||
}
|
||||
state.requestBodies.push({ operation: name, body });
|
||||
if (name === 'admin.profiles.list') {
|
||||
return response([profile(state.runtimeRunning, state.resetDefaults)]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { appRouter } from '@sammo-ts/game-api';
|
||||
|
||||
@@ -15,6 +16,7 @@ export const createGameTrpc = (profile: string, port: number, gameToken?: string
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url,
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
|
||||
import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers() {
|
||||
const token = getSessionToken();
|
||||
return token ? { 'x-session-token': token } : {};
|
||||
@@ -24,6 +26,7 @@ export const directTrpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers() {
|
||||
const token = getSessionToken();
|
||||
return token ? { 'x-session-token': token } : {};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* tRPC keeps procedure semantics in its envelope, so browser inputs belong in a JSON body
|
||||
* instead of a percent-encoded URL query string.
|
||||
*/
|
||||
export const trpcJsonBodyHttpClientOptions = {
|
||||
methodOverride: 'POST',
|
||||
} as const;
|
||||
|
||||
/** POST may execute queries, while tRPC still rejects mutations sent as GET. */
|
||||
export const trpcJsonBodyHttpServerOptions = {
|
||||
allowMethodOverride: true,
|
||||
} as const;
|
||||
@@ -27,3 +27,4 @@ export * from './auth/accountIconProjection.js';
|
||||
export * from './logging/formatLegacyLogHtml.js';
|
||||
export * from './gateway/profileStatus.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
|
||||
@@ -147,7 +147,7 @@ const installFixture = async (page: Page): Promise<void> => {
|
||||
return response(listPayload);
|
||||
}
|
||||
if (operation === 'dynasty.getDetail') {
|
||||
const input = new URL(route.request().url()).searchParams.get('input') ?? '';
|
||||
const input = route.request().postData() ?? new URL(route.request().url()).searchParams.get('input') ?? '';
|
||||
return input.includes('999')
|
||||
? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.')
|
||||
: response(detailPayload);
|
||||
|
||||
Reference in New Issue
Block a user