deckhearth/.convoys/fix-auth-bypass/brief-5-vitest-and-auth-tests.md
Randall Stillwell 1667b87ee3 convoy(fix-auth-bypass): architect plan + 5 briefs (Wave A/B/C dispatch)
Architect pass for P0 security-critical convoy (closes ship-blockers
#1, #2, #4, #5, #6 partial). Produces 5 briefs with explicit
slice_dependencies for /multitask fan-out.

Decomposition:
- Brief 1: Central JWT secret helper + 24h token TTL (8 files, ~120 LOC)
- Brief 2: Remove the synthetic-admin bypass (2 files, ~25 LOC net negative)
- Brief 3: Delete 4 dev-only endpoints + CI guard (6 files, ~30 LOC)
- Brief 4: Tighten auth surface — CORS + rate limit (5 files, ~150 LOC)
- Brief 5: Install vitest + auth tests + re-enable CI test job (8 files, ~280 LOC)

Total estimate: ~600 LOC across 5 PRs. All under 400-LOC budget.

Wave A (parallel from t=0): Briefs 1 + 3 (disjoint files)
Wave B (parallel after Brief 1): Briefs 2 + 4 (disjoint subsets of Brief 1's exports)
Wave C (after Briefs 2 + 4): Brief 5 alone (lockfile sequencing + functional dep on Brief 2's null contract)

Architect's calls (3 decisions documented in convoy file Decisions log):
- Token TTL = 24h (matches current login.js UX; security-conservative)
- Rate-limit = @upstash/ratelimit@^2.0.8 + @upstash/redis@^1.38.0
  (DIY-Postgres needs schema change OOS; DIY-memory broken on Vercel
  cold starts; next-rate-limit is stale)
- Vitest in this convoy (not split to adopt-vitest); pinned to ^3.2.4
  to dodge vitest@4's non-optional vite peer dep

Boot-the-brief findings (9 verifications, 0 revisions):
- 24/24 getUserFromRequest callers already handle null correctly —
  Brief 2 is safer than the convoy file predicted
- 7 JWT_SECRET literal sites match AGENTS.md gotcha #3 exactly
- Dev endpoints have zero runtime references (only doc references) —
  safe to delete
- Cross-brief commitments declared in both directions for every
  Brief-1 -> {2,4,5} pair

Risk list: 12 risks documented (R1-R12). Headlines:
- R1: JWT_SECRET fail-loud throws may break unexpected import chains
- R3: existing tokens stop verifying once literal fallback removed
  (one-time "log back in" pre-launch is acceptable)
- R5-R6: rate-limit IP extraction + Upstash quota; fail-open mitigation
- R10: JWT_SECRET rotation now requires a deploy (no silent fallback)

Pre-merge env-var checklist (user action required before Brief 4 ships):
- UPSTASH_REDIS_REST_URL (new — Vercel project settings)
- UPSTASH_REDIS_REST_TOKEN (new — Vercel project settings)
- JWT_SECRET (verify already set — no fallback any more)

Awaiting human gate 1 (plan approval) before implementers run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 02:58:16 -05:00

12 KiB

convoy brief_number depends_on files cross_brief_commitments
fix-auth-bypass 5
1
2
4
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
brief description
1 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 description
2 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 description
4 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.jsnew
  • test/setup.jsnew (sets test env, mocks @vercel/postgres)
  • test/lib/auth-secret.test.jsnew
  • test/lib/permission-middleware.test.jsnew
  • test/api/auth-utils.test.jsnew
  • .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:
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:
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:
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 missinggetUserFromRequest({ 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:

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.