feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
|
||||
gameSchemaHead: '20260820002000_persist_official_game_index',
|
||||
gatewaySchemaHead: '20260823010000_add_web_push_notifications',
|
||||
gameSchemaHead: '20260823010000_add_web_push_outbox',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveGatewayApiConfigFromEnv } from '../src/config.js';
|
||||
|
||||
const requiredEnv = {
|
||||
GAME_TOKEN_SECRET: 'test-game-token-secret',
|
||||
KAKAO_REST_KEY: 'test-kakao-key',
|
||||
KAKAO_REDIRECT_URI: 'https://gateway.test.invalid/gateway/oauth/callback',
|
||||
};
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveGatewayApiConfigFromEnv web push', () => {
|
||||
it('stays disabled without reading a configured private-key file', () => {
|
||||
const config = resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'false',
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: '/does/not/exist',
|
||||
});
|
||||
|
||||
expect(config.webPushEnabled).toBe(false);
|
||||
expect(config.webPushVapidPrivateKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the VAPID private key from a file only when enabled', () => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'sammo-web-push-config-'));
|
||||
tempDirectories.push(directory);
|
||||
const privateKeyFile = path.join(directory, 'vapid-private-key');
|
||||
writeFileSync(privateKeyFile, 'test-private-key\n', { mode: 0o600 });
|
||||
|
||||
const config = resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'true',
|
||||
WEB_PUSH_VAPID_SUBJECT: 'mailto:admin@test.invalid',
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: 'test-public-key',
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: privateKeyFile,
|
||||
});
|
||||
|
||||
expect(config.webPushEnabled).toBe(true);
|
||||
expect(config.webPushVapidPrivateKey).toBe('test-private-key');
|
||||
});
|
||||
|
||||
it('fails closed when activation is incomplete', () => {
|
||||
expect(() =>
|
||||
resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'true',
|
||||
})
|
||||
).toThrow(/WEB_PUSH_ENABLED requires/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { WebPushCoordinator } from '../src/webPush/coordinator.js';
|
||||
|
||||
const databaseUrl = process.env.WEB_PUSH_GATEWAY_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const schema = process.env.WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA;
|
||||
const userId = '8c770c8d-3515-4f6c-8a54-5f17330d9f66';
|
||||
const profileName = 'hwe:web-push-integration';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!schema?.endsWith('_web_push_integration') || actual !== schema) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the web-push integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
integration('web push Gateway persistence boundary', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let coordinator: WebPushCoordinator;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
|
||||
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'web-push-integration',
|
||||
displayName: '웹 푸시 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
},
|
||||
});
|
||||
await db.gatewayProfile.create({
|
||||
data: {
|
||||
profileName,
|
||||
profile: 'hwe',
|
||||
instanceKey: 'web-push-integration',
|
||||
currentScenario: 'default',
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RESERVED',
|
||||
},
|
||||
});
|
||||
await db.webPushSubscription.create({
|
||||
data: {
|
||||
userId,
|
||||
endpoint: 'https://push.example.invalid/subscription/integration',
|
||||
p256dh: 'public-key-placeholder',
|
||||
auth: 'auth-placeholder',
|
||||
},
|
||||
});
|
||||
const vapid = webPush.generateVAPIDKeys();
|
||||
coordinator = new WebPushCoordinator(db, {
|
||||
enabled: true,
|
||||
vapidSubject: 'mailto:web-push-test@example.invalid',
|
||||
vapidPublicKey: vapid.publicKey,
|
||||
vapidPrivateKey: vapid.privateKey,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
|
||||
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('fans out an enabled private-message event once without persisting its content', async () => {
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
enabled: true,
|
||||
});
|
||||
const event = {
|
||||
version: 1 as const,
|
||||
eventId: 'integration:private-message:1',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED' as const,
|
||||
profileName,
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
};
|
||||
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: true });
|
||||
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: false });
|
||||
|
||||
const notifications = await db.webPushNotification.findMany({
|
||||
where: { profileName, eventType: 'PRIVATE_MESSAGE_RECEIVED' },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toMatchObject({
|
||||
userId,
|
||||
title: '새 개인 서신',
|
||||
url: '/hwe/',
|
||||
deliveries: [expect.objectContaining({ status: 'PENDING' })],
|
||||
});
|
||||
expect(
|
||||
JSON.stringify(notifications.map(({ title, body, url, tag }) => ({ title, body, url, tag })))
|
||||
).not.toContain('private message content');
|
||||
});
|
||||
|
||||
it('matches a target-date preference and records profile lifecycle transitions', async () => {
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'TARGET_DATE_REACHED',
|
||||
enabled: true,
|
||||
targetYear: 201,
|
||||
targetMonth: 3,
|
||||
});
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'PROFILE_PREOPENED',
|
||||
enabled: true,
|
||||
});
|
||||
await coordinator.ingest({
|
||||
version: 1,
|
||||
eventId: 'integration:calendar:201:3',
|
||||
eventType: 'TARGET_DATE_REACHED',
|
||||
profileName,
|
||||
userIds: [],
|
||||
year: 201,
|
||||
month: 3,
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
});
|
||||
await coordinator.reconcileProfiles(new Date('2026-08-23T00:00:00.000Z'));
|
||||
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'PREOPEN' } });
|
||||
await coordinator.reconcileProfiles(new Date('2026-08-23T00:01:00.000Z'));
|
||||
|
||||
const rows = await db.webPushNotification.findMany({
|
||||
where: { profileName, eventType: { in: ['TARGET_DATE_REACHED', 'PROFILE_PREOPENED'] } },
|
||||
orderBy: { eventType: 'asc' },
|
||||
});
|
||||
expect(rows.map((row) => row.eventType).sort()).toEqual(['PROFILE_PREOPENED', 'TARGET_DATE_REACHED']);
|
||||
expect(rows.find((row) => row.eventType === 'TARGET_DATE_REACHED')?.body).toContain('201년 3월');
|
||||
});
|
||||
|
||||
it('drops events while globally disabled instead of creating a future backlog', async () => {
|
||||
const disabled = new WebPushCoordinator(db, { enabled: false });
|
||||
await expect(
|
||||
disabled.ingest({
|
||||
version: 1,
|
||||
eventId: 'integration:disabled:1',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
profileName,
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({ queued: false });
|
||||
await expect(
|
||||
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WebPushCoordinator } from '../src/webPush/coordinator.js';
|
||||
import { deriveWebPushIngestToken, registerWebPushInternalRoute } from '../src/webPush/internalRoute.js';
|
||||
|
||||
const secret = 'web-push-route-test-secret';
|
||||
const userId = '11111111-1111-4111-8111-111111111111';
|
||||
const event = {
|
||||
version: 1,
|
||||
eventId: 'game:hwe:default:message:42',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
profileName: 'hwe:default',
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('web push internal event route', () => {
|
||||
it('accepts the strict privacy-safe envelope with a purpose-derived token', async () => {
|
||||
const app = fastify();
|
||||
const ingest = vi.fn().mockResolvedValue({ queued: true });
|
||||
registerWebPushInternalRoute(app, {
|
||||
secret,
|
||||
webPush: { ingest } as unknown as WebPushCoordinator,
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
payload: event,
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
|
||||
payload: event,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({ ok: true, queued: true });
|
||||
expect(ingest).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('rejects payload extensions such as private message text', async () => {
|
||||
const app = fastify();
|
||||
const ingest = vi.fn();
|
||||
registerWebPushInternalRoute(app, {
|
||||
secret,
|
||||
webPush: { ingest } as unknown as WebPushCoordinator,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
|
||||
payload: { ...event, message: 'private message content' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user