---
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.
