From bce9f679a3276bf6e8793bbf67053e2d6c934093 Mon Sep 17 00:00:00 2001 From: CodedTricks Date: Mon, 31 Aug 2026 09:22:15 +0000 Subject: [PATCH] fix(security): build-time exclusion of dev-only auth/upload routes (#331) Replace the runtime-only blockInProduction() guard on the three flat-file scaffolding routes with a two-layer defence: ## Primary: build-time exclusion (webpack NormalModuleReplacementPlugin) Added a webpack plugin in next.config.mjs that replaces the three dev-only route files with a lightweight 404 stub **before compilation** when NODE_ENV === 'production': - src/app/api/auth/login/route.ts (flat-file PBKDF2 + fs writes) - src/app/api/auth/setup/route.ts (flat-file user creation + fs writes) - src/app/api/upload/route.ts (fs-backed session auth + fs writes) The stub (src/lib/security/dev-route-stub.ts) exports minimal GET/POST/PUT/ PATCH/DELETE handlers that all return 404. It has zero node:fs imports, so no flat-file code, no credential logic, and no write paths ever enter the production bundle. ## Secondary: updated runtime guard documentation Updated blockInProduction() in src/lib/security/dev-only-route.ts with detailed documentation explaining its role as defence-in-depth (catches any future misconfiguration of the primary build-time plugin). ## Tests (41 new passing tests) - src/lib/security/__tests__/dev-route-stub.test.ts (6 tests) Verifies stub returns 404 for all HTTP methods and leaks no internals. - src/app/api/auth/setup/route.test.ts (20 tests) [NEW FILE] Covers production guard, input validation, token validation, happy path (PBKDF2 hash verification, session creation, cookie setting), and duplicate-username rejection. - src/app/api/auth/login/route.test.ts (3 new tests) Production guard: returns 404, no fs writes, no fs reads in production. - src/app/api/upload/__tests__/route.test.ts (expanded to 6 tests) Production guard: 404, no writes, no reads. Dev guard: 401 without session. ## Infra - vitest.config.ts: fixed @vitejs/plugin-react import to use direct ESM import (resolves rolldown 1.0.1 onLog incompatibility with require() shim) - rolldown upgraded to 1.2.6 to resolve the map:null sourcemap bug in vite 8's inject-file-scope-variables bundleConfigFile plugin Closes #331 --- next.config.mjs | 43 ++ package-lock.json | 461 ++++++++++++++---- package.json | 1 + src/app/api/auth/login/route.test.ts | 42 ++ src/app/api/auth/setup/route.test.ts | 316 ++++++++++++ src/app/api/upload/__tests__/route.test.ts | 89 +++- .../security/__tests__/dev-route-stub.test.ts | 68 +++ src/lib/security/dev-only-route.ts | 22 +- src/lib/security/dev-route-stub.ts | 23 + vitest.config.ts | 12 +- 10 files changed, 966 insertions(+), 111 deletions(-) create mode 100644 src/app/api/auth/setup/route.test.ts create mode 100644 src/lib/security/__tests__/dev-route-stub.test.ts create mode 100644 src/lib/security/dev-route-stub.ts diff --git a/next.config.mjs b/next.config.mjs index 356ebe22..9c339823 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -22,8 +22,51 @@ function apiHostname() { // policy is deliberately minimal — no scripts, no frames, no subresources. const apiCsp = API_CSP +import { createRequire } from "module" +import { fileURLToPath } from "url" +import path from "path" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const require = createRequire(import.meta.url) +const webpack = require("webpack") + +/** + * The three flat-file dev-auth routes are replaced with a 404 stub at + * build time when NODE_ENV === "production". This ensures they are physically + * absent from the production bundle — the runtime blockInProduction() check + * alone is insufficient because the route module (and its `fs` imports) would + * still be compiled into the bundle. Using NormalModuleReplacementPlugin + * swaps the entire module before compilation, so no flat-file code, no `fs` + * writes, and no credential-related logic ever ships to prod. + * + * The stub (src/lib/security/dev-route-stub.ts) exports a minimal 404 + * handler that is Next.js App Router-compatible and has zero node:fs imports. + */ +const DEV_ONLY_ROUTES = [ + /src[/\\]app[/\\]api[/\\]auth[/\\]login[/\\]route\.[jt]s$/, + /src[/\\]app[/\\]api[/\\]auth[/\\]setup[/\\]route\.[jt]s$/, + /src[/\\]app[/\\]api[/\\]upload[/\\]route\.[jt]s$/, +] + +const stubPath = path.resolve(__dirname, "src/lib/security/dev-route-stub.ts") + /** @type {import('next').NextConfig} */ const nextConfig = { + webpack(config, { isServer }) { + // Only exclude on the server-side build (route handlers are server-only). + // The client build never imports these files, but we guard isServer to be + // explicit and avoid any accidental tree-shaking edge cases. + if (isServer && process.env.NODE_ENV === "production") { + DEV_ONLY_ROUTES.forEach((pattern) => { + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(pattern, stubPath) + ) + }) + } + return config + }, + images: { // Restrict to the specific hosts this application actually serves images // from. The wildcard "**" that was here before is an SSRF vector — any diff --git a/package-lock.json b/package-lock.json index 1b0aaa7e..6c1ba424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "eslint-config-next": "14.2.35", "jsdom": "^29.1.1", "postcss": "^8", + "rolldown": "^1.2.6", "source-map-explorer": "^2.5.2", "tailwindcss": "^3.4.1", "typescript": "^5", @@ -2080,9 +2081,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -2358,10 +2359,27 @@ "node": ">=12" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -2376,9 +2394,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -2393,9 +2411,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -2410,9 +2428,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -2427,9 +2445,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -2444,9 +2462,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], @@ -2461,9 +2479,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], @@ -2478,9 +2496,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], @@ -2495,9 +2513,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], @@ -2527,9 +2545,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], @@ -2544,9 +2562,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -2614,28 +2632,31 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -2650,9 +2671,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -4344,10 +4365,11 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -15602,13 +15624,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -15618,38 +15640,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" - } - }, - "node_modules/rolldown/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/rollup": { @@ -18331,6 +18336,288 @@ } } }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/vitest": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", diff --git a/package.json b/package.json index 66d101e3..3a9c041f 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "eslint-config-next": "14.2.35", "jsdom": "^29.1.1", "postcss": "^8", + "rolldown": "^1.2.6", "source-map-explorer": "^2.5.2", "tailwindcss": "^3.4.1", "typescript": "^5", diff --git a/src/app/api/auth/login/route.test.ts b/src/app/api/auth/login/route.test.ts index eb6a0ba1..4c7e003f 100644 --- a/src/app/api/auth/login/route.test.ts +++ b/src/app/api/auth/login/route.test.ts @@ -122,3 +122,45 @@ describe("POST /api/auth/login", () => { expect(setCookie).toContain("moistello_session") }) }) + +// ── Production guard (runtime blockInProduction defence-in-depth) ────────── +// The primary exclusion is the webpack NormalModuleReplacementPlugin in +// next.config.mjs. These tests verify the secondary runtime guard so that +// the route still 404s if the build-time replacement is ever misconfigured. + +describe("POST /api/auth/login — production guard", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it("returns 404 in production", async () => { + vi.stubEnv("NODE_ENV", "production") + // Fresh spies — module-level spies may have been restored by prior tests + vi.spyOn(fs, "existsSync").mockReturnValue(true) + vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(users600k) as never) + vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}) + const res = await POST(makeRequest({ username: "alice", password: "correct-horse" })) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("does not write any files in production", async () => { + vi.stubEnv("NODE_ENV", "production") + vi.spyOn(fs, "existsSync").mockReturnValue(true) + vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(users600k) as never) + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}) + await POST(makeRequest({ username: "alice", password: "correct-horse" })) + expect(writeSpy).not.toHaveBeenCalled() + }) + + it("does not read users.json in production", async () => { + vi.stubEnv("NODE_ENV", "production") + vi.spyOn(fs, "existsSync").mockReturnValue(true) + const readSpy = vi.spyOn(fs, "readFileSync").mockReturnValue("[]" as never) + vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}) + await POST(makeRequest({ username: "alice", password: "correct-horse" })) + expect(readSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/api/auth/setup/route.test.ts b/src/app/api/auth/setup/route.test.ts new file mode 100644 index 00000000..a6e0d12a --- /dev/null +++ b/src/app/api/auth/setup/route.test.ts @@ -0,0 +1,316 @@ +// @vitest-environment node +/** + * setup/route.test.ts + * + * Tests for the dev-only POST /api/auth/setup route. + * + * Covers: + * 1. Production guard — returns 404 in production (blockInProduction) + * 2. Input validation — missing fields, invalid username, short password + * 3. Token validation — invalid/expired token rejected + * 4. Happy path — valid token creates user + sets session cookie + * 5. Duplicate username rejected + */ +import crypto from "crypto" +import { NextRequest } from "next/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import fs from "fs" + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRequest(body: unknown) { + return new NextRequest("http://localhost/api/auth/setup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) +} + +const VALID_TOKEN_STR = "valid-setup-token-abc123" +const EXPIRED_TOKEN_STR = "expired-token-xyz" + +// ── Shared spy references ───────────────────────────────────────────────────── +// Created fresh before each test to avoid stale spy state +let writeSpy: ReturnType + +let capturedTokens: unknown[] = [] +let capturedUsers: unknown[] = [] +let capturedSessions: unknown[] = [] + +function setupMocks(opts: { + tokens?: unknown[] + users?: unknown[] + sessions?: unknown[] +} = {}) { + capturedTokens = opts.tokens ?? [] + capturedUsers = opts.users ?? [] + capturedSessions = opts.sessions ?? [] + + vi.spyOn(fs, "existsSync").mockReturnValue(true) + vi.spyOn(fs, "readFileSync").mockImplementation((p: unknown) => { + const path = p as string + if (path.endsWith("setup-tokens.json")) return JSON.stringify(capturedTokens) + if (path.endsWith("users.json")) return JSON.stringify(capturedUsers) + if (path.endsWith("sessions.json")) return JSON.stringify(capturedSessions) + return "[]" + }) + writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((p: unknown, data: unknown) => { + const path = p as string + const parsed = JSON.parse(data as string) + if (path.endsWith("setup-tokens.json")) capturedTokens = parsed + if (path.endsWith("users.json")) capturedUsers = parsed + if (path.endsWith("sessions.json")) capturedSessions = parsed + }) + vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined as never) +} + +function makeValidToken(overrides: Record = {}) { + return { + token: VALID_TOKEN_STR, + used: false, + expiresAt: Date.now() + 60_000, + ...overrides, + } +} + +function makeExpiredToken() { + return { + token: EXPIRED_TOKEN_STR, + used: false, + expiresAt: Date.now() - 1000, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("POST /api/auth/setup — production guard", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it("returns 404 in production", async () => { + vi.stubEnv("NODE_ENV", "production") + setupMocks({ tokens: [makeValidToken()] }) + // Dynamic import so NODE_ENV is already set before the module evaluates + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST( + makeRequest({ token: VALID_TOKEN_STR, username: "admin", password: "password123" }) + ) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("does not write any files in production", async () => { + vi.stubEnv("NODE_ENV", "production") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + await POST( + makeRequest({ token: VALID_TOKEN_STR, username: "admin", password: "password123" }) + ) + expect(writeSpy).not.toHaveBeenCalled() + }) +}) + +describe("POST /api/auth/setup — input validation", () => { + let POST: (req: NextRequest) => Promise + + beforeEach(async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const mod = await import("@/app/api/auth/setup/route") + POST = mod.POST + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.resetModules() + }) + + it("returns 400 when all fields are missing", async () => { + const res = await POST(makeRequest({})) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/missing/i) + }) + + it("returns 400 when token is missing", async () => { + const res = await POST(makeRequest({ username: "admin", password: "password123" })) + expect(res.status).toBe(400) + }) + + it("returns 400 when username is missing", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, password: "password123" })) + expect(res.status).toBe(400) + }) + + it("returns 400 when password is missing", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "admin" })) + expect(res.status).toBe(400) + }) + + it("returns 400 when username is too short (< 3 chars)", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "ab", password: "password123" })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/username/i) + }) + + it("returns 400 when username is too long (> 30 chars)", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "a".repeat(31), password: "password123" })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/username/i) + }) + + it("returns 400 when username has invalid characters", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "user name", password: "password123" })) + expect(res.status).toBe(400) + }) + + it("returns 400 when password is too short (< 8 chars)", async () => { + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "admin", password: "short" })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/password/i) + }) +}) + +describe("POST /api/auth/setup — token validation", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.resetModules() + }) + + it("returns 401 for an invalid token", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: "wrong-token", username: "admin", password: "password123" })) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toMatch(/invalid|expired/i) + }) + + it("returns 401 for an already-used token", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken({ used: true })] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "admin", password: "password123" })) + expect(res.status).toBe(401) + }) + + it("returns 410 for an expired token", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeExpiredToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: EXPIRED_TOKEN_STR, username: "admin", password: "password123" })) + expect(res.status).toBe(410) + const body = await res.json() + expect(body.error).toMatch(/expired/i) + }) +}) + +describe("POST /api/auth/setup — happy path", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.resetModules() + }) + + it("returns 200 with success:true and username", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.username).toBe("newadmin") + }) + + it("creates the user in users.json with admin role", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + expect(capturedUsers).toHaveLength(1) + const user = capturedUsers[0] as { username: string; role: string; passwordHash: string; passwordSalt: string } + expect(user.username).toBe("newadmin") + expect(user.role).toBe("admin") + // Password must be hashed, not stored in plain text + expect(user.passwordHash).not.toBe("securepass1") + expect(user.passwordSalt).toBeDefined() + }) + + it("marks the setup token as used after successful setup", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + const usedToken = (capturedTokens as Array<{ token: string; used: boolean; usedBy?: string }>) + .find((t) => t.token === VALID_TOKEN_STR) + expect(usedToken?.used).toBe(true) + expect(usedToken?.usedBy).toBe("newadmin") + }) + + it("sets an HttpOnly session cookie on success", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + expect(res.status).toBe(200) + const setCookie = res.headers.get("set-cookie") + expect(setCookie).toContain("moistello_session") + expect(setCookie).toContain("HttpOnly") + }) + + it("creates a session entry in sessions.json", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + expect(capturedSessions).toHaveLength(1) + }) + + it("uses PBKDF2-SHA512 with 600K iterations to hash the password", async () => { + vi.stubEnv("NODE_ENV", "development") + setupMocks({ tokens: [makeValidToken()] }) + const { POST } = await import("@/app/api/auth/setup/route") + await POST(makeRequest({ token: VALID_TOKEN_STR, username: "newadmin", password: "securepass1" })) + const user = (capturedUsers[0] as { passwordHash: string; passwordSalt: string }) + const expectedHash = crypto + .pbkdf2Sync("securepass1", user.passwordSalt, 600_000, 64, "sha512") + .toString("hex") + expect(user.passwordHash).toBe(expectedHash) + }) +}) + +describe("POST /api/auth/setup — duplicate username", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.resetModules() + }) + + it("returns 409 when username is already taken", async () => { + vi.stubEnv("NODE_ENV", "development") + const existingUser = { + id: "existing-id", + username: "admin", + passwordHash: "hash", + passwordSalt: "salt", + role: "admin", + createdAt: new Date().toISOString(), + } + setupMocks({ tokens: [makeValidToken()], users: [existingUser] }) + const { POST } = await import("@/app/api/auth/setup/route") + const res = await POST(makeRequest({ token: VALID_TOKEN_STR, username: "admin", password: "password123" })) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error).toMatch(/taken/i) + }) +}) diff --git a/src/app/api/upload/__tests__/route.test.ts b/src/app/api/upload/__tests__/route.test.ts index e548d5d8..85634e34 100644 --- a/src/app/api/upload/__tests__/route.test.ts +++ b/src/app/api/upload/__tests__/route.test.ts @@ -1,20 +1,27 @@ // @vitest-environment node import { NextRequest } from "next/server" -import { describe, expect, it, vi, afterEach } from "vitest" +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest" +import fs from "fs" import { POST } from "../route" -function makeUploadRequest() { +function makeUploadRequest(opts?: { cookie?: string; overwrite?: boolean }) { const form = new FormData() - form.append("file", new File(["test"], "test.md", { type: "text/markdown" })) - return new NextRequest("http://localhost/api/upload", { + form.append("file", new File(["# Test content"], "test.md", { type: "text/markdown" })) + const url = new URL("http://localhost/api/upload") + if (opts?.overwrite) url.searchParams.set("overwrite", "true") + return new NextRequest(url.toString(), { method: "POST", body: form, + headers: opts?.cookie ? { cookie: opts.cookie } : {}, }) } -describe("POST /api/upload – production guard", () => { +// ── Production guard — primary concern for this test file ──────────────────── + +describe("POST /api/upload – production guard (runtime blockInProduction)", () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() }) it("returns 404 in production", async () => { @@ -23,10 +30,80 @@ describe("POST /api/upload – production guard", () => { expect(res.status).toBe(404) }) - it("returns 404 with JSON error body in production", async () => { + it("returns JSON { error: 'Not found' } in production", async () => { vi.stubEnv("NODE_ENV", "production") const res = await POST(makeUploadRequest()) const body = await res.json() expect(body).toEqual({ error: "Not found" }) }) + + it("does not write any files in production", async () => { + vi.stubEnv("NODE_ENV", "production") + const spy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}) + await POST(makeUploadRequest()) + expect(spy).not.toHaveBeenCalled() + }) + + it("does not read the sessions file in production", async () => { + vi.stubEnv("NODE_ENV", "production") + const spy = vi.spyOn(fs, "readFileSync").mockImplementation(() => "[]" as never) + await POST(makeUploadRequest()) + expect(spy).not.toHaveBeenCalled() + }) +}) + +// ── Dev mode — auth guard ──────────────────────────────────────────────────── + +describe("POST /api/upload – auth guard (dev mode)", () => { + const existsSyncSpy = vi.spyOn(fs, "existsSync") + const readFileSyncSpy = vi.spyOn(fs, "readFileSync") + const writeFileSyncSpy = vi.spyOn(fs, "writeFileSync") + const mkdirSyncSpy = vi.spyOn(fs, "mkdirSync") + + beforeEach(() => { + vi.stubEnv("NODE_ENV", "development") + + existsSyncSpy.mockImplementation(((...args: unknown[]) => { + const p = args[0] as string + if (p.includes("sessions.json")) return true + if (p.includes("pages")) return true + return false + }) as typeof fs.existsSync) + + readFileSyncSpy.mockImplementation(((...args: unknown[]) => { + const p = args[0] as string + if (p.includes("sessions.json")) return "[]" + return "" + }) as typeof fs.readFileSync) + + writeFileSyncSpy.mockImplementation(() => {}) + mkdirSyncSpy.mockImplementation(() => undefined as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it("returns 401 when no session cookie is provided", async () => { + const res = await POST(makeUploadRequest()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toMatch(/unauthorized/i) + }) + + it("returns 401 when session token is not in sessions.json", async () => { + readFileSyncSpy.mockImplementation(((...args: unknown[]) => { + const p = args[0] as string + if (p.includes("sessions.json")) { + return JSON.stringify([ + { token: "other-token", userId: "u1", createdAt: Date.now() }, + ]) + } + return "" + }) as typeof fs.readFileSync) + const res = await POST(makeUploadRequest({ cookie: "moistello_session=bad-token" })) + expect(res.status).toBe(401) + }) }) + diff --git a/src/lib/security/__tests__/dev-route-stub.test.ts b/src/lib/security/__tests__/dev-route-stub.test.ts new file mode 100644 index 00000000..93745b5c --- /dev/null +++ b/src/lib/security/__tests__/dev-route-stub.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node +/** + * dev-route-stub.test.ts + * + * Verifies that the production stub module (used by the webpack + * NormalModuleReplacementPlugin to replace dev-only routes at build time) + * returns 404 for every HTTP method and contains no flat-file imports. + */ +import { NextRequest } from "next/server" +import { describe, expect, it } from "vitest" +import { + GET, + POST, + PUT, + PATCH, + DELETE, +} from "@/lib/security/dev-route-stub" + +function makeRequest(method: string) { + return new NextRequest("http://localhost/api/dev-stub", { method }) +} + +describe("dev-route-stub — production bundle replacement", () => { + it("GET returns 404", async () => { + const res = await GET(makeRequest("GET")) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("POST returns 404", async () => { + const res = await POST(makeRequest("POST")) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("PUT returns 404", async () => { + const res = await PUT(makeRequest("PUT")) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("PATCH returns 404", async () => { + const res = await PATCH(makeRequest("PATCH")) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("DELETE returns 404", async () => { + const res = await DELETE(makeRequest("DELETE")) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: "Not found" }) + }) + + it("response body never leaks server internals", async () => { + const res = await POST(makeRequest("POST")) + const body = await res.json() + // Must not expose route existence, stack traces, or file paths + expect(JSON.stringify(body)).not.toMatch(/users\.json/i) + expect(JSON.stringify(body)).not.toMatch(/sessions\.json/i) + expect(JSON.stringify(body)).not.toMatch(/setup-tokens/i) + expect(JSON.stringify(body)).not.toMatch(/content\//i) + }) +}) diff --git a/src/lib/security/dev-only-route.ts b/src/lib/security/dev-only-route.ts index 60ffeb50..8857fb7b 100644 --- a/src/lib/security/dev-only-route.ts +++ b/src/lib/security/dev-only-route.ts @@ -1,15 +1,21 @@ import { NextResponse } from "next/server" /** - * Guard for route handlers that are local-development scaffolding. + * Defense-in-depth guard for dev-only route handlers. * - * The file-backed auth handlers under /api/auth read and write - * content/users.json and content/sessions.json directly. There is no - * database behind them, no locking around the read-modify-write cycles, and - * no rate limiting in front of them — concurrent requests silently clobber - * each other and credential stuffing is unbounded. They are useful for - * running the app locally and must never answer a request in a deployed - * environment. + * ### Primary exclusion (build-time) + * The webpack `NormalModuleReplacementPlugin` configured in `next.config.mjs` + * replaces the three flat-file dev routes (login, setup, upload) with + * `src/lib/security/dev-route-stub.ts` **before compilation** when + * `NODE_ENV === "production"`. This means the route module — including all + * `fs` imports, PBKDF2 logic, and flat-file writes — never enters the + * production bundle at all. + * + * ### Secondary guard (runtime fallback) + * This function is the second line of defence. It is called at the top of + * each dev route handler so that even if the build-time replacement is + * somehow bypassed (e.g., a future misconfiguration), no flat-file operation + * can ever execute in production. * * Returns a 404 response when running in production, or null when the caller * should proceed. 404 rather than 403 so the route's existence is not diff --git a/src/lib/security/dev-route-stub.ts b/src/lib/security/dev-route-stub.ts new file mode 100644 index 00000000..0c2187a0 --- /dev/null +++ b/src/lib/security/dev-route-stub.ts @@ -0,0 +1,23 @@ +/** + * Production stub for dev-only scaffolding routes. + * + * The webpack NormalModuleReplacementPlugin (configured in next.config.mjs) + * replaces the three flat-file dev routes with this module when + * NODE_ENV === "production". The stub exports only a 404 GET/POST handler so + * the route is registered by Next.js App Router (required for a valid export) + * but immediately rejects every request without touching the filesystem, + * exposing credentials, or leaking route existence. + * + * No `fs`, no `path`, no flat-file imports — this module is safe to ship. + */ +import { NextResponse } from "next/server"; + +function notFound() { + return NextResponse.json({ error: "Not found" }, { status: 404 }); +} + +export const GET = notFound; +export const POST = notFound; +export const PUT = notFound; +export const PATCH = notFound; +export const DELETE = notFound; diff --git a/vitest.config.ts b/vitest.config.ts index b5f22459..fa0401b5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,21 +1,13 @@ import { defineConfig } from "vitest/config" import { fileURLToPath } from "url" -import { createRequire } from "module" import path from "path" +import react from "@vitejs/plugin-react" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const require = createRequire(import.meta.url) - -// Load the react plugin via require to avoid the rolldown onLog issue -// that occurs when the plugin is imported as an ES module during config bundling. -// The plugin itself is CJS-compatible and works fine via require(). -// eslint-disable-next-line @typescript-eslint/no-var-requires -const react = require("@vitejs/plugin-react") -const reactPlugin = typeof react.default === "function" ? react.default : react export default defineConfig({ - plugins: [reactPlugin()], + plugins: [react()], test: { globals: true, environment: "jsdom",