merge: 최신 main의 NPC 정책과 운영 보완을 오픈 요청에 통합한다
This commit is contained in:
@@ -40,10 +40,10 @@
|
||||
"@trpc/server": "^11.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.12.5",
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.35.0",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { authedProcedure } from '../../trpc.js';
|
||||
import { getAuthenticatedUserId } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
import { projectPolicyConfiguration, projectPolicySummary } from '../playAudit/policies.js';
|
||||
import { readAudit, readAuditWorld } from '../playAudit/shared.js';
|
||||
|
||||
const area = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY']);
|
||||
const summarySelect = {
|
||||
id: true,
|
||||
schemaVersion: true,
|
||||
nationId: true,
|
||||
area: true,
|
||||
revision: true,
|
||||
previousId: true,
|
||||
source: true,
|
||||
year: true,
|
||||
month: true,
|
||||
actor: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditPolicySelect;
|
||||
|
||||
// 소속·기밀 권한과 이력을 같은 snapshot에서 읽어 이적/권한 변경 경합을 막는다.
|
||||
const readNationId = async (ctx: GameApiContext, tx: GamePrisma.TransactionClient): Promise<number> => {
|
||||
const general = await tx.general.findFirst({ where: { userId: getAuthenticatedUserId(ctx) } });
|
||||
if (!general) throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
||||
if (general.nationId <= 0)
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
|
||||
const nation = await tx.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } });
|
||||
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
if (resolveSecretPermission(general, nation.meta) < 1)
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
return general.nationId;
|
||||
};
|
||||
|
||||
export const policyHistory = authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
area,
|
||||
cursor: z.number().int().positive().optional(),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const nationId = await readNationId(ctx, tx);
|
||||
const world = await readAuditWorld(tx);
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditPolicy.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
nationId,
|
||||
area: input.area,
|
||||
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
|
||||
},
|
||||
orderBy: { revision: 'desc' },
|
||||
take: input.limit + 1,
|
||||
select: summarySelect,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
historyGap: world.historyGap,
|
||||
items: rows.slice(0, input.limit).map(projectPolicySummary),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const policyVersion = authedProcedure
|
||||
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const nationId = await readNationId(ctx, tx);
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditPolicy.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId, nationId, area: { in: area.options } },
|
||||
select: { ...summarySelect, before: true, after: true },
|
||||
})
|
||||
: null;
|
||||
if (!row)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '자국의 현재 기수 정책 기록을 찾을 수 없습니다.' });
|
||||
return projectPolicyConfiguration(row);
|
||||
})
|
||||
);
|
||||
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
import { policyHistory, policyVersion } from './history.js';
|
||||
|
||||
type SetterInfo = {
|
||||
setter: string | null;
|
||||
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
|
||||
};
|
||||
|
||||
export const npcRouter = router({
|
||||
policyHistory,
|
||||
policyVersion,
|
||||
getPolicy: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
import fastify from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
|
||||
const databaseUrl = process.env.NPC_POLICY_DATABASE_URL;
|
||||
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
|
||||
const redis = process.env.REDIS_URL ? createRedisConnector({ url: process.env.REDIS_URL }) : null;
|
||||
let runtime: TurnDaemonRuntime | undefined;
|
||||
let loop: Promise<void> | undefined;
|
||||
const profile = `npc-policy:${databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : 'fixture'}`;
|
||||
const baseTime = new Date('0185-01-01T00:00:00Z');
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!databaseUrl) return;
|
||||
if (!new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_policy_fixture'))
|
||||
throw new Error('Dedicated NPC policy fixture schema required');
|
||||
await connector!.connect();
|
||||
await redis!.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (loop) {
|
||||
await runtime?.lifecycle.stop();
|
||||
await loop;
|
||||
}
|
||||
await runtime?.close();
|
||||
await connector?.disconnect();
|
||||
await redis?.disconnect();
|
||||
});
|
||||
|
||||
integration(
|
||||
'HTTP user edits survive daemon restart and real NPC turns, with readable actor and before/after history',
|
||||
async () => {
|
||||
const db = connector!.prisma;
|
||||
await db.playAuditPolicy.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ serverId: 'npc-policy-lifecycle' },
|
||||
{ id: { in: ['c'.repeat(64), 'd'.repeat(64), 'e'.repeat(64)] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.playAuditMonth.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: 'npc-policy-lifecycle' } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'npc-policy-http-' } } });
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date(),
|
||||
installOptions: {
|
||||
openAt: new Date(Date.now() + 86_400_000),
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 2,
|
||||
showImgLevel: 3,
|
||||
serverId: 'npc-policy-lifecycle',
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
await db.generalTurn.deleteMany();
|
||||
await db.nationTurn.deleteMany();
|
||||
await db.general.deleteMany();
|
||||
await db.nation.deleteMany();
|
||||
await db.diplomacy.deleteMany();
|
||||
// 185년 고정 fixture에 180년 시나리오의 지연 등장 이벤트를 이월하지 않는다.
|
||||
// 일반턴·월간 재정/도시 갱신은 실제 runtime handler를 그대로 실행한다.
|
||||
await db.event.deleteMany();
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const world = await db.worldState.findFirstOrThrow();
|
||||
await db.worldState.update({
|
||||
where: { id: world.id },
|
||||
data: {
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
clockBaseTime: baseTime,
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockPhase: 'RUNNING',
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(),
|
||||
config: { ...asRecord(world.config), tournamentTrig: false },
|
||||
},
|
||||
});
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: '정책국',
|
||||
color: '#ffffff',
|
||||
level: 3,
|
||||
chiefGeneralId: 1,
|
||||
capitalCityId: city.id,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
tech: 3000,
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
await db.city.updateMany({ data: { nationId: 0 } });
|
||||
await db.city.update({
|
||||
where: { id: city.id },
|
||||
data: {
|
||||
nationId: 1,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
supplyState: 1,
|
||||
frontState: 1,
|
||||
meta: { trust: 80, trade: 100 },
|
||||
},
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 'npc-policy-owner',
|
||||
name: '정책군주',
|
||||
nationId: 1,
|
||||
cityId: city.id,
|
||||
officerLevel: 12,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0185-02-01Z'),
|
||||
turnTick: 100_000_000n,
|
||||
meta: { belong: 5, killturn: 24 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '징병검증장',
|
||||
nationId: 1,
|
||||
cityId: city.id,
|
||||
officerLevel: 5,
|
||||
npcState: 2,
|
||||
leadership: 80,
|
||||
strength: 80,
|
||||
intel: 30,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
crew: 0,
|
||||
turnTime: baseTime,
|
||||
turnTick: 0n,
|
||||
meta: { killturn: 24, armType: 1, dex1: 1000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 10_000);
|
||||
const auth: NonNullable<GameApiContext['auth']> = {
|
||||
version: 1,
|
||||
profile,
|
||||
sessionId: 'fixture-session',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
user: { id: 'npc-policy-owner', username: 'fixture', displayName: 'fixture', roles: [] },
|
||||
sanctions: {},
|
||||
};
|
||||
const app = fastify();
|
||||
let request = 0;
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/che/api/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: ({
|
||||
req,
|
||||
}: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
}): GameApiContext => ({
|
||||
db,
|
||||
redis: redis!.client,
|
||||
profile: { id: 'hwe', scenario: '903', name: profile },
|
||||
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
|
||||
requestId: `npc-policy-http-${++request}`,
|
||||
turnDaemon: transport,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis!.client, profile),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
uploadDir: '/tmp',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
gameTokenSecret: 'fixture-only',
|
||||
}),
|
||||
},
|
||||
});
|
||||
const actions: Array<{ kind: string; actionKey: string; generalId: number; completed?: boolean }> = [];
|
||||
const start = async () => {
|
||||
const instance = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableLeaseHeartbeat: false,
|
||||
onActionResolved: (action) => actions.push(action),
|
||||
});
|
||||
// 자연 진행은 정지하고, 사용자 command와 명시한 due 턴만 처리한다.
|
||||
instance.lifecycle.pause('NPC policy fixture');
|
||||
return instance;
|
||||
};
|
||||
const call = async (endpoint: string, input: unknown, mutation = false) => {
|
||||
const response = await app.inject({
|
||||
method: mutation ? 'POST' : 'GET',
|
||||
url: `/che/api/trpc/npc.${endpoint}${mutation ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`,
|
||||
headers: {
|
||||
'x-session-token': 'fixture-session',
|
||||
...(mutation ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(mutation ? { payload: JSON.stringify(input) } : {}),
|
||||
});
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
return response.json<{ result: { data: unknown } }>().result.data;
|
||||
};
|
||||
const configuration = (value: unknown) => {
|
||||
const data = asRecord(value);
|
||||
return {
|
||||
values: data.currentNationPolicy,
|
||||
nation: data.currentNationPriority,
|
||||
general: data.currentGeneralActionPriority,
|
||||
setters: data.lastSetters,
|
||||
};
|
||||
};
|
||||
try {
|
||||
runtime = await start();
|
||||
loop = runtime.lifecycle.start();
|
||||
// 발령과 징병을 목록에서 빼면 실제 턴에서도 수행하지 않아야 한다.
|
||||
await call('setNationPriority', ['NPC포상'], true);
|
||||
await call('setGeneralPriority', ['출병', '일반내정'], true);
|
||||
await call(
|
||||
'setNationPolicy',
|
||||
{ minWarCrew: 2000, minNPCRecruitCityPopulation: 30000, safeRecruitCityPopulationRatio: 0 },
|
||||
true
|
||||
);
|
||||
const withoutRecruitment = await call('getPolicy', {});
|
||||
expect(withoutRecruitment).toMatchObject({
|
||||
currentNationPriority: ['NPC포상'],
|
||||
currentGeneralActionPriority: ['출병', '일반내정'],
|
||||
});
|
||||
await runtime.lifecycle.stop();
|
||||
await loop;
|
||||
loop = undefined;
|
||||
await runtime.close();
|
||||
runtime = await start();
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
|
||||
let result = await runtime.processor.run(baseTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 });
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
|
||||
expect(actions).toContainEqual(expect.objectContaining({ generalId: 2, kind: 'nation' }));
|
||||
expect(
|
||||
actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병')
|
||||
).toBe(false);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
const nationDecision = await db.playAuditDecision.findFirstOrThrow({
|
||||
where: { generalId: 2, phase: 'nation' },
|
||||
include: { chunks: { orderBy: { ordinal: 'asc' } } },
|
||||
});
|
||||
const procedures = nationDecision.chunks.flatMap((chunk) =>
|
||||
Array.isArray(chunk.steps)
|
||||
? chunk.steps
|
||||
.map(asRecord)
|
||||
.filter((step) => step.kind === 'PROCEDURE_START')
|
||||
.map((step) => step.procedure)
|
||||
: []
|
||||
);
|
||||
expect(procedures).toEqual(['NPC포상']);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
|
||||
// 같은 장수·자원으로 징병 우선순위를 맨 앞에 저장한 뒤 다시 시작한다.
|
||||
loop = runtime.lifecycle.start();
|
||||
await call('setGeneralPriority', ['징병', '출병', '일반내정'], true);
|
||||
const saved = await call('getPolicy', {});
|
||||
await runtime.lifecycle.stop();
|
||||
await loop;
|
||||
loop = undefined;
|
||||
await runtime.close();
|
||||
runtime = await start();
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
const beforeMeta = asRecord(runtime.world.getNationById(1)!.meta);
|
||||
// 실제 due 턴과 command를 실행한다. 전방의 중립 도시가 전쟁 상태를 만든다.
|
||||
actions.length = 0;
|
||||
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 1,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBeGreaterThan(0);
|
||||
expect(actions).toContainEqual(
|
||||
expect.objectContaining({ generalId: 2, actionKey: 'che_징병', completed: true })
|
||||
);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: 2 } })).crew).toBeGreaterThan(0);
|
||||
expect(asRecord(runtime.world.getNationById(1)!.meta).npc_nation_policy).toEqual(
|
||||
beforeMeta.npc_nation_policy
|
||||
);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
// 병력이 소진되고 도시 인구가 정책 하한 미만이면 징병을 거른다.
|
||||
runtime.world.updateGeneral(2, { crew: 0 });
|
||||
runtime.world.updateCity(city.id, { population: 30_100 });
|
||||
actions.length = 0;
|
||||
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 1,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
|
||||
expect(actions.some((action) => action.actionKey === 'che_징병')).toBe(false);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
// 이후의 개인턴/월간 갱신도 정책을 덮어쓰면 안 된다.
|
||||
const target = new Date(runtime.world.getGeneralById(2)!.turnTime.getTime() + 30 * 60_000);
|
||||
result = await runtime.processor.run(target, { budgetMs: 10_000, maxGenerals: 100, catchUpCap: 12 });
|
||||
expect(result.processedGenerals).toBeGreaterThan(1);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect(await call('getPolicy', {})).toMatchObject({
|
||||
currentNationPriority: ['NPC포상'],
|
||||
currentGeneralActionPriority: ['징병', '출병', '일반내정'],
|
||||
currentNationPolicy: { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000 },
|
||||
});
|
||||
expect(runtime.world.getState().meta.playAuditGap).toBeUndefined();
|
||||
const history = await call('policyHistory', { area: 'NPC_NATION_PRIORITY' });
|
||||
expect(history).toMatchObject({
|
||||
items: [
|
||||
expect.objectContaining({ source: 'CHANGE', actor: expect.objectContaining({ name: '정책군주' }) }),
|
||||
expect.anything(),
|
||||
],
|
||||
});
|
||||
const row = await db.playAuditPolicy.findFirstOrThrow({
|
||||
where: { nationId: 1, area: 'NPC_NATION_PRIORITY', source: 'CHANGE' },
|
||||
});
|
||||
expect(await call('policyVersion', { id: row.id })).toMatchObject({
|
||||
actor: { name: '정책군주' },
|
||||
fields: [{ key: 'priority', beforeJson: 'null', afterJson: '["NPC포상"]', changed: true }],
|
||||
});
|
||||
expect(row.inputSequence).not.toBeNull();
|
||||
expect((await db.inputEvent.findUniqueOrThrow({ where: { sequence: row.inputSequence! } })).status).toBe(
|
||||
'SUCCEEDED'
|
||||
);
|
||||
// 외국/이전 기수/국방 정책 ID를 알아도 자국 NPC 정책으로 조회할 수 없다.
|
||||
for (const [id, patch] of [
|
||||
['c'.repeat(64), { nationId: 2 }],
|
||||
['d'.repeat(64), { serverId: 'previous-season' }],
|
||||
['e'.repeat(64), { area: 'DEFENCE' }],
|
||||
] as const) {
|
||||
await db.playAuditPolicy.create({
|
||||
data: {
|
||||
...row,
|
||||
id,
|
||||
...patch,
|
||||
actor: row.actor ?? GamePrisma.DbNull,
|
||||
before: row.before ?? GamePrisma.DbNull,
|
||||
after: row.after ?? GamePrisma.JsonNull,
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
url: `/che/api/trpc/npc.policyVersion?input=${encodeURIComponent(JSON.stringify({ id }))}`,
|
||||
headers: { 'x-session-token': 'fixture-session' },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
}
|
||||
await db.general.update({ where: { id: 1 }, data: { penalty: { noChief: true } } });
|
||||
const denied = await app.inject({
|
||||
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify({ area: 'NPC_VALUES' }))}`,
|
||||
headers: { 'x-session-token': 'fixture-session' },
|
||||
});
|
||||
expect(denied.statusCode).toBe(403);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
},
|
||||
90_000
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fastify from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
@@ -96,10 +98,16 @@ const createContext = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
nation?: typeof baseNation;
|
||||
world?: typeof baseWorld;
|
||||
world?: {
|
||||
config: typeof baseWorld.config;
|
||||
meta: Record<string, unknown>;
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
};
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
troopRows?: Array<{ troopLeaderId: number }>;
|
||||
cityRows?: Array<{ id: number }>;
|
||||
historyRows?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||
const requestCommand =
|
||||
@@ -117,6 +125,11 @@ const createContext = (
|
||||
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
|
||||
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
|
||||
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
|
||||
playAuditPolicy: {
|
||||
findMany: vi.fn(async () => options.historyRows ?? []),
|
||||
findFirst: vi.fn(async () => options.historyRows?.[0] ?? null),
|
||||
},
|
||||
$transaction: async <T>(read: (tx: object) => Promise<T>): Promise<T> => read(db),
|
||||
};
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
return {
|
||||
@@ -140,6 +153,146 @@ const createContext = (
|
||||
};
|
||||
|
||||
describe('NPC policy router', () => {
|
||||
const historyRow = {
|
||||
id: 'a'.repeat(64),
|
||||
schemaVersion: 1,
|
||||
nationId: 1,
|
||||
area: 'NPC_NATION_PRIORITY',
|
||||
revision: 2,
|
||||
previousId: 'b'.repeat(64),
|
||||
source: 'CHANGE',
|
||||
year: 185,
|
||||
month: 1,
|
||||
createdAt: new Date('2026-01-01Z'),
|
||||
actor: {
|
||||
userId: 'private-user',
|
||||
generalId: 22,
|
||||
name: '정책담당',
|
||||
nationId: 1,
|
||||
officerLevel: 12,
|
||||
npcState: 0,
|
||||
permission: 4,
|
||||
},
|
||||
before: { priority: ['NPC전방발령', 'NPC포상'] },
|
||||
after: { priority: ['NPC포상'] },
|
||||
};
|
||||
const historyContext = (options: Parameters<typeof createContext>[0] = {}) =>
|
||||
createContext({
|
||||
...options,
|
||||
world: {
|
||||
...baseWorld,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
meta: { ...baseWorld.meta, serverId: 'current-season' },
|
||||
},
|
||||
historyRows: options.historyRows ?? [historyRow],
|
||||
});
|
||||
|
||||
it('lets an ordinary nation reader inspect the actor and changes without admin request/account fields', async () => {
|
||||
const fixture = historyContext({ me: { ...baseGeneral, officerLevel: 2 } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const page = await caller.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
|
||||
expect(page.items[0]).toMatchObject({ actor: { name: '정책담당' }, revision: 2 });
|
||||
const detail = await caller.npc.policyVersion({ id: historyRow.id });
|
||||
expect(detail.fields).toEqual([
|
||||
{ key: 'priority', beforeJson: '["NPC전방발령","NPC포상"]', afterJson: '["NPC포상"]', changed: true },
|
||||
]);
|
||||
expect(JSON.stringify(detail)).not.toMatch(/private-user|permission|requestId|inputSequence/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ nationId: 0 },
|
||||
{ officerLevel: 0 },
|
||||
{ officerLevel: 1, meta: { belong: 0 } },
|
||||
{ penalty: { noChief: true } },
|
||||
])('denies history when current nation read permission is missing: %j', async (patch) => {
|
||||
const fixture = historyContext({ me: { ...baseGeneral, ...patch } });
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('scopes cursor and version lookups to the authenticated nation and current season', async () => {
|
||||
const fixture = historyContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
await caller.npc.policyHistory({ area: 'NPC_VALUES', cursor: 7, limit: 1 });
|
||||
const tx = await fixture.context.db.$transaction!(async (db) => db);
|
||||
expect(tx.playAuditPolicy.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { serverId: 'current-season', nationId: 1, area: 'NPC_VALUES', revision: { lt: 7 } },
|
||||
take: 2,
|
||||
})
|
||||
);
|
||||
await caller.npc.policyVersion({ id: historyRow.id });
|
||||
expect(tx.playAuditPolicy.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: historyRow.id,
|
||||
serverId: 'current-season',
|
||||
nationId: 1,
|
||||
area: { in: ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY'] },
|
||||
},
|
||||
})
|
||||
);
|
||||
await expect(caller.npc.policyHistory({ area: 'NPC_VALUES', limit: 51 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.npc.policyVersion({ id: 'bad' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('requires authentication and rejects caller-supplied nation or defence history over HTTP', async () => {
|
||||
const fixture = historyContext();
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/che/api/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: ({ req }: { req: { headers: Record<string, string | string[] | undefined> } }) => ({
|
||||
...fixture.context,
|
||||
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const query = (input: object, authenticated = true) =>
|
||||
app.inject({
|
||||
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify(input))}`,
|
||||
headers: authenticated ? { 'x-session-token': 'fixture-session' } : {},
|
||||
});
|
||||
expect((await query({ area: 'NPC_VALUES' }, false)).statusCode).toBe(401);
|
||||
expect((await query({ area: 'NPC_VALUES' })).statusCode).toBe(200);
|
||||
expect((await query({ area: 'DEFENCE' })).statusCode).toBe(400);
|
||||
expect((await query({ area: 'NPC_VALUES', nationId: 2 })).statusCode).toBe(400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('denies sanctioned sessions before reading history', async () => {
|
||||
const fixture = historyContext();
|
||||
fixture.context.auth = { ...auth, sanctions: { suspendedUntil: '2999-01-01T00:00:00Z' } };
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(fixture.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('paginates revisions without exposing a missing or unavailable version', async () => {
|
||||
const fixture = historyContext({ historyRows: [historyRow, { ...historyRow, revision: 1 }] });
|
||||
const page = await appRouter
|
||||
.createCaller(fixture.context)
|
||||
.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.nextCursor).toBe(2);
|
||||
const missing = historyContext({ historyRows: [] });
|
||||
await expect(
|
||||
appRouter.createCaller(missing.context).npc.policyVersion({ id: historyRow.id })
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
const noIdentity = createContext();
|
||||
await expect(
|
||||
appRouter.createCaller(noIdentity.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).resolves.toMatchObject({ coverage: 'IDENTITY_MISSING', items: [], nextCursor: null });
|
||||
});
|
||||
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
|
||||
const fixture = createContext();
|
||||
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { devices, expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -14,6 +14,8 @@ type FixtureState = {
|
||||
mutations: string[];
|
||||
nationPriorityInput?: string[];
|
||||
currentNationPriority?: string[];
|
||||
currentGeneralPriority?: string[];
|
||||
historyVersion?: number;
|
||||
};
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
@@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({
|
||||
currentNationPriority: state.currentNationPriority ?? nationPriority,
|
||||
availableNationPriorityItems: nationPriority,
|
||||
defaultGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority,
|
||||
availableGeneralActionPriorityItems: generalPriority,
|
||||
lastSetters: {
|
||||
policy: { setter: null, date: null },
|
||||
@@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
: response(policyFixture(state));
|
||||
}
|
||||
if (operation === 'npc.policyHistory') {
|
||||
return response({
|
||||
coverage: 'RECORDED_VERSIONS_ONLY',
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
id: 'a'.repeat(64),
|
||||
revision: state.historyVersion ?? 2,
|
||||
year: 185,
|
||||
month: 1,
|
||||
source: 'CHANGE',
|
||||
actor: { name: '정책담당', generalId: 22 },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'npc.policyVersion') {
|
||||
return response({
|
||||
id: 'a'.repeat(64),
|
||||
revision: state.historyVersion ?? 2,
|
||||
year: 185,
|
||||
month: 1,
|
||||
source: 'CHANGE',
|
||||
createdAt: '2026-10-22T00:00:00Z',
|
||||
actor: { name: '정책담당', generalId: 22 },
|
||||
fields: [
|
||||
{
|
||||
key: 'priority',
|
||||
beforeJson: '["NPC전방발령","NPC포상"]',
|
||||
afterJson: '["NPC포상"]',
|
||||
changed: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (
|
||||
operation === 'npc.setNationPolicy' ||
|
||||
operation === 'npc.setNationPriority' ||
|
||||
@@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
state.failNextMutation = false;
|
||||
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
|
||||
}
|
||||
state.historyVersion = (state.historyVersion ?? 2) + 1;
|
||||
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput))
|
||||
state.currentNationPriority = jsonInput.map(String);
|
||||
if (operation === 'npc.setGeneralPriority' && Array.isArray(jsonInput))
|
||||
state.currentGeneralPriority = jsonInput.map(String);
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
@@ -206,6 +248,103 @@ const screenshot = async (page: Page, name: string) => {
|
||||
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
|
||||
};
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`일반 사용자가 정책 변경자와 전후 이력을 조회하고 새로고침한다 ${width}px`, async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { permissionLevel: 1, mutations: [], currentNationPriority: ['NPC포상'] };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
|
||||
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
|
||||
await expect(history).toContainText('정책담당 (#22)');
|
||||
await history.getByLabel('이력 종류').selectOption('NPC_NATION_PRIORITY');
|
||||
await history.getByRole('button', { name: '버전 2', exact: true }).click();
|
||||
const detail = page.getByRole('region', { name: '정책 변경 내용', exact: true });
|
||||
await expect(detail).toContainText('["NPC전방발령","NPC포상"]');
|
||||
await expect(detail).toContainText('["NPC포상"]');
|
||||
await history.getByLabel('이력 종류').focus();
|
||||
await expect(history.getByLabel('이력 종류')).toBeFocused();
|
||||
const measurements = await history.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const cell = element.querySelector('td')!;
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
style: {
|
||||
verticalAlign: getComputedStyle(cell).verticalAlign,
|
||||
whiteSpace: getComputedStyle(cell.querySelector('pre')!).whiteSpace,
|
||||
},
|
||||
html: element.outerHTML,
|
||||
overflow: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
expect(measurements.style).toEqual({ verticalAlign: 'middle', whiteSpace: 'pre-wrap' });
|
||||
expect(measurements.overflow).toBe(false);
|
||||
await testInfo.attach('history-dom-geometry-style', {
|
||||
body: JSON.stringify(measurements, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await writeFile(testInfo.outputPath('history-dom-geometry-style.json'), JSON.stringify(measurements, null, 2));
|
||||
await page.screenshot({ path: testInfo.outputPath(`npc-history-${width}.png`), fullPage: true });
|
||||
state.historyVersion = 3;
|
||||
await history.getByRole('button', { name: '이력 새로고침' }).click();
|
||||
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
const nationPanel = page.locator('.priority-panel').first();
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').nth(1).getByText('NPC전방발령', { exact: true })
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('발령 비활성화와 징병 우선순위를 저장한 뒤 새로고침해도 유지되고 열린 이력이 갱신된다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permissionLevel: 4,
|
||||
mutations: [],
|
||||
currentNationPriority: ['NPC전방발령', 'NPC포상'],
|
||||
currentGeneralPriority: ['출병', '일반내정', '징병'],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
|
||||
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
|
||||
await expect(history.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
|
||||
const nationPanel = page.locator('.priority-panel').first();
|
||||
await nationPanel
|
||||
.locator('.priority-column')
|
||||
.nth(1)
|
||||
.getByText('NPC전방발령', { exact: true })
|
||||
.dragTo(nationPanel.locator('.priority-column').first().locator('.priority-list'));
|
||||
await nationPanel.locator('.submit_btn').click();
|
||||
await acceptAppConfirmation(page, '저장할까요?');
|
||||
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
|
||||
const generalPanel = page.locator('.priority-panel').nth(1);
|
||||
const active = generalPanel.locator('.priority-column').nth(1);
|
||||
// 목록을 화면 중앙에 놓아 창 가장자리의 Sortable auto-scroll을 피한다.
|
||||
await active
|
||||
.locator('.priority-list')
|
||||
.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
|
||||
await touchDrag(page, active.getByText('징병', { exact: true }), active.getByText('출병', { exact: true }), {
|
||||
targetYRatio: 0.1,
|
||||
});
|
||||
await expect(active.locator('.priority_info > span:nth-child(2)').first()).toHaveText('징병');
|
||||
await generalPanel.locator('.submit_btn').click();
|
||||
await acceptAppConfirmation(page, '저장할까요?');
|
||||
await expect(history.getByRole('button', { name: '버전 4', exact: true })).toBeVisible();
|
||||
expect(state.currentNationPriority).toEqual(['NPC포상']);
|
||||
expect(state.currentGeneralPriority).toEqual(['징병', '출병', '일반내정']);
|
||||
await page.reload();
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
generalPanel.locator('.priority-column').nth(1).locator('.priority_info > span:nth-child(2)').first()
|
||||
).toHaveText('징병');
|
||||
});
|
||||
|
||||
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -35,14 +35,14 @@
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"@sammo-ts/logic": "workspace:*",
|
||||
"@tiptap/extension-image": "^3.5.0",
|
||||
"@tiptap/extension-link": "^3.5.0",
|
||||
"@tiptap/extension-placeholder": "^3.5.0",
|
||||
"@tiptap/extension-text-align": "^3.5.0",
|
||||
"@tiptap/extension-text-style": "^3.5.0",
|
||||
"@tiptap/extension-underline": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"@tiptap/vue-3": "^3.5.0",
|
||||
"@tiptap/extension-image": "^3.30.5",
|
||||
"@tiptap/extension-link": "^3.30.5",
|
||||
"@tiptap/extension-placeholder": "^3.30.5",
|
||||
"@tiptap/extension-text-align": "^3.30.5",
|
||||
"@tiptap/extension-text-style": "^3.30.5",
|
||||
"@tiptap/extension-underline": "^3.30.5",
|
||||
"@tiptap/starter-kit": "^3.30.5",
|
||||
"@tiptap/vue-3": "^3.30.5",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@trpc/server": "^11.8.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tippy.js": "6.3.7",
|
||||
"vue": "^3.5.26",
|
||||
"vue": "^3.5.42",
|
||||
"vue-draggable-plus": "0.6.1",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { fieldLabels } from './playAudit/policyLabels';
|
||||
|
||||
const props = defineProps<{ revision: number }>();
|
||||
type History = Awaited<ReturnType<typeof trpc.npc.policyHistory.query>>;
|
||||
type Version = Awaited<ReturnType<typeof trpc.npc.policyVersion.query>>;
|
||||
const open = ref(false);
|
||||
const area = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY'>('NPC_VALUES');
|
||||
const history = ref<History | null>(null);
|
||||
const version = ref<Version | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const labels = { BASELINE: '최초 관측', CHANGE: '변경', OBSERVED_GAP: '관측 누락 이후 기준' };
|
||||
let generation = 0;
|
||||
const load = async (append = false) => {
|
||||
const request = ++generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
if (!append) {
|
||||
history.value = null;
|
||||
version.value = null;
|
||||
}
|
||||
try {
|
||||
const response = await trpc.npc.policyHistory.query({
|
||||
area: area.value,
|
||||
cursor: append ? (history.value?.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
if (request === generation)
|
||||
history.value = {
|
||||
...response,
|
||||
items: append ? [...(history.value?.items ?? []), ...response.items] : response.items,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === generation)
|
||||
error.value = cause instanceof Error ? cause.message : '이력을 조회하지 못했습니다.';
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
const select = async (id: string) => {
|
||||
const request = ++generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
version.value = null;
|
||||
try {
|
||||
const response = await trpc.npc.policyVersion.query({ id });
|
||||
if (request === generation) version.value = response;
|
||||
} catch (cause) {
|
||||
if (request === generation)
|
||||
error.value = cause instanceof Error ? cause.message : '변경 내용을 조회하지 못했습니다.';
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
watch([open, area, () => props.revision], () => {
|
||||
if (open.value) void load();
|
||||
else {
|
||||
++generation;
|
||||
loading.value = false;
|
||||
history.value = null;
|
||||
version.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="npc-policy-history" aria-label="NPC 정책 변경 이력">
|
||||
<button class="legacy-button" :aria-expanded="open" @click="open = !open">
|
||||
정책 변경 이력 {{ open ? '닫기' : '조회' }}
|
||||
</button>
|
||||
<div v-if="open">
|
||||
<p>자국의 현재 기수 기록입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
|
||||
<p>우선순위는 실행을 시도하는 순서입니다. 징병은 전쟁 상태·병력·인구·자원 조건을 만족해야 합니다.</p>
|
||||
<p>0이면 자동 계산하는 항목은 저장값이 같아도 기술·내정 비용에 따라 기준이 달라질 수 있습니다.</p>
|
||||
<label
|
||||
>이력 종류
|
||||
<select v-model="area">
|
||||
<option value="NPC_VALUES">국가 정책</option>
|
||||
<option value="NPC_NATION_PRIORITY">국가턴 우선순위</option>
|
||||
<option value="NPC_GENERAL_PRIORITY">일반턴 우선순위</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="legacy-button" :disabled="loading" @click="load()">이력 새로고침</button>
|
||||
<p v-if="loading" role="status">조회 중…</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<template v-if="history">
|
||||
<p v-if="history.historyGap" role="status">
|
||||
{{ history.historyGap.firstYear }}년 {{ history.historyGap.firstMonth }}월 이후 일부 기록에 누락이
|
||||
있습니다. 이력이 없다는 사실만으로 변경이 없었다고 판단할 수 없습니다.
|
||||
</p>
|
||||
<p v-if="history.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 이력을 조회할 수 없습니다.</p>
|
||||
<p v-else-if="!history.items.length">기록된 정책 이력이 없습니다.</p>
|
||||
<ol v-else>
|
||||
<li v-for="item in history.items" :key="item.id">
|
||||
<button class="legacy-button" :disabled="loading" @click="select(item.id)">
|
||||
버전 {{ item.revision }}
|
||||
</button>
|
||||
{{ item.year }}년 {{ item.month }}월 · {{ labels[item.source] }} ·
|
||||
{{ item.actor ? `${item.actor.name} (#${item.actor.generalId})` : '변경 주체 미상' }}
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
v-if="history.nextCursor !== null"
|
||||
class="legacy-button"
|
||||
:disabled="loading"
|
||||
@click="load(true)"
|
||||
>
|
||||
이전 기록 더 보기
|
||||
</button>
|
||||
</template>
|
||||
<section v-if="version" aria-label="정책 변경 내용">
|
||||
<h3>버전 {{ version.revision }} · {{ version.actor?.name ?? '변경 주체 미상' }}</h3>
|
||||
<p>{{ version.year }}년 {{ version.month }}월 · 기록 시각 {{ version.createdAt }}</p>
|
||||
<p v-if="version.source !== 'CHANGE'">관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.</p>
|
||||
<p>null은 개별 설정 없이 기본값을 상속함을 뜻합니다.</p>
|
||||
<div class="table-scroll" tabindex="0" aria-label="정책 변경 전후 값">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>설정</th>
|
||||
<th>변경 전</th>
|
||||
<th>변경 후</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in version.fields" :key="field.key" :class="{ changed: field.changed }">
|
||||
<th scope="row">
|
||||
{{ fieldLabels[field.key] ?? field.key }}{{ field.changed ? ' (변경)' : '' }}
|
||||
</th>
|
||||
<td>
|
||||
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
|
||||
</td>
|
||||
<td>
|
||||
<pre>{{ field.afterJson }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.npc-policy-history {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
select,
|
||||
button {
|
||||
margin: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
li {
|
||||
margin-block: 6px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border: 1px solid gray;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 100px;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.changed {
|
||||
background: #343425;
|
||||
}
|
||||
[role='alert'] {
|
||||
color: #ffb9b9;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--sammo-font-size-normal);
|
||||
}
|
||||
</style>
|
||||
@@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const { pageExitLabel, exitPage } = usePageExit();
|
||||
const historyRevision = ref(0);
|
||||
|
||||
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
|
||||
try {
|
||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||
historyRevision.value += 1;
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
await trpc.npc.setGeneralPriority.mutate(state.active);
|
||||
lastSavedGeneralPriority.value = [...state.active];
|
||||
}
|
||||
historyRevision.value += 1;
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
|
||||
<div class="sortable-compat-controls" aria-hidden="true">
|
||||
<button type="button" tabindex="-1" />
|
||||
<input v-for="index in 20" :key="index" type="hidden" />
|
||||
|
||||
@@ -40,11 +40,12 @@
|
||||
"@trpc/server": "^11.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.12.5",
|
||||
"ipaddr.js": "2.5.0",
|
||||
"pm2": "^7.0.3",
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.35.0",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
|
||||
@@ -148,12 +148,9 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
|
||||
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
||||
const key = this.stateKey(state);
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthPendingState>(raw);
|
||||
// Callback 재사용 방지는 읽기와 삭제를 같은 Redis 실행에서 완료해야 한다.
|
||||
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||
return typeof raw === 'string' ? parseJson<OAuthPendingState>(raw) : null;
|
||||
}
|
||||
|
||||
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
||||
|
||||
@@ -54,6 +54,8 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
||||
args: definition.args,
|
||||
env: sanitizePm2IdentityEnv(definition.env ?? {}),
|
||||
autorestart: true,
|
||||
// PM2의 chokidar/braces glob parser에 사용자 입력을 전달하는 watch 경로는 사용하지 않는다.
|
||||
watch: false,
|
||||
max_restarts: 5,
|
||||
min_uptime: 10_000,
|
||||
restart_delay: 2_000,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
@@ -200,8 +201,7 @@ export class WebPushCoordinator {
|
||||
|
||||
async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise<void> {
|
||||
if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
|
||||
const endpointUrl = new URL(subscription.endpoint);
|
||||
if (endpointUrl.protocol !== 'https:') throw new Error('보안 연결의 Push 구독만 저장할 수 있습니다.');
|
||||
await validatePushEndpoint(subscription.endpoint);
|
||||
const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null;
|
||||
await this.prisma.webPushSubscription.upsert({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
@@ -428,7 +428,7 @@ export class WebPushCoordinator {
|
||||
const expired = await this.prisma.$transaction(async (tx) => {
|
||||
const count = await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery AS delivery
|
||||
SET status = 'FAILED'::"WebPushDeliveryStatus",
|
||||
SET status = 'FAILED',
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = 'Push subscription expired.'
|
||||
@@ -453,6 +453,8 @@ export class WebPushCoordinator {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
|
||||
parsePushEndpoint(delivery.subscription.endpoint);
|
||||
await webPush.sendNotification(
|
||||
{
|
||||
endpoint: delivery.subscription.endpoint,
|
||||
@@ -464,11 +466,11 @@ export class WebPushCoordinator {
|
||||
url: delivery.notification.url,
|
||||
tag: delivery.notification.tag,
|
||||
}),
|
||||
{ TTL: 60 * 60 }
|
||||
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
|
||||
);
|
||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
SET status = 'DELIVERED'::"WebPushDeliveryStatus",
|
||||
SET status = 'DELIVERED',
|
||||
delivered_at = CURRENT_TIMESTAMP,
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
@@ -481,6 +483,7 @@ export class WebPushCoordinator {
|
||||
? Number((error as { statusCode?: unknown }).statusCode)
|
||||
: 0;
|
||||
const terminal =
|
||||
error instanceof PushEndpointPolicyError ||
|
||||
statusCode === 404 ||
|
||||
statusCode === 410 ||
|
||||
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
@@ -493,7 +496,7 @@ export class WebPushCoordinator {
|
||||
const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
SET status = ${nextStatus}::"WebPushDeliveryStatus",
|
||||
SET status = ${nextStatus},
|
||||
available_at = CURRENT_TIMESTAMP
|
||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||
locked_at = NULL,
|
||||
@@ -501,7 +504,7 @@ export class WebPushCoordinator {
|
||||
last_error = ${safeError}
|
||||
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
||||
`);
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
if (error instanceof PushEndpointPolicyError || statusCode === 404 || statusCode === 410) {
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_subscription
|
||||
SET disabled_at = CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import type { LookupAddress } from 'node:dns';
|
||||
import { Agent } from 'node:https';
|
||||
import { isIP, type LookupFunction } from 'node:net';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
export class PushEndpointPolicyError extends Error {
|
||||
constructor() {
|
||||
// URL, DNS 응답과 구독 capability는 API 오류나 로그에 포함하지 않는다.
|
||||
super('Push endpoint must use HTTPS on port 443 and a public destination.');
|
||||
this.name = 'PushEndpointPolicyError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PushAddressResolver = (hostname: string) => Promise<LookupAddress[]>;
|
||||
const resolveAddresses: PushAddressResolver = (hostname) => lookup(hostname, { all: true });
|
||||
|
||||
export const isPublicPushAddress = (address: string): boolean => {
|
||||
try {
|
||||
// 사설, loopback, link-local, multicast, 문서용·전환·예약 주소를 모두 거부한다.
|
||||
return ipaddr.process(address).range() === 'unicast';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePushEndpoint = (endpoint: string): URL => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new PushEndpointPolicyError();
|
||||
}
|
||||
const hostname = url.hostname.replace(/^\[|\]$/gu, '');
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
(url.port !== '' && url.port !== '443') ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
!hostname ||
|
||||
isIP(hostname) !== 0
|
||||
) {
|
||||
// IP literal은 Node가 custom lookup을 건너뛰므로 구독으로 받지 않는다.
|
||||
throw new PushEndpointPolicyError();
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const resolvePublicPushAddresses = async (
|
||||
hostname: string,
|
||||
resolver: PushAddressResolver = resolveAddresses
|
||||
): Promise<LookupAddress[]> => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const addresses = await Promise.race([
|
||||
resolver(hostname),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Push endpoint resolution failed.')), 5_000);
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
addresses.length === 0 ||
|
||||
addresses.some(({ address, family }) => !isPublicPushAddress(address) || isIP(address) !== family)
|
||||
) {
|
||||
throw new PushEndpointPolicyError();
|
||||
}
|
||||
return addresses;
|
||||
} catch (error) {
|
||||
if (error instanceof PushEndpointPolicyError) throw error;
|
||||
// 일시 DNS 장애는 전송 재시도 대상으로 남기고 구독을 영구 비활성화하지 않는다.
|
||||
// eslint-disable-next-line preserve-caught-error -- DNS 오류의 hostname 등 비공개 목적지는 cause에도 복제하지 않는다.
|
||||
throw new Error('Push endpoint resolution failed.');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
export const validatePushEndpoint = async (endpoint: string): Promise<void> => {
|
||||
const url = parsePushEndpoint(endpoint);
|
||||
await resolvePublicPushAddresses(url.hostname);
|
||||
};
|
||||
|
||||
export const createPublicPushLookup =
|
||||
(resolver: PushAddressResolver = resolveAddresses): LookupFunction =>
|
||||
(hostname, options, callback) => {
|
||||
// 저장 시 검사만으로는 DNS rebinding을 막지 못한다. socket이 실제 연결할
|
||||
// DNS 응답을 검사하고 바로 그 주소만 반환하여 재조회 경로를 없앤다.
|
||||
void resolvePublicPushAddresses(hostname, resolver).then(
|
||||
(addresses) => {
|
||||
const requestedFamily = typeof options.family === 'number' ? options.family : 0;
|
||||
const matching = requestedFamily
|
||||
? addresses.filter(({ family }) => family === requestedFamily)
|
||||
: addresses;
|
||||
if (matching.length === 0) {
|
||||
callback(new PushEndpointPolicyError(), []);
|
||||
} else if (options.all) {
|
||||
callback(null, matching);
|
||||
} else {
|
||||
callback(null, matching[0].address, matching[0].family);
|
||||
}
|
||||
},
|
||||
(error: Error) => callback(error, [])
|
||||
);
|
||||
};
|
||||
|
||||
// web-push는 https.request를 직접 호출하고 redirect를 따라가지 않는다.
|
||||
export const publicPushAgent = new Agent({ lookup: createPublicPushLookup(), maxSockets: 10 });
|
||||
@@ -14,6 +14,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
const userIds = new Set<string>();
|
||||
const challengeIds = new Set<string>();
|
||||
const sessionIds = new Set<string>();
|
||||
const stateIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
@@ -24,6 +25,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
||||
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
||||
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
|
||||
...[...stateIds].map((id) => `${prefix}:oauth-state:${id}`),
|
||||
];
|
||||
if (keys.length > 0) {
|
||||
await client.del(keys);
|
||||
@@ -45,6 +47,14 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
return challenge;
|
||||
};
|
||||
|
||||
it('consumes a pending OAuth state once under concurrent callbacks', async () => {
|
||||
const pending = await store.createPendingState('verify', ['account_email'], randomUUID());
|
||||
stateIds.add(pending.state);
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => store.consumePendingState(pending.state)));
|
||||
expect(results.filter((result) => result !== null)).toEqual([pending]);
|
||||
await expect(store.consumePendingState(pending.state)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('preserves and consumes a retained-email recovery target once', async () => {
|
||||
const targetUserId = randomUUID();
|
||||
const session = await store.createSession({
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('buildPm2StartOptions', () => {
|
||||
expect(options).toMatchObject({
|
||||
name: 'sammo:che:2:game-api',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_restarts: 5,
|
||||
min_uptime: 10_000,
|
||||
restart_delay: 2_000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
@@ -22,6 +22,7 @@ integration('web push Gateway persistence boundary', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let coordinator: WebPushCoordinator;
|
||||
const dispatchErrors: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
@@ -64,12 +65,16 @@ integration('web push Gateway persistence boundary', () => {
|
||||
},
|
||||
});
|
||||
const vapid = webPush.generateVAPIDKeys();
|
||||
coordinator = new WebPushCoordinator(db, {
|
||||
enabled: true,
|
||||
vapidSubject: 'mailto:web-push-test@example.invalid',
|
||||
vapidPublicKey: vapid.publicKey,
|
||||
vapidPrivateKey: vapid.privateKey,
|
||||
});
|
||||
coordinator = new WebPushCoordinator(
|
||||
db,
|
||||
{
|
||||
enabled: true,
|
||||
vapidSubject: 'mailto:web-push-test@example.invalid',
|
||||
vapidPublicKey: vapid.publicKey,
|
||||
vapidPrivateKey: vapid.privateKey,
|
||||
},
|
||||
(error) => dispatchErrors.push(error instanceof Error ? error.message : 'Unknown dispatcher error')
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -166,4 +171,72 @@ integration('web push Gateway persistence boundary', () => {
|
||||
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a private endpoint before persisting a subscription', async () => {
|
||||
const before = await db.webPushSubscription.count({ where: { userId } });
|
||||
await expect(
|
||||
coordinator.subscribe(userId, {
|
||||
endpoint: 'https://127.0.0.1/internal',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: 'unused', auth: 'unused' },
|
||||
})
|
||||
).rejects.toThrow('public destination');
|
||||
expect(await db.webPushSubscription.count({ where: { userId } })).toBe(before);
|
||||
});
|
||||
|
||||
it('quarantines an existing unsafe subscription without sending a request', async () => {
|
||||
await coordinator.stop();
|
||||
await db.webPushDelivery.deleteMany({ where: { notification: { userId } } });
|
||||
dispatchErrors.length = 0;
|
||||
const subscription = await db.webPushSubscription.create({
|
||||
data: {
|
||||
userId,
|
||||
endpoint: 'https://[::1]/internal',
|
||||
p256dh: 'unused',
|
||||
auth: 'unused',
|
||||
},
|
||||
});
|
||||
const notification = await db.webPushNotification.create({
|
||||
data: {
|
||||
userId,
|
||||
profileName,
|
||||
dedupeKey: 'integration:unsafe-endpoint',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
title: 'synthetic notification',
|
||||
body: 'synthetic body',
|
||||
url: '/hwe/',
|
||||
tag: 'synthetic-tag',
|
||||
},
|
||||
});
|
||||
const delivery = await db.webPushDelivery.create({
|
||||
data: {
|
||||
subscriptionId: subscription.id,
|
||||
notificationId: notification.id,
|
||||
},
|
||||
});
|
||||
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 });
|
||||
try {
|
||||
coordinator.start();
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(dispatchErrors).toEqual([]);
|
||||
expect((await db.webPushDelivery.findUniqueOrThrow({ where: { id: delivery.id } })).status).toBe(
|
||||
'FAILED'
|
||||
);
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
expect(await db.webPushSubscription.findUnique({ where: { id: subscription.id } })).toMatchObject({
|
||||
disabledAt: expect.any(Date),
|
||||
});
|
||||
expect(sender).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: subscription.endpoint }),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
} finally {
|
||||
await coordinator.stop();
|
||||
sender.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createServer, request, Agent } from 'node:https';
|
||||
import type { LookupAddress, LookupOptions } from 'node:dns';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createPublicPushLookup,
|
||||
isPublicPushAddress,
|
||||
parsePushEndpoint,
|
||||
PushEndpointPolicyError,
|
||||
resolvePublicPushAddresses,
|
||||
} from '../src/webPush/endpointPolicy.js';
|
||||
|
||||
describe('Web Push outbound destination policy', () => {
|
||||
it.each([
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'10.0.0.1',
|
||||
'172.16.0.1',
|
||||
'192.168.1.1',
|
||||
'169.254.169.254',
|
||||
'100.64.0.1',
|
||||
'192.0.2.1',
|
||||
'198.18.0.1',
|
||||
'224.0.0.1',
|
||||
'255.255.255.255',
|
||||
'::1',
|
||||
'::',
|
||||
'fc00::1',
|
||||
'fe80::1',
|
||||
'ff02::1',
|
||||
'2001:db8::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'64:ff9b::a00:1',
|
||||
'2002:0a00:0001::1',
|
||||
'not-an-ip',
|
||||
])('rejects non-public address %s', (address) => {
|
||||
expect(isPublicPushAddress(address)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111'])('accepts public address %s', (address) => {
|
||||
expect(isPublicPushAddress(address)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://push.example/subscription',
|
||||
'https://push.example:8443/subscription',
|
||||
'https://user:password@push.example/subscription',
|
||||
'https://push.example/subscription#secret',
|
||||
'https://127.0.0.1/subscription',
|
||||
'https://2130706433/subscription',
|
||||
'https://0x7f000001/subscription',
|
||||
'https://[::1]/subscription',
|
||||
'https://[::ffff:127.0.0.1]/subscription',
|
||||
'not-a-url',
|
||||
])('rejects endpoint %s without DNS or network I/O', (endpoint) => {
|
||||
expect(() => parsePushEndpoint(endpoint)).toThrow(PushEndpointPolicyError);
|
||||
});
|
||||
|
||||
it('preserves provider capability paths and query parameters', () => {
|
||||
const endpoint = 'https://fcm.googleapis.com:443/fcm/send/synthetic-capability?version=1';
|
||||
expect(parsePushEndpoint(endpoint).hostname).toBe('fcm.googleapis.com');
|
||||
expect(parsePushEndpoint(endpoint).search).toBe('?version=1');
|
||||
});
|
||||
|
||||
it('rejects mixed public/private answers, empty answers and resolution errors without leaking values', async () => {
|
||||
for (const addresses of [
|
||||
[],
|
||||
[
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
],
|
||||
]) {
|
||||
await expect(resolvePublicPushAddresses('push.example', async () => addresses)).rejects.toThrow(
|
||||
PushEndpointPolicyError
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
resolvePublicPushAddresses('push.example', async () => {
|
||||
throw new Error('sensitive resolver details');
|
||||
})
|
||||
).rejects.toThrow('Push endpoint resolution failed.');
|
||||
});
|
||||
|
||||
it('returns only the validated DNS answer to the socket and respects family/all', async () => {
|
||||
const resolver = vi.fn(async () => [
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '2606:4700:4700::1111', family: 6 },
|
||||
]);
|
||||
const lookup = createPublicPushLookup(resolver);
|
||||
const runLookup = (options: LookupOptions) =>
|
||||
new Promise<string | LookupAddress[]>((resolve, reject) => {
|
||||
lookup('push.example', options, (error, address) => (error ? reject(error) : resolve(address)));
|
||||
});
|
||||
await expect(runLookup({ family: 6 })).resolves.toBe('2606:4700:4700::1111');
|
||||
await expect(runLookup({ all: true, family: 4 })).resolves.toEqual([{ address: '8.8.8.8', family: 4 }]);
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('blocks a rebound hostname at the actual HTTPS socket before contacting a local listener', async () => {
|
||||
let connections = 0;
|
||||
const server = createServer();
|
||||
server.on('connection', () => {
|
||||
connections += 1;
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
|
||||
.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
|
||||
const agent = new Agent({ lookup: createPublicPushLookup(resolver) });
|
||||
try {
|
||||
await resolvePublicPushAddresses('push.example', resolver);
|
||||
const failure = await new Promise<Error>((resolve, reject) => {
|
||||
const req = request({ hostname: 'push.example', port, agent, timeout: 1_000 }, () =>
|
||||
reject(new Error('Unexpected connection'))
|
||||
);
|
||||
req.on('error', resolve);
|
||||
req.on('timeout', () => req.destroy(new Error('Unexpected timeout')));
|
||||
req.end();
|
||||
});
|
||||
expect(failure).toBeInstanceOf(PushEndpointPolicyError);
|
||||
expect(connections).toBe(0);
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
agent.destroy();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@
|
||||
"es-toolkit": "^1.43.0",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user