Files
suaveplan/openspec/specs/testing-integration-oauth-mock/spec.md
2026-05-27 23:29:53 +01:00

6.1 KiB

testing-integration-oauth-mock

Purpose

Real-HTTP mock OAuth 2.0 Identity Provider fixture for integration tests. Boots a node:http listener on an ephemeral 127.0.0.1 port and implements OAuth 2.0 authorization-code + refresh-token grants with PKCE, an OIDC discovery + JWKS pair (opt-in), GitHub/Google/Generic user shapes (or a custom mapper), and FIFO test-control levers (primeUser, primeError) plus a frozen audit log of every served request. Rule-8 testing-infrastructure package — zero @suaveplan/* runtime dependencies; only node:* builtins.

Requirements

Requirement: Exposes a controllable mock OAuth 2.0 IdP

The package SHALL export createMockOauthIdp(opts) returning a handle { url, port, stop, primeUser, primeError, lastRequest, allRequests } backed by a real HTTP listener (node:http createServer) — not a mock library — implementing OAuth 2.0 authorization-code flow with PKCE.

Scenario: factory boots a real HTTP server on an ephemeral port

  • WHEN createMockOauthIdp({}) is called
  • THEN handle.url is a http://127.0.0.1:<port> origin AND a GET to ${url}/authorize?...&state=abc responds with a 302 carrying the same state and a generated code

Scenario: stop releases the port

  • WHEN handle.stop() is called and awaited
  • THEN the port is free for subsequent binds AND a second GET to the URL fails with ECONNREFUSED

Verified by: src/server/server.test.ts::"factory boots a real HTTP server on an ephemeral port" AND src/server/server.test.ts::"stop releases the port"


Requirement: Implements authorize → token → userinfo round-trip

The IdP SHALL accept GET /authorize (state + code_challenge), POST /token (code + code_verifier exchange OR refresh-token grant), and GET /userinfo (Bearer authentication). The full round-trip MUST succeed without external network calls.

Scenario: full happy-path round-trip

  • WHEN a client primes a user, hits /authorize, exchanges the returned code at /token, and fetches /userinfo with the access token
  • THEN /userinfo returns the primed user payload AND lastRequest() records every step

Scenario: PKCE S256 challenge enforced

  • WHEN /token is called with a code_verifier whose SHA-256 does not match the original code_challenge
  • THEN the response is HTTP 400 with { error: "invalid_grant" }

Scenario: refresh-token grant rotates the token pair

  • WHEN /token is called with grant_type=refresh_token and a valid refresh token
  • THEN a new access+refresh pair is returned AND the previous refresh token is invalidated

Verified by: src/server/server.test.ts::"full happy-path round-trip" AND src/endpoints/token.test.ts::"PKCE S256 challenge enforced — mismatch returns invalid_grant" AND src/endpoints/token.test.ts::"refresh-token grant rotates the token pair"


Requirement: Test-control levers (primeUser, primeError)

Tests SHALL be able to control IdP behaviour without restarting the server.

Scenario: primeUser seeds the next callback's user payload

  • WHEN handle.primeUser({ id: "42", login: "alice" }) is called before /authorize
  • THEN the following /userinfo response carries { id: "42", login: "alice", ... }

Scenario: primeError forces a specific RFC-6749 error

  • WHEN handle.primeError("access_denied") is called and the user hits /authorize
  • THEN the redirect carries ?error=access_denied (no code)

Scenario: queues are FIFO

  • WHEN primeUser(A) then primeUser(B) is called and two round-trips run
  • THEN the first returns A and the second returns B

Verified by: src/control/primer.test.ts::"primeUser seeds the next callback (FIFO)" AND src/control/primer.test.ts::"primeError forces a specific RFC-6749 error" AND src/control/primer.test.ts::"queues are FIFO across both lanes independently"


Requirement: Audit surface (lastRequest, allRequests)

The handle SHALL record every HTTP request the IdP served as a frozen snapshot: { method, path, headers, query, body, at }.

Scenario: lastRequest returns the most recent record

  • WHEN two requests are served and handle.lastRequest() is called
  • THEN the returned record matches the second request

Scenario: allRequests returns a frozen ordered array

  • WHEN handle.allRequests() is called
  • THEN the returned array is frozen AND iterating it yields requests in serve-order

Verified by: src/control/audit.test.ts::"lastRequest returns the most recent record" AND src/control/audit.test.ts::"allRequests returns a frozen ordered array"


Requirement: Provider-shaped user payloads

The factory SHALL accept userShape: "github" | "google" | "generic" (default "github") or a custom mapper, producing realistic provider-shaped userinfo responses.

Scenario: github shape includes GitHub-canonical fields

  • WHEN userShape: "github" is set and primeUser({ id: 42 }) is called
  • THEN /userinfo returns { id: 42, login, email, avatar_url, name, ... }

Verified by: src/shapes/github.test.ts::"populates canonical GitHub claims by default" AND src/shapes/github.test.ts::"honours primed id + login"


Requirement: No mocks; rule 8 testing-infrastructure compliance

The package MUST NOT depend on any @suaveplan/* runtime package and MUST NOT use mocking libraries (no vi.mock, no jest.mock). The server is a real node:http listener. Test files import test-runner primitives from @suaveplan/testing/runner per rule 11.

Scenario: no @suaveplan/* runtime deps

  • WHEN package.json is inspected
  • THEN the dependencies block contains no @suaveplan/* entry; peerDependencies may declare @suaveplan/types types-only

Scenario: test-runner imports go through @suaveplan/testing/runner

  • WHEN bun scripts/lint/check-test-runner-imports.ts is run
  • THEN the exit code is 0 (no direct vitest or bun:test imports inside src/)

Verified by: tests/package-shape.test.ts::"package.json declares no @suaveplan/* runtime deps" AND tests/package-shape.test.ts::"no direct vitest or bun:test imports in src/"