feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회

This commit is contained in:
2026-09-16 05:02:32 +00:00
parent d549cbf887
commit 3d60e6122d
8 changed files with 845 additions and 11 deletions
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { projectPolicyConfiguration } from '../src/router/playAudit/policies.js';
const row = {
id: 'policy',
schemaVersion: 1,
nationId: 2,
area: 'DEFENCE',
revision: 2,
previousId: 'baseline',
source: 'CHANGE',
year: 190,
month: 2,
tick: 99,
ordinal: 18,
actor: {
userId: 'private-account',
generalId: 3,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
permission: 4,
debug: 'internal',
},
createdAt: new Date('2026-09-16T00:00:00Z'),
before: { scout: 0 },
after: { scout: 1 },
requestId: 'internal-request',
inputSequence: 123n,
};
describe('policy public configuration boundary', () => {
it('retains historical office and values while excluding account and admin diagnostics', () => {
const projection = projectPolicyConfiguration(row);
expect(projection).toMatchObject({
nationId: 2,
revision: 2,
actor: { generalId: 3, officerLevel: 12 },
fields: [{ key: 'scout', beforeJson: '0', afterJson: '1', changed: true }],
});
const serialized = JSON.stringify(projection);
for (const excluded of [
'private-account',
'permission',
'debug',
'internal-request',
'inputSequence',
'tick',
'ordinal',
])
expect(serialized).not.toContain(excluded);
});
it('refuses an unsupported record schema rather than guessing its meaning', () => {
expect(() => projectPolicyConfiguration({ ...row, schemaVersion: 2 })).toThrow('지원하지 않는 정책 기록 버전');
});
it('distinguishes unobserved baseline from a recorded inherited setting', () => {
expect(
projectPolicyConfiguration({
...row,
source: 'BASELINE',
actor: null,
before: null,
after: { scout: null },
}).fields
).toEqual([{ key: 'scout', beforeJson: null, afterJson: 'null', changed: false }]);
});
});
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
import fs from 'node:fs/promises';
@@ -2154,6 +2155,8 @@ integration('game API security over HTTP transport', () => {
const auditUserId = `audit-http-${process.pid}`;
const seasonId = `audit-season-${process.pid}`;
const sampleId = `${seasonId}:190:1`;
const policyId = (revision: number) =>
createHash('sha256').update(`${seasonId}:policy:${revision}`).digest('hex');
const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } });
const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => {
const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId);
@@ -2238,6 +2241,100 @@ integration('game API security over HTTP transport', () => {
});
const admin = await token([`admin.playAudit.read:${profileName}`]);
const beforeInputs = await db.inputEvent.count();
const policyInput = {
nationId: 99128,
area: 'DEFENCE',
from: { year: 190, month: 1 },
to: { year: 190, month: 2 },
};
await db.playAuditPolicy.createMany({
data: [1, 2, 3].map((revision) => ({
id: policyId(revision),
serverId: seasonId,
nationId: 99128,
area: 'DEFENCE',
revision,
previousId: revision > 1 ? policyId(revision - 1) : null,
source: revision === 1 ? 'BASELINE' : 'CHANGE',
year: 190,
month: revision === 3 ? 2 : 1,
ordinal: revision,
tick: 12,
requestId: revision > 1 ? 'audit-policy-request' : null,
inputSequence: revision > 1 ? 9007199254740993n : null,
actor:
revision > 1
? {
userId: 'hidden-account',
generalId,
name: '당시군주',
nationId: 99128,
officerLevel: 12,
npcState: 0,
permission: 3,
extra: 'hidden-extra',
}
: GamePrisma.DbNull,
before: revision === 1 ? GamePrisma.DbNull : { scout: revision - 1 },
after: { scout: revision },
hash: 'fixture',
})),
});
const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 });
expect(policyPage.status).toBe(200);
expect(policyPage.body).toMatchObject({
result: {
data: {
nextCursor: 3,
items: [{ revision: 3, source: 'CHANGE', actor: { name: '당시군주', officerLevel: 12 } }],
},
},
});
for (const excluded of ['hidden-account', 'hidden-extra', 'before', 'after', 'inputSequence', 'requestId'])
expect(JSON.stringify(policyPage.body)).not.toContain(excluded);
expect((await get('policyHistory', admin, { ...policyInput, cursor: 3 })).body).toMatchObject({
result: { data: { nextCursor: null, items: [{ revision: 2 }, { revision: 1, actor: null }] } },
});
expect(
(await get('policyHistory', admin, { ...policyInput, to: { year: 190, month: 1 } })).body
).toMatchObject({ result: { data: { items: [{ revision: 2 }, { revision: 1 }] } } });
const policyDetail = await get('policyVersion', admin, { id: policyId(3) });
expect(policyDetail.status).toBe(200);
expect(policyDetail.body).toMatchObject({
result: {
data: {
version: {
previousId: policyId(2),
inputSequence: '9007199254740993',
fields: [{ key: 'scout', beforeJson: '2', afterJson: '3', changed: true }],
},
},
},
});
expect(JSON.stringify(policyDetail.body)).not.toContain('hidden-account');
expect((await get('policyVersion', admin, { id: policyId(1) })).body).toMatchObject({
result: { data: { version: { fields: [{ beforeJson: null, changed: false }] } } },
});
expect((await get('policyVersion', admin, { id: policyId(99) })).status).toBe(404);
for (const patch of [
{ limit: 201 },
{ cursor: 0 },
{ area: 'ANY' },
{ from: { year: 189, month: 12 } },
{ to: { year: 191, month: 1 } },
{ from: { year: 190, month: 2 }, to: { year: 190, month: 1 } },
])
expect((await get('policyHistory', admin, { ...policyInput, ...patch })).status).toBe(400);
expect((await get('policyVersion', admin, { id: '../bad' })).status).toBe(400);
for (const [operation, input] of [
['policyHistory', policyInput],
['policyVersion', { id: policyId(3) }],
] as const) {
expect((await get(operation, undefined, input)).status).toBe(401);
for (const roles of [['admin'], ['admin.playAudit.read:other:default']])
expect((await get(operation, await token(roles), input)).status).toBe(403);
}
const logGeneralId = 99129; // No live general: death must not hide retained records.
const ownLogs = await Promise.all(
['HISTORY', 'ACTION', 'BATTLE_BRIEF', 'BATTLE_DETAIL'].map((category) =>
@@ -2398,6 +2495,8 @@ integration('game API security over HTTP transport', () => {
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
});
expect((await get('capabilities', blocked)).status).toBe(403);
expect((await get('policyHistory', blocked, policyInput)).status).toBe(403);
expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403);
const population = {
count: 0,
gold: 0,
@@ -2647,6 +2746,10 @@ integration('game API security over HTTP transport', () => {
expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
expect((await get('policyHistory', admin, policyInput)).body).toMatchObject({
result: { data: { items: [] } },
});
expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404);
expect(await db.inputEvent.count()).toBe(beforeInputs);
await redis!.client.publish(
`${redisPrefix}:flush`,
@@ -2658,6 +2761,7 @@ integration('game API security over HTTP transport', () => {
);
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally {
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });