fix: 삭제 메시지를 tombstone으로 보존한다

메시지 삭제 시 유효기간을 만료시키지 않고 본문을 삭제 안내로 치환해 송수신 행을 유지한다. 외교 메시지 조회 권한 부족은 삭제 상태와 분리하고 API, PostgreSQL 통합, Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-24 08:39:10 +00:00
parent 69e34e3c95
commit 98d9115a61
5 changed files with 155 additions and 29 deletions
+24 -1
View File
@@ -1,8 +1,8 @@
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
export interface MessageView {
id: number;
@@ -203,3 +203,26 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
},
});
};
export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
await db.$executeRaw(
GamePrisma.sql`
UPDATE message
SET message = jsonb_set(
jsonb_set(message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
'{option}',
(
CASE
WHEN jsonb_typeof(message->'option') = 'object' THEN message->'option'
ELSE '{}'::jsonb
END
) || jsonb_build_object('invalid', true),
true
)
WHERE id IN (${GamePrisma.join(uniqueIds)})
`
);
};
+3 -7
View File
@@ -19,8 +19,8 @@ import {
fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox,
fetchMessageById,
invalidateMessages,
insertMessage,
tombstoneMessages,
type MessageView,
} from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js';
@@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
}
return {
...message,
text: '(외교 메시지입니다)',
option: {
...(message.option ?? {}),
invalid: true,
},
text: '조회 권한이 없는 외교 메시지입니다.',
};
});
};
@@ -303,7 +299,7 @@ export const messagesRouter = router({
message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
];
await invalidateMessages(ctx.db, ids);
await tombstoneMessages(ctx.db, ids);
const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId
@@ -0,0 +1,84 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { tombstoneMessages } from '../src/messages/store.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('message deletion tombstone persistence', () => {
let db: GamePrismaClient;
let close: (() => Promise<void>) | undefined;
beforeAll(async () => {
const schema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!schema?.endsWith('conditional_integration')) {
throw new Error(`Unsafe schema: ${schema ?? '(missing)'}`);
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => close?.());
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
const rollback = new Error('rollback message tombstone fixture');
await expect(
db.$transaction(async (transaction) => {
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const receiver = await transaction.message.create({
data: {
mailbox: 8,
type: 'private',
src: 7,
dest: 8,
time: new Date('2026-08-24T00:00:00.000Z'),
validUntil,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text: '수신 사본 원문',
option: { senderMessageID: 0 },
},
},
});
const sender = await transaction.message.create({
data: {
mailbox: 7,
type: 'private',
src: 7,
dest: 8,
time: new Date('2026-08-24T00:00:00.000Z'),
validUntil,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text: '송신 사본 원문',
option: { receiverMessageID: receiver.id },
},
},
});
await tombstoneMessages(transaction, [sender.id, receiver.id]);
const rows = await transaction.message.findMany({
where: { id: { in: [sender.id, receiver.id] } },
orderBy: { id: 'asc' },
});
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.validUntil).toEqual(validUntil);
expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.',
option: { invalid: true },
});
expect(JSON.stringify(row.message)).not.toContain('사본 원문');
}
throw rollback;
})
).rejects.toBe(rollback);
});
});
+12 -14
View File
@@ -176,13 +176,15 @@ describe('messages router missing-flow compatibility', () => {
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
});
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
@@ -585,15 +587,13 @@ describe('messages router missing-flow compatibility', () => {
},
]);
const changeJournal = new ChangeJournal();
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [21, 22] } },
data: { validUntil: expect.any(Date) },
});
expect(executeRaw).toHaveBeenCalledOnce();
expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 },
@@ -632,15 +632,13 @@ describe('messages router missing-flow compatibility', () => {
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [25] } },
data: { validUntil: expect.any(Date) },
});
expect(executeRaw).toHaveBeenCalledOnce();
expect(updateMany).not.toHaveBeenCalled();
});
it('rejects deleting another general message', async () => {
@@ -91,7 +91,7 @@ const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
const buildMessages = (permission: number) => ({
const buildMessages = (permission: number, tombstonedMessageIds: ReadonlySet<number> = new Set()) => ({
result: true,
public: [
{
@@ -99,8 +99,8 @@ const buildMessages = (permission: number) => ({
msgType: 'public',
src: ownTarget,
dest: null,
text: '전체 메시지 본문',
option: {},
text: tombstonedMessageIds.has(101) ? '삭제된 메시지입니다.' : '전체 메시지 본문',
option: tombstonedMessageIds.has(101) ? { invalid: true } : {},
time: messageTime,
},
],
@@ -150,11 +150,11 @@ const buildMessages = (permission: number) => ({
msgType: 'diplomacy',
src: foreignTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
text: permission >= 3 ? '외교 메시지 본문' : '조회 권한이 없는 외교 메시지입니다.',
option:
permission >= 3
? { action: 'noAggression', deletable: false }
: { action: 'noAggression', deletable: false, invalid: true },
: { action: 'noAggression', deletable: false },
time: messageTime,
},
],
@@ -203,6 +203,7 @@ const installFixture = async (
options: { permission: number; sendError?: string }
): Promise<Array<{ operation: string; body: unknown }>> => {
const mutations: Array<{ operation: string; body: unknown }> = [];
const tombstonedMessageIds = new Set<number>();
await page.addInitScript(
({ gameToken, profile }) => {
window.localStorage.setItem('sammo-game-token', gameToken);
@@ -272,7 +273,9 @@ const installFixture = async (
if (operation === 'general.getRecentRecords') {
return response({ global: [], general: [], history: [] });
}
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
if (operation === 'messages.getRecent') {
return response(buildMessages(options.permission, tombstonedMessageIds));
}
if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
@@ -287,6 +290,10 @@ const installFixture = async (
if (operation === 'messages.send' && options.sendError) {
return errorResponse(operation, options.sendError);
}
if (operation === 'messages.delete') {
tombstonedMessageIds.add(101);
return response({ ok: true, deletedIds: [101] });
}
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
}
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
@@ -437,6 +444,16 @@ test('exposes nation targets including wanderers, reply, read, delete, and succe
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await expect(page.locator('.PublicTalk .msg-plate').filter({ hasText: '삭제된 메시지입니다' })).toBeVisible();
await expect(page.locator('.PublicTalk')).not.toContainText('전체 메시지 본문');
await expect(page.locator('.PublicTalk .delete-message')).toHaveCount(0);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.PublicTalk').screenshot({
path: resolve(artifactRoot, 'message-delete-tombstone-500.png'),
animations: 'disabled',
});
}
await select.selectOption('9000');
await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요');
@@ -496,9 +513,17 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9000"]')).toHaveCount(0);
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).toContainText('조회 권한이 없는 외교 메시지입니다.');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.DiplomacyTalk').screenshot({
path: resolve(artifactRoot, 'diplomacy-permission-redaction-500.png'),
animations: 'disabled',
});
}
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('차단될 메시지');