prole/docs/plans/onboarding-tdd.md
chrisfu 748de6beff test(infra): Phase A — onboarding TDD design doc + hard fixture
Co-authored-by: Junie <junie@jetbrains.com>
2026-04-30 16:07:39 -07:00

19 KiB
Raw Blame History

Onboarding TDD — Design Doc

Status: Phase A complete. This doc is the spec Codex (Phase B) implements. Do not make architectural calls when writing test bodies — execute against this doc.


1. Scope

Covers the five commits that landed without automated test coverage:

Commit Surface
f805404 oauth2-proxy --skip-auth-route for Supabase API surfaces
79a3f76 GRANT anon, authenticated, service_role TO supabase_storage_admin; auth-header passthrough removed; /support 302 stop-gap
8c2956b pg_stat_statements moved to extensions schema
317b20b TCP LB pg.0.knoe.dev, CNPG cert SAN, knoe_developer role, per-engineer roles, pg_hba tightened
313ee88 Reusable engineer onboarding: etc/onboard_engineer.sh, knoe-onboard nginx pod, /onboard.html Kong route, +knoe_developer pg_hba

Policy: cover all five commits in this round. The test debt only grows otherwise, and the surfaces are tightly coupled (pg_hba + onboarding + oauth2-proxy are one security envelope).


2. Directory layout

tests/
  onboarding/
    conftest.py                  # ← Phase A: the hard fixture (external-client simulation)
    test_pg_hba_matrix.py        # Tier 1 — pg_hba rejection matrix (Phase B)
    test_knoe_developer_membership.py  # Tier 1 — membership semantics (Phase B)
    test_skip_auth_route.py      # Tier 1 — oauth2-proxy regex tightness (Phase B)
    test_hostnossl_precedence.py # Tier 1 — rule-order property test (Phase B)
    test_onboard_script.py       # Tier 2 — onboard_engineer.sh correctness (Phase B)
    test_onboard_page.py         # Tier 2 — reveal page headless browser (Phase B)
    test_ca_cert_fingerprint.py  # Tier 2 — CA cert matches live (Phase B)
    test_studio_gating.py        # Tier 3 — Studio paths still gated (Phase B)
    test_storage_grant.py        # Tier 3 — storage/v1 reachable as service-role (Phase B)
    test_pg_stat_statements.py   # Tier 3 — pg_stat_statements in extensions schema (Phase B)
    test_api_alias.py            # Tier 3 — api.0.knoe.dev alias still works (Phase B)

Conventions (match existing tests):

  • from __future__ import annotations at top
  • REPO_ROOT = Path(__file__).resolve().parents[N] for path resolution
  • No unittest.TestCase — plain functions and pytest.mark only
  • monkeypatch for env isolation; tmp_path for temp files
  • Fixtures in conftest.py at the nearest scope that needs them

3. Test taxonomy

Tier 1 — Security boundaries

Hard-gating on the deploy pipeline (blocks deploy, not just merge). All 36+ pg_hba matrix cells must be green. No partial credit.

T1.1 — pg_hba rejection matrix

For each combination of:

  • Source: external IP (simulated via the external_psql_client fixture — see §5), internal cluster IP (pod in-cluster)
  • Encryption: TLS (sslmode=require), plaintext (sslmode=disable)
  • Role: chrisfu, ron, a freshly-provisioned knoe_developer member (test_member), postgres, supabase_admin, anon, a role with LOGIN+password but NOT in knoe_developer (test_nonmember)

Expected outcomes per cell:

Source TLS Role Expected
external TLS chrisfu connect
external TLS ron connect
external TLS test_member (in knoe_developer) connect
external TLS postgres auth-fail (pg_hba match but SCRAM fails — postgres has no external password)
external TLS supabase_admin reject (not in knoe_developer, no matching hostssl rule)
external TLS anon reject
external TLS test_nonmember (LOGIN, valid pw, not in group) reject
external plaintext any role reject (hostnossl all all all reject fires first)
internal TLS any role connect (RFC1918 rules allow)
internal plaintext postgres connect (internal plaintext allowed for supabase services)
internal plaintext supabase_admin connect
internal plaintext authenticator connect

Assertion per cell: attempt connection → assert psycopg2.OperationalError message matches expected pattern ("no pg_hba.conf entry" for reject, "password authentication failed" for auth-fail) or connection succeeds.

T1.2 — +knoe_developer membership semantics

Three assertions, each independent:

  1. A role granted knoe_developer (GRANT knoe_developer TO test_member) can connect externally with TLS.
  2. After REVOKE knoe_developer FROM test_member, the same credentials are rejected externally (new connection attempt — existing sessions are not the test target here).
  3. A role with LOGIN + valid password but never granted knoe_developer cannot connect externally even with correct credentials.

Assertion: psycopg2 connect attempt → success or OperationalError with "no pg_hba.conf entry".

T1.3 — oauth2-proxy --skip-auth-route regex tightness

Fixture: HTTP client that hits https://db.0.knoe.dev (or the k3d equivalent) without a Google session cookie.

For each declared skip route, assert HTTP 200 (or the backend's own response, not a 302 to accounts.google.com):

  • /auth/v1/ (exact prefix)
  • /rest/v1/ (exact prefix)
  • /realtime/v1/ (exact prefix)
  • /storage/v1/ (exact prefix)
  • /functions/v1/ (exact prefix)
  • /graphql/v1/ (exact prefix)
  • /pg/ (exact prefix)
  • /onboard.html (exact path)
  • /support (exact path)

For each of the following, assert HTTP 302 with Location header containing accounts.google.com:

  • / (Studio root — must be gated)
  • /api/profile (Studio internal)
  • /Auth/v1/foo (case-variant — must NOT skip)
  • //rest/v1/ (double-slash — must NOT skip)
  • /rest/v1/../admin (traversal probe — must NOT skip after normalization)
  • /onboard.html.bak (suffix extension — must NOT skip)

Each negative test is the regression detector: if the regex is accidentally broadened, the negative test fails.

T1.4 — hostnossl reject precedence (property test)

Parse the pg_hba block from deploy/gcp/gke/knoe-db.yaml (the pg_hba key in the CNPG Cluster manifest). For every host (non-SSL) rule that matches an external IP range:

  • Assert that a hostnossl ... reject rule appears before it in the file for the same or broader address range.

This is a static analysis test — no live cluster needed. It catches the externalTrafficPolicy: Cluster → Local class of bug where rule ordering lets external plaintext traffic match a permissive rule.

Implementation: parse the YAML, extract pg_hba lines, walk them in order, maintain a set of "covered by reject" address ranges, assert no permissive host rule appears for an address range not yet covered.


Tier 2 — Onboarding flow correctness

CI-gating (blocks merge). Not deploy-gating.

T2.5 — onboard_engineer.sh idempotency

Three sub-cases (each run against the k3d fixture cluster):

  1. Run twice with same <user> <email> → exactly one role exists; second run's password is the active one.
  2. --rotate on a non-existent user → exits non-zero with a clear error message; no partial state (no role created).
  3. --revoke while a session is open → role dropped, active sessions terminated (check pg_stat_activity is empty for that role after revoke).

Assertion: psql queries against the fixture cluster + script exit codes + stderr content.

T2.6 — onboard_engineer.sh input validation

Each sub-case: run script with bad input, assert non-zero exit and stderr contains the expected error string.

  • Username RON (uppercase) → "username must match"
  • Username r (too short) → "username must match"
  • Username ro n (space) → "username must match"
  • Email ron@gmail.com (wrong domain) → "email must be *@knoey.com"
  • Username '; DROP TABLE pg_roles; -- (SQL injection probe) → "username must match" (regex rejects before SQL)
  • Username $(rm -rf /) (shell injection probe) → "username must match"

T2.7 — Reveal page renders correctly

Headless browser (pytest-selenium + headless Chrome — see §4 for framework decision). Six sub-cases:

  1. onboard.html#user=test&pw=Zm9v&exp=2099-01-01T00:00:00Z → password field shows foo (base64 decoded).
  2. exp set to a past timestamp → page shows expired-state UI (element with class expired is visible).
  3. Missing pw field → error UI visible (element with class error is visible).
  4. Copy button for password → navigator.clipboard.writeText called with the decoded password (assert via JS execution).
  5. After page load, window.location.hash is empty (history.replaceState stripped the fragment).
  6. pw with URL-safe base64 chars (- and _) → decoded correctly.

T2.8 — CA cert in repo matches live CA

Parse etc/knoe-db-ca.crt and compute its SHA256 fingerprint. Compare against the fingerprint from kubectl get secret knoe-db-ca -n knoe-db-0 -o jsonpath='{.data.ca\.crt}' (base64-decoded, then SHA256).

This test is skipped when KUBECONTEXT is not set (local dev without cluster access). It runs in CI when DB_CLUSTER_KUBECONTEXT is available.

Assertion: fingerprints match. Failure message: "CA cert in etc/knoe-db-ca.crt does not match live cluster CA — CNPG may have rotated. Update the committed cert.".


Tier 3 — Adjacent surfaces

CI-gating (blocks merge). Regression coverage for the five commits.

T3.9 — Studio internal paths still gate via Google

HTTP GET (no session cookie) to:

  • https://db.0.knoe.dev/ → 302 to accounts.google.com
  • https://db.0.knoe.dev/api/profile → 302 to accounts.google.com
  • https://db.0.knoe.dev/api/database/default/table → 302 to accounts.google.com

T3.10 — db.0.knoe.dev/storage/v1/bucket reachable as service-role

HTTP GET https://db.0.knoe.dev/storage/v1/bucket with apikey: <service_role_key> header → HTTP 200 (bucket list, may be empty). Pins the GRANT anon, authenticated, service_role TO supabase_storage_admin from commit 79a3f76.

T3.11 — pg_stat_statements lives in extensions schema

Connect to the fixture cluster as postgres. Assert:

  • SELECT extschema FROM pg_extension WHERE extname = 'pg_stat_statements' returns extensions.
  • SELECT count(*) FROM pg_catalog.pg_class WHERE relname = 'pg_stat_statements' AND relnamespace = 'public'::regnamespace returns 0.

T3.12 — api.0.knoe.dev alias still works

HTTP GET https://api.0.knoe.dev/rest/v1/ with valid apikey → HTTP 200. This test is marked xfail with reason "alias retirement tracked in TODO #14" so it becomes a loud signal when the alias is dropped.


4. Architectural decisions

4a. Fixture architecture for "external client"

Decision: k3d cluster with a labeled namespace + NetworkPolicy to simulate external traffic.

Rationale:

  • The live GKE cluster option pollutes prod with test roles and requires GCP credentials in CI — ruled out.
  • A full k3d cluster with a real LB IP is the highest-fidelity local option, but k3d's MetalLB integration is non-trivial to make deterministic in CI (IP allocation races).
  • The hybrid option (k3d for most, nightly live-cluster job) is the right long-term answer but adds a second test rig to maintain before Phase B even lands.

Chosen approach: k3d cluster (spun up once per CI run, shared across the Tier 1 suite) with two namespaces:

  • knoe-db-test — the CNPG cluster pod
  • external-client — a pod that has no RFC1918 route to knoe-db-test (enforced via NetworkPolicy deny-from-external-client-to-db that blocks the external-client namespace from reaching knoe-db-test except via the LoadBalancer service IP)

The external_psql_client fixture in tests/onboarding/conftest.py returns a connect() helper that:

  • When called with source="external": connects via the k3d LoadBalancer service ClusterIP, with sslmode=require or sslmode=disable as specified. The NetworkPolicy ensures this traffic hits pg_hba with the pod's IP, which is outside the RFC1918 ranges whitelisted for internal access.
  • When called with source="internal": connects directly to the pod IP (bypasses the LB), simulating in-cluster supabase services.

This diverges slightly from prod (real LB IP vs. ClusterIP) but the pg_hba rules being tested are IP-range-based, not LB-specific. The externalTrafficPolicy: Local behavior (source IP preservation) is what matters, and k3d's kube-proxy preserves source IPs for ClusterIP services when the client is in a different namespace.

Tradeoff recorded: If a future bug is LB-SNAT-specific (like the externalTrafficPolicy: Cluster → Local bug from Phase 1), this fixture will not catch it. The T1.4 static analysis test (hostnossl reject precedence) is the backstop for that class of bug.

4b. Browser-test framework

Decision: pytest-selenium (Python) + headless Chrome.

Rationale: There are exactly 6 browser-tested behaviors (T2.7 sub-cases), all on a single static HTML page. Adding a Node.js toolchain (Playwright, Puppeteer, Cypress) for 6 test cases is not justified. pytest-selenium keeps the entire test suite in Python, uses the same pytest runner, and headless Chrome is available on all CI runners. If the browser-tested surface grows beyond ~15 cases, revisit Playwright.

Dependency: pytest-selenium>=4.0 + selenium>=4.0 added to requirements-test.txt. Chrome/chromedriver assumed present on CI runner (Gitea Actions macOS runner has it via brew).

4c. k3d-per-run vs. shared persistent local cluster

Decision: shared persistent k3d cluster for local dev; per-run ephemeral cluster in CI.

Rationale:

Per-run Shared persistent
Speed 60-90s spinup ~0s
Determinism High Low (test pollution)
Local dev UX Friction Zero friction
CI parallel runs Required Doesn't work

The fixture (conftest.py) detects the environment:

  • If KNOE_TEST_CLUSTER env var is set (pointing to an existing kubeconfig context), use it as-is (shared persistent mode — local dev).
  • If KNOE_TEST_CLUSTER is unset, spin up a fresh k3d cluster, run the suite, tear it down (per-run mode — CI).

The cluster name for per-run mode: knoe-test-{uuid4()[:8]} to avoid collisions in parallel CI jobs.

This decision applies to Phase 2 of docs/pipeline-phases.md as well — record it there when Phase 2 lands.

4d. Acceptance criteria per tier

Tier 1 — done when:

  • All 36+ pg_hba matrix cells (T1.1) are green with no skips.
  • T1.2 membership semantics: all 3 assertions green.
  • T1.3 skip-auth-route: all declared routes pass positive test; all negative probes pass.
  • T1.4 static analysis: passes against current deploy/gcp/gke/knoe-db.yaml.
  • No pytest.mark.skip or xfail on any Tier 1 test without explicit chrisfu approval.
  • Tier 1 is deploy-gating: the deploy pipeline (./deploy.sh) must not proceed if Tier 1 is red.

Tier 2 — done when:

  • T2.5, T2.6, T2.7, T2.8 all green (T2.8 may be skipped in local dev without cluster access).
  • Tier 2 is merge-gating: PRs cannot merge if Tier 2 is red.

Tier 3 — done when:

  • T3.9, T3.10, T3.11 green.
  • T3.12 green or xfail (alias still live) — a passing xfail is a signal to retire the alias.
  • Tier 3 is merge-gating.

Mutation testing: not required for Phase B. Revisit after Tier 1 is fully green — the matrix structure makes mutation testing high-value (a single wrong rejectconnect in the expected column would be caught).

4e. Retroactive coverage policy

Cover all five commits in this round (Tiers 13 above). The pg_stat_statements schema move (T3.11) and storage GRANT (T3.10) are directly covered. The api.0.knoe.dev alias (T3.12) is covered with an xfail marker. No surfaces from the five commits are left uncovered.


5. CI integration

Tests slot into Phase 1 of docs/pipeline-phases.md.

Markers

# pyproject.toml [tool.pytest.ini_options] markers:
integration = "requires a live k3d or k8s cluster (slow)"
browser = "requires headless Chrome / selenium"

Run configurations:

  • pytest tests/onboarding/ -m "not integration and not browser" — fast unit-style tests (T1.4 static analysis, T2.6 input validation, T2.8 CA cert fingerprint when cluster available). Runs on every commit, <5s.
  • pytest tests/onboarding/ -m integration — cluster-dependent tests (T1.1, T1.2, T1.3, T2.5, T2.7, T2.8, T3.9T3.12). Runs in CI with k3d. ~3-5 min.
  • pytest tests/onboarding/ -m browser — headless browser tests (T2.7). Runs in CI with Chrome. ~30s.

The integration marker requires KNOE_TEST_CLUSTER or auto-provisions a k3d cluster. Tests without the marker must not touch any cluster.

pyproject.toml additions

[tool.pytest.ini_options]
markers = [
    "integration: requires a live k3d or k8s cluster",
    "browser: requires headless Chrome / selenium",
]

6. The hard fixture (Phase A deliverable)

See tests/onboarding/conftest.py (committed in Phase A). It provides:

  • k3d_cluster (session-scoped): ensures a k3d cluster is available (reuses KNOE_TEST_CLUSTER if set, else creates knoe-test-<uuid>). Yields the kubeconfig path. Tears down on session end if it created the cluster.
  • external_psql_client (function-scoped): returns a PsqlClient with .connect(role, password, sslmode, source) method. source="external" routes through the simulated-external namespace; source="internal" connects directly.

The one green Tier 1 test that validates the fixture is in tests/onboarding/conftest.py itself (or a minimal test_fixture_smoke.py): it asserts that the fixture cluster is reachable and that a connection attempt from the external-client namespace with sslmode=disable is rejected (the hostnossl reject rule fires). This is the simplest possible Tier 1 assertion and proves the fixture works end-to-end.


7. Files Codex must read before writing test bodies

In order:

  1. This doc (docs/plans/onboarding-tdd.md)
  2. tests/onboarding/conftest.py — the fixture Codex's tests use
  3. etc/onboard_engineer.sh — the script under test for Tier 2
  4. deploy/gcp/gke/knoe-db.yaml — pg_hba block (Tier 1 static analysis + matrix expected values)
  5. deploy/gcp/gke/oauth2-proxy-deployment.yaml--skip-auth-route args (Tier 1.3 expected routes)
  6. deploy/gcp/gke/knoe-onboard.yaml — the HTML page under test (Tier 2.7)
  7. docs/db-access.md and docs/onboarding.md — engineer-facing docs (context for expected behavior)
  8. tests/test_render_supabase_hostname.py — existing test pattern to match

8. Out of scope for Phase B

  • Updating docs/onboarding.md / docs/db-access.md to mention the test suite (Phase B end task).
  • Mutation-testing setup.
  • Live-cluster integration tests against knoe-dev-cnpg-0 (only k3d fixture in Phase B).
  • CI wiring for the browser marker on the Gitea Actions runner (separate PR after T2.7 lands).