deckhearth/.convoys/fix-auth-bypass/brief-5-vitest-and-auth-tests.md
Randall Stillwell f64a80ba2a 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 11:27:48 -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.