Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
7.8 KiB
TypeScript
240 lines
7.8 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import crypto from "crypto";
|
|
import bcrypt from "bcryptjs";
|
|
import * as OTPAuthLib from "otpauth";
|
|
import { buildApp, extractCookie } from "./helpers";
|
|
import prisma from "../config/database";
|
|
import { config } from "../config/env";
|
|
import { encrypt } from "../utils/encryption";
|
|
|
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
|
|
|
// Fixture prefix so cleanup never touches real data.
|
|
const N = "totp_backup_test_";
|
|
const TEST_USERNAME = `${N}user`;
|
|
|
|
// Plaintext backup codes seeded for the fixture user (the enable route stores
|
|
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
|
|
const BACKUP_CODE_1 = "AAAA1111";
|
|
const BACKUP_CODE_2 = "BBBB2222";
|
|
|
|
let testUserId: number;
|
|
|
|
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
|
|
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
|
|
const cookies = res.headers["set-cookie"];
|
|
if (!cookies) return undefined;
|
|
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
|
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
|
|
| string
|
|
| undefined;
|
|
}
|
|
|
|
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
|
|
async function issueLoginToken(userId: number): Promise<string> {
|
|
const raw = crypto.randomBytes(32).toString("hex");
|
|
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
|
await prisma.totp_login_tokens.create({
|
|
data: {
|
|
user_id: userId,
|
|
token_hash: hash,
|
|
expires_at: new Date(Date.now() + 5 * 60_000),
|
|
},
|
|
});
|
|
return raw;
|
|
}
|
|
|
|
// /totp/backup-verify carries its own per-IP rate limit
|
|
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
|
|
// more attempts than that — give each request a distinct source IP.
|
|
let ipCounter = 0;
|
|
function postBackupVerify(payload: Record<string, unknown>) {
|
|
ipCounter += 1;
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/totp/backup-verify",
|
|
payload,
|
|
remoteAddress: `10.99.0.${ipCounter}`,
|
|
});
|
|
}
|
|
|
|
async function fixtureUser() {
|
|
const user = await prisma.users.findUniqueOrThrow({
|
|
where: { id: testUserId },
|
|
});
|
|
return user;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
|
|
// Clean up any leftover fixture from a previous failed run.
|
|
const leftovers = await prisma.users.findMany({
|
|
where: { username: { startsWith: N } },
|
|
select: { id: true },
|
|
});
|
|
if (leftovers.length) {
|
|
const ids = leftovers.map((u) => u.id);
|
|
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
|
await prisma.totp_login_tokens.deleteMany({
|
|
where: { user_id: { in: ids } },
|
|
});
|
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
|
}
|
|
|
|
const role = await prisma.roles.findFirst();
|
|
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
|
|
|
const hashedCodes = [
|
|
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
|
|
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
|
|
];
|
|
|
|
const user = await prisma.users.create({
|
|
data: {
|
|
username: TEST_USERNAME,
|
|
email: `${N}user@test.local`,
|
|
password_hash: await bcrypt.hash(
|
|
"irrelevant",
|
|
config.security.bcryptCost,
|
|
),
|
|
first_name: "TotpBackup",
|
|
last_name: "Test",
|
|
is_active: true,
|
|
totp_enabled: true,
|
|
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
|
|
totp_backup_codes: JSON.stringify(hashedCodes),
|
|
role_id: role.id,
|
|
},
|
|
});
|
|
testUserId = user.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.refresh_tokens
|
|
.deleteMany({ where: { user_id: testUserId } })
|
|
.catch(() => {});
|
|
await prisma.totp_login_tokens
|
|
.deleteMany({ where: { user_id: testUserId } })
|
|
.catch(() => {});
|
|
await prisma.users
|
|
.deleteMany({ where: { username: { startsWith: N } } })
|
|
.catch(() => {});
|
|
await app.close();
|
|
});
|
|
|
|
describe("POST /api/admin/totp/backup-verify", () => {
|
|
it("returns 400 for missing fields", async () => {
|
|
const res = await postBackupVerify({});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().success).toBe(false);
|
|
});
|
|
|
|
it("returns 401 for an invalid login token", async () => {
|
|
const res = await postBackupVerify({
|
|
login_token: "not-a-real-token",
|
|
backup_code: BACKUP_CODE_1,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.json().success).toBe(false);
|
|
});
|
|
|
|
it("rejects a wrong backup code without consuming the stored codes", async () => {
|
|
const loginToken = await issueLoginToken(testUserId);
|
|
const res = await postBackupVerify({
|
|
login_token: loginToken,
|
|
backup_code: "ZZZZ9999",
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.json().success).toBe(false);
|
|
|
|
// Both backup codes must still be stored — nothing consumed.
|
|
const user = await fixtureUser();
|
|
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
|
|
|
|
// Reset the failed-attempt counter so this test can't lock the fixture
|
|
// user for later tests (max_login_attempts comes from system settings).
|
|
await prisma.users.update({
|
|
where: { id: testUserId },
|
|
data: { failed_login_attempts: 0 },
|
|
});
|
|
await prisma.totp_login_tokens.deleteMany({
|
|
where: { user_id: testUserId },
|
|
});
|
|
});
|
|
|
|
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
|
|
const loginToken = await issueLoginToken(testUserId);
|
|
const res = await postBackupVerify({
|
|
login_token: loginToken,
|
|
backup_code: BACKUP_CODE_1,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(typeof body.data.access_token).toBe("string");
|
|
expect(body.data.access_token.length).toBeGreaterThan(0);
|
|
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
|
expect(body.data.user.userId).toBe(testUserId);
|
|
|
|
// Same login completion as the TOTP path: httpOnly refresh cookie set.
|
|
const cookie = extractCookie(res, "refresh_token");
|
|
expect(cookie).toBeTruthy();
|
|
const raw = rawSetCookie(res, "refresh_token")!;
|
|
expect(raw).toMatch(/HttpOnly/i);
|
|
expect(raw).toMatch(/SameSite=Strict/i);
|
|
|
|
// The used backup code must be removed (single-use).
|
|
const user = await fixtureUser();
|
|
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
|
|
expect(remaining).toHaveLength(1);
|
|
|
|
// The login token must be consumed: replaying it (even with the second,
|
|
// still-valid backup code) is rejected.
|
|
const replay = await postBackupVerify({
|
|
login_token: loginToken,
|
|
backup_code: BACKUP_CODE_2,
|
|
});
|
|
expect(replay.statusCode).toBe(401);
|
|
});
|
|
|
|
it("rejects reuse of an already-consumed backup code", async () => {
|
|
const loginToken = await issueLoginToken(testUserId);
|
|
const res = await postBackupVerify({
|
|
login_token: loginToken,
|
|
backup_code: BACKUP_CODE_1,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.json().success).toBe(false);
|
|
|
|
await prisma.users.update({
|
|
where: { id: testUserId },
|
|
data: { failed_login_attempts: 0 },
|
|
});
|
|
await prisma.totp_login_tokens.deleteMany({
|
|
where: { user_id: testUserId },
|
|
});
|
|
});
|
|
|
|
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
|
|
const loginToken = await issueLoginToken(testUserId);
|
|
const res = await postBackupVerify({
|
|
login_token: loginToken,
|
|
backup_code: BACKUP_CODE_2,
|
|
remember_me: true,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const raw = rawSetCookie(res, "refresh_token")!;
|
|
expect(raw).toMatch(
|
|
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
|
|
);
|
|
|
|
const refreshRow = await prisma.refresh_tokens.findFirst({
|
|
where: { user_id: testUserId },
|
|
orderBy: { id: "desc" },
|
|
});
|
|
expect(refreshRow?.remember_me).toBe(true);
|
|
});
|
|
});
|