--- convoy: fix-auth-bypass brief_number: 5 depends_on: [1, 2, 4] files: - package.json - package-lock.json - vitest.config.js - test/setup.js - test/lib/auth-secret.test.js - test/lib/permission-middleware.test.js - test/api/auth-utils.test.js - .github/workflows/ci.yml cross_brief_commitments: - brief: 1 description: | Brief 1 created `lib/auth-secret.js` with the `JWT_SECRET` fail-loud throw. Brief 5's `test/setup.js` MUST set `process.env.JWT_SECRET` to a stable test value BEFORE any test file imports any auth code, or every test crashes at module load. - brief: 2 description: | Brief 2 fixed `getUserFromRequest` to return `null` for unauthenticated requests. Brief 5's `permission-middleware.test.js` exists to lock that behavior in. If Brief 2 is reverted or partially regressed, these tests MUST fail. - brief: 4 description: | Brief 4 added `package.json` + `package-lock.json` changes for `@upstash/ratelimit`. Brief 5 stacks vitest + vite + (transitively installed) onto the same lockfile. If Brief 4 has not landed when Brief 5 starts, the implementer MUST rebase / coordinate the lockfile regen. Slice_dependencies enforces the order. --- # Brief 5: Install vitest + write the auth unit tests + re-enable the CI test job ## Goal (1 sentence) Install `vitest@^3.2.4`, write unit tests that lock in the post-Brief-2 behavior of `getUserFromRequest` (null for missing/malformed/expired tokens; user object for valid tokens) plus thin coverage of `auth-utils.generateToken` / `verifyToken`, and re-enable the disabled `test:` job in `.github/workflows/ci.yml`. ## Files in scope (do not edit anything else) - `package.json` — modified (add vitest as devDep, add `test` and `test:run` scripts) - `package-lock.json` — modified (regenerated) - `vitest.config.js` — **new** - `test/setup.js` — **new** (sets test env, mocks `@vercel/postgres`) - `test/lib/auth-secret.test.js` — **new** - `test/lib/permission-middleware.test.js` — **new** - `test/api/auth-utils.test.js` — **new** - `.github/workflows/ci.yml` — modified (uncomment + adjust the disabled `test:` job) ## Conventions to follow - `.cursor/rules/no-go-zones.mdc` — do not edit any auth source file (those are Briefs 1 / 2 / 4). Tests **read** the source; they do not modify it. - `.cursor/rules/auth-and-permissions.mdc` is the contract under test — every assertion in these tests should map to a bullet in that rule. - `package.json` formatting: 2-space indent, alphabetical key order within `devDependencies`. New `scripts` keys go alphabetically among existing keys. - ESM throughout (`"type": "module"` is set). All test files use `import`. - File naming: `*.test.js` (vitest's default `include` pattern). - **Plain JavaScript only.** Do not add a `tsconfig.json`. Do not use `.ts` files. Do not import `@types/*` packages. The repo is JavaScript-only; the existing `typescript@^5.9.3` devDep is purely a transitive requirement of `eslint-config-next@16` and is NOT a language switch (per AGENTS.md and the bump-next-js retro). ## Acceptance criteria ### `package.json` changes - [ ] `devDependencies` gains `"vitest": "^3.2.4"`. (Verified at architect time: vitest@3.2.4 has `vite` as a regular dependency, not a peer dependency, so we do **not** need to install Vite separately. vitest@4.x requires `vite ^6 || ^7 || ^8` as a non-optional peer — that's why we pin to v3.) - [ ] No `vite` direct devDep. (vitest@3 bundles vite transitively.) - [ ] No `@types/node` or any `@types/*` package — JS-only. - [ ] No `@vitest/ui`, `@vitest/coverage-v8`, `happy-dom`, `jsdom` — none needed for unit tests of pure-Node modules. - [ ] `scripts` gains: - `"test": "vitest"` (watch mode, dev convenience) - `"test:run": "vitest run"` (single-pass, CI mode) - [ ] `scripts` does NOT gain a `test:ui` or `test:coverage` script in this brief — those are follow-up. ### `package-lock.json` changes - [ ] Regenerated via `npm install`. - [ ] `npm ls vitest` reports a single `3.2.x` version. - [ ] `npm ls vite` reports a single `5.x`, `6.x`, or `7.x` version (vitest@3.2.4's regular dep range is `^5.0.0 || ^6.0.0 || ^7.0.0-0`; the locked version depends on what npm resolves at install time). - [ ] `npm install` exits cleanly with no `ERESOLVE` errors. **`npm warn deprecated` lines are tolerated** for transitive deps (vitest's tree pulls in `glob@7` and `inflight` historically). If the warnings are loud, capture them in the PR description but don't block. ### `vitest.config.js` (new) - [ ] ESM (`export default`), 2-space indent. - [ ] Verbatim shape: ```js import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', globals: false, setupFiles: ['./test/setup.js'], include: ['test/**/*.test.js'], testTimeout: 5000, }, }); ``` - [ ] No `coverage:` block. No `pool:` override. No `transform:` config (vitest's default Vite-based transform handles `.js` ESM out of the box). ### `test/setup.js` (new) - [ ] Sets stable test env BEFORE any module is imported elsewhere. Verbatim shape: ```js process.env.JWT_SECRET = 'test-secret-for-vitest-only-do-not-use-in-prod'; process.env.NODE_ENV = 'test'; ``` - [ ] **Do NOT set `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`.** The rate-limit module's no-op fallback fires under `NODE_ENV !== 'production'` with Upstash unset. If a future test wants to assert rate-limit behavior, it can mock `@upstash/ratelimit` per-test. - [ ] No `dotenv` import. Vitest does not automatically read `.env.local`, and we do not want production secrets leaking into test runs. ### `test/lib/auth-secret.test.js` (new) Cover the two exports and the import-time throw. - [ ] `import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'` succeeds when `process.env.JWT_SECRET` is set (it is, via `test/setup.js`). - [ ] `JWT_SECRET` equals the value set in `test/setup.js`. - [ ] `JWT_TOKEN_TTL` equals `'24h'`. - [ ] **Import-time throw test:** use `vi.resetModules()` + `vi.stubEnv('JWT_SECRET', '')` + `await expect(import('../../lib/auth-secret.js')).rejects.toThrow(/JWT_SECRET/)`. Then `vi.unstubAllEnvs()` to restore. (Verbatim pattern lives in vitest docs §"Mocking → Environment Variables"; the test must use `await import(...)` because static `import` resolves at file-parse time and would crash the test runner.) - [ ] Test count: 3. ### `test/lib/permission-middleware.test.js` (new) This is the core security test. Lock in Brief 2's behavior. - [ ] `vi.mock('@vercel/postgres', () => ({ sql: vi.fn() }))` at the top of the file. The `sql` mock returns `Promise.resolve({ rows: [...] })` per-test, allowing each test to set the user-row shape it expects. - [ ] Helper to mint a valid token in tests: ```js import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../../lib/auth-secret.js'; function makeToken(payload, opts = {}) { return jwt.sign(payload, JWT_SECRET, { expiresIn: opts.expiresIn ?? '1h' }); } ``` - [ ] Test cases (each maps to a bullet in `.cursor/rules/auth-and-permissions.mdc` § "Token model"): - `returns null when Authorization header is missing` — `getUserFromRequest({ headers: {} })` resolves to `null`. **No DB query is made** (assert `sql` mock not called). - `returns null when Authorization header is not Bearer` — `{ headers: { authorization: 'Basic foo' } }` resolves to `null`. - `returns null when token is malformed` — `{ headers: { authorization: 'Bearer not-a-jwt' } }` resolves to `null`. - `returns null when token signature uses a wrong secret` — sign a payload with `'other-secret'`, expect `null`. - `returns null when token is expired` — sign with `expiresIn: '-1s'`, expect `null`. - `returns null when token is valid but user-row is missing` — set `sql` to return `{ rows: [] }`, expect `null`. - `returns user object when token is valid and user-row exists` — set `sql` to return `{ rows: [{ id: 42, email: 'a@b.c', role: 'user' }] }`. Expect `{ userId: 42, email: 'a@b.c', role: 'user' }`. Note the `userId` (not `id`) field name — that is the helper's documented contract. - [ ] **Negative regression test (Brief 2 lock):** confirm the helper does NOT return the synthetic admin shape `{ userId: 1, email: 'admin@tcgvault.com', role: 'admin' }` when no header is present. This is a smoke against the bug specifically. - [ ] Test count: 8. ### `test/api/auth-utils.test.js` (new) Thin coverage of the JWT-mint contract. - [ ] `vi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))` — `auth-utils.js` imports `db`, but the tests only exercise `generateToken` / `verifyToken`, which don't touch the DB. The mock just satisfies the import. - [ ] Test cases: - `generateToken issues a token whose expiry is 24h from now (±5s tolerance)` — decode the token, check `decoded.exp - decoded.iat === 86400`. - `generateToken includes userId, email, role from the user arg` — decode, assert payload. - `verifyToken returns the payload for a valid token`. - `verifyToken returns null for a malformed token`. - `verifyToken returns null for a token signed with a different secret`. - [ ] Test count: 5. ### `.github/workflows/ci.yml` The current file has the `test:` job commented out at lines 87-103. Re-enable it. Verbatim replacement for that block: ```yaml test: name: Unit tests (vitest) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - run: npm run test:run env: JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod ``` - [ ] **No** `POSTGRES_URL` env in CI. The unit tests mock `@vercel/postgres`; they don't need a real connection. Setting it to a fake value would mask import-time validation that may exist in `lib/database.js`. - [ ] **No** `UPSTASH_REDIS_REST_*` envs. Tests don't exercise the rate-limit module. - [ ] The job is **blocking** (no `|| true`, no `::warning`). - [ ] Concurrency is inherited from the workflow level; no per-job override. - [ ] Remove the trailing comment block at the bottom of the file (the `# test:` placeholder lines 87-103). They become real lines now. - [ ] Update the workflow header comment (lines 11-13) to remove the "tcg-vault has no test runner installed yet" note. ### Smoke (manual) - [ ] `npm install` from a clean tree succeeds. - [ ] `npm run test:run` runs all 16 tests and exits 0. - [ ] `npm run test` (watch mode) shows the same 16 tests passing on save. - [ ] **Failure-mode smoke:** temporarily revert one line of Brief 2's fix (e.g. add back the `return { userId: 1, ... }` synthetic admin in `getUserFromRequest`). Run `npm run test:run`. Expect: `permission-middleware.test.js`'s "returns null when Authorization header is missing" test FAILS. Restore Brief 2 before opening the PR. - [ ] Push to a draft PR and confirm the GitHub Actions `test` job runs and is green. ### Out of scope - [ ] No tests for `lib/rate-limit.js` (Brief 4). The graceful-fallback branch is hard to test cleanly without an Upstash mock; defer to a follow-up. - [ ] No tests for `pages/api/auth/login.js` / `register.js` integration paths (would require fluent HTTP-handler mocking; defer to a follow-up Playwright / supertest convoy). - [ ] No tests for `withCollectionPermission`, `checkCollectionPermission`, `logCollectionActivity`. This convoy is scoped to the auth-bypass surface; collection-permission tests are their own follow-up. - [ ] No `tsconfig.json` or `.ts` files. JS-only, per AGENTS.md. - [ ] No coverage report or coverage gate. Follow-up convoy. - [ ] No Playwright / E2E. Follow-up convoy (`adopt-playwright`). ## Rationale (≤3 sentences) Bringing vitest forward by one slot in the launch sequence is justified by the security blast radius of an auth refactor — the alternative is shipping Brief 2 untested and waiting for the test-runner convoy to backfill, which leaves `getUserFromRequest`'s null-return contract unenforced for an unknown number of PRs. Pinning vitest to v3.2.4 (rather than the latest v4.1.7) avoids the non-optional `vite` peer-dep that v4 introduced, keeping the devDep set minimal for a JS-only repo. Mocking `@vercel/postgres` in unit tests rather than spinning up a real Postgres in CI keeps the test job under 30 seconds end-to-end and avoids the operational cost of a CI-only DB.