212 lines
12 KiB
Markdown
212 lines
12 KiB
Markdown
|
|
---
|
||
|
|
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.
|