Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions api/src/audit/audit.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jest.mock("bcrypt", () => ({
describe("Login audit (integration)", () => {
let app: INestApplication

const auditService = { log: jest.fn().mockResolvedValue(undefined) }
const auditService = { logSafely: jest.fn().mockResolvedValue(undefined) }
const usersRepository = {
findByEmail: jest.fn(),
findByUsername: jest.fn(),
Expand All @@ -58,6 +58,7 @@ describe("Login audit (integration)", () => {
password_hash:
"$2b$10$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12",
created_at: new Date("2026-01-01T00:00:00Z"),
is_admin: false,
}

beforeAll(async () => {
Expand Down Expand Up @@ -96,8 +97,8 @@ describe("Login audit (integration)", () => {
.send({ email: user.email, password: "correctPassword" })

expect(res.status).toBe(200)
expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
user.id,
AuditAction.AUTH_LOGIN_SUCCESS,
{ email: user.email },
Expand All @@ -114,8 +115,8 @@ describe("Login audit (integration)", () => {
.send({ email: user.email, password: "wrongPassword" })

expect(res.status).toBe(401)
expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
user.id,
AuditAction.AUTH_LOGIN_FAILURE,
{ reason: "invalid_password", email: user.email },
Expand All @@ -131,8 +132,8 @@ describe("Login audit (integration)", () => {
.send({ email: "nobody@example.com", password: "whatever" })

expect(res.status).toBe(401)
expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
null,
AuditAction.AUTH_LOGIN_FAILURE,
{ reason: "user_not_found", email: "nobody@example.com" },
Expand Down
34 changes: 17 additions & 17 deletions api/src/audit/audit.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { AuditInterceptor } from "./audit.interceptor"
import { AuditService } from "./audit.service"

describe("AuditInterceptor", () => {
let auditService: { log: jest.Mock }
let auditService: { logSafely: jest.Mock }
let interceptor: AuditInterceptor

beforeEach(() => {
auditService = { log: jest.fn().mockResolvedValue(undefined) }
auditService = { logSafely: jest.fn().mockResolvedValue(undefined) }
interceptor = new AuditInterceptor(auditService as unknown as AuditService)
})

Expand Down Expand Up @@ -55,8 +55,8 @@ describe("AuditInterceptor", () => {
it("captures PATCH /users/me as PROFILE_UPDATE with the acting user id", async () => {
await runThroughInterceptor("PATCH", "/users/me", { auth: { userId: 42 } })

expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
42,
AuditAction.PROFILE_UPDATE,
{},
Expand All @@ -69,8 +69,8 @@ describe("AuditInterceptor", () => {
auth: { userId: 42 },
})

expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
42,
AuditAction.PASSWORD_CHANGE,
{},
Expand All @@ -84,11 +84,11 @@ describe("AuditInterceptor", () => {
params: { id: "7" },
})

expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
3,
AuditAction.STREAM_DELETE,
{ streamId: 7 },
{},
"203.0.113.7",
)
})
Expand All @@ -99,18 +99,18 @@ describe("AuditInterceptor", () => {
params: { id: "7" },
})

expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledWith(
3,
AuditAction.STREAM_DELETE,
{ streamId: 7 },
{},
expect.any(String),
)
})

it("does not capture POST /auth/login: AuthService owns login auditing", async () => {
await runThroughInterceptor("POST", "/auth/login")

expect(auditService.log).not.toHaveBeenCalled()
expect(auditService.logSafely).not.toHaveBeenCalled()
})

it("does not capture routes that do not exist", async () => {
Expand All @@ -119,15 +119,15 @@ describe("AuditInterceptor", () => {
// The real route is DELETE /streams/:id; the id-less path is not a route.
await runThroughInterceptor("DELETE", "/streams")

expect(auditService.log).not.toHaveBeenCalled()
expect(auditService.logSafely).not.toHaveBeenCalled()
})

it("does not capture DELETE /streams/:id when the id is non-numeric", async () => {
await runThroughInterceptor("DELETE", "/streams/abc", {
params: { id: "abc" },
})

expect(auditService.log).not.toHaveBeenCalled()
expect(auditService.logSafely).not.toHaveBeenCalled()
})

it("writes no audit row when the request handler fails", async () => {
Expand All @@ -140,14 +140,14 @@ describe("AuditInterceptor", () => {
),
).rejects.toThrow("boom")

expect(auditService.log).not.toHaveBeenCalled()
expect(auditService.logSafely).not.toHaveBeenCalled()
})

it("records a NULL user id when the request carries no actor", async () => {
await runThroughInterceptor("PATCH", "/users/me")

expect(auditService.log).toHaveBeenCalledTimes(1)
expect(auditService.log).toHaveBeenCalledWith(
expect(auditService.logSafely).toHaveBeenCalledTimes(1)
expect(auditService.logSafely).toHaveBeenCalledWith(
null,
AuditAction.PROFILE_UPDATE,
{},
Expand Down
2 changes: 1 addition & 1 deletion api/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"
import { APP_INTERCEPTOR } from "@nestjs/core"

import { AdminAuditController } from "./admin-audit.controller"
import { AdminAuditController } from "../admin/admin-audit.controller"
import { AuditInterceptor } from "./audit.interceptor"
import { AuditService } from "./audit.service"
import { MetricsModule } from "../metrics/metrics.module"
Expand Down
5 changes: 3 additions & 2 deletions api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { UnauthorizedException } from "@nestjs/common"

import { AuthController } from "./auth.controller"
import { AuthResponse, AuthService } from "./auth.service"

Expand All @@ -23,10 +24,10 @@ function makeController(service: MockAuthService): AuthController {
function authResponse(): AuthResponse {
return {
user: {
id: 1,
id: "1",
username: "testuser",
email: "test@example.com",
createdAt: new Date("2026-01-01T00:00:00Z"),
createdAt: "2026-01-01T00:00:00.000Z",
},
accessToken: "access.token",
refreshToken: "refresh.token",
Expand Down
53 changes: 21 additions & 32 deletions api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@ import {
Post,
Req,
Res,
UnauthorizedException,
} from "@nestjs/common"
import { Throttle } from "@nestjs/throttler"
import {
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger"
import type { Request, Response } from "express"
import { Throttle } from "@nestjs/throttler"


import { AuthResponse, AuthService } from "./auth.service"
import { ForgotPasswordDto } from "./dto/forgot-password.dto"
import { LoginDto } from "./dto/login.dto"
import { RegisterDto } from "./dto/register.dto"
import { ForgotPasswordDto } from "./dto/forgot-password.dto"
import { ResetPasswordDto } from "./dto/reset-password.dto"

import type { Request, Response } from "express"

const REFRESH_COOKIE_NAME = "refresh_token"
const COOKIE_OPTIONS = {
httpOnly: true,
Expand Down Expand Up @@ -84,17 +88,24 @@ export class AuthController {
@Post("refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh the access token",
summary: "Refresh an expired access token",
description:
"Reads the refresh token from the httpOnly cookie and returns a new access token.",
"Accepts a refresh token via the request body (`refreshToken`) or via " +
"the `refresh_token` httpOnly cookie. Returns a fresh access token, " +
"refresh token, and the user profile.",
})
@ApiOkResponse({
description: "Access token refreshed.",
description: "Token refresh successful. New token pair returned.",
})
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<AuthResponse> {
const result = await this.authService.refresh(req)
res.cookie(REFRESH_COOKIE_NAME, result.refreshToken, COOKIE_OPTIONS)
return result
refresh(
@Body("refreshToken") bodyToken?: string,
@Req() req?: { cookies?: Record<string, string> },
): Promise<AuthResponse> {
const token = bodyToken ?? req?.cookies?.refresh_token
if (!token) {
throw new UnauthorizedException("refresh token is required")
}
return this.authService.refresh(token)
}

@Post("logout")
Expand Down Expand Up @@ -160,26 +171,4 @@ export class AuthController {
}
}

@Post("refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh an expired access token",
description:
"Accepts a refresh token via the request body (`refreshToken`) or via " +
"the `refresh_token` httpOnly cookie. Returns a fresh access token, " +
"refresh token, and the user profile.",
})
@ApiOkResponse({
description: "Token refresh successful. New token pair returned.",
})
refresh(
@Body("refreshToken") bodyToken?: string,
@Req() req?: { cookies?: Record<string, string> },
): Promise<AuthResponse> {
const token = bodyToken ?? req?.cookies?.refresh_token
if (!token) {
throw new UnauthorizedException("refresh token is required")
}
return this.authService.refresh(token)
}
}
Loading
Loading