merge: 최신 main의 갱신 비용 변경을 tRPC 전송에 통합
# Conflicts: # app/game-api/src/server.ts # app/game-frontend/e2e/mainNavigation.spec.ts # app/game-frontend/src/utils/trpc.ts
This commit is contained in:
@@ -4,7 +4,7 @@ import { applyReadModelDelta } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { dashboardRouter } from '../src/router/dashboard/index.js';
|
||||
import { dashboardRouter, requiresDashboardProjection } from '../src/router/dashboard/index.js';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -141,6 +141,55 @@ const contextOnly = {
|
||||
};
|
||||
|
||||
describe('dashboardRouter.getContextBundleDelta', () => {
|
||||
it('classifies revision-only checks separately from PostgreSQL projection rebuilds', () => {
|
||||
const sourceRevision = 'S'.repeat(22);
|
||||
const sourceState = {
|
||||
coverageVersion: 1,
|
||||
identity: { generalId: 7, cityId: 0, nationId: 0 },
|
||||
sourceRevisions: {
|
||||
context: sourceRevision,
|
||||
commandTable: 'T'.repeat(22),
|
||||
boardAccess: 'B'.repeat(22),
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
requiresDashboardProjection({
|
||||
included: true,
|
||||
sourceState,
|
||||
slice: 'context',
|
||||
knownContent: 'C'.repeat(22),
|
||||
knownSource: sourceRevision,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresDashboardProjection({
|
||||
included: true,
|
||||
sourceState,
|
||||
slice: 'context',
|
||||
knownContent: 'C'.repeat(22),
|
||||
knownSource: 'X'.repeat(22),
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresDashboardProjection({
|
||||
included: true,
|
||||
sourceState,
|
||||
slice: 'context',
|
||||
knownContent: 'C'.repeat(22),
|
||||
knownSource: sourceRevision,
|
||||
forceSnapshot: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresDashboardProjection({
|
||||
included: false,
|
||||
sourceState,
|
||||
slice: 'context',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
|
||||
const fixture = buildContext(true);
|
||||
const caller = dashboardRouter.createCaller(fixture.context);
|
||||
|
||||
@@ -58,12 +58,25 @@ integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
let previousCoverageVersion: number | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
previousCoverageVersion =
|
||||
(
|
||||
await db.readModelRevisionMeta.findUnique({
|
||||
where: { id: 1 },
|
||||
select: { coverageVersion: true },
|
||||
})
|
||||
)?.coverageVersion ?? null;
|
||||
await db.readModelRevisionMeta.upsert({
|
||||
where: { id: 1 },
|
||||
create: { id: 1, coverageVersion: 1 },
|
||||
update: { coverageVersion: 1 },
|
||||
});
|
||||
await db.generalAccessLog.deleteMany({
|
||||
where: {
|
||||
generalId: {
|
||||
@@ -126,6 +139,14 @@ integration('general access tracking persistence', () => {
|
||||
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
|
||||
await db.general.deleteMany({ where: { id: endpointGeneralId } });
|
||||
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||
if (previousCoverageVersion === null) {
|
||||
await db.readModelRevisionMeta.deleteMany({ where: { id: 1 } });
|
||||
} else {
|
||||
await db.readModelRevisionMeta.update({
|
||||
where: { id: 1 },
|
||||
data: { coverageVersion: previousCoverageVersion },
|
||||
});
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
@@ -380,6 +401,10 @@ integration('general access tracking persistence', () => {
|
||||
scenario: 'default',
|
||||
},
|
||||
profileStatusSource: { get: async () => 'RUNNING' as const },
|
||||
redis: {
|
||||
get: async () => null,
|
||||
set: async () => 'OK',
|
||||
},
|
||||
} as unknown as GameApiContext;
|
||||
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
||||
|
||||
@@ -387,12 +412,52 @@ integration('general access tracking persistence', () => {
|
||||
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
|
||||
const initialDashboard = await dashboardCaller.dashboard.getContextBundleDelta({
|
||||
include: { context: true, commandTable: false, boardAccess: false },
|
||||
forceSnapshot: true,
|
||||
});
|
||||
expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } });
|
||||
const initialContext = initialDashboard.context;
|
||||
if (!initialContext?.sourceRevision) {
|
||||
throw new Error('dashboard snapshot did not include its post-access source revision');
|
||||
}
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
|
||||
|
||||
await expect(
|
||||
dashboardCaller.dashboard.getContextBundleDelta({
|
||||
include: { context: true, commandTable: false, boardAccess: false },
|
||||
known: { context: initialContext.revision },
|
||||
knownSource: { context: initialContext.sourceRevision },
|
||||
})
|
||||
).resolves.toMatchObject({ context: { kind: 'unchanged' } });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
|
||||
|
||||
await db.generalAccessLog.delete({ where: { generalId: endpointGeneralId } });
|
||||
const realtimeDashboardCaller = appRouter.createCaller({ ...context, realtimeAccessGranted: true });
|
||||
await expect(
|
||||
realtimeDashboardCaller.dashboard.getContextBundleDelta({
|
||||
include: { context: true, commandTable: false, boardAccess: false },
|
||||
forceSnapshot: true,
|
||||
})
|
||||
).resolves.toMatchObject({ context: { kind: 'snapshot' } });
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
|
||||
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
|
||||
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
|
||||
const grantedBoundaryCaller = endpointBoundaryRouter.createCaller({
|
||||
...context,
|
||||
realtimeAccessGranted: true,
|
||||
});
|
||||
await expect(grantedBoundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -3,9 +3,15 @@ import { describe, expect, it } from 'vitest';
|
||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||
|
||||
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
|
||||
import {
|
||||
shouldReloadRealtimeViewerIdentity,
|
||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||
} from '../src/realtime/publicEvent.js';
|
||||
|
||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
||||
const refreshGrant = 'opaque-grant';
|
||||
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
||||
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
||||
|
||||
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
|
||||
type: 'turnCompleted',
|
||||
@@ -42,6 +48,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
refreshGrant,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
@@ -99,6 +106,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
)
|
||||
).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
refreshGrant,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: true,
|
||||
@@ -119,6 +127,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
refreshGrant,
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
@@ -146,7 +155,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
senderId: 99,
|
||||
};
|
||||
|
||||
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
|
||||
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||
});
|
||||
|
||||
@@ -157,13 +166,10 @@ describe('public realtime event privacy boundary', () => {
|
||||
};
|
||||
|
||||
const publicEvent = toPublicRealtimeEvent(event, [viewer]);
|
||||
expect(publicEvent).toEqual({ type: 'messagesInvalidated' });
|
||||
expect(publicEvent).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u);
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
{ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] },
|
||||
[viewer]
|
||||
)
|
||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, [viewer])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -195,9 +201,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
|
||||
).toMatchObject({
|
||||
expect(toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])).toMatchObject({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import {
|
||||
consumeRealtimeAccessGrantHeader,
|
||||
createRealtimeAccessGrant,
|
||||
REALTIME_ACCESS_GRANT_TTL_MS,
|
||||
registerRealtimeAccessGrant,
|
||||
verifyRealtimeAccessGrant,
|
||||
verifyRealtimeAccessGrantHeader,
|
||||
} from '../src/auth/realtimeAccessGrant.js';
|
||||
|
||||
const secret = 'realtime-access-grant-test-secret-with-enough-entropy';
|
||||
const now = new Date('2026-08-17T10:00:00.000Z');
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'hwe:default',
|
||||
issuedAt: '2026-08-17T09:00:00.000Z',
|
||||
expiresAt: '2026-08-17T11:00:00.000Z',
|
||||
sessionId: 'session-private-value',
|
||||
user: {
|
||||
id: 'user-private-value',
|
||||
username: 'grant-user',
|
||||
displayName: '갱신 사용자',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
describe('realtime access grant', () => {
|
||||
it('binds an opaque short-lived grant to the authenticated session and profile', () => {
|
||||
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
||||
|
||||
expect(grant).not.toContain(auth.user.id);
|
||||
expect(grant).not.toContain(auth.sessionId);
|
||||
expect(grant).not.toContain('hwe:default');
|
||||
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, now)).toBe(true);
|
||||
expect(verifyRealtimeAccessGrantHeader([grant], auth, 'hwe:default', secret, now)).toBe(true);
|
||||
expect(
|
||||
verifyRealtimeAccessGrant(grant, { ...auth, sessionId: 'another-session' }, 'hwe:default', secret, now)
|
||||
).toBe(false);
|
||||
expect(verifyRealtimeAccessGrant(grant, auth, 'che:default', secret, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects expired, tampered, unauthenticated, and malformed grants', () => {
|
||||
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
||||
const atExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS);
|
||||
const afterExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS + 1);
|
||||
const grantParts = grant.split('.');
|
||||
const encryptedPart = grantParts[1] ?? '';
|
||||
grantParts[1] = `${encryptedPart.startsWith('A') ? 'B' : 'A'}${encryptedPart.slice(1)}`;
|
||||
const tampered = grantParts.join('.');
|
||||
|
||||
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, atExpiry)).toBe(false);
|
||||
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, afterExpiry)).toBe(false);
|
||||
expect(verifyRealtimeAccessGrant(tampered, auth, 'hwe:default', secret, now)).toBe(false);
|
||||
expect(verifyRealtimeAccessGrant(grant, null, 'hwe:default', secret, now)).toBe(false);
|
||||
expect(verifyRealtimeAccessGrant('not-a-grant', auth, 'hwe:default', secret, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('registers a grant in Redis and consumes it exactly once', async () => {
|
||||
const values = new Set<string>();
|
||||
const redis = {
|
||||
set: async (key: string) => {
|
||||
if (values.has(key)) return null;
|
||||
values.add(key);
|
||||
return 'OK';
|
||||
},
|
||||
eval: async (_script: string, options: { keys: string[] }) =>
|
||||
values.delete(options.keys[0] ?? '') ? 1 : 0,
|
||||
};
|
||||
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
||||
|
||||
await expect(registerRealtimeAccessGrant(redis, grant, 'hwe:default')).resolves.toBe(true);
|
||||
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user