diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d261033..fabe9c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# CI: install, lint, format check, build, unit tests, e2e (Playwright) +# CI: install, lint, format check, build, unit tests, migrations + DB integration tests, e2e (Playwright) # Uses latest action versions; Node 24 + pnpm 10 per package.json engines. name: CI @@ -31,8 +31,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: "24" - cache: "pnpm" + node-version: '24' + cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -73,8 +73,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: "24" - cache: "pnpm" + node-version: '24' + cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -85,6 +85,63 @@ jobs: - name: Run unit tests run: pnpm run test + integration: + name: Migrations & DB integration tests + runs-on: ubuntu-latest + # postgres:17-alpine matches production (docker-compose.yml), so the migration chain is + # exercised against the same major version it runs on in prod. + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: bunker46 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/bunker46 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace packages + run: pnpm --filter @bunker46/shared-types --filter @bunker46/config run build + + # Applies every committed migration in order against an empty DB: proves the full chain + # (initial schema -> current) applies cleanly. Uses `migrate deploy`, NOT `db:push`, so it + # is the migration SQL that actually runs — db:push would bypass the migration files. + - name: Apply migrations from scratch (full chain) + run: pnpm --filter @bunker46/server run db:deploy + + # Fails (exit code 2) if the migrated DB and schema.prisma disagree — catches a schema.prisma + # edit that was never captured in a migration. + - name: Check migrations match schema (no drift) + run: pnpm --filter @bunker46/server run db:check-drift + + # Runs the RUN_DB_TESTS-gated integration specs against the migration-built schema. + - name: Run DB integration tests + run: pnpm --filter @bunker46/server run test:integration + e2e: name: E2E (Playwright) runs-on: ubuntu-latest @@ -118,8 +175,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: "24" - cache: "pnpm" + node-version: '24' + cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -156,4 +213,4 @@ jobs: pnpm run e2e env: CI: true - E2E_USE_PREVIEW: "1" + E2E_USE_PREVIEW: '1' diff --git a/apps/server/package.json b/apps/server/package.json index c4e37a6..da9422e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,8 +12,11 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:integration": "RUN_DB_TESTS=1 vitest run int.spec", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:check-drift": "prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code", "db:baseline-legacy": "prisma db execute --file prisma/baseline-from-legacy.sql && prisma migrate resolve --applied 20260330100000_initial_schema", "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", diff --git a/apps/server/prisma/migrations/20260624120000_decouple_signing_logs_from_connection/migration.sql b/apps/server/prisma/migrations/20260624120000_decouple_signing_logs_from_connection/migration.sql new file mode 100644 index 0000000..47c279e --- /dev/null +++ b/apps/server/prisma/migrations/20260624120000_decouple_signing_logs_from_connection/migration.sql @@ -0,0 +1,43 @@ +-- Preserve audit logs after a connection is deleted. +-- +-- Previously signing_logs.connection_id was NOT NULL with ON DELETE CASCADE, so deleting a +-- connection wiped its entire signing history. We now decouple the log from the connection +-- lifecycle: the FK becomes nullable + ON DELETE SET NULL, and the owning user, connection name +-- and client pubkey are denormalized onto each log so an orphaned record stays scoped to its +-- user and remains readable. Deleting a *user* still purges their logs (new FK below cascades). + +-- AlterTable: add denormalized columns (nullable first so existing rows can be backfilled) +ALTER TABLE "signing_logs" + ADD COLUMN "user_id" TEXT, + ADD COLUMN "connection_name" TEXT, + ADD COLUMN "client_pubkey" TEXT; + +-- Backfill from the connections that still exist (every current log has a valid connection, +-- since the old FK was NOT NULL + CASCADE, so there are no orphans to leave behind). +UPDATE "signing_logs" sl +SET "user_id" = bc."user_id", + "connection_name" = bc."name", + "client_pubkey" = bc."client_pubkey" +FROM "bunker_connections" bc +WHERE sl."connection_id" = bc."id"; + +-- Enforce NOT NULL now that the columns are populated +ALTER TABLE "signing_logs" + ALTER COLUMN "user_id" SET NOT NULL, + ALTER COLUMN "connection_name" SET NOT NULL, + ALTER COLUMN "client_pubkey" SET NOT NULL; + +-- Make connection_id nullable so ON DELETE SET NULL can apply +ALTER TABLE "signing_logs" ALTER COLUMN "connection_id" DROP NOT NULL; + +-- Swap the connection FK from CASCADE to SET NULL +ALTER TABLE "signing_logs" DROP CONSTRAINT "signing_logs_connection_id_fkey"; +ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_connection_id_fkey" + FOREIGN KEY ("connection_id") REFERENCES "bunker_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add the user FK (CASCADE so deleting a user still purges their audit logs) +ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Index for user-scoped log queries +CREATE INDEX "signing_logs_user_id_idx" ON "signing_logs"("user_id"); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 89e186b..dc3b0fe 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { passkeys Passkey[] nsecKeys NsecKey[] connections BunkerConnection[] + signingLogs SigningLog[] relayConfigs RelayConfig[] @@map("users") @@ -131,19 +132,27 @@ model ConnectionPermission { } model SigningLog { - id String @id @default(cuid()) - connectionId String @map("connection_id") - method String - eventKind Int? @map("event_kind") - result LogResult - durationMs Int @map("duration_ms") - errorMessage String? @map("error_message") - metadata Json? - createdAt DateTime @default(now()) @map("created_at") - - connection BunkerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + // Nullable: when a connection is deleted the FK is set to null (ON DELETE SET NULL) so the + // audit record survives. The owning user, connection name and client pubkey below are + // denormalized at write time so an orphaned log stays scoped to its user and readable. + connectionId String? @map("connection_id") + userId String @map("user_id") + connectionName String @map("connection_name") + clientPubkey String @map("client_pubkey") + method String + eventKind Int? @map("event_kind") + result LogResult + durationMs Int @map("duration_ms") + errorMessage String? @map("error_message") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + connection BunkerConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([connectionId]) + @@index([userId]) @@index([createdAt]) @@index([method]) @@map("signing_logs") diff --git a/apps/server/src/bunker/bunker-rpc.handler.ts b/apps/server/src/bunker/bunker-rpc.handler.ts index 7a14abd..f7d2839 100644 --- a/apps/server/src/bunker/bunker-rpc.handler.ts +++ b/apps/server/src/bunker/bunker-rpc.handler.ts @@ -234,6 +234,9 @@ export class BunkerRpcHandler { await this.loggingService.logSigningAction({ connectionId: connection.id, + userId: connection.userId, + connectionName: connection.name, + clientPubkey: connection.clientPubkey, method: request.method, eventKind, result: error ? 'ERROR' : 'APPROVED', diff --git a/apps/server/src/logging/logging.service.spec.ts b/apps/server/src/logging/logging.service.spec.ts index ba26a88..a8c285e 100644 --- a/apps/server/src/logging/logging.service.spec.ts +++ b/apps/server/src/logging/logging.service.spec.ts @@ -24,6 +24,9 @@ describe('LoggingService', () => { it('should create signing log entry', async () => { await service.logSigningAction({ connectionId: 'conn-1', + userId: 'user-1', + connectionName: 'My App', + clientPubkey: 'a'.repeat(64), method: 'sign_event', result: 'APPROVED', durationMs: 100, @@ -31,6 +34,9 @@ describe('LoggingService', () => { expect(prisma.signingLog?.create).toHaveBeenCalledWith({ data: expect.objectContaining({ connectionId: 'conn-1', + userId: 'user-1', + connectionName: 'My App', + clientPubkey: 'a'.repeat(64), method: 'sign_event', result: 'APPROVED', durationMs: 100, @@ -43,6 +49,9 @@ describe('LoggingService', () => { it('should include eventKind and metadata when provided', async () => { await service.logSigningAction({ connectionId: 'conn-1', + userId: 'user-1', + connectionName: 'My App', + clientPubkey: 'a'.repeat(64), method: 'sign_event', eventKind: 1, result: 'DENIED', @@ -58,6 +67,54 @@ describe('LoggingService', () => { }); }); + describe('getDashboardActivity', () => { + it('scopes by denormalized userId (not the connection relation) so deleted-connection logs survive', async () => { + vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([ + { + id: 'log-1', + connectionId: null, // connection was deleted; FK is SET NULL + connectionName: 'Deleted App', + method: 'sign_event', + eventKind: 1, + result: 'APPROVED', + createdAt: new Date('2026-01-01T00:00:00Z'), + } as never, + ]); + vi.mocked(prisma.signingLog!.count!).mockResolvedValue(1); + + const result = await service.getDashboardActivity('user-1'); + + // Must filter on the denormalized userId, never via `connection: { userId }`, + // otherwise orphaned (connectionId=null) logs drop out of the feed. + const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!; + expect(findManyArgs.where).toEqual({ userId: 'user-1' }); + expect(findManyArgs.where).not.toHaveProperty('connection'); + // No relation include anymore — the name comes from the denormalized column. + expect(findManyArgs).not.toHaveProperty('include'); + + expect(result.data[0]).toMatchObject({ + id: 'log-1', + connectionName: 'Deleted App', + method: 'sign_event', + }); + expect(result.total).toBe(1); + }); + + it('applies connectionName and method filters against denormalized columns', async () => { + vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([]); + vi.mocked(prisma.signingLog!.count!).mockResolvedValue(0); + + await service.getDashboardActivity('user-1', 1, 15, 'My App', 'sign_event'); + + const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!; + expect(findManyArgs.where).toEqual({ + userId: 'user-1', + connectionName: 'My App', + method: 'sign_event', + }); + }); + }); + describe('getLogsForConnection', () => { it('should return paginated logs with total', async () => { vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([ diff --git a/apps/server/src/logging/logging.service.ts b/apps/server/src/logging/logging.service.ts index 138857f..9cd62ef 100644 --- a/apps/server/src/logging/logging.service.ts +++ b/apps/server/src/logging/logging.service.ts @@ -5,6 +5,10 @@ import type { Prisma } from '@/generated/prisma/client.js'; interface LogEntry { connectionId: string; + // Denormalized so the log survives connection deletion (FK is SET NULL) and stays scoped/readable. + userId: string; + connectionName: string; + clientPubkey: string; method: string; eventKind?: number; result: 'APPROVED' | 'DENIED' | 'ERROR'; @@ -24,6 +28,9 @@ export class LoggingService { await this.prisma.signingLog.create({ data: { connectionId: entry.connectionId, + userId: entry.userId, + connectionName: entry.connectionName, + clientPubkey: entry.clientPubkey, method: entry.method, eventKind: entry.eventKind, result: entry.result as LogResult, @@ -44,18 +51,17 @@ export class LoggingService { connectionName?: string, method?: string, ) { + // Scope by the denormalized userId/connectionName so logs from deleted connections + // (connectionId is null) still appear in the owner's activity feed. const where: Prisma.SigningLogWhereInput = { - connection: { - userId, - ...(connectionName ? { name: connectionName } : {}), - }, + userId, + ...(connectionName ? { connectionName } : {}), ...(method ? { method } : {}), }; const [data, total] = await Promise.all([ this.prisma.signingLog.findMany({ where, - include: { connection: { select: { name: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, @@ -68,7 +74,7 @@ export class LoggingService { id: log.id, method: log.method, eventKind: log.eventKind, - connectionName: log.connection.name, + connectionName: log.connectionName, result: log.result, timestamp: log.createdAt.toISOString(), })), diff --git a/apps/server/src/logging/stats.service.spec.ts b/apps/server/src/logging/stats.service.spec.ts index 099c0a7..dec2d68 100644 --- a/apps/server/src/logging/stats.service.spec.ts +++ b/apps/server/src/logging/stats.service.spec.ts @@ -15,6 +15,7 @@ describe('StatsService', () => { }, signingLog: { count: vi.fn().mockResolvedValueOnce(10).mockResolvedValueOnce(42), + // Call order in getDashboardStats: methodCounts, kindCounts, connectionNames, distinctMethods. groupBy: vi .fn() .mockResolvedValueOnce([ @@ -22,6 +23,7 @@ describe('StatsService', () => { { method: 'ping', _count: { method: 2 } }, ]) .mockResolvedValueOnce([{ eventKind: 1, _count: { eventKind: 5 } }]) + .mockResolvedValueOnce([{ connectionName: 'App' }, { connectionName: 'Deleted App' }]) .mockResolvedValueOnce([{ method: 'sign_event' }, { method: 'ping' }]), findMany: vi.fn(), }, @@ -46,7 +48,9 @@ describe('StatsService', () => { signingByMethod: { sign_event: 8, ping: 2 }, signingByKind: { '1': 5 }, chartRange: '7d', - connectionNames: ['App', 'Other'], + // Union of live connection names (App, Other) + names preserved on logs (App, Deleted App), + // so a deleted connection stays filterable. + connectionNames: ['App', 'Deleted App', 'Other'], methods: ['ping', 'sign_event'], }); expect(result.activityBuckets).toHaveLength(7); @@ -63,14 +67,29 @@ describe('StatsService', () => { expect.objectContaining({ where: expect.objectContaining({ userId: 'user-99' }) }), ); expect(prisma.bunkerConnection?.count).toHaveBeenCalledTimes(2); + // connectionNames now unions live connection names with names preserved on logs, sorted in JS. expect(prisma.bunkerConnection?.findMany).toHaveBeenCalledWith({ where: { userId: 'user-99' }, select: { name: true }, - orderBy: { name: 'asc' }, }); expect(prisma.signingLog?.count).toHaveBeenCalledTimes(2); - expect(prisma.signingLog?.groupBy).toHaveBeenCalledTimes(3); + expect(prisma.signingLog?.groupBy).toHaveBeenCalledTimes(4); expect(prisma.$queryRaw).toHaveBeenCalled(); }); + + it('scopes every signingLog query by the denormalized userId, not the connection relation', async () => { + await service.getDashboardStats('user-99'); + // A revert to `connection: { userId }` would silently drop deleted-connection logs from + // counts/charts — assert the denormalized scoping on every count + groupBy call. + const calls = [ + ...vi.mocked(prisma.signingLog!.count!).mock.calls, + ...vi.mocked(prisma.signingLog!.groupBy!).mock.calls, + ]; + expect(calls.length).toBeGreaterThan(0); + for (const [args] of calls) { + expect(args.where).toMatchObject({ userId: 'user-99' }); + expect(args.where).not.toHaveProperty('connection'); + } + }); }); }); diff --git a/apps/server/src/logging/stats.service.ts b/apps/server/src/logging/stats.service.ts index 9fc975c..6133406 100644 --- a/apps/server/src/logging/stats.service.ts +++ b/apps/server/src/logging/stats.service.ts @@ -49,30 +49,38 @@ export class StatsService { this.prisma.bunkerConnection.count({ where: { userId } }), this.prisma.bunkerConnection.count({ where: { userId, status: 'ACTIVE' } }), this.prisma.signingLog.count({ - where: { connection: { userId }, createdAt: { gte: twentyFourHoursAgo } }, + where: { userId, createdAt: { gte: twentyFourHoursAgo } }, }), this.prisma.signingLog.count({ - where: { connection: { userId }, createdAt: { gte: sevenDaysAgo } }, + where: { userId, createdAt: { gte: sevenDaysAgo } }, }), this.prisma.signingLog.groupBy({ by: ['method'], - where: { connection: { userId }, createdAt: { gte: rangeStart } }, + where: { userId, createdAt: { gte: rangeStart } }, _count: { method: true }, }), this.prisma.signingLog.groupBy({ by: ['eventKind'], where: { - connection: { userId }, + userId, createdAt: { gte: rangeStart }, eventKind: { not: null }, }, _count: { eventKind: true }, }), - this.prisma.bunkerConnection - .findMany({ where: { userId }, select: { name: true }, orderBy: { name: 'asc' } }) - .then((rows) => rows.map((r) => r.name)), + // Union of live connection names + names preserved on logs, so deleted connections + // (whose logs survive with connectionId=null) remain filterable in the dashboard. + Promise.all([ + this.prisma.bunkerConnection.findMany({ where: { userId }, select: { name: true } }), + this.prisma.signingLog.groupBy({ by: ['connectionName'], where: { userId } }), + ]).then(([conns, logs]) => { + const names = new Set(); + for (const c of conns) names.add(c.name); + for (const l of logs) names.add(l.connectionName); + return Array.from(names).sort(); + }), this.prisma.signingLog - .groupBy({ by: ['method'], where: { connection: { userId } } }) + .groupBy({ by: ['method'], where: { userId } }) .then((rows) => rows.map((r) => r.method).sort()), ]); @@ -122,12 +130,11 @@ export class StatsService { if (range === '7d') { const rows = await this.prisma.$queryRaw>` - SELECT DATE(sl.created_at) AS ts, COUNT(*) AS count - FROM signing_logs sl - JOIN bunker_connections bc ON sl.connection_id = bc.id - WHERE bc.user_id = ${userId} - AND sl.created_at >= ${rangeStart} - GROUP BY DATE(sl.created_at) + SELECT DATE(created_at) AS ts, COUNT(*) AS count + FROM signing_logs + WHERE user_id = ${userId} + AND created_at >= ${rangeStart} + GROUP BY DATE(created_at) ORDER BY ts ASC `; const map = new Map(); @@ -150,12 +157,11 @@ export class StatsService { if (range === '24h') { const rows = await this.prisma.$queryRaw>` - SELECT DATE_TRUNC('hour', sl.created_at) AS ts, COUNT(*) AS count - FROM signing_logs sl - JOIN bunker_connections bc ON sl.connection_id = bc.id - WHERE bc.user_id = ${userId} - AND sl.created_at >= ${rangeStart} - GROUP BY DATE_TRUNC('hour', sl.created_at) + SELECT DATE_TRUNC('hour', created_at) AS ts, COUNT(*) AS count + FROM signing_logs + WHERE user_id = ${userId} + AND created_at >= ${rangeStart} + GROUP BY DATE_TRUNC('hour', created_at) ORDER BY ts ASC `; const map = new Map(); @@ -182,13 +188,12 @@ export class StatsService { // 1h — 5-minute buckets (12 slots) const rows = await this.prisma.$queryRaw>` SELECT - TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM sl.created_at) / 300) * 300) AS ts, + TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM created_at) / 300) * 300) AS ts, COUNT(*) AS count - FROM signing_logs sl - JOIN bunker_connections bc ON sl.connection_id = bc.id - WHERE bc.user_id = ${userId} - AND sl.created_at >= ${rangeStart} - GROUP BY FLOOR(EXTRACT(EPOCH FROM sl.created_at) / 300) + FROM signing_logs + WHERE user_id = ${userId} + AND created_at >= ${rangeStart} + GROUP BY FLOOR(EXTRACT(EPOCH FROM created_at) / 300) ORDER BY ts ASC `; const map = new Map(); diff --git a/apps/server/test/audit-retention.int.spec.ts b/apps/server/test/audit-retention.int.spec.ts new file mode 100644 index 0000000..d5e72c9 --- /dev/null +++ b/apps/server/test/audit-retention.int.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaService } from '../src/prisma/prisma.service.js'; +import { LoggingService } from '../src/logging/logging.service.js'; +import { StatsService } from '../src/logging/stats.service.js'; + +/** + * DB integration test for audit-log retention (Option B: decoupled signing logs). + * + * Opt-in: requires a real Postgres reachable via DATABASE_URL with the migrations applied. + * Gated behind RUN_DB_TESTS=1 so the default `pnpm test` (no DB) stays green. See README / + * `pnpm test:integration` for how to bring up the throwaway DB. + * + * Proves the guarantees that mocked unit tests cannot: + * - deleting a connection sets signing_logs.connection_id to NULL (ON DELETE SET NULL) instead + * of cascading the rows away, so the audit history survives; + * - the denormalized userId/connectionName/clientPubkey stay intact and keep the orphaned log + * scoped + readable in the dashboard activity feed; + * - deleting the owning user still purges their logs (the user FK cascades). + */ +const RUN = process.env['RUN_DB_TESTS'] === '1'; + +describe.skipIf(!RUN)('audit-log retention (DB integration)', () => { + let prisma: PrismaService; + let logging: LoggingService; + let stats: StatsService; + + beforeAll(async () => { + prisma = new PrismaService(); + await prisma.onModuleInit(); + logging = new LoggingService(prisma); + stats = new StatsService(prisma); + }); + + afterAll(async () => { + if (prisma) await prisma.onModuleDestroy(); + }); + + beforeEach(async () => { + // Clean slate. Order matters for the non-cascade FKs. + await prisma.signingLog.deleteMany(); + await prisma.bunkerConnection.deleteMany(); + await prisma.nsecKey.deleteMany(); + await prisma.user.deleteMany(); + }); + + async function seedConnectionWithLog() { + const user = await prisma.user.create({ + data: { username: `audit-${Date.now()}-${Math.random()}`, passwordHash: 'x' }, + }); + const nsecKey = await prisma.nsecKey.create({ + data: { userId: user.id, publicKey: `pk-${Math.random()}`, encryptedNsec: 'enc' }, + }); + const conn = await prisma.bunkerConnection.create({ + data: { + userId: user.id, + nsecKeyId: nsecKey.id, + clientPubkey: `client-${Math.random()}`, + name: 'My App', + }, + }); + await logging.logSigningAction({ + connectionId: conn.id, + userId: user.id, + connectionName: conn.name, + clientPubkey: conn.clientPubkey, + method: 'sign_event', + eventKind: 1, + result: 'APPROVED', + durationMs: 5, + }); + return { user, conn }; + } + + it('retains signing logs (connection_id -> NULL) when the connection is deleted', async () => { + const { user, conn } = await seedConnectionWithLog(); + expect(await prisma.signingLog.count()).toBe(1); + + await prisma.bunkerConnection.delete({ where: { id: conn.id } }); + + // Connection is gone, but the audit log survives with the FK nulled out. + expect(await prisma.bunkerConnection.count()).toBe(0); + const logs = await prisma.signingLog.findMany(); + expect(logs).toHaveLength(1); + expect(logs[0]!.connectionId).toBeNull(); + expect(logs[0]!.userId).toBe(user.id); + expect(logs[0]!.connectionName).toBe('My App'); + expect(logs[0]!.clientPubkey).toBe(conn.clientPubkey); + + // And it still shows up in the dashboard activity feed (scoped by denormalized userId). + const activity = await logging.getDashboardActivity(user.id); + expect(activity.total).toBe(1); + expect(activity.data[0]).toMatchObject({ connectionName: 'My App', method: 'sign_event' }); + }); + + it('still counts orphaned logs in dashboard stats and keeps the deleted name filterable', async () => { + const { user, conn } = await seedConnectionWithLog(); + await prisma.bunkerConnection.delete({ where: { id: conn.id } }); + + const s = await stats.getDashboardStats(user.id, '7d'); + + // The orphaned log (connectionId=null) is still aggregated into counts/charts via userId... + expect(s.signingActions7d).toBe(1); + expect(s.signingByMethod['sign_event']).toBe(1); + // ...and the deleted connection's name remains in the filter list (sourced from the logs). + expect(s.connectionNames).toContain('My App'); + }); + + it('purges signing logs when the owning user is deleted (user FK cascades)', async () => { + const { user } = await seedConnectionWithLog(); + expect(await prisma.signingLog.count()).toBe(1); + + await prisma.user.delete({ where: { id: user.id } }); + + expect(await prisma.user.count()).toBe(0); + expect(await prisma.signingLog.count()).toBe(0); + }); +}); diff --git a/packages/shared-types/src/nip46.ts b/packages/shared-types/src/nip46.ts index f3d3f19..04de926 100644 --- a/packages/shared-types/src/nip46.ts +++ b/packages/shared-types/src/nip46.ts @@ -65,8 +65,10 @@ export interface BunkerConnectionDto { export interface SigningLogEntryDto { id: string; - connectionId: string; + // null once the originating connection has been deleted (the log is retained for audit). + connectionId: string | null; connectionName: string; + clientPubkey: string; method: Nip46Method; eventKind?: number; result: 'approved' | 'denied' | 'error';