merge: tRPC 조회 입력의 JSON 본문 전송을 반영
This commit is contained in:
@@ -4,7 +4,12 @@ import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { buildGameEventChannel, REALTIME_ACCESS_GRANT_HEADER, type RealtimeViewerIdentity } from '@sammo-ts/common';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
REALTIME_ACCESS_GRANT_HEADER,
|
||||
trpcJsonBodyHttpServerOptions,
|
||||
type RealtimeViewerIdentity,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
@@ -205,6 +210,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;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ type NavigationFixture = {
|
||||
boardAccessKind: string | null;
|
||||
}>;
|
||||
dashboardRequests?: DashboardBundleInput[];
|
||||
trpcRequests?: Array<{
|
||||
operations: string[];
|
||||
method: string;
|
||||
url: string;
|
||||
body: unknown;
|
||||
}>;
|
||||
dashboardGrantHeaders?: Array<string | null>;
|
||||
};
|
||||
|
||||
@@ -82,9 +88,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);
|
||||
};
|
||||
@@ -364,6 +374,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)) &&
|
||||
@@ -2725,6 +2741,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,6 +1,6 @@
|
||||
import { REALTIME_ACCESS_GRANT_HEADER, trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/game-api';
|
||||
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common';
|
||||
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
||||
|
||||
const getGameToken = (): string | null => {
|
||||
@@ -15,6 +15,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
headers({ opList }) {
|
||||
const token = getGameToken();
|
||||
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -283,7 +283,8 @@ test('automatically recovers profile details after a transient update outage', a
|
||||
});
|
||||
|
||||
test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => {
|
||||
await installFixture(page, { lobbyBundleFailures: 1 });
|
||||
// Keep the scheduled retry in the error state so it cannot race the manual retry after the screenshot.
|
||||
await installFixture(page, { lobbyBundleFailures: 2 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await page.goto('lobby');
|
||||
|
||||
@@ -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