diff --git a/docs/catalog.json b/docs/catalog.json index 8ef806b5..12bd00e2 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -415,6 +415,7 @@ "packages/core/src/compliance/plugin.ts", "packages/core/src/engagement/chat-commands/plugin.ts", "packages/core/src/pam/player-management/plugin.ts", + "packages/core/src/pam/player-note/plugin.ts", "packages/core/src/wallet/plugin.ts" ] }, diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 1201f19d..02b13847 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -17,6 +17,7 @@ export type DirectAuditAction = | 'admin.user.updated' | 'admin.player.updated' | 'admin.player.removed' + | 'admin.player_note.created' | 'audit.export' | 'compliance.kyc.bulk_approve' | 'wallet.withdrawal.auto_approved' diff --git a/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts b/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts new file mode 100644 index 00000000..cd4b4c59 --- /dev/null +++ b/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts @@ -0,0 +1,72 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { call } from '@orpc/server'; +import { sql } from 'drizzle-orm'; +import type { AdminGuard } from '@openora/core/server'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { makeAdminGuard, makeAuditWriter, testContext } from '../../../testing/mock.js'; +import { migrate as migratePlayerNote } from '../migrate.js'; +import { createPlayerNoteRouter } from '../router/index.js'; +import { playerNote } from '../schema/index.js'; +import { PlayerNoteService } from '../service/player-note.service.js'; + +const CTX = testContext(); +const CALLER = '44444444-4444-4444-8444-444444444444'; +const PLAYER_ID = '11111111-1111-4111-8111-111111111111'; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migratePlayerNote]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute(sql`TRUNCATE ${playerNote} RESTART IDENTITY`); +}); + +const guardAllowing = (allow: readonly string[]) => + makeAdminGuard({ allow, caller: { userId: CALLER, ip: '127.0.0.1', userAgent: 'test' } }); + +function build(adminGuard: AdminGuard) { + const audit = makeAuditWriter(); + return { + router: createPlayerNoteRouter(new PlayerNoteService(db.drizzle), adminGuard, audit), + audit, + }; +} + +describe('player note router', () => { + it('records adding an internal note in the audit log', async () => { + const { router, audit } = build(guardAllowing(['player-note:create'])); + + const created = await call( + router.create, + { playerId: PLAYER_ID, content: 'Contacted player about verification.' }, + { context: CTX }, + ); + + expect(audit.record).toHaveBeenCalledWith({ + actorId: CALLER, + actorType: 'admin', + action: 'admin.player_note.created', + resourceType: 'player', + resourceId: PLAYER_ID, + after: { noteId: created.id, content: created.content }, + ip: '127.0.0.1', + userAgent: 'test', + }); + }); + + it('does not record an audit entry when permission is denied', async () => { + const { router, audit } = build(guardAllowing([])); + + await expect( + call(router.create, { playerId: PLAYER_ID, content: 'Private note' }, { context: CTX }), + ).rejects.toBeDefined(); + + expect(audit.record).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index 50041007..aaead612 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,13 +1,19 @@ import { DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { AUDIT_WRITER } from '@openora/core/contracts'; import { PlayerNoteService } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; export default { id: 'player-note', + dependsOn: ['audit'], register(ctx) { ctx.routers.add('player-note', (c) => - createPlayerNoteRouter(new PlayerNoteService(c.get(DRIZZLE)), c.get(ADMIN_GUARD)), + createPlayerNoteRouter( + new PlayerNoteService(c.get(DRIZZLE)), + c.get(ADMIN_GUARD), + c.get(AUDIT_WRITER), + ), ); }, } as const satisfies Plugin; diff --git a/packages/core/src/pam/player-note/router/index.ts b/packages/core/src/pam/player-note/router/index.ts index a2e5cc9c..7bb582cd 100644 --- a/packages/core/src/pam/player-note/router/index.ts +++ b/packages/core/src/pam/player-note/router/index.ts @@ -1,9 +1,14 @@ import { implement } from '@orpc/server'; import { AdminGuard, type OssContext } from '@openora/core/server'; +import type { AuditWritePort } from '@openora/core/contracts'; import { playerNoteContract } from '../contract/index.js'; import { PlayerNoteService } from '../service/player-note.service.js'; -export function createPlayerNoteRouter(svc: PlayerNoteService, adminGuard: AdminGuard) { +export function createPlayerNoteRouter( + svc: PlayerNoteService, + adminGuard: AdminGuard, + audit: AuditWritePort, +) { const os = implement(playerNoteContract).$context(); return os.router({ @@ -20,7 +25,18 @@ export function createPlayerNoteRouter(svc: PlayerNoteService, adminGuard: Admin create: os.create.handler(async ({ input, context }) => { const caller = await adminGuard.assert(context, 'player-note', 'create'); - return svc.create(input, caller.userId); + const created = await svc.create(input, caller.userId); + await audit.record({ + actorId: caller.userId, + actorType: 'admin', + action: 'admin.player_note.created', + resourceType: 'player', + resourceId: input.playerId, + after: { noteId: created.id, content: created.content }, + ip: caller.ip, + userAgent: caller.userAgent, + }); + return created; }), }); }