deckhearth/.convoys/fix-auth-bypass/brief-5-vitest-and-auth-tests.md

212 lines
12 KiB
Markdown
Raw Normal View History

docs: post-convoy cleanup for fix-auth-bypass Closes out the fix-auth-bypass convoy (PRs #6–#11, merged through 1629afb) on the docs side. Code already on main; this PR is docs only. Updates: AGENTS.md - §1 auth bullet refreshed (auth-secret SoT, 24h TTL, no synthetic admin, login/register rate limit) - §3 conventions point at lib/auth-secret.js + lib/rate-limit.js - §4 gotchas #2/#3/#5 converted to "Resolved" notes in place (NOT renumbered, to preserve cross-references) - new #12 documents the KV_REST_API_* env-var convention - §5 setup list adds the rate-limit env vars - §6 testing rewritten for Vitest (16 unit tests, blocking CI gate) .cursor/rules/auth-and-permissions.mdc - canonical-surface table gains lib/auth-secret.js + lib/rate-limit.js - token model now 24h (was 7d) with fail-loud explanation - server-side authorization patterns lead with null → 401 contract .cursor/rules/api-routes.mdc - removes the "CRITICAL — known bug" callout (resolved by Brief 2) - adds a "Rate limiting" section with verbatim shape + env-var notes - "Dev/test endpoints" → "Removed" historical note so future agents searching for test-db understand why it's gone .convoys/fix-auth-bypass.md (restored — was on convoy branch only) - frontmatter → status: shipped - new "Convoy outcome" section: briefs + commits + resolved gotchas, R1-R12 risk walk, env-var-rename deviation record, queued follow-up convoys, lessons learned .convoys/fix-auth-bypass/brief-{1..5}-*.md (restored from convoy branch) - audit-trail completeness; convoy plan references them by name - brief 4 additionally updated: UPSTASH_REDIS_REST_* → KV_REST_API_* across init rules, smoke, pre-deploy checklist - brief 4 has a new "Post-merge addendum" explaining the rename .convoys/ship-readiness.md - P0 #1, #2, #4 → RESOLVED with merge-commit citations - P0 #5 (CORS), #6 (rate limit) → PARTIAL with deferral pointers (cors-tighten and add-rate-limiting convoys) - each item gains an "As-shipped" line for self-containment README.md - Next.js 15 → 16, TypeScript claim corrected to JS-with-devDep - auth + rate-limit + testing bullets updated - env-var template extended with KV_REST_API_* - deleted dev-endpoints note added to the API list - "Default Admin Account" section LEFT ALONE — drop-public-setup territory Verified: build exit 0 (with JWT_SECRET set), 16/16 vitest tests pass, lint baseline unchanged (128/81/47). Convoy: fix-auth-bypass / role-doc-writer (closeout) Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 12:27:48 -04:00
---
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.