mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
test(auth): add end-to-end coverage for authentication flows
This commit is contained in:
@@ -3,8 +3,8 @@ name: E2E Runner (reusable)
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
suite-script:
|
||||
description: "pnpm root script to run (e.g. test:e2e:scanner)"
|
||||
suite-id:
|
||||
description: "E2E suite id from scripts/test/e2e-suites.mjs (e.g. scanner)"
|
||||
required: true
|
||||
type: string
|
||||
check-name:
|
||||
@@ -15,10 +15,6 @@ on:
|
||||
description: "Name for the uploaded artifact"
|
||||
required: true
|
||||
type: string
|
||||
junit-glob:
|
||||
description: "Glob pattern for the JUnit XML report"
|
||||
required: true
|
||||
type: string
|
||||
job-timeout-minutes:
|
||||
description: "Job timeout in minutes"
|
||||
required: false
|
||||
@@ -69,14 +65,14 @@ jobs:
|
||||
|
||||
- name: Run e2e suite
|
||||
shell: bash
|
||||
run: pnpm run ${{ inputs.suite-script }}
|
||||
run: pnpm run test:e2e -- ${{ inputs.suite-id }}
|
||||
|
||||
- name: Publish test report annotations
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
with:
|
||||
check_name: ${{ inputs.check-name }}
|
||||
files: ${{ inputs.junit-glob }}
|
||||
files: test-results/server/*-e2e-junit.xml
|
||||
fail_on: nothing
|
||||
comment_mode: off
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
scanner-file-ops-e2e:
|
||||
uses: ./.github/workflows/e2e-runner.yml
|
||||
with:
|
||||
suite-script: test:e2e:scanner:file-ops
|
||||
suite-id: scanner-file-ops
|
||||
check-name: Server Scanner File Ops E2E
|
||||
artifact-name: scanner-file-ops-e2e-report
|
||||
junit-glob: test-results/server/scanner-file-ops-e2e-junit.xml
|
||||
job-timeout-minutes: 60
|
||||
|
||||
@@ -32,8 +32,7 @@ jobs:
|
||||
scanner-e2e:
|
||||
uses: ./.github/workflows/e2e-runner.yml
|
||||
with:
|
||||
suite-script: test:e2e:scanner
|
||||
suite-id: scanner
|
||||
check-name: Server Scanner E2E
|
||||
artifact-name: scanner-e2e-report
|
||||
junit-glob: test-results/server/scanner-e2e-junit.xml
|
||||
job-timeout-minutes: 45
|
||||
|
||||
+39
-13
@@ -114,9 +114,12 @@ Opens Drizzle Studio in your browser for browsing and editing tables directly.
|
||||
pnpm run test # all tests (server + client)
|
||||
pnpm run test:server # server unit tests only
|
||||
pnpm run test:client # client unit tests only
|
||||
pnpm run test:e2e:smoke # e2e smoke suite (requires running DB)
|
||||
pnpm run test:e2e:scanner # scanner e2e suite (uses dedicated projectx_e2e DB)
|
||||
pnpm run test:e2e:scanner:file-ops # scanner file operation e2e suite
|
||||
pnpm run test:e2e -- all # run all e2e suites sequentially
|
||||
pnpm run test:e2e -- smoke # e2e smoke suite (default DB)
|
||||
pnpm run test:e2e -- scanner # scanner e2e suite (dedicated projectx_e2e DB)
|
||||
pnpm run test:e2e -- scanner-file-ops # scanner file operation e2e suite (dedicated projectx_e2e DB)
|
||||
pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all"
|
||||
pnpm run test:e2e:list # list all supported e2e suite ids
|
||||
```
|
||||
|
||||
Watch mode while working on a specific area:
|
||||
@@ -125,9 +128,23 @@ Watch mode while working on a specific area:
|
||||
cd server && pnpm test:watch
|
||||
```
|
||||
|
||||
Run a focused scanner scenario:
|
||||
|
||||
```bash
|
||||
pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flattening
|
||||
```
|
||||
|
||||
### E2E runner architecture
|
||||
|
||||
- All suite metadata lives in `scripts/test/e2e-suites.mjs` (suite id, spec file, JUnit output, DB prep mode).
|
||||
- Local and CI use the same command: `pnpm run test:e2e -- <suite-id>`.
|
||||
- Use `all` as a composite suite id to run every configured suite in order.
|
||||
- Dedicated-db suites auto-start local PostgreSQL when `CI != true`, then reset and migrate the e2e database through `pnpm run db:prepare:e2e`.
|
||||
- JUnit outputs are written to `test-results/server/`.
|
||||
|
||||
### Scanner e2e details
|
||||
|
||||
- `pnpm run test:e2e:scanner` prepares and migrates a dedicated e2e database (`projectx_e2e`) on each run.
|
||||
- `pnpm run test:e2e -- scanner` prepares and migrates a dedicated e2e database (`projectx_e2e`) on each run.
|
||||
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
|
||||
- Results are written to `test-results/server/`:
|
||||
- `scanner-e2e-junit.xml`
|
||||
@@ -135,23 +152,32 @@ cd server && pnpm test:watch
|
||||
|
||||
### Scanner file operation e2e details
|
||||
|
||||
- `pnpm run test:e2e:scanner:file-ops` prepares and migrates the same dedicated e2e database (`projectx_e2e`) before running.
|
||||
- `pnpm run test:e2e -- scanner-file-ops` prepares and migrates the same dedicated e2e database (`projectx_e2e`) before running.
|
||||
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
|
||||
- Results are written to `test-results/server/`:
|
||||
- `scanner-file-ops-e2e-junit.xml`
|
||||
- `scanner-file-ops-e2e-scenarios.json`
|
||||
|
||||
### Scanner e2e in CI (manual)
|
||||
### E2E in CI (how to trigger)
|
||||
|
||||
- Open GitHub Actions and run the **Scanner E2E** workflow manually (`workflow_dispatch`).
|
||||
- The workflow runs the full scanner scenario matrix against a service-container Postgres instance.
|
||||
- It uploads `test-results/server/` as an artifact and publishes JUnit annotations.
|
||||
Both workflows call the reusable `E2E Runner (reusable)` workflow, which runs:
|
||||
|
||||
### Scanner file operation e2e in CI
|
||||
```bash
|
||||
pnpm run test:e2e -- <suite-id>
|
||||
```
|
||||
|
||||
- Open GitHub Actions and run the **Scanner File Ops E2E** workflow manually (`workflow_dispatch`) or wait for the nightly run.
|
||||
- The workflow runs the full file operation matrix against a service-container Postgres instance.
|
||||
- It uploads `test-results/server/` as an artifact and publishes JUnit annotations.
|
||||
| Workflow | Suite id | Triggered by |
|
||||
| -------------------- | ------------------ | -------------------------------------------------------------------------------- |
|
||||
| Scanner E2E | `scanner` | `workflow_dispatch`, `push` (scanner-related paths), `pull_request` (same paths) |
|
||||
| Scanner File Ops E2E | `scanner-file-ops` | `workflow_dispatch`, nightly schedule (`0 4 * * *`), `push`, `pull_request` |
|
||||
|
||||
Manual trigger steps:
|
||||
|
||||
1. Open GitHub Actions.
|
||||
2. Select **Scanner E2E** or **Scanner File Ops E2E**.
|
||||
3. Click **Run workflow**.
|
||||
|
||||
Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-5
@@ -7,8 +7,8 @@
|
||||
"dev:client": "cd client && npm run dev -- --host",
|
||||
"dev:server": "cd server && npm run start:dev",
|
||||
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
|
||||
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
|
||||
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
|
||||
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e -- smoke",
|
||||
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e -- smoke",
|
||||
"build:client": "pnpm --filter client build",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client",
|
||||
@@ -22,7 +22,9 @@
|
||||
"test": "pnpm run test:server && pnpm run test:client",
|
||||
"test:server": "pnpm --filter server test",
|
||||
"test:client": "pnpm --filter client test:unit --run",
|
||||
"test:e2e:smoke": "pnpm --filter server test:e2e",
|
||||
"test:e2e": "node ./scripts/test/run-e2e-suite.mjs",
|
||||
"test:e2e:all": "node ./scripts/test/run-e2e-suite.mjs all",
|
||||
"test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list",
|
||||
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
|
||||
"coverage:server": "pnpm --filter server test:cov",
|
||||
"coverage:client": "pnpm --filter client test:cov --run",
|
||||
@@ -33,8 +35,6 @@
|
||||
"db:reset": "bash ./scripts/db/reset.sh",
|
||||
"db:seed": "bash ./scripts/db/seed.sh",
|
||||
"db:prepare:e2e": "bash ./scripts/db/prepare-e2e.sh",
|
||||
"test:e2e:scanner": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner",
|
||||
"test:e2e:scanner:file-ops": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const TEST_RESULTS_DIR = "../test-results/server";
|
||||
|
||||
export const E2E_SUITES = Object.freeze({
|
||||
smoke: {
|
||||
id: "smoke",
|
||||
description: "Server smoke suite",
|
||||
vitestTarget: "test/app.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/smoke-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: false,
|
||||
useDedicatedDatabase: false,
|
||||
},
|
||||
scanner: {
|
||||
id: "scanner",
|
||||
description: "Scanner scenario matrix",
|
||||
vitestTarget: "test/scanner.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/scanner-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"scanner-file-ops": {
|
||||
id: "scanner-file-ops",
|
||||
description: "Scanner file operation matrix",
|
||||
vitestTarget: "test/scanner-file-ops.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/scanner-file-ops-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"auth-session-security": {
|
||||
id: "auth-session-security",
|
||||
description: "Auth session security suite",
|
||||
vitestTarget: "test/auth-session-security.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/auth-session-security-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"auth-recovery-oidc-logout": {
|
||||
id: "auth-recovery-oidc-logout",
|
||||
description: "Auth recovery and OIDC logout suite",
|
||||
vitestTarget: "test/auth-recovery-oidc-logout.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/auth-recovery-oidc-logout-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
});
|
||||
|
||||
export function listE2ESuites() {
|
||||
return Object.values(E2E_SUITES);
|
||||
}
|
||||
|
||||
export function getE2ESuite(suiteId) {
|
||||
return E2E_SUITES[suiteId] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getE2ESuite, listE2ESuites } from "./e2e-suites.mjs";
|
||||
|
||||
const DEFAULT_E2E_DATABASE_URL = "postgres://projectx:projectx@localhost:5432/projectx_e2e";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(scriptDir, "..", "..");
|
||||
const resultsDir = join(rootDir, "test-results", "server");
|
||||
|
||||
function isCiEnvironment() {
|
||||
return (process.env.CI ?? "").toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function trimLeadingSeparator(args) {
|
||||
if (args[0] === "--") {
|
||||
return args.slice(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
const usage = [
|
||||
"Usage:",
|
||||
" pnpm run test:e2e -- <suite-id> [vitest args...]",
|
||||
"",
|
||||
"Examples:",
|
||||
" pnpm run test:e2e -- smoke",
|
||||
" pnpm run test:e2e -- all",
|
||||
" pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flattening",
|
||||
" pnpm run test:e2e -- --list",
|
||||
" pnpm run test:e2e:list",
|
||||
];
|
||||
console.log(usage.join("\n"));
|
||||
}
|
||||
|
||||
function printSuiteList() {
|
||||
const suites = listE2ESuites();
|
||||
console.log("Available e2e suites:");
|
||||
console.log("- all [composite] - Run all suites sequentially");
|
||||
for (const suite of suites) {
|
||||
const dbMode = suite.prepareDedicatedDatabase ? "dedicated-db" : "default-db";
|
||||
console.log(`- ${suite.id} [${dbMode}] - ${suite.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(command, args, envOverrides = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
reject(new Error(`Command failed (${reason}): ${command} ${args.join(" ")}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliArgs = trimLeadingSeparator(process.argv.slice(2));
|
||||
if (cliArgs.length === 0 || cliArgs.includes("--help") || cliArgs.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(cliArgs.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (cliArgs.includes("--list")) {
|
||||
printSuiteList();
|
||||
return;
|
||||
}
|
||||
|
||||
const [suiteId, ...remainingArgs] = cliArgs;
|
||||
const vitestArgs = trimLeadingSeparator(remainingArgs);
|
||||
const suites = suiteId === "all" ? listE2ESuites() : [getE2ESuite(suiteId)];
|
||||
|
||||
if (suiteId !== "all" && !suites[0]) {
|
||||
console.error(`Unknown e2e suite "${suiteId}".`);
|
||||
printSuiteList();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (suiteId === "all" && suites.length === 0) {
|
||||
console.error("No e2e suites are configured.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (suiteId === "all" && process.env.JUNIT_OUTPUT) {
|
||||
console.error("JUNIT_OUTPUT is only supported when running a single suite. Unset JUNIT_OUTPUT or run one suite id.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(resultsDir, { recursive: true });
|
||||
|
||||
const e2eDatabaseUrl = process.env.E2E_DATABASE_URL ?? process.env.DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
|
||||
if (!isCiEnvironment() && suites.some((suite) => suite.prepareDedicatedDatabase)) {
|
||||
console.log("Starting local PostgreSQL (dev compose)...");
|
||||
await runCommand("pnpm", ["run", "db:up"]);
|
||||
}
|
||||
|
||||
for (const [index, suite] of suites.entries()) {
|
||||
const runEnvironment = suite.useDedicatedDatabase ? { DATABASE_URL: e2eDatabaseUrl, E2E_DATABASE_URL: e2eDatabaseUrl } : {};
|
||||
|
||||
if (suite.prepareDedicatedDatabase) {
|
||||
console.log(`Resetting and migrating dedicated e2e database for suite ${suite.id}...`);
|
||||
await runCommand("pnpm", ["run", "db:prepare:e2e"], { E2E_DATABASE_URL: e2eDatabaseUrl });
|
||||
}
|
||||
|
||||
const junitOutput = process.env.JUNIT_OUTPUT ?? suite.junitOutput;
|
||||
if (suites.length > 1) {
|
||||
console.log(`Running e2e suite [${index + 1}/${suites.length}]: ${suite.id}...`);
|
||||
} else {
|
||||
console.log(`Running e2e suite: ${suite.id}...`);
|
||||
}
|
||||
|
||||
await runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
"--filter",
|
||||
"server",
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
suite.vitestTarget,
|
||||
"--reporter=default",
|
||||
"--reporter=junit",
|
||||
`--outputFile.junit=${junitOutput}`,
|
||||
...vitestArgs,
|
||||
],
|
||||
runEnvironment,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,36 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: run-e2e-suite.sh <server-script-name> [extra vitest args...]
|
||||
#
|
||||
# Example:
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner -- --testNamePattern="book-per-folder-disc-folder-flattening"
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <server-script-name> [extra vitest args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITE_SCRIPT="$1"
|
||||
shift
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
|
||||
|
||||
JUNIT_DIR="$ROOT_DIR/test-results/server"
|
||||
mkdir -p "$JUNIT_DIR"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo "Starting local PostgreSQL (dev compose)..."
|
||||
pnpm run db:up
|
||||
fi
|
||||
|
||||
echo "Resetting and migrating dedicated e2e database..."
|
||||
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
|
||||
|
||||
echo "Running e2e suite: $SUITE_SCRIPT..."
|
||||
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server "$SUITE_SCRIPT" "$@"
|
||||
node ./scripts/test/run-e2e-suite.mjs "$@"
|
||||
|
||||
+9
-1
@@ -50,10 +50,18 @@ pnpm db:studio # open Drizzle Studio (DB browser)
|
||||
```bash
|
||||
pnpm test # unit tests
|
||||
pnpm test:watch # unit tests in watch mode
|
||||
pnpm test:e2e # end-to-end tests
|
||||
pnpm test:e2e # smoke end-to-end test from server package
|
||||
pnpm test:cov # unit tests with coverage
|
||||
```
|
||||
|
||||
For suite-based e2e runs (scanner, scanner-file-ops, auth suites), run from the repository root:
|
||||
|
||||
```bash
|
||||
pnpm run test:e2e:list
|
||||
pnpm run test:e2e -- all
|
||||
pnpm run test:e2e -- scanner
|
||||
```
|
||||
|
||||
## Quality
|
||||
|
||||
```bash
|
||||
|
||||
+2
-3
@@ -16,6 +16,7 @@
|
||||
"start:prod": "node dist/main",
|
||||
"db:generate": "drizzle-kit generate --name",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate:e2e": "DATABASE_URL=postgres://projectx:projectx@localhost:5432/projectx_e2e pnpm db:migrate",
|
||||
"db:migrate:runtime": "node dist/scripts/migrate.js",
|
||||
"db:prepare:e2e": "tsx src/scripts/db-prepare-e2e.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
@@ -28,9 +29,7 @@
|
||||
"test": "vitest run --exclude test/scanner.e2e-spec.ts --exclude test/scanner-file-ops.e2e-spec.ts",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:e2e": "vitest run test/app.e2e-spec.ts",
|
||||
"test:e2e:scanner": "vitest run test/scanner.e2e-spec.ts --reporter=default --reporter=junit --outputFile.junit=${JUNIT_OUTPUT:-../test-results/server/scanner-e2e-junit.xml}",
|
||||
"test:e2e:scanner:file-ops": "vitest run test/scanner-file-ops.e2e-spec.ts --reporter=default --reporter=junit --outputFile.junit=${JUNIT_OUTPUT:-../test-results/server/scanner-file-ops-e2e-junit.xml}"
|
||||
"test:e2e": "vitest run test/app.e2e-spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"7z-wasm": "^1.2.0",
|
||||
|
||||
@@ -304,6 +304,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('throws UnauthorizedException and revokes all sessions when revoked token is reused', async () => {
|
||||
const { service, db } = makeService();
|
||||
const reply = makeReply();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 5,
|
||||
@@ -311,8 +312,19 @@ describe('AuthService', () => {
|
||||
expiresAt: new Date(Date.now() + 100000),
|
||||
});
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'revoked-token' }), makeReply())).rejects.toThrow(UnauthorizedException);
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'revoked-token' }), reply)).rejects.toThrow(UnauthorizedException);
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'',
|
||||
expect.objectContaining({ path: '/api/v1/auth', maxAge: 0 }),
|
||||
);
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'',
|
||||
expect.objectContaining({ path: '/api', maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when token is expired', async () => {
|
||||
@@ -595,14 +607,79 @@ describe('AuthService', () => {
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when OIDC is disabled', async () => {
|
||||
const { service, db, userService, appSettings } = makeService();
|
||||
it('revokes OIDC session and returns empty object when OIDC is disabled', async () => {
|
||||
const { service, db, userService, appSettings, oidcSessionRepo } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 5 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
idTokenHint: 'id-token-hint',
|
||||
});
|
||||
|
||||
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
|
||||
expect(result).toEqual({});
|
||||
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('revokes OIDC session even when there is no id token hint', async () => {
|
||||
const { service, db, userService, oidcSessionRepo } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 6 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
idTokenHint: null,
|
||||
});
|
||||
|
||||
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
|
||||
expect(result).toEqual({});
|
||||
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('returns logout URL when OIDC end-session endpoint is available', async () => {
|
||||
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 7 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
idTokenHint: 'id-token-hint',
|
||||
});
|
||||
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
|
||||
authorizationEndpoint: 'https://issuer.example/auth',
|
||||
tokenEndpoint: 'https://issuer.example/token',
|
||||
userinfoEndpoint: 'https://issuer.example/userinfo',
|
||||
jwksUri: 'https://issuer.example/jwks',
|
||||
issuer: 'https://issuer.example',
|
||||
endSessionEndpoint: 'https://issuer.example/logout',
|
||||
});
|
||||
|
||||
const request = makeRequest({ refresh_token: 'some-token' });
|
||||
request.headers.origin = 'http://localhost:5173';
|
||||
|
||||
const result = await service.logout(request, makeReply());
|
||||
expect(result).toEqual({
|
||||
logoutUrl: 'https://issuer.example/logout?id_token_hint=id-token-hint&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Flogin',
|
||||
});
|
||||
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('revokes OIDC session and returns empty object when end-session endpoint is missing', async () => {
|
||||
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 8 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
idTokenHint: 'id-token-hint',
|
||||
});
|
||||
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
|
||||
authorizationEndpoint: 'https://issuer.example/auth',
|
||||
tokenEndpoint: 'https://issuer.example/token',
|
||||
userinfoEndpoint: 'https://issuer.example/userinfo',
|
||||
jwksUri: 'https://issuer.example/jwks',
|
||||
issuer: 'https://issuer.example',
|
||||
});
|
||||
|
||||
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
|
||||
expect(result).toEqual({});
|
||||
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,11 +237,19 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Reuse of a revoked token → theft signal, revoke all user sessions
|
||||
// Reuse of a revoked token indicates possible theft.
|
||||
// Revoke refresh sessions and bump tokenVersion to invalidate active access tokens.
|
||||
if (row.revokedAt) {
|
||||
this.logger.warn(`[auth.refresh] [fail] user=${row.userId} reason="Token revoked - reuse attempt"`);
|
||||
await this.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, row.userId));
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ tokenVersion: sql`${schema.users.tokenVersion} + 1` })
|
||||
.where(eq(schema.users.id, row.userId));
|
||||
await tx.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, row.userId));
|
||||
});
|
||||
this.clearRefreshCookie(reply);
|
||||
this.clearAccessCookie(reply);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
@@ -306,7 +314,10 @@ export class AuthService {
|
||||
if (!userId) return {};
|
||||
|
||||
const oidcSession = await this.oidcSessionRepo.findActiveByUserId(userId);
|
||||
if (!oidcSession?.idTokenHint) return {};
|
||||
if (!oidcSession) return {};
|
||||
|
||||
await this.oidcSessionRepo.revokeByUserId(userId);
|
||||
if (!oidcSession.idTokenHint) return {};
|
||||
|
||||
try {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
@@ -321,8 +332,6 @@ export class AuthService {
|
||||
const params = new URLSearchParams({ id_token_hint: oidcSession.idTokenHint });
|
||||
if (postLogoutUri) params.set('post_logout_redirect_uri', postLogoutUri);
|
||||
|
||||
await this.oidcSessionRepo.revokeByUserId(userId);
|
||||
|
||||
return { logoutUrl: `${disc.endSessionEndpoint}?${params.toString()}` };
|
||||
} catch {
|
||||
return {};
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import { hash } from 'bcryptjs';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { DB } from '../src/db';
|
||||
import * as schema from '../src/db/schema';
|
||||
import { MetadataService } from '../src/modules/metadata/metadata.service';
|
||||
import { OidcDiscoveryService } from '../src/modules/auth/oidc/oidc-discovery.service';
|
||||
import { SystemMailService } from '../src/modules/email/system-mail.service';
|
||||
import type { Db } from './e2e/app-harness';
|
||||
|
||||
type CookieJar = Map<string, string>;
|
||||
|
||||
interface AuthRecoveryContext {
|
||||
app: NestFastifyApplication;
|
||||
db: Db;
|
||||
systemMailMock: {
|
||||
isConfigured: ReturnType<typeof vi.fn>;
|
||||
sendPasswordReset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
oidcDiscoveryMock: {
|
||||
getDiscoveryDoc: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalUserCredentials {
|
||||
userId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
jar: CookieJar;
|
||||
}
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'recovery-e2e-admin',
|
||||
name: 'Recovery E2E Admin',
|
||||
email: 'recovery-e2e-admin@example.com',
|
||||
password: 'RecoveryAdmin123',
|
||||
};
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function getSetCookieLines(headers: Record<string, string | string[] | undefined>): string[] {
|
||||
const raw = headers['set-cookie'];
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
function parseCookiePair(setCookieLine: string): { name: string; value: string } | null {
|
||||
const firstSegment = setCookieLine.split(';', 1)[0];
|
||||
if (!firstSegment) return null;
|
||||
const eqIndex = firstSegment.indexOf('=');
|
||||
if (eqIndex <= 0) return null;
|
||||
return {
|
||||
name: firstSegment.slice(0, eqIndex),
|
||||
value: firstSegment.slice(eqIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function cookieValue(setCookieLines: string[], cookieName: string): string | null {
|
||||
const line = setCookieLines.find((entry) => entry.startsWith(`${cookieName}=`));
|
||||
if (!line) return null;
|
||||
const parsed = parseCookiePair(line);
|
||||
return parsed?.value ?? null;
|
||||
}
|
||||
|
||||
function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void {
|
||||
for (const line of setCookieLines) {
|
||||
const parsed = parseCookiePair(line);
|
||||
if (!parsed) continue;
|
||||
if (parsed.value === '') {
|
||||
jar.delete(parsed.name);
|
||||
} else {
|
||||
jar.set(parsed.name, parsed.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader(jar: CookieJar): string {
|
||||
return Array.from(jar.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function makeMetadataNoopMock(): Pick<
|
||||
MetadataService,
|
||||
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||
> {
|
||||
return {
|
||||
extractAndSave: () => Promise.resolve(undefined),
|
||||
refreshCoverForBook: () => Promise.resolve(false),
|
||||
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultDiscoveryDoc() {
|
||||
return {
|
||||
issuer: 'https://issuer.example',
|
||||
authorizationEndpoint: 'https://issuer.example/auth',
|
||||
tokenEndpoint: 'https://issuer.example/token',
|
||||
jwksUri: 'https://issuer.example/jwks',
|
||||
userinfoEndpoint: 'https://issuer.example/userinfo',
|
||||
endSessionEndpoint: 'https://issuer.example/logout',
|
||||
backchannelLogoutSupported: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAuthRecoveryContext(): Promise<AuthRecoveryContext> {
|
||||
const systemMailMock = {
|
||||
isConfigured: vi.fn().mockResolvedValue(true),
|
||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const oidcDiscoveryMock = {
|
||||
getDiscoveryDoc: vi.fn().mockResolvedValue(defaultDiscoveryDoc()),
|
||||
};
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.overrideProvider(SystemMailService)
|
||||
.useValue(systemMailMock)
|
||||
.overrideProvider(OidcDiscoveryService)
|
||||
.useValue(oidcDiscoveryMock)
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const db = app.get<Db>(DB);
|
||||
return { app, db, systemMailMock, oidcDiscoveryMock };
|
||||
}
|
||||
|
||||
async function ensureInitialSetup(app: NestFastifyApplication): Promise<void> {
|
||||
const statusResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/setup-status',
|
||||
});
|
||||
expect(statusResponse.statusCode).toBe(200);
|
||||
const statusBody = statusResponse.json() as { needsSetup: boolean };
|
||||
if (!statusBody.needsSetup) return;
|
||||
|
||||
const setupResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: ADMIN_SETUP_DTO,
|
||||
});
|
||||
expect(setupResponse.statusCode).toBe(201);
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
db: Db,
|
||||
options?: {
|
||||
password?: string;
|
||||
provisioningMethod?: 'local' | 'oidc';
|
||||
active?: boolean;
|
||||
},
|
||||
): Promise<LocalUserCredentials> {
|
||||
const suffix = randomUUID().replace(/-/g, '');
|
||||
const password = options?.password ?? 'AuthUser123';
|
||||
const username = `auth-recovery-${suffix}`;
|
||||
const email = `${username}@example.com`;
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
const [created] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username,
|
||||
name: `Auth Recovery ${suffix}`,
|
||||
email,
|
||||
passwordHash,
|
||||
active: options?.active ?? true,
|
||||
provisioningMethod: options?.provisioningMethod ?? 'local',
|
||||
isDefaultPassword: false,
|
||||
})
|
||||
.returning({ id: schema.users.id });
|
||||
|
||||
return {
|
||||
userId: created.id,
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function login(app: NestFastifyApplication, username: string, password: string): Promise<LoginResult> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { username, password },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const body = response.json() as { accessToken: string };
|
||||
const setCookieLines = getSetCookieLines(response.headers);
|
||||
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
|
||||
expect(refreshToken).toBeTruthy();
|
||||
|
||||
const jar: CookieJar = new Map();
|
||||
mergeCookieJar(jar, setCookieLines);
|
||||
|
||||
return {
|
||||
accessToken: body.accessToken,
|
||||
refreshToken: refreshToken!,
|
||||
jar,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForExpectation(assertion: () => void, timeoutMs = 4_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function latestResetTokenFromMail(context: AuthRecoveryContext): string {
|
||||
const token = context.systemMailMock.sendPasswordReset.mock.calls.at(-1)?.[2];
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
throw new Error('Password reset token was not captured by the mail mock');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function passwordResetTokenCount(db: Db): Promise<number> {
|
||||
const rows = await db.select({ id: schema.passwordResetTokens.id }).from(schema.passwordResetTokens);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function waitForStablePasswordResetTokenCount(db: Db, expected: number, stableMs = 300, timeoutMs = 4_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let stableSince: number | null = null;
|
||||
let latestCount = -1;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
latestCount = await passwordResetTokenCount(db);
|
||||
if (latestCount === expected) {
|
||||
if (stableSince === null) {
|
||||
stableSince = Date.now();
|
||||
} else if (Date.now() - stableSince >= stableMs) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stableSince = null;
|
||||
}
|
||||
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
throw new Error(`Password reset token count was not stable at ${expected}. Last count: ${latestCount}`);
|
||||
}
|
||||
|
||||
async function activeSessionCount(db: Db, userId: number): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ id: schema.refreshTokens.id })
|
||||
.from(schema.refreshTokens)
|
||||
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function setOidcEnabledConfig(db: Db, issuerUri = 'https://issuer.example'): Promise<void> {
|
||||
const value = JSON.stringify({
|
||||
enabled: true,
|
||||
providerName: 'Example OIDC',
|
||||
issuerUri,
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
scopes: 'openid profile email',
|
||||
claimMapping: {
|
||||
username: 'preferred_username',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
groups: 'groups',
|
||||
},
|
||||
autoProvision: {
|
||||
enabled: false,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(schema.appSettings).values({ key: 'oidc_config', value }).onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
|
||||
}
|
||||
|
||||
describe('Auth recovery and OIDC logout hardening (e2e)', () => {
|
||||
let context: AuthRecoveryContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
context = await createAuthRecoveryContext();
|
||||
await ensureInitialSetup(context.app);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
context.systemMailMock.isConfigured.mockReset();
|
||||
context.systemMailMock.isConfigured.mockResolvedValue(true);
|
||||
context.systemMailMock.sendPasswordReset.mockClear();
|
||||
context.oidcDiscoveryMock.getDiscoveryDoc.mockReset();
|
||||
context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(defaultDiscoveryDoc());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await context.app.close();
|
||||
});
|
||||
|
||||
describe('forgot/reset password', () => {
|
||||
it('issues reset token, resets password, and revokes active sessions', async () => {
|
||||
const user = await createUser(context.db, { password: 'OldPass123' });
|
||||
const session = await login(context.app, user.username, user.password);
|
||||
|
||||
const forgotResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: user.email },
|
||||
});
|
||||
expect(forgotResponse.statusCode).toBe(200);
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const rawToken = latestResetTokenFromMail(context);
|
||||
const tokenRowBeforeReset = await context.db.query.passwordResetTokens.findFirst({
|
||||
where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)),
|
||||
});
|
||||
expect(tokenRowBeforeReset?.userId).toBe(user.userId);
|
||||
expect(tokenRowBeforeReset?.usedAt).toBeNull();
|
||||
|
||||
const resetResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: rawToken,
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(resetResponse.statusCode).toBe(204);
|
||||
|
||||
const oldPasswordLogin = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
},
|
||||
});
|
||||
expect(oldPasswordLogin.statusCode).toBe(401);
|
||||
|
||||
const newPasswordLogin = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: user.username,
|
||||
password: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(newPasswordLogin.statusCode).toBe(200);
|
||||
expect(await activeSessionCount(context.db, user.userId)).toBe(1);
|
||||
const newAccessToken = (newPasswordLogin.json() as { accessToken: string }).accessToken;
|
||||
|
||||
const refreshAfterReset = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${session.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshAfterReset.statusCode).toBe(401);
|
||||
expect(await activeSessionCount(context.db, user.userId)).toBe(0);
|
||||
|
||||
const meAfterRevokedReuse = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: {
|
||||
authorization: `Bearer ${newAccessToken}`,
|
||||
},
|
||||
});
|
||||
expect(meAfterRevokedReuse.statusCode).toBe(401);
|
||||
|
||||
const tokenRowAfterReset = await context.db.query.passwordResetTokens.findFirst({
|
||||
where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)),
|
||||
});
|
||||
expect(tokenRowAfterReset?.usedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects reset token reuse after successful password reset', async () => {
|
||||
const user = await createUser(context.db);
|
||||
|
||||
const forgotResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: user.email },
|
||||
});
|
||||
expect(forgotResponse.statusCode).toBe(200);
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const rawToken = latestResetTokenFromMail(context);
|
||||
const firstReset = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: rawToken,
|
||||
newPassword: 'ReuseCheck123',
|
||||
},
|
||||
});
|
||||
expect(firstReset.statusCode).toBe(204);
|
||||
|
||||
const secondReset = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: rawToken,
|
||||
newPassword: 'ReuseCheck456',
|
||||
},
|
||||
});
|
||||
expect(secondReset.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns success but does not issue token for unknown or OIDC users', async () => {
|
||||
const tokenCountBefore = await passwordResetTokenCount(context.db);
|
||||
|
||||
const unknownEmailResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: `missing-${randomUUID()}@example.com` },
|
||||
});
|
||||
expect(unknownEmailResponse.statusCode).toBe(200);
|
||||
|
||||
const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' });
|
||||
const oidcForgotResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: oidcUser.email },
|
||||
});
|
||||
expect(oidcForgotResponse.statusCode).toBe(200);
|
||||
|
||||
await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore);
|
||||
expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled();
|
||||
|
||||
const tokenCountAfter = await passwordResetTokenCount(context.db);
|
||||
expect(tokenCountAfter).toBe(tokenCountBefore);
|
||||
});
|
||||
|
||||
it('returns success but does not issue token for inactive local users', async () => {
|
||||
const tokenCountBefore = await passwordResetTokenCount(context.db);
|
||||
const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' });
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: inactiveUser.email },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore);
|
||||
expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled();
|
||||
|
||||
const tokenRowsForUser = await context.db
|
||||
.select({ id: schema.passwordResetTokens.id })
|
||||
.from(schema.passwordResetTokens)
|
||||
.where(eq(schema.passwordResetTokens.userId, inactiveUser.userId));
|
||||
expect(tokenRowsForUser).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns 503 when self-service mail is not configured', async () => {
|
||||
context.systemMailMock.isConfigured.mockResolvedValueOnce(false);
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: `config-check-${randomUUID()}@example.com` },
|
||||
});
|
||||
expect(response.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it('rejects invalid and expired reset tokens', async () => {
|
||||
const invalidTokenResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: `invalid-${randomUUID()}`,
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(invalidTokenResponse.statusCode).toBe(400);
|
||||
|
||||
const user = await createUser(context.db);
|
||||
const expiredToken = `expired-${randomUUID()}`;
|
||||
await context.db.insert(schema.passwordResetTokens).values({
|
||||
userId: user.userId,
|
||||
tokenHash: sha256(expiredToken),
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
const expiredTokenResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: expiredToken,
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(expiredTokenResponse.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects reset tokens for inactive and OIDC users', async () => {
|
||||
const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' });
|
||||
const inactiveUserToken = `inactive-${randomUUID()}`;
|
||||
await context.db.insert(schema.passwordResetTokens).values({
|
||||
userId: inactiveUser.userId,
|
||||
tokenHash: sha256(inactiveUserToken),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const inactiveResetResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: inactiveUserToken,
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(inactiveResetResponse.statusCode).toBe(400);
|
||||
|
||||
const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' });
|
||||
const oidcUserToken = `oidc-${randomUUID()}`;
|
||||
await context.db.insert(schema.passwordResetTokens).values({
|
||||
userId: oidcUser.userId,
|
||||
tokenHash: sha256(oidcUserToken),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const oidcResetResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: {
|
||||
token: oidcUserToken,
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
expect(oidcResetResponse.statusCode).toBe(400);
|
||||
|
||||
const inactiveTokenRow = await context.db.query.passwordResetTokens.findFirst({
|
||||
where: eq(schema.passwordResetTokens.tokenHash, sha256(inactiveUserToken)),
|
||||
});
|
||||
expect(inactiveTokenRow?.usedAt).toBeNull();
|
||||
|
||||
const oidcTokenRow = await context.db.query.passwordResetTokens.findFirst({
|
||||
where: eq(schema.passwordResetTokens.tokenHash, sha256(oidcUserToken)),
|
||||
});
|
||||
expect(oidcTokenRow?.usedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC logout', () => {
|
||||
it('returns provider logout URL and revokes local OIDC session', async () => {
|
||||
const user = await createUser(context.db);
|
||||
const session = await login(context.app, user.username, user.password);
|
||||
|
||||
await setOidcEnabledConfig(context.db);
|
||||
const [createdOidcSession] = await context.db
|
||||
.insert(schema.oidcSessions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
oidcSubject: `sub-${randomUUID()}`,
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
oidcSessionId: `sid-${randomUUID()}`,
|
||||
idTokenHint: `id-token-${randomUUID()}`,
|
||||
})
|
||||
.returning({ id: schema.oidcSessions.id, idTokenHint: schema.oidcSessions.idTokenHint });
|
||||
|
||||
const logoutResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: cookieHeader(session.jar),
|
||||
origin: 'http://localhost:5173',
|
||||
},
|
||||
});
|
||||
expect(logoutResponse.statusCode).toBe(200);
|
||||
|
||||
const body = logoutResponse.json() as { logoutUrl?: string };
|
||||
expect(body.logoutUrl).toEqual(expect.any(String));
|
||||
const logoutUrl = new URL(body.logoutUrl!);
|
||||
expect(`${logoutUrl.origin}${logoutUrl.pathname}`).toBe('https://issuer.example/logout');
|
||||
expect(logoutUrl.searchParams.get('id_token_hint')).toBe(createdOidcSession.idTokenHint);
|
||||
expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5173/login');
|
||||
const logoutCookies = getSetCookieLines(logoutResponse.headers);
|
||||
expect(cookieValue(logoutCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(logoutCookies, 'access_token')).toBe('');
|
||||
|
||||
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.id, createdOidcSession.id),
|
||||
});
|
||||
expect(oidcSessionAfter?.revoked).toBe(true);
|
||||
|
||||
const refreshAfterLogout = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${session.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshAfterLogout.statusCode).toBe(401);
|
||||
|
||||
const meAfterLogout = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: {
|
||||
authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(meAfterLogout.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('revokes local OIDC session even when id token hint is missing', async () => {
|
||||
const user = await createUser(context.db);
|
||||
const session = await login(context.app, user.username, user.password);
|
||||
await setOidcEnabledConfig(context.db);
|
||||
|
||||
const [createdOidcSession] = await context.db
|
||||
.insert(schema.oidcSessions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
oidcSubject: `sub-${randomUUID()}`,
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
oidcSessionId: `sid-${randomUUID()}`,
|
||||
idTokenHint: null,
|
||||
})
|
||||
.returning({ id: schema.oidcSessions.id });
|
||||
|
||||
const logoutResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: cookieHeader(session.jar),
|
||||
},
|
||||
});
|
||||
|
||||
expect(logoutResponse.statusCode).toBe(200);
|
||||
expect(logoutResponse.json()).toEqual({});
|
||||
|
||||
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.id, createdOidcSession.id),
|
||||
});
|
||||
expect(oidcSessionAfter?.revoked).toBe(true);
|
||||
});
|
||||
|
||||
it('still revokes local OIDC session when end-session endpoint is unavailable', async () => {
|
||||
const user = await createUser(context.db);
|
||||
const session = await login(context.app, user.username, user.password);
|
||||
await setOidcEnabledConfig(context.db);
|
||||
|
||||
const [createdOidcSession] = await context.db
|
||||
.insert(schema.oidcSessions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
oidcSubject: `sub-${randomUUID()}`,
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
oidcSessionId: `sid-${randomUUID()}`,
|
||||
idTokenHint: `id-token-${randomUUID()}`,
|
||||
})
|
||||
.returning({ id: schema.oidcSessions.id });
|
||||
|
||||
context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue({
|
||||
...defaultDiscoveryDoc(),
|
||||
endSessionEndpoint: undefined,
|
||||
});
|
||||
|
||||
const logoutResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: cookieHeader(session.jar),
|
||||
},
|
||||
});
|
||||
expect(logoutResponse.statusCode).toBe(200);
|
||||
expect(logoutResponse.json()).toEqual({});
|
||||
|
||||
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.id, createdOidcSession.id),
|
||||
});
|
||||
expect(oidcSessionAfter?.revoked).toBe(true);
|
||||
});
|
||||
|
||||
it('still revokes local OIDC session when discovery lookup fails', async () => {
|
||||
const user = await createUser(context.db);
|
||||
const session = await login(context.app, user.username, user.password);
|
||||
await setOidcEnabledConfig(context.db);
|
||||
|
||||
const [createdOidcSession] = await context.db
|
||||
.insert(schema.oidcSessions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
oidcSubject: `sub-${randomUUID()}`,
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
oidcSessionId: `sid-${randomUUID()}`,
|
||||
idTokenHint: `id-token-${randomUUID()}`,
|
||||
})
|
||||
.returning({ id: schema.oidcSessions.id });
|
||||
|
||||
context.oidcDiscoveryMock.getDiscoveryDoc.mockRejectedValueOnce(new Error('discovery unavailable'));
|
||||
|
||||
const logoutResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: cookieHeader(session.jar),
|
||||
},
|
||||
});
|
||||
expect(logoutResponse.statusCode).toBe(200);
|
||||
expect(logoutResponse.json()).toEqual({});
|
||||
|
||||
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.id, createdOidcSession.id),
|
||||
});
|
||||
expect(oidcSessionAfter?.revoked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import { hash } from 'bcryptjs';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { DB } from '../src/db';
|
||||
import * as schema from '../src/db/schema';
|
||||
import { MetadataService } from '../src/modules/metadata/metadata.service';
|
||||
import type { Db } from './e2e/app-harness';
|
||||
|
||||
type CookieJar = Map<string, string>;
|
||||
|
||||
interface AuthE2EContext {
|
||||
app: NestFastifyApplication;
|
||||
db: Db;
|
||||
}
|
||||
|
||||
interface LocalUserCredentials {
|
||||
userId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
userId: number;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
jar: CookieJar;
|
||||
}
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'auth-e2e-admin',
|
||||
name: 'Auth E2E Admin',
|
||||
email: 'auth-e2e-admin@example.com',
|
||||
password: 'AuthAdmin123',
|
||||
};
|
||||
|
||||
const SETUP_RESET_HINT =
|
||||
'Initial setup already completed. Run "pnpm run test:e2e -- auth-session-security" from the repository root before this suite.';
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function getSetCookieLines(headers: Record<string, string | string[] | undefined>): string[] {
|
||||
const raw = headers['set-cookie'];
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
function parseCookiePair(setCookieLine: string): { name: string; value: string } | null {
|
||||
const firstSegment = setCookieLine.split(';', 1)[0];
|
||||
if (!firstSegment) return null;
|
||||
const eqIndex = firstSegment.indexOf('=');
|
||||
if (eqIndex <= 0) return null;
|
||||
return {
|
||||
name: firstSegment.slice(0, eqIndex),
|
||||
value: firstSegment.slice(eqIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function findCookieLine(setCookieLines: string[], cookieName: string): string | undefined {
|
||||
return setCookieLines.find((line) => line.startsWith(`${cookieName}=`));
|
||||
}
|
||||
|
||||
function cookieValue(setCookieLines: string[], cookieName: string): string | null {
|
||||
const line = findCookieLine(setCookieLines, cookieName);
|
||||
if (!line) return null;
|
||||
const parsed = parseCookiePair(line);
|
||||
return parsed?.value ?? null;
|
||||
}
|
||||
|
||||
function cookieAttribute(setCookieLine: string, attribute: string): string | null {
|
||||
const segments = setCookieLine.split(';').map((segment) => segment.trim());
|
||||
const target = attribute.toLowerCase();
|
||||
for (const segment of segments.slice(1)) {
|
||||
const [key, ...rest] = segment.split('=');
|
||||
if (key.toLowerCase() !== target) continue;
|
||||
return rest.length > 0 ? rest.join('=') : '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void {
|
||||
for (const line of setCookieLines) {
|
||||
const parsed = parseCookiePair(line);
|
||||
if (!parsed) continue;
|
||||
if (parsed.value === '') {
|
||||
jar.delete(parsed.name);
|
||||
} else {
|
||||
jar.set(parsed.name, parsed.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader(jar: CookieJar): string {
|
||||
return Array.from(jar.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function makeMetadataNoopMock(): Pick<
|
||||
MetadataService,
|
||||
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||
> {
|
||||
return {
|
||||
extractAndSave: () => Promise.resolve(undefined),
|
||||
refreshCoverForBook: () => Promise.resolve(false),
|
||||
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function createAuthE2EContext(): Promise<AuthE2EContext> {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const db = app.get<Db>(DB);
|
||||
return { app, db };
|
||||
}
|
||||
|
||||
async function createLocalUser(db: Db, overrides?: Partial<Omit<LocalUserCredentials, 'userId'>>): Promise<LocalUserCredentials> {
|
||||
const suffix = randomUUID().replace(/-/g, '');
|
||||
const password = overrides?.password ?? 'AuthUser123';
|
||||
const username = overrides?.username ?? `auth-user-${suffix}`;
|
||||
const email = overrides?.email ?? `${username}@example.com`;
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
const [created] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username,
|
||||
name: `Auth User ${suffix}`,
|
||||
email,
|
||||
passwordHash,
|
||||
isDefaultPassword: false,
|
||||
provisioningMethod: 'local',
|
||||
})
|
||||
.returning({ id: schema.users.id });
|
||||
|
||||
return {
|
||||
userId: created.id,
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function findRefreshTokenByRawToken(db: Db, rawToken: string) {
|
||||
return db.query.refreshTokens.findFirst({
|
||||
where: eq(schema.refreshTokens.tokenHash, sha256(rawToken)),
|
||||
});
|
||||
}
|
||||
|
||||
async function activeSessionCount(db: Db, userId: number): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ id: schema.refreshTokens.id })
|
||||
.from(schema.refreshTokens)
|
||||
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function login(app: NestFastifyApplication, username: string, password: string): Promise<LoginResult> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { username, password },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { accessToken: string; user: { id: number } };
|
||||
const setCookieLines = getSetCookieLines(response.headers);
|
||||
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
|
||||
expect(refreshToken).toBeTruthy();
|
||||
|
||||
const jar: CookieJar = new Map();
|
||||
mergeCookieJar(jar, setCookieLines);
|
||||
|
||||
return {
|
||||
userId: body.user.id,
|
||||
accessToken: body.accessToken,
|
||||
refreshToken: refreshToken!,
|
||||
jar,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Auth session security (e2e)', () => {
|
||||
let context: AuthE2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
context = await createAuthE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await context.app.close();
|
||||
});
|
||||
|
||||
describe('setup + login', () => {
|
||||
it('completes initial setup and issues auth cookies', async () => {
|
||||
const statusBefore = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/setup-status',
|
||||
});
|
||||
|
||||
expect(statusBefore.statusCode).toBe(200);
|
||||
const beforeBody = statusBefore.json() as { needsSetup: boolean };
|
||||
if (!beforeBody.needsSetup) {
|
||||
throw new Error(SETUP_RESET_HINT);
|
||||
}
|
||||
|
||||
const setupResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: ADMIN_SETUP_DTO,
|
||||
});
|
||||
|
||||
expect(setupResponse.statusCode).toBe(201);
|
||||
const setupBody = setupResponse.json() as { accessToken: string; user: { username: string; email: string } };
|
||||
expect(setupBody.accessToken).toEqual(expect.any(String));
|
||||
expect(setupBody.user).toMatchObject({
|
||||
username: ADMIN_SETUP_DTO.username,
|
||||
email: ADMIN_SETUP_DTO.email,
|
||||
});
|
||||
|
||||
const setupCookies = getSetCookieLines(setupResponse.headers);
|
||||
const refreshCookieLine = findCookieLine(setupCookies, 'refresh_token');
|
||||
const accessCookieLine = findCookieLine(setupCookies, 'access_token');
|
||||
expect(refreshCookieLine).toBeDefined();
|
||||
expect(accessCookieLine).toBeDefined();
|
||||
expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth');
|
||||
expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api');
|
||||
|
||||
const statusAfter = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/setup-status',
|
||||
});
|
||||
|
||||
expect(statusAfter.statusCode).toBe(200);
|
||||
expect(statusAfter.json()).toEqual({ needsSetup: false });
|
||||
});
|
||||
|
||||
it('rejects setup when already completed', async () => {
|
||||
const setupAgain = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: {
|
||||
...ADMIN_SETUP_DTO,
|
||||
username: 'another-admin',
|
||||
email: 'another-admin@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(setupAgain.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('allows admin login and rejects invalid credentials', async () => {
|
||||
const loginResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: ADMIN_SETUP_DTO.username,
|
||||
password: ADMIN_SETUP_DTO.password,
|
||||
},
|
||||
});
|
||||
|
||||
expect(loginResponse.statusCode).toBe(200);
|
||||
expect(loginResponse.json()).toMatchObject({
|
||||
user: {
|
||||
username: ADMIN_SETUP_DTO.username,
|
||||
},
|
||||
});
|
||||
|
||||
const loginCookies = getSetCookieLines(loginResponse.headers);
|
||||
expect(cookieValue(loginCookies, 'refresh_token')).toEqual(expect.any(String));
|
||||
expect(cookieValue(loginCookies, 'access_token')).toEqual(expect.any(String));
|
||||
|
||||
const invalidLoginResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: ADMIN_SETUP_DTO.username,
|
||||
password: 'WrongPass123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidLoginResponse.statusCode).toBe(401);
|
||||
expect(getSetCookieLines(invalidLoginResponse.headers)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh failure paths', () => {
|
||||
it('rejects refresh without cookie', async () => {
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects refresh with unknown token', async () => {
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: 'refresh_token=unknown-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects refresh with expired token and clears the refresh cookie', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
||||
expect(tokenRow).toBeDefined();
|
||||
|
||||
await context.db
|
||||
.update(schema.refreshTokens)
|
||||
.set({ expiresAt: new Date(Date.now() - 60_000) })
|
||||
.where(eq(schema.refreshTokens.id, tokenRow!.id));
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${loginResult.refreshToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
const responseCookies = getSetCookieLines(response.headers);
|
||||
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(responseCookies, 'access_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects refresh for disabled users and clears both auth cookies', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
|
||||
await context.db.update(schema.users).set({ active: false }).where(eq(schema.users.id, credentials.userId));
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: cookieHeader(loginResult.jar),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
const responseCookies = getSetCookieLines(response.headers);
|
||||
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
||||
|
||||
const meResponse = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: {
|
||||
authorization: `Bearer ${loginResult.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(meResponse.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh rotation', () => {
|
||||
it('rotates refresh tokens and revokes the previous token', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
|
||||
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
||||
|
||||
const refreshResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: cookieHeader(loginResult.jar),
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
const refreshBody = refreshResponse.json() as { accessToken: string };
|
||||
expect(refreshBody.accessToken).toEqual(expect.any(String));
|
||||
|
||||
const refreshCookies = getSetCookieLines(refreshResponse.headers);
|
||||
const rotatedRefreshToken = cookieValue(refreshCookies, 'refresh_token');
|
||||
expect(rotatedRefreshToken).toBeTruthy();
|
||||
expect(rotatedRefreshToken).not.toBe(loginResult.refreshToken);
|
||||
|
||||
mergeCookieJar(loginResult.jar, refreshCookies);
|
||||
|
||||
const oldTokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
||||
expect(oldTokenRow?.revokedAt).not.toBeNull();
|
||||
|
||||
const newTokenRow = await findRefreshTokenByRawToken(context.db, rotatedRefreshToken!);
|
||||
expect(newTokenRow?.userId).toBe(credentials.userId);
|
||||
expect(newTokenRow?.revokedAt).toBeNull();
|
||||
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoked refresh token reuse', () => {
|
||||
it('revokes all sessions and invalidates access tokens when a revoked token is reused', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const initialLogin = await login(context.app, credentials.username, credentials.password);
|
||||
const revokedRefreshToken = initialLogin.refreshToken;
|
||||
|
||||
const rotateResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: cookieHeader(initialLogin.jar),
|
||||
},
|
||||
});
|
||||
|
||||
expect(rotateResponse.statusCode).toBe(200);
|
||||
const rotatedAccessToken = (rotateResponse.json() as { accessToken: string }).accessToken;
|
||||
|
||||
const reuseResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${revokedRefreshToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(reuseResponse.statusCode).toBe(401);
|
||||
const reuseCookies = getSetCookieLines(reuseResponse.headers);
|
||||
expect(cookieValue(reuseCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(reuseCookies, 'access_token')).toBe('');
|
||||
|
||||
const remainingSessions = await context.db
|
||||
.select({ id: schema.refreshTokens.id })
|
||||
.from(schema.refreshTokens)
|
||||
.where(eq(schema.refreshTokens.userId, credentials.userId));
|
||||
expect(remainingSessions).toHaveLength(0);
|
||||
|
||||
const [userAfterReuse] = await context.db
|
||||
.select({ tokenVersion: schema.users.tokenVersion })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, credentials.userId))
|
||||
.limit(1);
|
||||
expect(userAfterReuse?.tokenVersion).toBeGreaterThan(1);
|
||||
|
||||
const meResponse = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: {
|
||||
authorization: `Bearer ${rotatedAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(meResponse.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears auth cookies even when no refresh cookie is present', async () => {
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({});
|
||||
const responseCookies = getSetCookieLines(response.headers);
|
||||
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
||||
});
|
||||
|
||||
it('does not revoke active sessions when logout receives an unknown refresh token', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: 'refresh_token=unknown-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({});
|
||||
const responseCookies = getSetCookieLines(response.headers);
|
||||
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
||||
|
||||
const refreshResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${loginResult.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('clears cookies, revokes refresh token, and blocks refresh/access reuse', async () => {
|
||||
const credentials = await createLocalUser(context.db);
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
|
||||
const logoutResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: cookieHeader(loginResult.jar),
|
||||
},
|
||||
});
|
||||
|
||||
expect(logoutResponse.statusCode).toBe(200);
|
||||
const logoutCookies = getSetCookieLines(logoutResponse.headers);
|
||||
expect(cookieValue(logoutCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(logoutCookies, 'access_token')).toBe('');
|
||||
|
||||
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
||||
expect(tokenRow?.revokedAt).not.toBeNull();
|
||||
|
||||
const refreshAfterLogout = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${loginResult.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshAfterLogout.statusCode).toBe(401);
|
||||
|
||||
const meAfterLogout = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: {
|
||||
authorization: `Bearer ${loginResult.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(meAfterLogout.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('change password', () => {
|
||||
it('revokes active sessions and requires new credentials', async () => {
|
||||
const credentials = await createLocalUser(context.db, { password: 'OldPass123' });
|
||||
const sessionA = await login(context.app, credentials.username, credentials.password);
|
||||
const sessionB = await login(context.app, credentials.username, credentials.password);
|
||||
const nextPassword = 'NewPass123';
|
||||
|
||||
expect(await activeSessionCount(context.db, credentials.userId)).toBe(2);
|
||||
|
||||
const changePasswordResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/change-password',
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionA.accessToken}`,
|
||||
cookie: cookieHeader(sessionA.jar),
|
||||
},
|
||||
payload: {
|
||||
currentPassword: credentials.password,
|
||||
newPassword: nextPassword,
|
||||
},
|
||||
});
|
||||
|
||||
expect(changePasswordResponse.statusCode).toBe(204);
|
||||
const changeCookies = getSetCookieLines(changePasswordResponse.headers);
|
||||
expect(cookieValue(changeCookies, 'refresh_token')).toBe('');
|
||||
expect(cookieValue(changeCookies, 'access_token')).toBe('');
|
||||
expect(await activeSessionCount(context.db, credentials.userId)).toBe(0);
|
||||
|
||||
const oldPasswordLogin = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
expect(oldPasswordLogin.statusCode).toBe(401);
|
||||
|
||||
const newPasswordLogin = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: {
|
||||
username: credentials.username,
|
||||
password: nextPassword,
|
||||
},
|
||||
});
|
||||
expect(newPasswordLogin.statusCode).toBe(200);
|
||||
|
||||
const refreshAfterPasswordChange = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${sessionB.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshAfterPasswordChange.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('keeps sessions active when current password is invalid', async () => {
|
||||
const credentials = await createLocalUser(context.db, { password: 'OldPass123' });
|
||||
const loginResult = await login(context.app, credentials.username, credentials.password);
|
||||
|
||||
const changePasswordResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/change-password',
|
||||
headers: {
|
||||
authorization: `Bearer ${loginResult.accessToken}`,
|
||||
cookie: cookieHeader(loginResult.jar),
|
||||
},
|
||||
payload: {
|
||||
currentPassword: 'WrongOldPass123',
|
||||
newPassword: 'NewPass123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(changePasswordResponse.statusCode).toBe(401);
|
||||
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
||||
|
||||
const refreshResponse = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${loginResult.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session revocation endpoint', () => {
|
||||
it('revokes target session only and enforces ownership', async () => {
|
||||
const owner = await createLocalUser(context.db, { password: 'OwnerPass123' });
|
||||
const sessionA = await login(context.app, owner.username, owner.password);
|
||||
const sessionB = await login(context.app, owner.username, owner.password);
|
||||
|
||||
const sessionARow = await findRefreshTokenByRawToken(context.db, sessionA.refreshToken);
|
||||
const sessionBRow = await findRefreshTokenByRawToken(context.db, sessionB.refreshToken);
|
||||
expect(sessionARow).toBeDefined();
|
||||
expect(sessionBRow).toBeDefined();
|
||||
|
||||
const sessionsResponse = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionA.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sessionsResponse.statusCode).toBe(200);
|
||||
const sessions = sessionsResponse.json() as Array<{ id: number }>;
|
||||
expect(sessions.map((session) => session.id)).toEqual(expect.arrayContaining([sessionARow!.id, sessionBRow!.id]));
|
||||
|
||||
const revokeResponse = await context.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/auth/sessions/${sessionARow!.id}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionB.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(revokeResponse.statusCode).toBe(204);
|
||||
|
||||
const sessionsAfterRevoke = await context.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionB.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(sessionsAfterRevoke.statusCode).toBe(200);
|
||||
const activeSessionIds = (sessionsAfterRevoke.json() as Array<{ id: number }>).map((session) => session.id);
|
||||
expect(activeSessionIds).toContain(sessionBRow!.id);
|
||||
expect(activeSessionIds).not.toContain(sessionARow!.id);
|
||||
|
||||
const otherUser = await createLocalUser(context.db, { password: 'OtherPass123' });
|
||||
const otherSession = await login(context.app, otherUser.username, otherUser.password);
|
||||
const forbiddenResponse = await context.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/auth/sessions/${sessionBRow!.id}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${otherSession.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(forbiddenResponse.statusCode).toBe(403);
|
||||
|
||||
const missingSessionResponse = await context.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/auth/sessions/999999',
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionB.accessToken}`,
|
||||
},
|
||||
});
|
||||
expect(missingSessionResponse.statusCode).toBe(401);
|
||||
|
||||
const activeSessionRefresh = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${sessionB.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(activeSessionRefresh.statusCode).toBe(200);
|
||||
|
||||
const revokedSessionRefresh = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${sessionA.refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(revokedSessionRefresh.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user