From 663783963cdaa3a132586846c3cf9cd7e0ea7ddc Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 10:24:28 +0100 Subject: [PATCH 01/13] feat(ci): add idempotent seed_zap_scan_user management command Seeds a low-privilege user from ZAP_AUTH_USERNAME/ZAP_AUTH_PASSWORD env vars for authenticated OWASP ZAP scans against an ephemeral CI instance (Phase C, C-2). Co-Authored-By: Claude Sonnet 5 --- .../management/commands/seed_zap_scan_user.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 core/management/commands/seed_zap_scan_user.py diff --git a/core/management/commands/seed_zap_scan_user.py b/core/management/commands/seed_zap_scan_user.py new file mode 100644 index 00000000..4a11a802 --- /dev/null +++ b/core/management/commands/seed_zap_scan_user.py @@ -0,0 +1,38 @@ +import os + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = ( + "Idempotently seed a low-privilege user for authenticated OWASP ZAP " + "scans. Reads credentials from ZAP_AUTH_USERNAME/ZAP_AUTH_PASSWORD " + "env vars. Intended for ephemeral CI databases only." + ) + + def handle(self, *args, **options): + username = os.environ.get("ZAP_AUTH_USERNAME") + password = os.environ.get("ZAP_AUTH_PASSWORD") + if not username or not password: + raise CommandError( + "ZAP_AUTH_USERNAME and ZAP_AUTH_PASSWORD env vars are required" + ) + + User = get_user_model() + user, created = User.objects.get_or_create( + username=username, + defaults={ + "is_staff": False, + "is_superuser": False, + "is_active": True, + }, + ) + user.is_staff = False + user.is_superuser = False + user.is_active = True + user.set_password(password) + user.save() + + action = "Created" if created else "Updated" + self.stdout.write(self.style.SUCCESS(f"{action} ZAP scan user '{username}'")) From d3771a35b04cb972e9226dfc7abb530d37b1eeff Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 10:29:13 +0100 Subject: [PATCH 02/13] feat(ci): add authenticated OWASP ZAP scan against ephemeral CI instance Adds a zap-authenticated-scan PR-gate job: migrates a throwaway SQLite DB, seeds a low-privilege scan account, starts a local runserver, and runs zap-full-scan.py with a new auth context file so the scan reaches pages behind /accounts/login/. Wired into the pr-gates-pass summary gate. post-deployment.yml's existing unauthenticated scan against the live artifact is unchanged (defense in depth). Documents the scope decision as LIMIT-22: authenticating against the real deployment would need deploy-pipeline access this repo doesn't have, same category as the deferred C-1/C-3 infra gaps. Phase C (C-2) of docs/TOP_01_PLAN.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci-quality.yml | 60 +++++++++++++++++++++++++++++- .zap/prei-auth-context.xml | 64 ++++++++++++++++++++++++++++++++ docs/KNOWN_LIMITATIONS.md | 12 ++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .zap/prei-auth-context.xml diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index dee98b90..3e3195f2 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -221,6 +221,63 @@ jobs: - name: Verify KPI calculations run: python -m pytest tests/test_finance_math.py -v --tb=short + # ── Authenticated security scan (ephemeral CI instance, not live deploy) ── + # See docs/KNOWN_LIMITATIONS.md LIMIT-22: this scans a freshly-migrated, + # freshly-seeded CI-only instance, not the real deployment. The existing + # unauthenticated full scan in post-deployment.yml still runs against the + # live artifact and is unchanged. + + zap-authenticated-scan: + name: "🛡️ Authenticated ZAP Scan" + runs-on: ubuntu-latest + env: + DJANGO_SETTINGS_MODULE: investor_app.settings_test + SECRET_KEY: ci-zap-scan-key + DATABASE_URL: "sqlite:////tmp/zap_scan.sqlite3" + ZAP_AUTH_USERNAME: zap-ci-scan-only + ZAP_AUTH_PASSWORD: ZapCiOnlyPw2026 + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/python-setup + - name: Install dependencies + run: pip install -r requirements.txt -q + - name: Migrate + run: python manage.py migrate --noinput + - name: Collect static files + run: python manage.py collectstatic --noinput --verbosity 0 + - name: Seed ZAP scan user + run: python manage.py seed_zap_scan_user + - name: Start server + run: nohup python manage.py runserver 0.0.0.0:8000 > server.log 2>&1 & + - name: Wait for healthy + run: | + set -e + HEALTHY=0 + for i in $(seq 1 60); do + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:8000/health/ 2>/dev/null || echo "000") + if [ "$STATUS" = "200" ]; then + echo "Healthy after ~$((i * 2))s" + HEALTHY=1 + break + fi + sleep 2 + done + if [ "$HEALTHY" != "1" ]; then + echo "::error::Server never became healthy within 120s" + cat server.log || true + exit 1 + fi + - name: OWASP ZAP Authenticated Full Scan + uses: zaproxy/action-full-scan@v1 + with: + target: "http://localhost:8000" + allow_issue_writing: false + fail_action: true + cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context.xml -U zap-ci-scan-only" + - name: Server logs + if: always() + run: cat server.log || true + # ── Coverage combine (gate: all test jobs must pass) ────────────────────── coverage: @@ -253,7 +310,7 @@ jobs: pr-gates-pass: name: "All Gates Passed" - needs: [pr-title, lint, secrets, django-checks, tests-unit, tests-integration, tests-e2e, typecheck, acceptance-check, finance-math, coverage] + needs: [pr-title, lint, secrets, django-checks, tests-unit, tests-integration, tests-e2e, typecheck, acceptance-check, finance-math, coverage, zap-authenticated-scan] runs-on: ubuntu-latest if: always() steps: @@ -271,6 +328,7 @@ jobs: if [ "${{ needs.acceptance-check.result }}" != "success" ]; then FAILED="$FAILED acceptance"; fi if [ "${{ needs.finance-math.result }}" != "success" ]; then FAILED="$FAILED finance-math"; fi if [ "${{ needs.coverage.result }}" != "success" ]; then FAILED="$FAILED coverage"; fi + if [ "${{ needs.zap-authenticated-scan.result }}" != "success" ]; then FAILED="$FAILED zap-authenticated-scan"; fi if [ -n "$FAILED" ]; then echo "❌ Failed gates:${FAILED}" exit 1 diff --git a/.zap/prei-auth-context.xml b/.zap/prei-auth-context.xml new file mode 100644 index 00000000..ea36cafb --- /dev/null +++ b/.zap/prei-auth-context.xml @@ -0,0 +1,64 @@ + + + + + prei-ci + 1 + prei ephemeral CI instance - authenticated scan + true + http://localhost:8000.* + http://localhost:8000/accounts/logout.* + + org.zaproxy.zap.model.StandardParameterParser + &= + + + org.zaproxy.zap.model.StandardParameterParser + &= + + + 2 + EACH_RESP + + + + 60 + REQUESTS +
+ http://localhost:8000/accounts/login/ + http://localhost:8000/accounts/login/ + csrfmiddlewaretoken=&username={%username%}&password={%password%} +
+
+ + 0 + + + 0 + + + 1;true;zap-ci-scan-only;1;dXNlcm5hbWU9emFwLWNpLXNjYW4tb25seSZwYXNzd29yZD1aYXBDaU9ubHlQdzIwMjY= + + 1 + + RESPONSE + \Q/accounts/logout/\E + \Qname="password"\E + 60 + REQUESTS + +
+
diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 98f2c033..93f60c4c 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -212,6 +212,18 @@ This means a user who runs the API pre-`populate_growth_areas` gets empty result --- +### [LIMIT-22] 🟡 HIGH — Authenticated OWASP ZAP scan runs against an ephemeral CI instance, not the live deployment + +**Location:** `.github/workflows/ci-quality.yml` — `zap-authenticated-scan` job; `.zap/prei-auth-context.xml`; `core/management/commands/seed_zap_scan_user.py` + +**Impact:** `docs/TOP_01_PLAN.md` Phase C (C-2) calls for the full OWASP ZAP scan to be made auth-aware so it can reach pages behind `/accounts/login/`. Authenticating against the *real* deployed environment (the target of `post-deployment.yml`'s existing unauthenticated scan) would require provisioning a scan-only account and credentials on that live environment — this repo has no deploy-pipeline or production-DB access to do that safely, the same infra gap documented for C-1 (canary) and C-3 (SLO dashboard) in `docs/TOP_01_PLAN.md`'s "What You Can't Ship Yet" section. + +**Workaround:** The authenticated scan instead runs pre-merge, inside a new PR-gate job (`zap-authenticated-scan`) against a fresh, ephemeral instance: a newly migrated SQLite DB seeded with a throwaway, low-privilege account via `manage.py seed_zap_scan_user`, torn down at job end. This is strictly a "shift security left" improvement (every PR, not just post-deploy) and involves no production credentials. `post-deployment.yml`'s existing unauthenticated full scan against the live artifact is unchanged, so defense in depth against the real deployment is preserved. + +**Fix tracked in:** Revisit once C-1/C-3 infrastructure (progressive delivery + monitoring stack) exists and a safe way to provision/rotate a live-environment scan account is designed. Not yet filed as a GitHub issue. + +--- + ## Resolved Limitations ### [LIMIT-R01] Docker container permissions — `app` user could not write `db.sqlite3` From 60197c85fdcced75ba52e0f49b2c0e5efcc86bf0 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 10:38:32 +0100 Subject: [PATCH 03/13] feat(test): add flaky test detection, quarantine, and ledger Adds pytest-reportlog + --report-log to pytest.ini so reruns are visible as distinct report-log entries; .github/scripts/flaky_report.py detects rerun-then-pass nodeids and maintains docs/quality/flaky_tests.json (cumulative counts) plus tests/.flaky_quarantine.txt (nodeids at or above the quarantine threshold). A new conftest.py pytest_collection_modifyitems hook marks quarantined nodeids xfail(strict=False) so a known-flaky test can't block a build while it's being fixed. Wiring into CI jobs (report on every PR, write only from the main-branch job) follows in the next commit. Phase C (C-4) of docs/TOP_01_PLAN.md. Co-Authored-By: Claude Sonnet 5 --- .github/scripts/flaky_report.py | 140 ++++++++++++++++++++++++++++++++ .gitignore | 1 + conftest.py | 27 ++++++ docs/quality/flaky_tests.json | 1 + pytest.ini | 2 +- requirements.txt | 1 + tests/.flaky_quarantine.txt | 0 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/flaky_report.py create mode 100644 docs/quality/flaky_tests.json create mode 100644 tests/.flaky_quarantine.txt diff --git a/.github/scripts/flaky_report.py b/.github/scripts/flaky_report.py new file mode 100644 index 00000000..200f43c9 --- /dev/null +++ b/.github/scripts/flaky_report.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Detect flaky tests from a pytest --report-log JSONL file. + +A test is "flaky this run" when pytest-rerunfailures had to rerun it +(outcome == "rerun" on an earlier "call" report) and the final "call" +report for the same nodeid passed. Tests that still fail after retries +are real failures, not flaky ones, and are left alone. + +Modes: + --mode report Print a markdown summary (and append to + $GITHUB_STEP_SUMMARY if set). Does not touch the ledger. + --mode write Same summary, plus updates docs/quality/flaky_tests.json + and tests/.flaky_quarantine.txt. Intended to run only + from the single main-branch writer job. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +DEFAULT_LEDGER = Path("docs/quality/flaky_tests.json") +DEFAULT_QUARANTINE = Path("tests/.flaky_quarantine.txt") +DEFAULT_THRESHOLD = 3 + + +def find_flaky_nodeids(report_log_path: Path) -> list[str]: + """Return nodeids whose call reports show rerun-then-pass this run.""" + call_outcomes: dict[str, list[str]] = {} + + if not report_log_path.exists(): + return [] + + with report_log_path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + record = json.loads(line) + if record.get("$report_type") != "TestReport": + continue + if record.get("when") != "call": + continue + nodeid = record.get("nodeid") + outcome = record.get("outcome") + if nodeid is None or outcome is None: + continue + call_outcomes.setdefault(nodeid, []).append(outcome) + + flaky = [] + for nodeid, outcomes in call_outcomes.items(): + if "rerun" in outcomes and outcomes[-1] == "passed": + flaky.append(nodeid) + return sorted(flaky) + + +def load_ledger(ledger_path: Path) -> dict: + if not ledger_path.exists(): + return {} + with ledger_path.open() as f: + data: dict = json.load(f) + return data + + +def update_ledger(ledger: dict, flaky_nodeids: list[str], threshold: int) -> dict: + now = datetime.now(timezone.utc).isoformat() + for nodeid in flaky_nodeids: + entry = ledger.get(nodeid, {"count": 0, "first_seen": now}) + entry["count"] = entry.get("count", 0) + 1 + entry.setdefault("first_seen", now) + entry["last_seen"] = now + entry["quarantined"] = entry["count"] >= threshold + ledger[nodeid] = entry + return dict(sorted(ledger.items())) + + +def write_quarantine(ledger: dict, quarantine_path: Path) -> None: + quarantined = sorted( + nodeid for nodeid, entry in ledger.items() if entry.get("quarantined") + ) + quarantine_path.parent.mkdir(parents=True, exist_ok=True) + with quarantine_path.open("w") as f: + for nodeid in quarantined: + f.write(f"{nodeid}\n") + + +def render_summary(flaky_nodeids: list[str], ledger: dict) -> str: + if not flaky_nodeids: + return "No flaky tests detected this run.\n" + + lines = [ + "| Test | Cumulative count | Quarantined |", + "| --- | --- | --- |", + ] + for nodeid in flaky_nodeids: + entry = ledger.get(nodeid, {}) + count = entry.get("count", "1 (this run)") + quarantined = "yes" if entry.get("quarantined") else "no" + lines.append(f"| `{nodeid}` | {count} | {quarantined} |") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report-log", type=Path, required=True) + parser.add_argument("--mode", choices=["report", "write"], required=True) + parser.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER) + parser.add_argument("--quarantine", type=Path, default=DEFAULT_QUARANTINE) + parser.add_argument("--threshold", type=int, default=DEFAULT_THRESHOLD) + args = parser.parse_args() + + flaky_nodeids = find_flaky_nodeids(args.report_log) + ledger = load_ledger(args.ledger) + + if args.mode == "write": + ledger = update_ledger(ledger, flaky_nodeids, args.threshold) + args.ledger.parent.mkdir(parents=True, exist_ok=True) + with args.ledger.open("w") as f: + json.dump(ledger, f, indent=2, sort_keys=True) + f.write("\n") + write_quarantine(ledger, args.quarantine) + + summary = render_summary(flaky_nodeids, ledger) + sys.stdout.write(summary) + + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary: + with open(step_summary, "a") as f: + f.write("\n## Flaky test report\n\n") + f.write(summary) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitignore b/.gitignore index 2457272e..f29c69ac 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,5 @@ test-report.md verification-output.md verification-report.md .coverage +.pytest-report.jsonl drafts/ diff --git a/conftest.py b/conftest.py index ce172ac7..e273d021 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ import os from decimal import Decimal +from pathlib import Path import pytest from django.contrib.auth import get_user_model @@ -11,11 +12,37 @@ User = get_user_model() +QUARANTINE_FILE = Path(__file__).parent / "tests" / ".flaky_quarantine.txt" + def pytest_configure(config) -> None: # noqa: ARG001 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "investor_app.settings_test") +def pytest_collection_modifyitems(config, items) -> None: # noqa: ARG001 + """Quarantine tests flagged as flaky (see docs/quality/flaky_tests.json). + + Quarantined tests keep running and reporting but never fail the build, + so a known-flaky test can't block a PR while it's being fixed. + """ + if not QUARANTINE_FILE.exists(): + return + quarantined = { + line.strip() + for line in QUARANTINE_FILE.read_text().splitlines() + if line.strip() + } + if not quarantined: + return + marker = pytest.mark.xfail( + reason="quarantined: flaky, see docs/quality/flaky_tests.json", + strict=False, + ) + for item in items: + if item.nodeid in quarantined: + item.add_marker(marker) + + @pytest.fixture def user(db): return User.objects.create_user( diff --git a/docs/quality/flaky_tests.json b/docs/quality/flaky_tests.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/docs/quality/flaky_tests.json @@ -0,0 +1 @@ +{} diff --git a/pytest.ini b/pytest.ini index 656e3793..a0d0a34f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ DJANGO_SETTINGS_MODULE = investor_app.settings_test python_files = tests.py test_*.py *_tests.py testpaths = tests core/tests tests_bdd -addopts = --reruns 1 --reruns-delay 5 -k "not e2e and not docker and not integration and not container and not startup and not add_to_pipeline and not acceptance and not export and not unreachable_url" +addopts = --reruns 1 --reruns-delay 5 --report-log=.pytest-report.jsonl -k "not e2e and not docker and not integration and not container and not startup and not add_to_pipeline and not acceptance and not export and not unreachable_url" markers = e2e: End-to-end tests that simulate full pipeline flow (skipped in CI) integration: Tests that hit live external APIs (skipped if API keys not set) diff --git a/requirements.txt b/requirements.txt index f01f3ac9..dd1ca5f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ pytest-bdd==8.1.0 coverage==7.15.1 pytest-asyncio==1.4.0 pytest-rerunfailures==16.1 # flaky test retry (Phase C) +pytest-reportlog==1.0.0 # flaky test detection report log (Phase C) ruff==0.15.22 # Must match .pre-commit-config.yaml rev: v0.15.20 mypy==2.2.0 django-stubs==6.0.7 diff --git a/tests/.flaky_quarantine.txt b/tests/.flaky_quarantine.txt new file mode 100644 index 00000000..e69de29b From edd1a7f4dd17469212336ca2f8da0775ebf054c6 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 10:40:56 +0100 Subject: [PATCH 04/13] feat(ci): wire flaky test reporting into PR and main-branch jobs ci-quality.yml: tests-unit/integration/e2e each run flaky_report.py --mode report after pytest (using the report-log pytest.ini already produces) and upload it as an artifact for inspection. Report mode never touches the ledger, so PRs can't race each other or fork PRs need write access. docker-publish.yml: the live-test job (push-to-main only) extracts the report log from the running container, runs flaky_report.py --mode write to update docs/quality/flaky_tests.json and tests/.flaky_quarantine.txt, and bot-commits any change back to main as github-actions[bot] with [skip ci]. This is the single writer for the shared ledger. Needs contents: write, added to the job's permissions. Phase C (C-4) of docs/TOP_01_PLAN.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci-quality.yml | 30 ++++++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 25 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 3e3195f2..68a16efb 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -104,6 +104,9 @@ jobs: coverage run --parallel-mode -m pytest tests/ core/tests/ tests_bdd/ prei/pipeline/tests/ \ -q --tb=short \ -k "not e2e and not docker and not integration and not container and not startup and not add_to_pipeline and not acceptance and not export and not unreachable_url" + - name: Flaky test report + if: always() + run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report - name: Upload coverage data uses: actions/upload-artifact@v7 with: @@ -111,6 +114,13 @@ jobs: path: .coverage.* include-hidden-files: true retention-days: 1 + - name: Upload test report log + if: always() + uses: actions/upload-artifact@v7 + with: + name: report-log-unit + path: .pytest-report.jsonl + retention-days: 1 tests-integration: name: "🔗 Integration Tests" @@ -132,6 +142,9 @@ jobs: coverage run --parallel-mode -m pytest tests/ core/tests/ \ -q --tb=short \ -k "integration" + - name: Flaky test report + if: always() + run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report - name: Upload coverage data uses: actions/upload-artifact@v7 with: @@ -139,6 +152,13 @@ jobs: path: .coverage.* include-hidden-files: true retention-days: 1 + - name: Upload test report log + if: always() + uses: actions/upload-artifact@v7 + with: + name: report-log-integration + path: .pytest-report.jsonl + retention-days: 1 tests-e2e: name: "🌐 E2E Tests" @@ -160,6 +180,9 @@ jobs: coverage run --parallel-mode -m pytest tests/ \ -q --tb=short \ -k "e2e or docker or container or startup or add_to_pipeline or export" + - name: Flaky test report + if: always() + run: python .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode report - name: Upload coverage data uses: actions/upload-artifact@v7 with: @@ -167,6 +190,13 @@ jobs: path: .coverage.* include-hidden-files: true retention-days: 1 + - name: Upload test report log + if: always() + uses: actions/upload-artifact@v7 + with: + name: report-log-e2e + path: .pytest-report.jsonl + retention-days: 1 typecheck: name: "🔷 Typecheck" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5d089a87..24fd9511 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -105,7 +105,7 @@ jobs: needs: build-image runs-on: ubuntu-latest permissions: - contents: read + contents: write packages: read steps: - name: "job-start" @@ -216,6 +216,29 @@ jobs: prei-live-test \ python -m pytest tests_bdd/ -q --tb=short 2>&1 echo "=== All BDD acceptance tests passed ===" + - name: Extract test report log + if: always() + run: docker cp prei-live-test:/app/.pytest-report.jsonl .pytest-report.jsonl 2>/dev/null || true + - name: Update flaky test ledger + if: always() + run: | + if [ -f .pytest-report.jsonl ]; then + python3 .github/scripts/flaky_report.py --report-log .pytest-report.jsonl --mode write + else + echo "No report log found, skipping flaky ledger update" + fi + - name: Commit flaky test ledger + if: always() + run: | + if git diff --quiet -- docs/quality/flaky_tests.json tests/.flaky_quarantine.txt; then + echo "No ledger changes" + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/quality/flaky_tests.json tests/.flaky_quarantine.txt + git commit -m "chore(ci): update flaky test ledger [skip ci]" + git push origin HEAD:main + fi - name: "job-finish" if: always() run: | From 558859872c5beeb4511356ae9430f8eee5c2b70e Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 10:44:28 +0100 Subject: [PATCH 05/13] docs(feature-flow): archive Phase A, regenerate root spec for Phase C Phase A (docs/TOP_01_PLAN.md) merged via PR #323 but its spec/design/ tasks were never archived per features/README.md's convention; move them to features/top01-phase-a/ and mark MERGED. Regenerate root specification.md/design.md/tasks.json for this PR's scope: Phase C, C-2 (authenticated ZAP scan) + C-4 (flaky test detection/quarantine) only. C-1 and C-3 remain explicitly deferred. Co-Authored-By: Claude Sonnet 5 --- design.md | 129 +++++++++++++++--------- features/top01-phase-a/design.md | 46 +++++++++ features/top01-phase-a/specification.md | 40 ++++++++ features/top01-phase-a/tasks.json | 61 +++++++++++ specification.md | 71 ++++++++----- tasks.json | 73 ++++++++------ 6 files changed, 317 insertions(+), 103 deletions(-) create mode 100644 features/top01-phase-a/design.md create mode 100644 features/top01-phase-a/specification.md create mode 100644 features/top01-phase-a/tasks.json diff --git a/design.md b/design.md index ac4f821d..f3b70a6d 100644 --- a/design.md +++ b/design.md @@ -1,46 +1,83 @@ -# Design: Phase A — CI/Test Quality Gaps - -### A-2: BDD pipeline suite over real HTTP -`tests_bdd/steps/pipeline_acceptance_steps.py` swaps `django.test.Client` for -an `httpx.Client` bound to pytest-django's `live_server.url`. `Given` steps -keep building fixtures via the ORM; `tests_bdd/conftest.py`'s `_reset_ctx` -fixture depends on `transactional_db` (not `db`) so rows committed by the test -process are visible to the live server's background-thread request handling. -POST steps fetch a CSRF token from the target form page first (`_csrf_token()` -helper) since httpx doesn't auto-handle Django CSRF the way the test client -does. - -### A-3: Acceptance suite runs pre-merge -`tests/acceptance/conftest.py`'s `base_url` fixture falls back to a -session-scoped `live_server` when `BASE_URL` is unset, lazily requested via -`request.getfixturevalue(...)` so `BASE_URL`-driven runs never touch Django's -DB fixtures. A separate autouse `_enable_db_for_live_server` fixture calls -`request.getfixturevalue("db")` per test function, since pytest-django blocks -DB access per-test regardless of a session-scoped fixture's own DB setup. -`ci-quality.yml`'s `acceptance-check` job drops `--collect-only` and runs the -suite for real, with `BASE_URL` intentionally unset. - -### A-4: Real build-time budget -`build-image`'s `job-start` step persists its epoch to `$GITHUB_ENV`. The -"Check build time" step computes the elapsed duration against that epoch and -fails (`::error::` + `exit 1`) past 600s. `timeout-minutes: 10` on the job -itself is a hard backstop independent of the soft check. - -### A-5: Response-shape validation -`schemas.py` gained two generic models — `LoginGateAssertion` -(`Literal[200, 302]`, for pages that redirect anonymous users to login) and -`NoCrashAssertion` (`status_code < 500`, for pages that must not error -regardless of auth state) — reused across the status-only files -(`test_brrrr.py`, `test_dashboard.py`, `test_pipeline.py`, `test_leasing.py`, -parts of `test_growth.py`/`test_property_pipeline.py`). Files with existing -purpose-built models (`LoginPageAssertion`, `DiscoveryPageAssertion`, -`StaticAssetAssertion` in `test_pages.py`; `GrowthAreasResponse` in -`test_growth.py`) now actually import and validate against them instead of -duplicating loose dict/status assertions. - -### Bugs surfaced by A-3 (fixed, not scope creep — this is what the new gate is for) -- `pipeline_list` view was missing `@login_required`, unlike sibling - `leasing_list`, causing a 500 instead of a redirect for anonymous access. -- `tests/acceptance/test_leasing.py` hardcoded a stale route (`/leasing/list/` - instead of `/leasing/`) that had never actually executed under - `--collect-only`. +# Design: Phase C (partial) — Deployment Reliability + +### C-2: Authenticated ZAP scan runs against an ephemeral CI instance + +Authenticating against the real deployed environment (what +`post-deployment.yml` targets) would require provisioning a scan account and +credentials on that live environment — the same category of infra gap as +C-1/C-3. Instead, a new `zap-authenticated-scan` job in `ci-quality.yml` runs +the authenticated scan against an ephemeral instance spun up inside the job +itself: fresh migrated SQLite DB, `runserver` bound to `0.0.0.0:8000`, scan +account seeded via a new idempotent management command +(`core/management/commands/seed_zap_scan_user.py`, mirroring the +`get_or_create`/`update_or_create` pattern in `seed_markets.py`). Credentials +(`ZAP_AUTH_USERNAME`/`ZAP_AUTH_PASSWORD`) are fixed CI-only literals defined +inline in the workflow — not a GitHub secret, since the DB is a throwaway +SQLite file destroyed at job end. + +`.zap/prei-auth-context.xml` defines form-based auth against +`/accounts/login/`, with logged-in/out indicator regexes (presence/absence of +`/accounts/logout/` and the password field) and a `` entry. The +credential is embedded as a base64 `username=...&password=...` blob directly +in the committed XML, rather than injected via a `-P username=... -P +password=...` CLI flag as originally sketched — `zap-full-scan.py` doesn't +expose a documented flag for that. This is acceptable because the credential +is non-secret: CI-only, throwaway-DB-scoped, with no access to anything real. + +The job runs `zap-full-scan.py -a -n /zap/wrk/.zap/prei-auth-context.xml -U +zap-ci-scan-only` and is a required check in `pr-gates-pass`, so it runs +pre-merge on every PR — a stronger "shift security left" posture than the +existing unauthenticated scan, which only runs post-deployment. +`post-deployment.yml`'s scan is left unchanged (defense in depth: one +authenticated pre-merge scan, one unauthenticated scan of the real artifact). + +Scope decision recorded in `docs/KNOWN_LIMITATIONS.md` LIMIT-22. + +### C-4: Flaky test detection, ledger, and quarantine + +`pytest.ini`'s `addopts` gains `--report-log=.pytest-report.jsonl`, provided +by the `pytest-reportlog` plugin (not built into pytest core — this was a +wrong assumption in the original plan, corrected during implementation once +`--report-log` failed with "unrecognized arguments"). With +`pytest-rerunfailures` already wired (`--reruns 1 --reruns-delay 5`), a +rerun-then-pass test shows up in the report log as two `"call"`-phase +reports for the same nodeid: an intermediate `"rerun"` outcome followed by a +final `"passed"` outcome. + +`.github/scripts/flaky_report.py` parses that JSONL file and detects exactly +that signature (`find_flaky_nodeids`). Two modes: + +- `--mode report`: prints a markdown summary (also appended to + `$GITHUB_STEP_SUMMARY` when set) without touching the ledger. Runs in + `ci-quality.yml`'s `tests-unit`/`tests-integration`/`tests-e2e` jobs on + every PR — visibility without any write contention between concurrent PR + runs or fork-PR permission issues. +- `--mode write`: same summary, plus updates `docs/quality/flaky_tests.json` + (nodeid → `count`, `first_seen`, `last_seen`, `quarantined`) and, once a + nodeid's cumulative count reaches the threshold (default 3), adds it to + `tests/.flaky_quarantine.txt`. Runs only from `docker-publish.yml`'s + `live-test` job (push-to-`main` only) — the single writer for the shared + ledger, avoiding merge conflicts between concurrent PRs. + +Because `live-test`'s BDD suite runs inside a Docker container (`docker exec +... pytest tests_bdd/`), the report log is written to `/app/.pytest-report.jsonl` +inside the container (picked up automatically from `pytest.ini`'s `addopts`, +since the image includes it) and copied out via `docker cp` before the +container is torn down. If the ledger or quarantine file changed, the job +bot-commits as `github-actions[bot]` with `chore(ci): update flaky test +ledger [skip ci]` and pushes directly to `main` (the job's `permissions` gains +`contents: write` for this). + +A new root `conftest.py` hook, `pytest_collection_modifyitems`, reads +`tests/.flaky_quarantine.txt` (if present) and marks matching nodeids +`pytest.mark.xfail(strict=False)` at collection time — quarantined tests keep +running and reporting on every run, but a known-flaky test can never fail the +build while it's being fixed. Verified end-to-end with a throwaway +deliberately-failing test converting to `1 xfailed`. + +### Residual gap + +The `docker-publish.yml` bot-commit step only runs on push-to-`main`, so it +can't be exercised from a PR branch. This is flagged in the PR description as +something to watch on the first post-merge run, not claimed as pre-merge +verified. diff --git a/features/top01-phase-a/design.md b/features/top01-phase-a/design.md new file mode 100644 index 00000000..ac4f821d --- /dev/null +++ b/features/top01-phase-a/design.md @@ -0,0 +1,46 @@ +# Design: Phase A — CI/Test Quality Gaps + +### A-2: BDD pipeline suite over real HTTP +`tests_bdd/steps/pipeline_acceptance_steps.py` swaps `django.test.Client` for +an `httpx.Client` bound to pytest-django's `live_server.url`. `Given` steps +keep building fixtures via the ORM; `tests_bdd/conftest.py`'s `_reset_ctx` +fixture depends on `transactional_db` (not `db`) so rows committed by the test +process are visible to the live server's background-thread request handling. +POST steps fetch a CSRF token from the target form page first (`_csrf_token()` +helper) since httpx doesn't auto-handle Django CSRF the way the test client +does. + +### A-3: Acceptance suite runs pre-merge +`tests/acceptance/conftest.py`'s `base_url` fixture falls back to a +session-scoped `live_server` when `BASE_URL` is unset, lazily requested via +`request.getfixturevalue(...)` so `BASE_URL`-driven runs never touch Django's +DB fixtures. A separate autouse `_enable_db_for_live_server` fixture calls +`request.getfixturevalue("db")` per test function, since pytest-django blocks +DB access per-test regardless of a session-scoped fixture's own DB setup. +`ci-quality.yml`'s `acceptance-check` job drops `--collect-only` and runs the +suite for real, with `BASE_URL` intentionally unset. + +### A-4: Real build-time budget +`build-image`'s `job-start` step persists its epoch to `$GITHUB_ENV`. The +"Check build time" step computes the elapsed duration against that epoch and +fails (`::error::` + `exit 1`) past 600s. `timeout-minutes: 10` on the job +itself is a hard backstop independent of the soft check. + +### A-5: Response-shape validation +`schemas.py` gained two generic models — `LoginGateAssertion` +(`Literal[200, 302]`, for pages that redirect anonymous users to login) and +`NoCrashAssertion` (`status_code < 500`, for pages that must not error +regardless of auth state) — reused across the status-only files +(`test_brrrr.py`, `test_dashboard.py`, `test_pipeline.py`, `test_leasing.py`, +parts of `test_growth.py`/`test_property_pipeline.py`). Files with existing +purpose-built models (`LoginPageAssertion`, `DiscoveryPageAssertion`, +`StaticAssetAssertion` in `test_pages.py`; `GrowthAreasResponse` in +`test_growth.py`) now actually import and validate against them instead of +duplicating loose dict/status assertions. + +### Bugs surfaced by A-3 (fixed, not scope creep — this is what the new gate is for) +- `pipeline_list` view was missing `@login_required`, unlike sibling + `leasing_list`, causing a 500 instead of a redirect for anonymous access. +- `tests/acceptance/test_leasing.py` hardcoded a stale route (`/leasing/list/` + instead of `/leasing/`) that had never actually executed under + `--collect-only`. diff --git a/features/top01-phase-a/specification.md b/features/top01-phase-a/specification.md new file mode 100644 index 00000000..763137c9 --- /dev/null +++ b/features/top01-phase-a/specification.md @@ -0,0 +1,40 @@ +# Specification: Phase A — CI/Test Quality Gaps (docs/TOP_01_PLAN.md) +# Written: 2026-07-27 +# Status: MERGED (PR #323) + +--- + +## 0. Problem + +`docs/TOP_01_PLAN.md` Phase A identifies 5 gaps between this repo's CI pipeline +and a genuinely trustworthy one: acceptance/BDD suites that don't exercise real +HTTP, a PR-gate acceptance job that only `--collect-only`s instead of running, +an unbounded Docker build step, and acceptance tests that only check status +codes instead of response shape. + +## 1. Requirements + +- A-1: `main-ci-guard.yml` blocks PR merges on Tier-2 (post-merge) failure. +- A-2: `tests_bdd/`'s pipeline acceptance suite drives real HTTP requests + (via pytest-django's `live_server`) instead of `django.test.Client`. +- A-3: `tests/acceptance/*.py` actually executes in the PR-gate tier + (`ci-quality.yml`), not just `--collect-only`, via a `live_server` fallback + when `BASE_URL` is unset. +- A-4: `docker-publish.yml`'s `build-image` job enforces a real 10-minute + build-time budget (soft check + hard `timeout-minutes` backstop). +- A-5: All `tests/acceptance/*.py` files validate response shape via + `schemas.py` Pydantic models, not just raw status codes. + +## 2. Acceptance Criteria + +| ID | Criterion | test_type | +|---|---|---| +| AC-A1-01 | `main-ci-guard.yml` fails the PR check when Tier 2 fails | ci | +| AC-A2-01 | `pytest tests_bdd/` passes using `live_server` + `httpx.Client` | unit | +| AC-A2-02 | POST-based BDD steps include a real CSRF token | unit | +| AC-A3-01 | `pytest tests/acceptance/` passes with no `BASE_URL` set (live_server fallback) | unit | +| AC-A3-02 | `ci-quality.yml`'s `acceptance-check` job runs tests for real, not `--collect-only` | ci | +| AC-A3-03 | `BASE_URL`-driven runs (`make test-acceptance`, `post-deployment.yml`) are unaffected | unit | +| AC-A4-01 | `build-image` job has `timeout-minutes: 10` | ci | +| AC-A4-02 | "Check build time" step fails the job if duration exceeds 600s | ci | +| AC-A5-01 | All 9 files in `tests/acceptance/` import and use `schemas.py` models | unit | diff --git a/features/top01-phase-a/tasks.json b/features/top01-phase-a/tasks.json new file mode 100644 index 00000000..3d8ac373 --- /dev/null +++ b/features/top01-phase-a/tasks.json @@ -0,0 +1,61 @@ +{ + "meta": { + "project": "prei", + "session": "top01-phase-a-20260727", + "date": "2026-07-27", + "feature": "Phase A — CI/Test Quality Gaps (docs/TOP_01_PLAN.md)", + "spec": "specification.md", + "design": "design.md" + }, + "tasks": [ + { + "id": "A-1", + "summary": "main-ci-guard blocks PR merge on Tier-2 failure", + "description": "Already implemented prior to this session; verified in .github/workflows/main-ci-guard.yml. No action taken.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-A1-01", "description": "main-ci-guard.yml fails the PR check when Tier 2 fails", "test_type": "ci"} + ] + }, + { + "id": "A-2", + "summary": "BDD pipeline suite drives real HTTP via live_server", + "description": "Rewrite tests_bdd/steps/pipeline_acceptance_steps.py to use an httpx.Client bound to pytest-django's live_server fixture instead of django.test.Client; switch tests_bdd/conftest.py's _reset_ctx to depend on transactional_db.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-A2-01", "description": "pytest tests_bdd/ passes using live_server + httpx.Client", "test_type": "unit"}, + {"id": "AC-A2-02", "description": "POST-based BDD steps include a real CSRF token", "test_type": "unit"} + ] + }, + { + "id": "A-3", + "summary": "Acceptance suite executes for real in the PR-gate tier", + "description": "Extend tests/acceptance/conftest.py's base_url fixture to fall back to a live_server when BASE_URL is unset; add an autouse fixture to unblock DB access per test; update ci-quality.yml's acceptance-check job to run pytest for real instead of --collect-only.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-A3-01", "description": "pytest tests/acceptance/ passes with no BASE_URL set (live_server fallback)", "test_type": "unit"}, + {"id": "AC-A3-02", "description": "ci-quality.yml's acceptance-check job runs tests for real, not --collect-only", "test_type": "ci"}, + {"id": "AC-A3-03", "description": "BASE_URL-driven runs (make test-acceptance, post-deployment.yml) are unaffected", "test_type": "unit"} + ] + }, + { + "id": "A-4", + "summary": "Real build-time budget on docker-publish.yml build-image job", + "description": "Persist job-start's epoch to $GITHUB_ENV; replace the no-op 'Check build time' stub with a real duration check that fails past 600s; add timeout-minutes: 10 to the job as a hard backstop.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-A4-01", "description": "build-image job has timeout-minutes: 10", "test_type": "ci"}, + {"id": "AC-A4-02", "description": "Check build time step fails the job if duration exceeds 600s", "test_type": "ci"} + ] + }, + { + "id": "A-5", + "summary": "Wire remaining acceptance test files to schemas.py", + "description": "Add LoginGateAssertion and NoCrashAssertion generic models to schemas.py; wire all 8 previously-unwired tests/acceptance/*.py files to use schemas.py models (existing purpose-built models where available, the new generic ones for status-only checks).", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-A5-01", "description": "All 9 files in tests/acceptance/ import and use schemas.py models", "test_type": "unit"} + ] + } + ] +} diff --git a/specification.md b/specification.md index e6fcde94..2e2894c3 100644 --- a/specification.md +++ b/specification.md @@ -1,39 +1,58 @@ -# Specification: Phase A — CI/Test Quality Gaps (docs/TOP_01_PLAN.md) -# Written: 2026-07-27 +# Specification: Phase C (partial) — Deployment Reliability (docs/TOP_01_PLAN.md) +# Written: 2026-07-28 +# Status: IN PROGRESS (feat/top01-phase-c) --- ## 0. Problem -`docs/TOP_01_PLAN.md` Phase A identifies 5 gaps between this repo's CI pipeline -and a genuinely trustworthy one: acceptance/BDD suites that don't exercise real -HTTP, a PR-gate acceptance job that only `--collect-only`s instead of running, -an unbounded Docker build step, and acceptance tests that only check status -codes instead of response shape. +`docs/TOP_01_PLAN.md` Phase C ("Deployment Reliability") lists four gaps: +canary deployment (C-1), an authenticated OWASP ZAP full scan (C-2), an SLO +dashboard (C-3), and flaky-test quarantine + a flaky-test dashboard (C-4). + +C-1 and C-3 both need infrastructure this repo doesn't have — a load +balancer/traffic-splitting layer for canary, and a monitoring/metrics stack +for SLOs — which `docs/TOP_01_PLAN.md`'s own "What You Can't Ship Yet" +section already flags. They are deferred to a future phase, not silently +dropped; see `docs/TOP_01_PLAN.md` and `docs/KNOWN_LIMITATIONS.md` LIMIT-22 +for the scope decision. **This PR implements C-2 and C-4 only.** + +Prior state: +- `post-deployment.yml`'s `security` job already runs an unauthenticated full + ZAP scan (`zaproxy/action-full-scan@v1`, `cmd_options: "-a"`) against the + live deployed URL. ZAP never sees anything behind `/accounts/login/`. +- `pytest-rerunfailures` was already wired via `pytest.ini`'s + `--reruns 1 --reruns-delay 5` — a rerun happens, but nothing records which + tests needed it, and nothing quarantines a test that fails repeatedly. ## 1. Requirements -- A-1: `main-ci-guard.yml` blocks PR merges on Tier-2 (post-merge) failure. -- A-2: `tests_bdd/`'s pipeline acceptance suite drives real HTTP requests - (via pytest-django's `live_server`) instead of `django.test.Client`. -- A-3: `tests/acceptance/*.py` actually executes in the PR-gate tier - (`ci-quality.yml`), not just `--collect-only`, via a `live_server` fallback - when `BASE_URL` is unset. -- A-4: `docker-publish.yml`'s `build-image` job enforces a real 10-minute - build-time budget (soft check + hard `timeout-minutes` backstop). -- A-5: All `tests/acceptance/*.py` files validate response shape via - `schemas.py` Pydantic models, not just raw status codes. +- C-2: An OWASP ZAP full scan authenticates before spidering/scanning, so it + can reach pages behind Django's login-required views. +- C-4: A test that needed a rerun to pass is recorded; once a given test's + cumulative flaky count crosses a threshold, it's quarantined (kept running + and reporting, but never fails the build) until fixed. ## 2. Acceptance Criteria | ID | Criterion | test_type | |---|---|---| -| AC-A1-01 | `main-ci-guard.yml` fails the PR check when Tier 2 fails | ci | -| AC-A2-01 | `pytest tests_bdd/` passes using `live_server` + `httpx.Client` | unit | -| AC-A2-02 | POST-based BDD steps include a real CSRF token | unit | -| AC-A3-01 | `pytest tests/acceptance/` passes with no `BASE_URL` set (live_server fallback) | unit | -| AC-A3-02 | `ci-quality.yml`'s `acceptance-check` job runs tests for real, not `--collect-only` | ci | -| AC-A3-03 | `BASE_URL`-driven runs (`make test-acceptance`, `post-deployment.yml`) are unaffected | unit | -| AC-A4-01 | `build-image` job has `timeout-minutes: 10` | ci | -| AC-A4-02 | "Check build time" step fails the job if duration exceeds 600s | ci | -| AC-A5-01 | All 9 files in `tests/acceptance/` import and use `schemas.py` models | unit | +| AC-C2-01 | `core/management/commands/seed_zap_scan_user.py` idempotently creates/updates a non-staff, non-superuser scan account from `ZAP_AUTH_USERNAME`/`ZAP_AUTH_PASSWORD` env vars | unit | +| AC-C2-02 | `.zap/prei-auth-context.xml` defines form-based auth against `/accounts/login/` with logged-in/out indicator regexes | manual | +| AC-C2-03 | `ci-quality.yml`'s `zap-authenticated-scan` job seeds the account, boots an ephemeral `runserver` instance against a fresh migrated SQLite DB, and runs `zap-full-scan.py` with the auth context | ci | +| AC-C2-04 | `zap-authenticated-scan` is a required check in `pr-gates-pass` | ci | +| AC-C2-05 | `docs/KNOWN_LIMITATIONS.md` documents that the authenticated scan targets an ephemeral CI instance, not the live deployment | manual | +| AC-C4-01 | `pytest.ini`'s `addopts` includes `--report-log=.pytest-report.jsonl` | unit | +| AC-C4-02 | `.github/scripts/flaky_report.py --mode report` detects a rerun-then-pass nodeid from a report-log file and prints a markdown summary without touching the ledger | unit | +| AC-C4-03 | `.github/scripts/flaky_report.py --mode write` increments `docs/quality/flaky_tests.json`'s per-nodeid count and marks it quarantined once the count reaches the threshold (default 3), writing matching nodeids to `tests/.flaky_quarantine.txt` | unit | +| AC-C4-04 | Root `conftest.py`'s `pytest_collection_modifyitems` hook marks nodeids listed in `tests/.flaky_quarantine.txt` as `xfail(strict=False)` so they can't fail the build | unit | +| AC-C4-05 | `ci-quality.yml`'s `tests-unit`/`tests-integration`/`tests-e2e` jobs run `flaky_report.py --mode report` and upload the report log as an artifact | ci | +| AC-C4-06 | `docker-publish.yml`'s `live-test` job (push-to-`main` only) runs `flaky_report.py --mode write` and bot-commits any ledger/quarantine change back to `main` | ci | + +## 3. Out of Scope (this PR) + +- C-1 (canary deployment) — deferred, needs traffic-splitting infra. +- C-3 (SLO dashboard) — deferred, needs a monitoring/metrics stack. +- Authenticating the ZAP scan against the real deployed environment — would + need provisioning a scan account and secrets on live infra, the same + category of gap as C-1/C-3. diff --git a/tasks.json b/tasks.json index 3d8ac373..63a1d981 100644 --- a/tasks.json +++ b/tasks.json @@ -1,60 +1,71 @@ { "meta": { "project": "prei", - "session": "top01-phase-a-20260727", - "date": "2026-07-27", - "feature": "Phase A — CI/Test Quality Gaps (docs/TOP_01_PLAN.md)", + "session": "top01-phase-c-20260728", + "date": "2026-07-28", + "feature": "Phase C (partial) — Deployment Reliability (docs/TOP_01_PLAN.md), C-2 + C-4 only", "spec": "specification.md", - "design": "design.md" + "design": "design.md", + "deferred": ["C-1 (canary deployment)", "C-3 (SLO dashboard)"] }, "tasks": [ { - "id": "A-1", - "summary": "main-ci-guard blocks PR merge on Tier-2 failure", - "description": "Already implemented prior to this session; verified in .github/workflows/main-ci-guard.yml. No action taken.", + "id": "C-2a", + "summary": "Idempotent ZAP scan-user seed command", + "description": "Add core/management/commands/seed_zap_scan_user.py: reads ZAP_AUTH_USERNAME/ZAP_AUTH_PASSWORD from env, get_or_create's a non-staff, non-superuser user and sets/updates the password idempotently.", "depends_on": [], "acceptance_criteria": [ - {"id": "AC-A1-01", "description": "main-ci-guard.yml fails the PR check when Tier 2 fails", "test_type": "ci"} + {"id": "AC-C2-01", "description": "seed_zap_scan_user idempotently creates/updates a non-staff, non-superuser scan account from env vars", "test_type": "unit"} ] }, { - "id": "A-2", - "summary": "BDD pipeline suite drives real HTTP via live_server", - "description": "Rewrite tests_bdd/steps/pipeline_acceptance_steps.py to use an httpx.Client bound to pytest-django's live_server fixture instead of django.test.Client; switch tests_bdd/conftest.py's _reset_ctx to depend on transactional_db.", - "depends_on": [], + "id": "C-2b", + "summary": "ZAP auth context file + authenticated scan CI job", + "description": "Add .zap/prei-auth-context.xml (form-based auth against /accounts/login/, logged-in/out indicator regexes). Add zap-authenticated-scan job to ci-quality.yml: migrate, seed the scan user, boot an ephemeral runserver, wait for healthy, run zap-full-scan.py with the auth context. Wire into pr-gates-pass as a required check.", + "depends_on": ["C-2a"], "acceptance_criteria": [ - {"id": "AC-A2-01", "description": "pytest tests_bdd/ passes using live_server + httpx.Client", "test_type": "unit"}, - {"id": "AC-A2-02", "description": "POST-based BDD steps include a real CSRF token", "test_type": "unit"} + {"id": "AC-C2-02", "description": ".zap/prei-auth-context.xml defines form-based auth with logged-in/out indicators", "test_type": "manual"}, + {"id": "AC-C2-03", "description": "zap-authenticated-scan job seeds the account and scans an ephemeral instance", "test_type": "ci"}, + {"id": "AC-C2-04", "description": "zap-authenticated-scan is a required check in pr-gates-pass", "test_type": "ci"} ] }, { - "id": "A-3", - "summary": "Acceptance suite executes for real in the PR-gate tier", - "description": "Extend tests/acceptance/conftest.py's base_url fixture to fall back to a live_server when BASE_URL is unset; add an autouse fixture to unblock DB access per test; update ci-quality.yml's acceptance-check job to run pytest for real instead of --collect-only.", - "depends_on": [], + "id": "C-2c", + "summary": "Document the ephemeral-instance scope decision", + "description": "Add docs/KNOWN_LIMITATIONS.md LIMIT-22: authenticated ZAP scanning runs against an ephemeral CI-seeded instance, not the live deployment, and why.", + "depends_on": ["C-2b"], "acceptance_criteria": [ - {"id": "AC-A3-01", "description": "pytest tests/acceptance/ passes with no BASE_URL set (live_server fallback)", "test_type": "unit"}, - {"id": "AC-A3-02", "description": "ci-quality.yml's acceptance-check job runs tests for real, not --collect-only", "test_type": "ci"}, - {"id": "AC-A3-03", "description": "BASE_URL-driven runs (make test-acceptance, post-deployment.yml) are unaffected", "test_type": "unit"} + {"id": "AC-C2-05", "description": "docs/KNOWN_LIMITATIONS.md documents the ephemeral-CI-instance scope decision", "test_type": "manual"} ] }, { - "id": "A-4", - "summary": "Real build-time budget on docker-publish.yml build-image job", - "description": "Persist job-start's epoch to $GITHUB_ENV; replace the no-op 'Check build time' stub with a real duration check that fails past 600s; add timeout-minutes: 10 to the job as a hard backstop.", + "id": "C-4a", + "summary": "Report-log flag + flaky detection/ledger script", + "description": "Add --report-log=.pytest-report.jsonl to pytest.ini's addopts (requires the pytest-reportlog dependency, added to requirements.txt). Add .github/scripts/flaky_report.py with --mode report (summary only) and --mode write (updates docs/quality/flaky_tests.json and tests/.flaky_quarantine.txt once a nodeid's count reaches the threshold).", "depends_on": [], "acceptance_criteria": [ - {"id": "AC-A4-01", "description": "build-image job has timeout-minutes: 10", "test_type": "ci"}, - {"id": "AC-A4-02", "description": "Check build time step fails the job if duration exceeds 600s", "test_type": "ci"} + {"id": "AC-C4-01", "description": "pytest.ini's addopts includes --report-log=.pytest-report.jsonl", "test_type": "unit"}, + {"id": "AC-C4-02", "description": "flaky_report.py --mode report detects rerun-then-pass nodeids without touching the ledger", "test_type": "unit"}, + {"id": "AC-C4-03", "description": "flaky_report.py --mode write increments the ledger and quarantines at the threshold", "test_type": "unit"} ] }, { - "id": "A-5", - "summary": "Wire remaining acceptance test files to schemas.py", - "description": "Add LoginGateAssertion and NoCrashAssertion generic models to schemas.py; wire all 8 previously-unwired tests/acceptance/*.py files to use schemas.py models (existing purpose-built models where available, the new generic ones for status-only checks).", - "depends_on": [], + "id": "C-4b", + "summary": "conftest.py quarantine hook", + "description": "Add pytest_collection_modifyitems hook to root conftest.py: nodeids listed in tests/.flaky_quarantine.txt get marked xfail(strict=False) at collection time.", + "depends_on": ["C-4a"], + "acceptance_criteria": [ + {"id": "AC-C4-04", "description": "conftest.py marks quarantined nodeids xfail(strict=False) so they can't fail the build", "test_type": "unit"} + ] + }, + { + "id": "C-4c", + "summary": "Wire flaky_report.py into CI jobs", + "description": "ci-quality.yml's tests-unit/tests-integration/tests-e2e jobs run flaky_report.py --mode report after pytest and upload the report log as an artifact. docker-publish.yml's live-test job (push-to-main only) extracts the report log from the container, runs flaky_report.py --mode write, and bot-commits any ledger/quarantine change to main as github-actions[bot] with [skip ci] (needs contents: write).", + "depends_on": ["C-4a", "C-4b"], "acceptance_criteria": [ - {"id": "AC-A5-01", "description": "All 9 files in tests/acceptance/ import and use schemas.py models", "test_type": "unit"} + {"id": "AC-C4-05", "description": "PR test jobs run flaky_report.py --mode report and upload the report log artifact", "test_type": "ci"}, + {"id": "AC-C4-06", "description": "live-test bot-commits ledger/quarantine changes to main via flaky_report.py --mode write", "test_type": "ci"} ] } ] From 32fa904f9d511afa8fc6a1e4fc736a61521ada6e Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 11:01:06 +0100 Subject: [PATCH 06/13] fix(ci): pin zaproxy/action-full-scan to v0.13.0, stop hardcoding ZAP scan credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zaproxy/action-full-scan has no v1 tag (latest is v0.13.0) — the zap-authenticated-scan job in ci-quality.yml and the never-yet-run security job in post-deployment.yml both referenced the nonexistent @v1. GitGuardian also flagged a hardcoded username/password pair in ci-quality.yml (the ZAP scan-only account credentials, also embedded as a base64 blob in .zap/prei-auth-context.xml). The password is now generated per-run with secrets.token_urlsafe and the auth context XML is rendered from a __ZAP_AUTH_CREDS_B64__ placeholder at job runtime, so no credential value is ever committed to git history. --- .github/workflows/ci-quality.yml | 12 +++++++--- .github/workflows/post-deployment.yml | 2 +- .gitignore | 1 + .zap/prei-auth-context.xml | 13 +++++++---- design.md | 33 +++++++++++++++++---------- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 68a16efb..b480830e 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -265,10 +265,12 @@ jobs: SECRET_KEY: ci-zap-scan-key DATABASE_URL: "sqlite:////tmp/zap_scan.sqlite3" ZAP_AUTH_USERNAME: zap-ci-scan-only - ZAP_AUTH_PASSWORD: ZapCiOnlyPw2026 steps: - uses: actions/checkout@v7 - uses: ./.github/actions/python-setup + - name: Generate throwaway scan credential + run: | + echo "ZAP_AUTH_PASSWORD=$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" >> "$GITHUB_ENV" - name: Install dependencies run: pip install -r requirements.txt -q - name: Migrate @@ -277,6 +279,10 @@ jobs: run: python manage.py collectstatic --noinput --verbosity 0 - name: Seed ZAP scan user run: python manage.py seed_zap_scan_user + - name: Render ZAP auth context + run: | + CREDS_B64=$(printf 'username=%s&password=%s' "$ZAP_AUTH_USERNAME" "$ZAP_AUTH_PASSWORD" | base64 -w0) + sed "s/__ZAP_AUTH_CREDS_B64__/$CREDS_B64/" .zap/prei-auth-context.xml > .zap/prei-auth-context-runtime.xml - name: Start server run: nohup python manage.py runserver 0.0.0.0:8000 > server.log 2>&1 & - name: Wait for healthy @@ -298,12 +304,12 @@ jobs: exit 1 fi - name: OWASP ZAP Authenticated Full Scan - uses: zaproxy/action-full-scan@v1 + uses: zaproxy/action-full-scan@v0.13.0 with: target: "http://localhost:8000" allow_issue_writing: false fail_action: true - cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context.xml -U zap-ci-scan-only" + cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context-runtime.xml -U zap-ci-scan-only" - name: Server logs if: always() run: cat server.log || true diff --git a/.github/workflows/post-deployment.yml b/.github/workflows/post-deployment.yml index 73c349ee..f6afeaf9 100644 --- a/.github/workflows/post-deployment.yml +++ b/.github/workflows/post-deployment.yml @@ -107,7 +107,7 @@ jobs: runs-on: ubuntu-latest steps: - name: OWASP ZAP Full Scan - uses: zaproxy/action-full-scan@v1 + uses: zaproxy/action-full-scan@v0.13.0 with: target: ${{ needs.smoke.outputs.target }} allow_issue_writing: false diff --git a/.gitignore b/.gitignore index f29c69ac..1fcb087f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,5 @@ verification-output.md verification-report.md .coverage .pytest-report.jsonl +.zap/prei-auth-context-runtime.xml drafts/ diff --git a/.zap/prei-auth-context.xml b/.zap/prei-auth-context.xml index ea36cafb..f6f1593b 100644 --- a/.zap/prei-auth-context.xml +++ b/.zap/prei-auth-context.xml @@ -8,10 +8,13 @@ the live deployment - post-deployment.yml's unauthenticated full scan is unchanged and still runs against the real deployed artifact. - The embedded credential is a fixed, CI-only, non-production literal - (matches ZAP_AUTH_USERNAME/ZAP_AUTH_PASSWORD in ci-quality.yml) scoped to - a throwaway database that is destroyed at job end - not a secret. - See docs/KNOWN_LIMITATIONS.md LIMIT-22. + The __ZAP_AUTH_CREDS_B64__ placeholder is substituted at job runtime (the + "Render ZAP auth context" step in ci-quality.yml) with a base64 + username=...&password=... blob for a randomly-generated, CI-only password + scoped to a throwaway database that is destroyed at job end. No credential + value is ever committed to the repository (see .gitignore for the rendered + prei-auth-context-runtime.xml output). See docs/KNOWN_LIMITATIONS.md + LIMIT-22. --> @@ -50,7 +53,7 @@ 0 - 1;true;zap-ci-scan-only;1;dXNlcm5hbWU9emFwLWNpLXNjYW4tb25seSZwYXNzd29yZD1aYXBDaU9ubHlQdzIwMjY= + 1;true;zap-ci-scan-only;1;__ZAP_AUTH_CREDS_B64__ 1 diff --git a/design.md b/design.md index f3b70a6d..df035d74 100644 --- a/design.md +++ b/design.md @@ -10,22 +10,31 @@ the authenticated scan against an ephemeral instance spun up inside the job itself: fresh migrated SQLite DB, `runserver` bound to `0.0.0.0:8000`, scan account seeded via a new idempotent management command (`core/management/commands/seed_zap_scan_user.py`, mirroring the -`get_or_create`/`update_or_create` pattern in `seed_markets.py`). Credentials -(`ZAP_AUTH_USERNAME`/`ZAP_AUTH_PASSWORD`) are fixed CI-only literals defined -inline in the workflow — not a GitHub secret, since the DB is a throwaway -SQLite file destroyed at job end. +`get_or_create`/`update_or_create` pattern in `seed_markets.py`). +`ZAP_AUTH_USERNAME` is a fixed, non-secret literal in the workflow; +`ZAP_AUTH_PASSWORD` is generated fresh each run (`secrets.token_urlsafe(24)` +in a "Generate throwaway scan credential" step, exported via `$GITHUB_ENV`) — +not a GitHub secret, since the DB is a throwaway SQLite file destroyed at job +end and the value never needs to be reused across runs. `.zap/prei-auth-context.xml` defines form-based auth against `/accounts/login/`, with logged-in/out indicator regexes (presence/absence of -`/accounts/logout/` and the password field) and a `` entry. The -credential is embedded as a base64 `username=...&password=...` blob directly -in the committed XML, rather than injected via a `-P username=... -P -password=...` CLI flag as originally sketched — `zap-full-scan.py` doesn't -expose a documented flag for that. This is acceptable because the credential -is non-secret: CI-only, throwaway-DB-scoped, with no access to anything real. +`/accounts/logout/` and the password field) and a `` entry containing +a `__ZAP_AUTH_CREDS_B64__` placeholder in place of a literal credential. A +"Render ZAP auth context" step substitutes the placeholder with a base64 +`username=...&password=...` blob built from that run's generated password, +writing the result to `.zap/prei-auth-context-runtime.xml` (gitignored, +never committed) — rather than injecting via a `-P username=... -P +password=...` CLI flag as originally sketched, since `zap-full-scan.py` +doesn't expose a documented flag for that. This avoids ever committing a +credential-shaped value to git history; an earlier version of this file did +embed a static credential blob directly, which GitGuardian correctly flagged +as a hardcoded secret during PR review, prompting this runtime-templating +fix. -The job runs `zap-full-scan.py -a -n /zap/wrk/.zap/prei-auth-context.xml -U -zap-ci-scan-only` and is a required check in `pr-gates-pass`, so it runs +The job runs `zap-full-scan.py -a -n +/zap/wrk/.zap/prei-auth-context-runtime.xml -U zap-ci-scan-only` and is a +required check in `pr-gates-pass`, so it runs pre-merge on every PR — a stronger "shift security left" posture than the existing unauthenticated scan, which only runs post-deployment. `post-deployment.yml`'s scan is left unchanged (defense in depth: one From 5d423a067532da5502103d9a78c7b67e91709993 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 11:36:36 +0100 Subject: [PATCH 07/13] fix(ci): correct ZAP auth-context parser config and user auth-method id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The urlparser/postparser fields need JSON ({"kvps":"&","kvs":"=","struct":[]}) per ZAP's StandardParameterParser, not a bare "&=" string — the bad format threw a JSONException during context import that cascaded into an NPE decoding the entry (auth-method-type registry never got populated). Also fixes the line's auth-method-type id (1 -> 2) to match 2. --- .zap/prei-auth-context.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.zap/prei-auth-context.xml b/.zap/prei-auth-context.xml index f6f1593b..b0e2db40 100644 --- a/.zap/prei-auth-context.xml +++ b/.zap/prei-auth-context.xml @@ -26,11 +26,11 @@ http://localhost:8000/accounts/logout.* org.zaproxy.zap.model.StandardParameterParser - &= + {"kvps":"&","kvs":"=","struct":[]} org.zaproxy.zap.model.StandardParameterParser - &= + {"kvps":"&","kvs":"=","struct":[]} 2 @@ -53,7 +53,7 @@ 0 - 1;true;zap-ci-scan-only;1;__ZAP_AUTH_CREDS_B64__ + 1;true;zap-ci-scan-only;2;__ZAP_AUTH_CREDS_B64__ 1 From 1ea0ba627f5254cee94e2e2b9732527d4047e617 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 11:41:43 +0100 Subject: [PATCH 08/13] chore(docs): trim stale stack line and extract GitOps principles skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md claimed Django 5.2 but requirements.txt pins 6.0.7 — point at the manifest instead of a version string that drifts. Move the 10-item GitOps Principles section to a lazy-loaded skill so it's only in context when touching workflows/deployment config, not on every request. --- .agents/skills/gitops-principles/SKILL.md | 19 +++++++++++++++++++ AGENTS.md | 13 ++----------- 2 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 .agents/skills/gitops-principles/SKILL.md diff --git a/.agents/skills/gitops-principles/SKILL.md b/.agents/skills/gitops-principles/SKILL.md new file mode 100644 index 00000000..81f3459a --- /dev/null +++ b/.agents/skills/gitops-principles/SKILL.md @@ -0,0 +1,19 @@ +# GitOps Principles Skill — prei + +## When to load +- Before editing anything under `.github/workflows/` +- Before touching deployment config, the GitOps manifest repo, or `docker-compose.yml` +- When reasoning about rollback, canary, or progressive-delivery behavior + +## Principles + +1. **Git is the source of truth.** Config, workflows, deployment state — all in git. Never modify running infrastructure directly. +2. **Immutable artifacts.** The Docker image is the deployable unit. Test the artifact, not the source. Never rebuild for deployment. +3. **PR gates are deploy gates.** Every merge to `main` is a deploy candidate. Broken `main` blocks all PRs — fix main CI before merging anything else. +4. **Declarative pipelines.** Workflows describe DESIRED STATE: what artifacts, what gates, what triggers. Not imperative scripts. +5. **Artifact verification.** Every PR that touches workflows or deployment config must be verified against the live container via `post-deployment.yml`. +6. **Rollback is `git revert`.** Rollback to a previous commit on the GitOps manifest repo. The rollback job in `post-deployment.yml` is a safety net, not the primary mechanism. +7. **Observability built-in.** Every workflow step logs `job-start` / `job-finish` timestamps. Build times, test results, deploy status are traceable. +8. **Progressive delivery.** Canary → staging → production (see `docs/DEPLOYMENT_STRATEGY.md`). Never ship to 100% in one step. +9. **Naming is infrastructure.** PR titles follow Conventional Commits. Commit messages describe intent. Tags trigger deployments. +10. **Branch discipline.** All work happens on feature branches off `main` (trunk-based development, short-lived). Never commit directly to `main`. Branch naming: `feat/`, `fix/`, `chore/`, `docs/`. Every branch opens a PR through CI gates before merge. diff --git a/AGENTS.md b/AGENTS.md index 51a5a6d3..2a857682 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ## Project Identity - prei: passive real estate investment analytics for buy-and-hold investors. -- Stack: Python 3.14, Django 5.2, DRF, SQLite (default/alpha), Postgres (reserved for post-MVP production — see docker-compose.yml). +- Stack: see requirements.txt/pyproject.toml. Postgres is reserved for post-MVP production — see docker-compose.yml. - Constraints: Decimal money; service-layer boundaries; no Bootstrap. - Design: custom design system with CSS custom properties (tokens.css + base.css). @@ -29,16 +29,7 @@ 9. Use uppercase in PR title description — the first word after `type(scope):` must be lowercase (see `docs/PR_STANDARD.md`). ## GitOps Principles -1. **Git is the source of truth.** Config, workflows, deployment state — all in git. Never modify running infrastructure directly. -2. **Immutable artifacts.** The Docker image is the deployable unit. Test the artifact, not the source. Never rebuild for deployment. -3. **PR gates are deploy gates.** Every merge to `main` is a deploy candidate. Broken `main` blocks all PRs — fix main CI before merging anything else. -4. **Declarative pipelines.** Workflows describe DESIRED STATE: what artifacts, what gates, what triggers. Not imperative scripts. -5. **Artifact verification.** Every PR that touches workflows or deployment config must be verified against the live container via `post-deployment.yml`. -6. **Rollback is `git revert`.** Rollback to a previous commit on the GitOps manifest repo. The rollback job in `post-deployment.yml` is a safety net, not the primary mechanism. -7. **Observability built-in.** Every workflow step logs `job-start` / `job-finish` timestamps. Build times, test results, deploy status are traceable. -8. **Progressive delivery.** Canary → staging → production (see `docs/DEPLOYMENT_STRATEGY.md`). Never ship to 100% in one step. -9. **Naming is infrastructure.** PR titles follow Conventional Commits. Commit messages describe intent. Tags trigger deployments. -10. **Branch discipline.** All work happens on feature branches off `main` (trunk-based development, short-lived). Never commit directly to `main`. Branch naming: `feat/`, `fix/`, `chore/`, `docs/`. Every branch opens a PR through CI gates before merge. +See `gitops-principles` skill — load before touching workflows, deployment config, or the GitOps manifest repo. ## Context Files | File | Why | From ff41ac24df542ea59ff9542a5ba7f319468ccc73 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 11:48:48 +0100 Subject: [PATCH 09/13] ci: retrigger PR checks From 0f4589c9a94419289f63a571090fb865b0c0017a Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 18:09:11 +0100 Subject: [PATCH 10/13] docs(phase-b): archive spec/design/tasks before phase C merge PR #324 merged without archiving Phase B's active spec/design/tasks into features//, per this repo's own convention. Preserving them here before feat/top01-phase-c merges main and overwrites the root files with Phase C's content. --- features/top01-phase-b/design.md | 74 +++++++++++++++++++++++++ features/top01-phase-b/specification.md | 65 ++++++++++++++++++++++ features/top01-phase-b/tasks.json | 70 +++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 features/top01-phase-b/design.md create mode 100644 features/top01-phase-b/specification.md create mode 100644 features/top01-phase-b/tasks.json diff --git a/features/top01-phase-b/design.md b/features/top01-phase-b/design.md new file mode 100644 index 00000000..68ecdf5c --- /dev/null +++ b/features/top01-phase-b/design.md @@ -0,0 +1,74 @@ +# Design: Phase B — Financial Math + +### B-1: IRR reference implementation +`ref_irr(cashflows: list[Decimal]) -> Decimal` in `tests/finance_reference.py` +is independent of `numpy_financial` (unlike production's `irr()`, which wraps +it). It brackets a sign change in `NPV(r) = Σ cashflows[t] / (1+r)^t` over a +coarse grid (`r ∈ (-0.9999, 10)`, step `0.01`), then bisects within the +bracket to a `1e-7` tolerance. Returns `Decimal("0")` when no sign change is +found (no real root), mirroring production's existing NaN/Inf fallback. + +### B-2: Expanded edge-case coverage +`tests/test_finance_math.py`'s case lists (`NOI_CASES`, `CAP_RATE_CASES`, +`COC_CASES`, `DSCR_CASES`, `ONE_PCT_CASES`, `GRM_CASES`, new `IRR_CASES`) were +expanded to 50+ rows each, organized by category: normal/typical, zero in +each param position, negative in each param position, extreme magnitude, +currency sub-cent precision, boundary/threshold, and int-vs-Decimal coercion. +`ref_one_percent_rule`/`ref_gross_rent_multiplier` were updated to raise +`ValueError` under the same zero/negative conditions as production, so +zero/negative edge cases can't silently diverge between "production raises" +and "reference returns a value." + +### B-3: No workflow change +`ci-quality.yml`'s `finance-math` job already runs the whole of +`tests/test_finance_math.py`; B-1/B-2 adding IRR cases to that same file +extends the existing gate automatically. + +### B-4: Derivation docstrings +`noi`, `cap_rate`, `cash_on_cash`, `dscr`, `irr` in +`investor_app/finance/utils.py` gained full docstrings (formula + "Derivation:" +paragraph + Args/Returns), following the Args/Returns/Raises style already +used by `one_percent_rule`/`gross_rent_multiplier`. Those two also gained a +one-line derivation note for completeness. No function bodies changed — +docstrings only, verified via `ast.parse` + full test rerun. + +### Underwriting.py: float → Decimal, dedup +`UnderwritingInput`/`UnderwritingMetrics` (`prei/pipeline/handlers/underwriting.py`) +became `Decimal`-typed pydantic models — pydantic v2 coerces int/float/str into +`Decimal` fields natively, so existing bare-numeric call sites keep working +unchanged. The local duplicate `cap_rate()` was deleted; the module now +imports `cap_rate`/`cash_on_cash`/`to_decimal` from `investor_app.finance.utils` +directly. `cash_on_cash_yield()` keeps its distinct name and semantics +(all-cash acquisition yield: NOI over price+rehab, no debt service netted +out) but delegates its division through the canonical `cash_on_cash()` instead +of reimplementing `/` locally. The remaining composition helpers +(`gross_potential_rent`, `effective_gross_income`, `total_operating_expenses`, +`net_operating_income`, `max_allowable_offer`) converted their arithmetic to +`Decimal` via the reused `to_decimal()` helper; `solve_underwriting()` uses +`.quantize()` instead of `round()` for the final output. + +`orchestrator.py`'s `UnderwritingInput` construction boundary (`price * 0.012`/ +`price * 0.004` tax/insurance defaults) was wrapped in `Decimal("0.012")`/ +`Decimal("0.004")` arithmetic against a `to_decimal(canonical.price or 0.0)`- +coerced price, reusing `to_decimal()` rather than reinventing coercion. + +**Two non-obvious risks found during implementation:** +- `pytest.approx()` compared against a `Decimal` actual is + fragile, not uniformly broken — it silently short-circuits via exact + equality for representable values but raises `TypeError` on near-matches + (`abs(expected - actual)` can't mix `float` and `Decimal`). Every affected + assertion site was fixed by wrapping the Decimal actual in `float(...)` + rather than relying on which literals happen to match exactly. +- `Decimal * float` arithmetic (not just comparison) raises `TypeError` + unconditionally. Test-file call sites that computed derived values inline + (e.g. `uw.mao * 1.15`, `low.mao * 0.07 / 0.10`) needed `float(...)`-wrapping + of the Decimal operand before the float arithmetic. +`prei/pipeline/handlers/offer.py`'s `OfferInput.mao: float` field is untouched +by this — pydantic coerces a `Decimal` input to `float` automatically since no +arithmetic happens before construction at the remaining safe call sites. + +### Documentation-only additions +Two new `docs/KNOWN_LIMITATIONS.md` entries (LIMIT-20, LIMIT-21) record the +issues found but deliberately not fixed in this PR: the bare-function vs. +`calculate_*` contract divergence plus the duplicate `score_listing_v2` +functions, and `offer.py`'s remaining float-currency issue. diff --git a/features/top01-phase-b/specification.md b/features/top01-phase-b/specification.md new file mode 100644 index 00000000..8ae66ac1 --- /dev/null +++ b/features/top01-phase-b/specification.md @@ -0,0 +1,65 @@ +# Specification: Phase B — Financial Math (docs/TOP_01_PLAN.md) +# Written: 2026-07-27 + +--- + +## 0. Problem + +`docs/TOP_01_PLAN.md` Phase B requires the core financial-math functions in +`investor_app/finance/utils.py` to have independent reference implementations, +broad edge-case coverage gated in CI, and mathematical derivation docstrings. +Investigation found Phase B partially done already (commit `30fd355`): B-1 was +missing IRR's reference implementation, B-2 had only 5-9 cases per function +(well short of "50+"), B-3 was already wired, and B-4 had zero derivation +docstrings anywhere. The audit also surfaced a live `AGENTS.md` "Never Do" +violation adjacent to this work: `prei/pipeline/handlers/underwriting.py` was +a second, fully float-based implementation of NOI/cap-rate/cash-on-cash living +outside `services/utils` (Never-Do #1 and #3) — approved for fixing in this +same PR. + +## 1. Requirements + +- B-1: `tests/finance_reference.py` has an independent, `numpy_financial`-free + reference implementation of every core KPI, including IRR. +- B-2: `tests/test_finance_math.py` covers 50+ parameterized edge cases per + function (normal, zero, negative, extreme magnitude, sub-cent precision, + boundary, int/Decimal coercion). +- B-3: `ci-quality.yml`'s `finance-math` job gates on the full expanded suite + (already wired; automatically covers new IRR cases once added). +- B-4: `noi`, `cap_rate`, `cash_on_cash`, `dscr`, `irr` in + `investor_app/finance/utils.py` have full derivation docstrings; `one_percent_rule`/ + `gross_rent_multiplier` get a derivation note added to their existing docstrings. +- UW-1: `prei/pipeline/handlers/underwriting.py` converts from `float` to + `Decimal` and stops duplicating `cap_rate`/`cash_on_cash` — it imports the + canonical implementations from `investor_app.finance.utils` instead. + +## 2. Acceptance Criteria + +| ID | Criterion | test_type | +|---|---|---| +| AC-B1-01 | `ref_irr` exists in `tests/finance_reference.py`, no numpy dependency | unit | +| AC-B2-01 | `pytest tests/test_finance_math.py` passes with 50+ cases per function | unit | +| AC-B2-02 | `one_percent_rule`/`gross_rent_multiplier` reference functions raise `ValueError` matching production's contract | unit | +| AC-B3-01 | `ci-quality.yml`'s `finance-math` job runs `tests/test_finance_math.py` (already true) | ci | +| AC-B4-01 | `noi`/`cap_rate`/`cash_on_cash`/`dscr`/`irr` each have a "Derivation:" docstring paragraph | unit | +| AC-UW-01 | `UnderwritingInput`/`UnderwritingMetrics` fields are `Decimal`, not `float` | unit | +| AC-UW-02 | `underwriting.py` imports `cap_rate`/`cash_on_cash` from `investor_app.finance.utils`, no local duplicate | unit | +| AC-UW-03 | `pytest prei/pipeline/tests/test_underwriting.py tests/test_underwriting_integration.py tests/test_offer_integration.py` passes | unit | +| AC-UW-04 | `orchestrator.py`'s `price * 0.012`/`price * 0.004` boundary uses `Decimal` arithmetic | unit | + +## 3. Out of Scope + +- `prei/pipeline/handlers/offer.py`'s remaining float-based currency — tracked + as `docs/KNOWN_LIMITATIONS.md` LIMIT-21, not fixed here. +- Reconciling the bare-function vs. `calculate_*` contract divergence and the + duplicate `score_listing_v2` functions in `investor_app/finance/utils.py` — + tracked as LIMIT-20, requires an API-contract decision out of scope for this PR. + +## 4. Verification + +- `pytest tests/test_finance_math.py -v --tb=short` +- `pytest prei/pipeline/tests/test_underwriting.py tests/test_underwriting_integration.py tests/test_offer_integration.py prei/pipeline/tests/test_orchestrator.py -q -o addopts=""` +- `pytest tests_bdd/ core/tests/ prei/pipeline/tests/ -q` +- `mypy core/ investor_app/finance/` (existing CI command) +- Push branch, open PR, watch `ci-quality.yml` go green. PR stays open for + human review/merge — never merge or push to `main` directly. diff --git a/features/top01-phase-b/tasks.json b/features/top01-phase-b/tasks.json new file mode 100644 index 00000000..1492457b --- /dev/null +++ b/features/top01-phase-b/tasks.json @@ -0,0 +1,70 @@ +{ + "meta": { + "project": "prei", + "session": "top01-phase-b-20260727", + "date": "2026-07-27", + "feature": "Phase B — Financial Math (docs/TOP_01_PLAN.md)", + "spec": "specification.md", + "design": "design.md" + }, + "tasks": [ + { + "id": "B-1", + "summary": "Add ref_irr to tests/finance_reference.py", + "description": "Independent, numpy_financial-free bisection-based IRR reference implementation, mirroring production's NaN/Inf -> Decimal(0) fallback for no-real-root cases.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-B1-01", "description": "ref_irr exists in tests/finance_reference.py, no numpy dependency", "test_type": "unit"} + ] + }, + { + "id": "B-2", + "summary": "Expand edge-case coverage to 50+ per function", + "description": "Expand NOI/cap_rate/CoC/DSCR/one_percent_rule/GRM case lists to 50+ rows each and add a new IRR_CASES block; fix ref_one_percent_rule/ref_gross_rent_multiplier to raise ValueError matching production's contract.", + "depends_on": ["B-1"], + "acceptance_criteria": [ + {"id": "AC-B2-01", "description": "pytest tests/test_finance_math.py passes with 50+ cases per function", "test_type": "unit"}, + {"id": "AC-B2-02", "description": "one_percent_rule/gross_rent_multiplier reference functions raise ValueError matching production's contract", "test_type": "unit"} + ] + }, + { + "id": "B-3", + "summary": "CI gate coverage for IRR (no workflow change needed)", + "description": "ci-quality.yml's finance-math job already runs the whole of tests/test_finance_math.py; B-1/B-2 automatically extend its coverage. Verified, no edit made.", + "depends_on": ["B-1", "B-2"], + "acceptance_criteria": [ + {"id": "AC-B3-01", "description": "ci-quality.yml's finance-math job runs tests/test_finance_math.py (already true)", "test_type": "ci"} + ] + }, + { + "id": "B-4", + "summary": "Add mathematical derivation docstrings to utils.py core functions", + "description": "Add formula + Derivation: docstrings to noi, cap_rate, cash_on_cash, dscr, irr in investor_app/finance/utils.py; add a one-line derivation note to one_percent_rule/gross_rent_multiplier's existing docstrings.", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-B4-01", "description": "noi/cap_rate/cash_on_cash/dscr/irr each have a Derivation: docstring paragraph", "test_type": "unit"} + ] + }, + { + "id": "UW-1", + "summary": "Fix underwriting.py float->Decimal + dedup cap_rate/cash_on_cash", + "description": "Convert UnderwritingInput/UnderwritingMetrics to Decimal; delete the local cap_rate() duplicate and import the canonical investor_app.finance.utils.cap_rate/cash_on_cash/to_decimal; convert composition helpers to Decimal arithmetic; fix orchestrator.py's price*0.012/price*0.004 boundary; fix downstream test-file Decimal/float interop breakage (test_underwriting.py, test_underwriting_integration.py, test_offer_integration.py, pipeline_steps.py).", + "depends_on": [], + "acceptance_criteria": [ + {"id": "AC-UW-01", "description": "UnderwritingInput/UnderwritingMetrics fields are Decimal, not float", "test_type": "unit"}, + {"id": "AC-UW-02", "description": "underwriting.py imports cap_rate/cash_on_cash from investor_app.finance.utils, no local duplicate", "test_type": "unit"}, + {"id": "AC-UW-03", "description": "pytest prei/pipeline/tests/test_underwriting.py tests/test_underwriting_integration.py tests/test_offer_integration.py passes", "test_type": "unit"}, + {"id": "AC-UW-04", "description": "orchestrator.py's price * 0.012/price * 0.004 boundary uses Decimal arithmetic", "test_type": "unit"} + ] + }, + { + "id": "DOC-1", + "summary": "Document deliberately out-of-scope findings in KNOWN_LIMITATIONS.md", + "description": "Add LIMIT-20 (bare-function vs. calculate_* contract divergence + duplicate score_listing_v2) and LIMIT-21 (offer.py remains float-based currency).", + "depends_on": ["UW-1"], + "acceptance_criteria": [ + {"id": "AC-DOC-01", "description": "docs/KNOWN_LIMITATIONS.md has LIMIT-20 and LIMIT-21 entries in the file's existing format", "test_type": "manual"} + ] + } + ] +} From 2e3ad8dff3eda8b199bc38c595139ab1fad8c837 Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 18:29:18 +0100 Subject: [PATCH 11/13] fix(ci): add missing basic block to ZAP context authorization The authenticated ZAP scan job (zap-authenticated-scan) failed on its first run: ZAP's context import throws a NullPointerException in BasicAuthorizationDetectionMethod when the element's 0 has no accompanying block, because the config-based constructor calls LogicalOperator.valueOf() on the (missing) logic value with no null-guard. Docker then exits 3 and the scan never runs. Add the sub-block (empty header/body regex, AND, code -1) that ZAP always expects when an element is present, even though this context doesn't use authorization detection. --- .zap/prei-auth-context.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.zap/prei-auth-context.xml b/.zap/prei-auth-context.xml index b0e2db40..38a81136 100644 --- a/.zap/prei-auth-context.xml +++ b/.zap/prei-auth-context.xml @@ -51,6 +51,12 @@ 0 + +
+ + AND + -1 + 1;true;zap-ci-scan-only;2;__ZAP_AUTH_CREDS_B64__ From 5ef70546a9aff5cd7b7e8206a65716a8dfef5bed Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 18:34:56 +0100 Subject: [PATCH 12/13] fix(ci): base64-encode the ZAP context user name The prior fix resolved the context-import NullPointerException, but the authenticated scan still failed: "ZAP failed to find user: zap-ci-scan-only". ZAP's User.encode()/decode() format is id;enabled;base64(name);authTypeId;credentials - the name field must be base64, and our line had it in plaintext. On import ZAP base64-decodes "zap-ci-scan-only" into garbage, so the -U flag's exact-name lookup in zap_set_scan_user() never matches. Encode the name (zap-ci-scan-only -> emFwLWNpLXNjYW4tb25seQ==) and add a comment documenting the field format so this isn't rediscovered by hand next time the context file is edited. --- .zap/prei-auth-context.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.zap/prei-auth-context.xml b/.zap/prei-auth-context.xml index 38a81136..cc45b942 100644 --- a/.zap/prei-auth-context.xml +++ b/.zap/prei-auth-context.xml @@ -58,8 +58,12 @@ -1 + - 1;true;zap-ci-scan-only;2;__ZAP_AUTH_CREDS_B64__ + 1;true;emFwLWNpLXNjYW4tb25seQ==;2;__ZAP_AUTH_CREDS_B64__ 1 From b0f00e103e135c15652835814ee883856403896d Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Tue, 28 Jul 2026 18:48:29 +0100 Subject: [PATCH 13/13] fix(ci): don't block PRs on pre-existing ZAP WARN findings Both blockers preventing the authenticated scan from running at all are now fixed (missing auth block, plaintext username needing base64). With auth working, ZAP reached pages behind /accounts/login/ for the first time and surfaced 10 WARN-level findings (missing security headers, non-HttpOnly cookie, insecure session ID transmission, missing SRI, etc.) - 0 FAIL-level alerts. zap-full-scan.py exits non-zero on any WARN by default, which would block every future PR on pre-existing app gaps unrelated to their changes. Add -I so the gate only fails on FAIL-level alerts; WARN findings still show up in the job's ZAP log for visibility. Document the deferred findings as LIMIT-23 with a recommended follow-up hardening PR. --- .github/workflows/ci-quality.yml | 2 +- docs/KNOWN_LIMITATIONS.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index b480830e..9789aac5 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -309,7 +309,7 @@ jobs: target: "http://localhost:8000" allow_issue_writing: false fail_action: true - cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context-runtime.xml -U zap-ci-scan-only" + cmd_options: "-a -n /zap/wrk/.zap/prei-auth-context-runtime.xml -U zap-ci-scan-only -I" - name: Server logs if: always() run: cat server.log || true diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 63c52b1e..aeb8f01c 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -248,6 +248,18 @@ This means a user who runs the API pre-`populate_growth_areas` gets empty result --- +### [LIMIT-23] 🟡 HIGH — Authenticated ZAP scan's first real run surfaced 10 unresolved WARN-level findings, currently non-blocking + +**Location:** `.github/workflows/ci-quality.yml` — `zap-authenticated-scan` job (`cmd_options` includes `-I`); application code (headers/cookies/templates, not yet touched) + +**Impact:** Once authentication started working (LIMIT-22), the scan reached pages behind `/accounts/login/` for the first time (`/dashboard`, `/growth-explorer`, `/leasing`, `/pipeline//`, etc.) and found 10 categories of WARN-level alerts that had never been scanned before: Cookie No HttpOnly Flag [10010], X-Content-Type-Options Header Missing [10021], Server Leaks Version Information [10036], CSP Header Not Set [10038], Permissions Policy Header Not Set [10063], HTTP Only Site [10106], Session ID Transmitted Insecurely [40013], Sub Resource Integrity Attribute Missing [90003], Cross-Origin-Resource-Policy Header Missing or Invalid [90004], Insecure HTTP Method - PUT [90028]. Zero FAIL-level alerts. `zap-full-scan.py` exits non-zero on any WARN by default, which would have blocked every future PR on pre-existing gaps unrelated to their changes. + +**Workaround:** `-I` (`--ignore-warn`) added to the scan's `cmd_options` so the job only fails on FAIL-level alerts; WARN findings still appear in the job's ZAP log/report for visibility, they just don't block merges. + +**Fix tracked in:** Not yet filed. Recommended as a dedicated hardening PR: Django `SECURE_*` settings + `django-csp`/`django-permissions-policy` middleware for the header findings, `SESSION_COOKIE_HTTPONLY`/`SESSION_COOKIE_SECURE` for the cookie/session findings, `integrity` attributes on external `