From 642dee20651fc7a5036ac45635df9b52fd0c5b0c Mon Sep 17 00:00:00 2001 From: peaceshallom37-rgb Date: Mon, 24 Aug 2026 14:51:57 +0000 Subject: [PATCH 1/3] fix(api): fail open on audit log writes so login/register survive audit DB hiccups (closes #530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed audit INSERT throws out of AuthService.login()/register() because the awaited auditService.log() is a bare pool.query with no error policy. A DB hiccup on the audit table turns a valid login or registration into a 503 — an observability dependency becomes an availability one. Add AuditService.logSafely(), the single fail-open wrapper: on write failure it logs the action, user, and IP that would have been recorded, bumps a new audit_log_write_failures_total Prometheus counter (labeled by action), and lets the audited action proceed. It never throws, performs exactly one write on success (no retry, no double-write), and is now the only path the auth flow and the audit interceptor use. The raw log() stays fail-closed for callers that want to observe write failures directly. The trade-off is documented on the service and module: audit coverage degrades visibly during a DB outage instead of taking auth down with it. Tests: audit.service.spec.ts covers the swallow/log/counter/single-write behavior; auth.service.spec.ts asserts login/register still resolve with the token pair when the audit write rejects, and still throw their domain errors when the audited action itself fails. --- api/src/audit/audit.interceptor.ts | 11 +- api/src/audit/audit.module.ts | 19 ++- api/src/audit/audit.service.spec.ts | 142 +++++++++++++++++ api/src/audit/audit.service.ts | 63 +++++++- .../auth/auth-rate-limit.integration.spec.ts | 3 +- api/src/auth/auth.service.spec.ts | 144 ++++++++++++++++-- api/src/auth/auth.service.ts | 30 ++-- api/src/contract-provider.spec.ts | 11 +- api/src/metrics/metrics.service.ts | 10 ++ 9 files changed, 393 insertions(+), 40 deletions(-) create mode 100644 api/src/audit/audit.service.spec.ts diff --git a/api/src/audit/audit.interceptor.ts b/api/src/audit/audit.interceptor.ts index ca2d919..8ad02ae 100644 --- a/api/src/audit/audit.interceptor.ts +++ b/api/src/audit/audit.interceptor.ts @@ -4,10 +4,11 @@ import { ExecutionContext, CallHandler, } from "@nestjs/common" -import { Observable, tap } from "rxjs" import { Request } from "express" -import { AuditService } from "./audit.service" +import { Observable, tap } from "rxjs" + import { AuditAction } from "./audit-action.enum" +import { AuditService } from "./audit.service" const SENSITIVE_ACTIONS: Record = { "POST /auth/login": AuditAction.LOGIN, @@ -37,8 +38,10 @@ export class AuditInterceptor implements NestInterceptor { return next.handle().pipe( // metadata is empty here because the interceptor doesn't have access to // the request body post-processing. Callers that need richer metadata - // (e.g. AuthService) call auditService.log() directly. - tap(() => this.auditService.log(userId, action, {}, ip)), + // (e.g. AuthService) call auditService.logSafely() directly. + // Fail-open policy (issue #530): logSafely never throws, so a failed + // audit INSERT cannot surface as a post-response error either. + tap(() => this.auditService.logSafely(userId, action, {}, ip)), ) } } diff --git a/api/src/audit/audit.module.ts b/api/src/audit/audit.module.ts index cfd5c4a..dba1d67 100644 --- a/api/src/audit/audit.module.ts +++ b/api/src/audit/audit.module.ts @@ -1,10 +1,25 @@ import { Module } from "@nestjs/common" import { APP_INTERCEPTOR } from "@nestjs/core" -import { AuditService } from "./audit.service" -import { AuditInterceptor } from "./audit.interceptor" + import { AdminAuditController } from "./admin-audit.controller" +import { AuditInterceptor } from "./audit.interceptor" +import { AuditService } from "./audit.service" +import { MetricsModule } from "../metrics/metrics.module" +/** + * Audit logging module. + * + * Fail-open policy (issue #530): audit log writes must never fail the + * primary request they are auditing. A DB hiccup on the audit table + * must not turn a valid login into a 503. `AuditService.logSafely` + * absorbs write failures (structured log + `audit_log_write_failures_total` + * metric) so security-relevant events stay observable while the + * audited action proceeds — a deliberate trade-off between audit + * coverage and product availability. The interceptor and the auth + * service both route through the safe path. + */ @Module({ + imports: [MetricsModule], controllers: [AdminAuditController], providers: [ AuditService, diff --git a/api/src/audit/audit.service.spec.ts b/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..312216d --- /dev/null +++ b/api/src/audit/audit.service.spec.ts @@ -0,0 +1,142 @@ +import { Logger } from "@nestjs/common" + +import { AuditAction } from "./audit-action.enum" +import { AuditService } from "./audit.service" + +interface MockPool { + query: jest.Mock +} + +interface MockMetricsService { + auditLogWriteFailuresTotal: { inc: jest.Mock } +} + +function makeService( + pool: MockPool, + metrics?: MockMetricsService, +): AuditService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the pool only needs `query` for these tests + return new AuditService(pool as any, metrics as any) +} + +describe("AuditService", () => { + let pool: MockPool + let metrics: MockMetricsService + let service: AuditService + + beforeEach(() => { + pool = { query: jest.fn() } + metrics = { + auditLogWriteFailuresTotal: { inc: jest.fn() }, + } + service = makeService(pool, metrics) + jest.clearAllMocks() + }) + + describe("log (fail-closed primitive)", () => { + it("inserts the audit row with stringified metadata", async () => { + pool.query.mockResolvedValue({ rows: [] }) + + await service.log( + 7, + AuditAction.AUTH_LOGIN_FAILURE, + { reason: "invalid_password" }, + "1.2.3.4", + ) + + expect(pool.query).toHaveBeenCalledWith( + "INSERT INTO audit_logs (user_id, action, metadata, ip) VALUES ($1, $2, $3, $4)", + [ + 7, + AuditAction.AUTH_LOGIN_FAILURE, + JSON.stringify({ reason: "invalid_password" }), + "1.2.3.4", + ], + ) + }) + + it("throws on database errors — callers on primary paths must use logSafely", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + + await expect( + service.log(null, AuditAction.AUTH_LOGIN_FAILURE, {}, "1.2.3.4"), + ).rejects.toThrow("connection refused") + }) + }) + + describe("logSafely (fail-open policy)", () => { + it("resolves without throwing when the audit INSERT fails", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + + await expect( + service.logSafely( + 1, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "203.0.113.7", + ), + ).resolves.toBeUndefined() + }) + + it("logs the failed write with the action, user, and IP that would have been recorded", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + const errorSpy = jest + .spyOn(Logger.prototype, "error") + .mockImplementation(() => undefined) + + try { + await service.logSafely( + 42, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "203.0.113.7", + ) + + const [message] = errorSpy.mock.calls[0] + expect(String(message)).toContain("action=AUTH_LOGIN_SUCCESS") + expect(String(message)).toContain("userId=42") + expect(String(message)).toContain("ip=203.0.113.7") + } finally { + errorSpy.mockRestore() + } + }) + + it("increments the audit write failure counter with the action label", async () => { + pool.query.mockRejectedValue(new Error("timeout")) + + await service.logSafely( + 1, + AuditAction.AUTH_REGISTER_SUCCESS, + {}, + "1.2.3.4", + ) + + expect(metrics.auditLogWriteFailuresTotal.inc).toHaveBeenCalledWith({ + action: AuditAction.AUTH_REGISTER_SUCCESS, + }) + }) + + it("performs a single write on success — no retry, no double-write, no counter bump", async () => { + pool.query.mockResolvedValue({ rows: [] }) + + await service.logSafely( + 1, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "1.2.3.4", + ) + + expect(pool.query).toHaveBeenCalledTimes(1) + expect(metrics.auditLogWriteFailuresTotal.inc).not.toHaveBeenCalled() + }) + + it("tolerates a missing metrics service (unit-test construction)", async () => { + const bare = makeService(pool) + pool.query.mockRejectedValue(new Error("down")) + + await expect( + bare.logSafely(1, AuditAction.AUTH_LOGIN_SUCCESS, {}, "1.2.3.4"), + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/api/src/audit/audit.service.ts b/api/src/audit/audit.service.ts index 65a0ffb..48038fd 100644 --- a/api/src/audit/audit.service.ts +++ b/api/src/audit/audit.service.ts @@ -1,15 +1,41 @@ -import { Inject, Injectable } from "@nestjs/common" +import { Inject, Injectable, Logger, Optional } from "@nestjs/common" import { Pool } from "pg" -import { PG_POOL } from "../database/database.module" + import { AuditAction } from "./audit-action.enum" +import { PG_POOL } from "../database/database.module" +import { MetricsService } from "../metrics/metrics.service" +/** + * Audit logging for security-relevant actions. + * + * Fail-open policy (issue #530): audit writes are an observability + * concern, not a trust boundary for the audited action. A failed + * INSERT must never fail the request being audited — a DB hiccup on + * the audit table must not turn a valid login into a 503. Primary + * request paths MUST go through {@link logSafely}, which absorbs the + * failure (structured log + `audit_log_write_failures_total` metric) + * and lets the audited action proceed. The raw {@link log} method + * stays fail-closed (it throws) so callers can still observe write + * failures directly; it is intentionally not awaited from request + * handlers. + */ @Injectable() export class AuditService { - constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + private readonly logger = new Logger(AuditService.name) + + constructor( + @Inject(PG_POOL) private readonly pool: Pool, + @Optional() private readonly metricsService?: MetricsService, + ) {} /** * Persist an audit log entry. * + * Fail-closed primitive: throws on any database error. Do NOT await + * this from a primary request path — use {@link logSafely} instead + * so an audit write failure cannot fail the audited action + * (fail-open policy, issue #530). + * * @param userId - the authenticated user, or `null` for anonymous events. * @param action - a value from {@link AuditAction}; never a free-form string. * @param metadata - structured context for the action (e.g. `{ email, reason }`). @@ -28,6 +54,37 @@ export class AuditService { ) } + /** + * Fail-open wrapper around {@link log} for primary request paths. + * + * Deliberate trade-off (see module comment): if the audit INSERT + * fails (DB down, constraint, timeout), the failure is logged with + * the action, user, and IP that would have been recorded and counted + * in `audit_log_write_failures_total`, then swallowed — the audited + * action proceeds. Audit coverage degrades visibly during a DB + * outage instead of taking login/register down with it. + * + * Never throws. Safe to await anywhere. On success behaves exactly + * like {@link log} — a single write, no retry, no duplicate row. + */ + async logSafely( + userId: number | null, + action: AuditAction, + metadata: Record, + ip: string, + ): Promise { + try { + await this.log(userId, action, metadata, ip) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.logger.error( + `audit log write failed (action=${action}, userId=${userId}, ip=${ip}): ${message}`, + err instanceof Error ? err.stack : undefined, + ) + this.metricsService?.auditLogWriteFailuresTotal.inc({ action }) + } + } + async findAll(page = 1, limit = 20) { const offset = (page - 1) * limit const totalResult = await this.pool.query( diff --git a/api/src/auth/auth-rate-limit.integration.spec.ts b/api/src/auth/auth-rate-limit.integration.spec.ts index 203583a..8dbab52 100644 --- a/api/src/auth/auth-rate-limit.integration.spec.ts +++ b/api/src/auth/auth-rate-limit.integration.spec.ts @@ -13,12 +13,12 @@ import { Test, TestingModule } from "@nestjs/testing" import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler" import request from "supertest" -import { AuditService } from "../audit/audit.service" import { AuthController } from "./auth.controller" import { AuthService } from "./auth.service" import { PasswordResetService } from "./password-reset.service" import { TokenDenylistService } from "./token-denylist.service" import { UsersRepository } from "./users.repository" +import { AuditService } from "../audit/audit.service" describe("Auth Rate Limiting (Integration)", () => { let app: INestApplication @@ -51,6 +51,7 @@ describe("Auth Rate Limiting (Integration)", () => { const mockAuditService = { log: jest.fn().mockResolvedValue(undefined), + logSafely: jest.fn().mockResolvedValue(undefined), } beforeEach(async () => { diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 521f22a..b71ab51 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -1,9 +1,13 @@ import { ConflictException, UnauthorizedException } from "@nestjs/common" import { JwtService } from "@nestjs/jwt" import * as bcrypt from "bcrypt" + import { AuthService } from "./auth.service" -import { User, UsersRepository } from "./users.repository" import { TokenDenylistService } from "./token-denylist.service" +import { User, UsersRepository } from "./users.repository" +import { AuditAction } from "../audit/audit-action.enum" + +import type { AuditService } from "../audit/audit.service" jest.mock("bcrypt", () => ({ hash: jest.fn(), @@ -36,6 +40,11 @@ interface MockTokenDenylistService { revoke: jest.Mock> } +interface MockAuditService { + log: jest.Mock> + logSafely: jest.Mock> +} + function mockJwtService(): MockJwtService { return { sign: jest.fn(), @@ -66,6 +75,7 @@ function makeService( users: MockUsersRepository, passwordReset: MockPasswordResetService, tokenDenylist: MockTokenDenylistService, + audit: MockAuditService, ): AuthService { return new AuthService( refreshJwt as unknown as JwtService, @@ -74,8 +84,7 @@ function makeService( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordResetService is typed separately via the mock interface passwordReset as unknown as any, tokenDenylist as unknown as TokenDenylistService, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Logger mock only needs `log` - { log: jest.fn() } as any, + audit as unknown as AuditService, ) } @@ -101,6 +110,7 @@ describe("AuthService", () => { let users: MockUsersRepository let passwordReset: MockPasswordResetService let tokenDenylist: MockTokenDenylistService + let audit: MockAuditService let service: AuthService beforeEach(() => { @@ -109,12 +119,14 @@ describe("AuthService", () => { users = mockUsersRepository() passwordReset = mockPasswordResetService() tokenDenylist = { revoke: jest.fn() } + audit = { log: jest.fn(), logSafely: jest.fn() } service = makeService( accessJwt, refreshJwt, users, passwordReset, tokenDenylist, + audit, ) jest.clearAllMocks() }) @@ -142,7 +154,7 @@ describe("AuthService", () => { const result = await service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(users.findByEmail).toHaveBeenCalledWith(dto.email) expect(users.findByUsername).toHaveBeenCalledWith(dto.username) @@ -151,6 +163,13 @@ describe("AuthService", () => { dto.email, "$2b$10$hashed", ) + expect(audit.logSafely).toHaveBeenCalledWith( + null, + AuditAction.AUTH_REGISTER_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + expect(audit.log).not.toHaveBeenCalled() expect(accessJwt.sign).toHaveBeenCalledWith({ sub: 1, email: dto.email, @@ -182,7 +201,7 @@ describe("AuthService", () => { service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any), + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) expect(users.create).not.toHaveBeenCalled() }) @@ -198,7 +217,7 @@ describe("AuthService", () => { service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any), + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) expect(users.create).not.toHaveBeenCalled() }) @@ -215,7 +234,7 @@ describe("AuthService", () => { await service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(bcrypt.hash).toHaveBeenCalledWith(dto.password, 12) const [storedUsername, storedEmail, storedHash] = @@ -235,9 +254,51 @@ describe("AuthService", () => { email: "dup@x.com", password: "someOtherPassword", }, - { ip: "127.0.0.1", headers: { "user-agent": "test" } } as any, - ), // eslint-disable-line @typescript-eslint/no-explicit-any + { ip: "127.0.0.1", headers: { "user-agent": "test" } } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ), + ).rejects.toThrow(ConflictException) + }) + + it("still resolves with the token pair when the audit log write fails (fail-open)", async () => { + users.findByEmail.mockResolvedValue(null) + users.findByUsername.mockResolvedValue(null) + users.create.mockResolvedValue( + dummyUser({ email: dto.email, username: dto.username }), + ) + accessJwt.sign.mockReturnValue("jwt.token.here") + refreshJwt.sign.mockReturnValue("refresh.token.here") + ;(bcrypt.hash as jest.Mock).mockResolvedValue("$2b$10$hashed") + + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + const result = await service.register(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(result.accessToken).toBe("jwt.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + expect(audit.logSafely).toHaveBeenCalledWith( + null, + AuditAction.AUTH_REGISTER_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + }) + + it("still throws ConflictException (not 503) when a conflict-path audit write fails", async () => { + users.findByEmail.mockResolvedValue(dummyUser({ email: dto.email })) + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + service.register(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) + expect(users.create).not.toHaveBeenCalled() }) }) @@ -289,13 +350,20 @@ describe("AuthService", () => { const result = await service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(users.findByEmail).toHaveBeenCalledWith(dto.email) expect(bcrypt.compare).toHaveBeenCalledWith( dto.password, user.password_hash, ) + expect(audit.logSafely).toHaveBeenCalledWith( + user.id, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + expect(audit.log).not.toHaveBeenCalled() expect(accessJwt.sign).toHaveBeenCalledWith({ sub: user.id, email: user.email, @@ -327,7 +395,53 @@ describe("AuthService", () => { service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any), + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toThrow(UnauthorizedException) + expect(accessJwt.sign).not.toHaveBeenCalled() + }) + + it("still resolves with the token pair when the audit log write fails (fail-open)", async () => { + const user = dummyUser({ email: dto.email }) + users.findByEmail.mockResolvedValue(user) + ;(bcrypt.compare as jest.Mock).mockResolvedValue(true) + accessJwt.sign.mockReturnValue("jwt.token.here") + refreshJwt.sign.mockReturnValue("refresh.token.here") + + // The raw audit INSERT is mocked to reject (DB hiccup). The auth + // flow must route through logSafely and never await the raw + // write, so a valid login still returns tokens. The swallow + // behaviour of logSafely itself is unit-tested in + // audit.service.spec.ts. + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + const result = await service.login(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(result.accessToken).toBe("jwt.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + expect(audit.logSafely).toHaveBeenCalledWith( + user.id, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + }) + + it("still throws UnauthorizedException (not 503) when a failure-path audit write fails", async () => { + const user = dummyUser({ email: dto.email }) + users.findByEmail.mockResolvedValue(user) + ;(bcrypt.compare as jest.Mock).mockResolvedValue(false) + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + service.login({ email: dto.email, password: "wrong" }, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(UnauthorizedException) expect(accessJwt.sign).not.toHaveBeenCalled() }) @@ -342,7 +456,7 @@ describe("AuthService", () => { service.login({ email: dto.email, password: "wrongPassword" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any), + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(UnauthorizedException) expect(accessJwt.sign).not.toHaveBeenCalled() @@ -356,7 +470,7 @@ describe("AuthService", () => { .login({ email: "no@user.com", password: "any" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any .catch((e) => e) expect(e1).toBeInstanceOf(UnauthorizedException) @@ -368,7 +482,7 @@ describe("AuthService", () => { .login({ email: dto.email, password: "bad" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any .catch((e) => e) expect(e2).toBeInstanceOf(UnauthorizedException) @@ -386,7 +500,7 @@ describe("AuthService", () => { await service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(bcrypt.compare).toHaveBeenCalledWith( dto.password, diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 1a16420..c901f12 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto" + import { ConflictException, Injectable, @@ -5,19 +7,21 @@ import { UnauthorizedException, } from "@nestjs/common" import { JwtService } from "@nestjs/jwt" -import type { User as SharedUser } from "@xstreamroll/types" import * as bcrypt from "bcrypt" -import { randomUUID } from "node:crypto" -import type { Request } from "express" -import { RegisterDto } from "./dto/register.dto" -import { LoginDto } from "./dto/login.dto" + + import { ForgotPasswordDto } from "./dto/forgot-password.dto" +import { LoginDto } from "./dto/login.dto" +import { RegisterDto } from "./dto/register.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" +import { PasswordResetService } from "./password-reset.service" import { TokenDenylistService } from "./token-denylist.service" import { User, UsersRepository } from "./users.repository" -import { PasswordResetService } from "./password-reset.service" -import { AuditService } from "../audit/audit.service" import { AuditAction } from "../audit/audit-action.enum" +import { AuditService } from "../audit/audit.service" + +import type { User as SharedUser } from "@xstreamroll/types" +import type { Request } from "express" /** Rounds for bcrypt key derivation (auto-salt). */ const BCRYPT_ROUNDS = 12 @@ -61,7 +65,7 @@ export class AuthService { const emailExists = await this.usersRepository.findByEmail(dto.email) if (emailExists) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_FAILURE, { reason: "email_conflict", email: dto.email }, @@ -74,7 +78,7 @@ export class AuthService { dto.username, ) if (usernameExists) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_FAILURE, { reason: "username_conflict", username: dto.username }, @@ -91,7 +95,7 @@ export class AuthService { passwordHash, ) - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_SUCCESS, { email: dto.email }, @@ -117,7 +121,7 @@ export class AuthService { const user = await this.usersRepository.findByEmail(dto.email) if (!user) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_LOGIN_FAILURE, { reason: "user_not_found", email: dto.email }, @@ -128,7 +132,7 @@ export class AuthService { const valid = await bcrypt.compare(dto.password, user.password_hash) if (!valid) { - await this.auditService.log( + await this.auditService.logSafely( user.id, AuditAction.AUTH_LOGIN_FAILURE, { reason: "invalid_password", email: dto.email }, @@ -137,7 +141,7 @@ export class AuthService { throw new UnauthorizedException("invalid email or password") } - await this.auditService.log( + await this.auditService.logSafely( user.id, AuditAction.AUTH_LOGIN_SUCCESS, { email: dto.email }, diff --git a/api/src/contract-provider.spec.ts b/api/src/contract-provider.spec.ts index 29693dc..f532aa7 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -25,17 +25,18 @@ import { type Contract, } from "@xstreamroll/contract-tests" import request from "supertest" + import { AuditService } from "./audit/audit.service" import { AuthController } from "./auth/auth.controller" import { AuthService } from "./auth/auth.service" import { PasswordResetService } from "./auth/password-reset.service" import { TokenDenylistService } from "./auth/token-denylist.service" import { User, UsersRepository } from "./auth/users.repository" -import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config" import { AuthGuard } from "./common/guards/auth.guard" import { JwtExtractorService } from "./common/guards/jwt-extractor.service" import { StreamOwnershipGuard } from "./common/guards/stream-ownership.guard" import { StreamOwnershipService } from "./common/guards/stream-ownership.service" +import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config" import { StreamsRepository } from "./streams/repository/streams.repository" import { StreamsController } from "./streams/streams.controller" import { StreamsService } from "./streams/streams.service" @@ -141,7 +142,13 @@ describe("Contract provider verification (api)", () => { }, { provide: UsersRepository, useClass: InMemoryUsersRepository }, { provide: PasswordResetService, useValue: {} }, - { provide: AuditService, useValue: { log: async () => undefined } }, + { + provide: AuditService, + useValue: { + log: async () => undefined, + logSafely: async () => undefined, + }, + }, ], }).compile() diff --git a/api/src/metrics/metrics.service.ts b/api/src/metrics/metrics.service.ts index 590a691..fa29085 100644 --- a/api/src/metrics/metrics.service.ts +++ b/api/src/metrics/metrics.service.ts @@ -38,6 +38,16 @@ export class MetricsService implements OnModuleInit { registers: [this.registry], }) + // Issue #530: audit writes are fail-open — a failed INSERT is logged + // and counted instead of failing the audited request. This counter + // makes audit gaps during DB outages visible to operators. + readonly auditLogWriteFailuresTotal = new Counter({ + name: "audit_log_write_failures_total", + help: "Total number of audit log writes that failed and were absorbed by the fail-open policy", + labelNames: ["action"], + registers: [this.registry], + }) + // Issue #328: PostgreSQL connection pool metrics readonly dbPoolActive = new Gauge({ name: "db_pool_active_connections", From 182f595c76b13479f6b8e34f9e2bcc859f93122c Mon Sep 17 00:00:00 2001 From: peaceshallom37-rgb Date: Mon, 24 Aug 2026 14:52:01 +0000 Subject: [PATCH 2/3] fix(api): serialize stream ids to strings in GET /streams list response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamsService.list() returns raw numeric Postgres ids, so GET /streams leaked numbers while every other stream endpoint (create/findById/update) stringifies via toStreamResponse — violating the @xstreamroll/types#Stream wire contract and failing the contract-provider suite. Map the list items through toStreamResponse, preserving the pagination envelope and hasMore. --- api/src/streams/streams.controller.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/api/src/streams/streams.controller.ts b/api/src/streams/streams.controller.ts index 5ec5e50..7754dc9 100644 --- a/api/src/streams/streams.controller.ts +++ b/api/src/streams/streams.controller.ts @@ -1,3 +1,4 @@ +import { CACHE_MANAGER } from "@nestjs/cache-manager" import { Body, Controller, @@ -14,7 +15,6 @@ import { Req, UseGuards, } from "@nestjs/common" -import { CACHE_MANAGER } from "@nestjs/cache-manager" import { ApiBearerAuth, ApiConflictResponse, @@ -28,17 +28,19 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger" -import type { PaginatedResponse, Stream } from "@xstreamroll/types" -import type { Request } from "express" import { Cache } from "cache-manager" -import { AuthGuard } from "../common/guards/auth.guard" -import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" + import { CreateStreamDto } from "./dto/create-stream.dto" import { ListStreamsQueryDto } from "./dto/list-streams.query.dto" import { StreamAnalyticsDto } from "./dto/stream-analytics.dto" import { toStreamResponse } from "./dto/stream-response.dto" import { UpdateStreamDto } from "./dto/update-stream.dto" import { StreamsService } from "./streams.service" +import { AuthGuard } from "../common/guards/auth.guard" +import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" + +import type { Stream } from "@xstreamroll/types" +import type { Request } from "express" const STREAM_ANALYTICS_CACHE_TTL_MS = 60_000 @@ -148,17 +150,24 @@ export class StreamsController { }) @ApiOkResponse({ description: "Paginated list of streams." }) @ApiUnauthorizedResponse({ description: "Authentication required." }) - list( + async list( @Query() query: ListStreamsQueryDto, @Req() req: Request & { auth?: { userId: number } }, ) { const page = query.page ?? 1 const limit = query.limit ?? 20 - return this.streamsService.list(page, limit, req.auth!.userId, { + const paged = await this.streamsService.list(page, limit, req.auth!.userId, { status: query.status, visibility: query.visibility, ownerOnly: query.ownerOnly, }) + // Serialize ids to strings at the API boundary, exactly like the + // single-stream endpoints — `GET /streams` must not leak the + // numeric Postgres ids (contract: `@xstreamroll/types#Stream`). + return { + ...paged, + data: paged.data.map(toStreamResponse), + } } /** From 1a3a2f89da839d05dc0cead66121b7b961b9bcef Mon Sep 17 00:00:00 2001 From: peaceshallom37-rgb Date: Mon, 24 Aug 2026 14:52:01 +0000 Subject: [PATCH 3/3] test(api): repair pre-existing unit tests that fail CI on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - streams.controller.spec.ts: the create assertion was stale — the controller forwards description to the service, matching the second create test in the same file. - jwt-secret-validator.spec.ts: passing explicit undefined triggered the secret's default parameter and read JWT_SECRET from the environment (always set in CI), so the "no secret" branch was never exercised. Pass an empty string, which is falsy and hits the same early return. --- api/src/config/jwt-secret-validator.spec.ts | 7 ++++++- api/src/streams/streams.controller.spec.ts | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/api/src/config/jwt-secret-validator.spec.ts b/api/src/config/jwt-secret-validator.spec.ts index 00e67f4..a3740e9 100644 --- a/api/src/config/jwt-secret-validator.spec.ts +++ b/api/src/config/jwt-secret-validator.spec.ts @@ -23,7 +23,12 @@ describe("validateJwtSecret (Issue #318)", () => { }) it("returns ok when no secret is configured", () => { - const result = validateJwtSecret(undefined, "production") + // Pass an empty string, not `undefined`: the function's default + // parameter (`secret = process.env.JWT_SECRET`) means an explicit + // `undefined` argument falls back to the environment, which is set + // in CI — so `undefined` would not exercise the "no secret" branch. + // An empty string is falsy and hits the same early return. + const result = validateJwtSecret("", "production") expect(result.ok).toBe(true) expect(exitSpy).not.toHaveBeenCalled() }) diff --git a/api/src/streams/streams.controller.spec.ts b/api/src/streams/streams.controller.spec.ts index 4d616eb..60c3f7c 100644 --- a/api/src/streams/streams.controller.spec.ts +++ b/api/src/streams/streams.controller.spec.ts @@ -14,14 +14,15 @@ jest.mock("../common/guards/auth.guard", () => ({ }, })) -import type { Cache } from "cache-manager" -import type { Request } from "express" -import { StreamsController } from "./streams.controller" import { CreateStreamDto } from "./dto/create-stream.dto" import { UpdateStreamDto } from "./dto/update-stream.dto" import { Stream } from "./stream.entity" +import { StreamsController } from "./streams.controller" import { StreamsService } from "./streams.service" +import type { Cache } from "cache-manager" +import type { Request } from "express" + type MockStreamsService = { create: jest.Mock list: jest.Mock @@ -95,6 +96,7 @@ describe("StreamsController", () => { expect(mockService.create).toHaveBeenCalledWith({ userId: 7, name: dto.name, + description: dto.description, visibility: undefined, }) })