diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index e8f445748..36679dbd6 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -2,10 +2,6 @@ name: Application CI on: pull_request: - branches: - - develop - - master - - "release/**" push: branches: - develop @@ -25,9 +21,24 @@ jobs: strategy: matrix: python-version: ["3.14"] + services: + postgres: + image: pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d test_db" + --health-interval 5s + --health-timeout 5s + --health-retries 12 env: PYTHONWARNINGS: error DISABLE_BACKGROUND_WORKERS: "1" + DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -38,6 +49,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -66,6 +78,23 @@ jobs: cd backend python -m ruff check . + - name: Generate ephemeral CI runtime secret + run: | + python - <<'PY' + import os + import secrets + + value = "Ci9!" + secrets.token_urlsafe(48) + print(f"::add-mask::{value}") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env_file: + env_file.write(f"AUTH_SESSION_HMAC_SECRET={value}\n") + PY + + - name: Run database migrations + run: | + cd backend + python scripts/migrate_db.py + - name: Run backend tests run: | set -o pipefail @@ -89,6 +118,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install pnpm run: corepack enable pnpm @@ -137,3 +167,19 @@ jobs: NARUON_FULL_PRODUCT_BASE_URL: "http://127.0.0.1:3001" NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke" run: cd frontend && pnpm run full:smoke + + - name: Run Playwright browser acceptance + env: + PLAYWRIGHT_HTML_OPEN: "never" + run: cd frontend && pnpm run test:e2e -- --reporter=line,html + + - name: Upload Playwright browser evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.6.2 + with: + name: playwright-browser-evidence-${{ github.event.pull_request.head.sha || github.sha }}-${{ github.run_attempt }} + path: | + frontend/playwright-report/ + frontend/test-results/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index c5c613c08..0e250389c 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -4,7 +4,6 @@ on: push: branches: [ develop, master ] pull_request: - branches: [ develop, master ] workflow_dispatch: permissions: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c303d1e61..21e607f7b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,10 +2,6 @@ name: Dependency Review on: pull_request: - branches: - - develop - - master - - "release/**" workflow_dispatch: permissions: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fc7058413..dd1015812 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,10 +5,6 @@ on: tags: - "v*" pull_request: - branches: - - develop - - master - - "release/**" permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 9104dd1f4..c8fbb38e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,13 @@ in this repo. ## PR automation and review defaults +- Stacked-PR trigger tests must parse the YAML event configuration, not search + source text for `**`: a comment can satisfy that search, and a later + `!feature/**` pattern can exclude the very stack being validated. Preserve the + Actions `on` key when choosing a YAML loader, inspect ordered branch patterns, + and retain rejection tests for both cases. The installed hash-locked PyYAML + dependency is sufficient; do not add a second parser for this contract. + - Follow `docs/development/merge-gate-policy.md` for PR gate interpretation. - PR Governance must stay metadata-only: no PR-head checkout, no admin merge, no review dismissal, and no security-check suppression. diff --git a/backend/tests/test_postgres_ci_contract.py b/backend/tests/test_postgres_ci_contract.py new file mode 100644 index 000000000..a86bfb2c2 --- /dev/null +++ b/backend/tests/test_postgres_ci_contract.py @@ -0,0 +1,83 @@ +"""Repository contract for PostgreSQL-backed backend acceptance.""" + +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +APP_CI = REPO_ROOT / ".github" / "workflows" / "app-ci.yml" +PGVECTOR_CI_IMAGE = ( + "pgvector/pgvector:pg16@" + "sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b" +) + + +def _workflow() -> dict[str, object]: + """Load the workflow without YAML 1.1 coercing the `on` key to a boolean.""" + return yaml.load(APP_CI.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def test_backend_ci_provisions_migrated_pgvector_database() -> None: + """Real PostgreSQL tests must run against a ready, migrated CI database.""" + workflow = _workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + backend = jobs["backend"] + assert isinstance(backend, dict) + + services = backend.get("services") + assert isinstance(services, dict), "backend CI must provision PostgreSQL" + postgres = services.get("postgres") + assert isinstance(postgres, dict), "backend CI must declare a postgres service" + assert postgres.get("image") == PGVECTOR_CI_IMAGE + assert postgres.get("env") == { + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + "POSTGRES_DB": "test_db", + } + options = postgres.get("options") + assert isinstance(options, str) + assert "pg_isready -U test -d test_db" in options + + environment = backend.get("env") + assert isinstance(environment, dict) + assert environment.get("DATABASE_URL") == ( + "postgresql+asyncpg://test:test@localhost:5432/test_db" + ) + assert "AUTH_SESSION_HMAC_SECRET" not in environment, ( + "CI runtime auth material must be generated per job, not committed as a fixture" + ) + + steps = backend.get("steps") + assert isinstance(steps, list) + named_steps = { + step.get("name"): step + for step in steps + if isinstance(step, dict) and isinstance(step.get("name"), str) + } + runtime_secret = named_steps.get("Generate ephemeral CI runtime secret") + assert isinstance(runtime_secret, dict), ( + "backend CI must generate auth material before importing runtime settings" + ) + runtime_secret_script = str(runtime_secret.get("run", "")) + assert "secrets.token_urlsafe(48)" in runtime_secret_script + assert "AUTH_SESSION_HMAC_SECRET" in runtime_secret_script + assert "GITHUB_ENV" in runtime_secret_script + assert 'print(f"::add-mask::{value}")' in runtime_secret_script, ( + "generated runtime auth material must be masked before later steps expose env" + ) + assert runtime_secret_script.index("::add-mask::") < runtime_secret_script.index( + "GITHUB_ENV" + ) + + migration = named_steps.get("Run database migrations") + assert isinstance(migration, dict), "backend CI must migrate before pytest" + assert "python scripts/migrate_db.py" in str(migration.get("run", "")) + + step_names = [ + step.get("name") for step in steps if isinstance(step, dict) and step.get("name") + ] + assert step_names.index("Generate ephemeral CI runtime secret") < step_names.index( + "Run database migrations" + ) < step_names.index("Run backend tests") diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index a23c70746..2e197b578 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -651,7 +651,6 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe workflow = read_repo_text(".github/workflows/app-ci.yml") assert "pull_request:" in workflow - assert "release/**" in workflow assert "python -m pytest" in workflow assert "PYTHONWARNINGS: error" in workflow assert 'DISABLE_BACKGROUND_WORKERS: "1"' in workflow @@ -666,6 +665,7 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe assert "uses: actions/setup-node@v" not in workflow push_block = workflow.split("push:", 1)[1].split("pull_request:", 1)[0] + assert "develop" in push_block assert "master" in push_block assert "release/**" not in push_block @@ -705,12 +705,8 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ == 2 ) push_block = workflow.split("push:", 1)[1].split("pull_request:", 1)[0] - pull_request_block = workflow.split("pull_request:", 1)[1].split("permissions:", 1)[ - 0 - ] assert "tags:" in push_block assert "branches:" not in push_block - assert "develop" in pull_request_block assert "ai_email_client-backend" in workflow assert "ai_email_client-frontend" in workflow assert workflow.count("image: naruon") == 2 @@ -1202,4 +1198,4 @@ def test_agents_records_ghcr_visibility_publication_runbook() -> None: assert "Package settings" in agents assert "Danger Zone" in agents assert "Change visibility" in normalized_agents - assert "anonymous pull/token access" in agents + assert "anonymous pull/token access" in agents \ No newline at end of file diff --git a/backend/tests/test_stacked_pr_workflow_triggers.py b/backend/tests/test_stacked_pr_workflow_triggers.py new file mode 100644 index 000000000..a568716ea --- /dev/null +++ b/backend/tests/test_stacked_pr_workflow_triggers.py @@ -0,0 +1,87 @@ +"""Guard repo-local PR validation on dependent stacked pull requests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PR_VALIDATION_WORKFLOWS = ( + ".github/workflows/app-ci.yml", + ".github/workflows/bandit.yml", + ".github/workflows/dependency-review.yml", + ".github/workflows/docker-publish.yml", +) +PLAYWRIGHT_UPLOAD_PIN = ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +) +EXACT_HEAD_REF = "${{ github.event.pull_request.head.sha || github.sha }}" + + +@pytest.mark.parametrize("workflow_path", PR_VALIDATION_WORKFLOWS) +def test_repo_local_pr_validation_accepts_every_base_branch(workflow_path: str) -> None: + """Require an unfiltered pull_request trigger for every stacked PR base.""" + workflow_text = (REPO_ROOT / workflow_path).read_text(encoding="utf-8") + # BaseLoader preserves the Actions `on` key instead of YAML 1.1 boolean coercion. + workflow_events = yaml.load(workflow_text, Loader=yaml.BaseLoader)["on"] + assert "pull_request" in workflow_events + pull_request_config = workflow_events["pull_request"] or {} + assert "branches" not in pull_request_config + assert "branches-ignore" not in pull_request_config + + +@pytest.mark.parametrize( + "branch_filter", + [ + "branches: ['**']", + "branches: ['**', '!feature/**']", + "branches: [develop] # '**' is only a comment", + "branches-ignore: [archive/**]", + ], +) +def test_stacked_trigger_guard_rejects_any_base_filter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, branch_filter: str +) -> None: + """Do not encode all-base verification through mutable branch patterns.""" + workflow_path = tmp_path / "workflow.yml" + workflow_path.write_text( + f"on:\n pull_request:\n {branch_filter}\n", encoding="utf-8" + ) + monkeypatch.setitem(globals(), "REPO_ROOT", tmp_path) + with pytest.raises(AssertionError): + test_repo_local_pr_validation_accepts_every_base_branch("workflow.yml") + + +def test_application_ci_executes_and_labels_exact_head_browser_acceptance() -> None: + """Require Playwright evidence to execute and identify the exact PR head.""" + workflow_path = REPO_ROOT / ".github/workflows/app-ci.yml" + workflow = yaml.load(workflow_path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + for job_name in ("backend", "frontend"): + job_steps = workflow["jobs"][job_name]["steps"] + steps_by_name = {step.get("name"): step for step in job_steps} + checkout_step = steps_by_name["Checkout repository"] + checkout_options = checkout_step.get("with") or {} + assert checkout_options.get("ref") == EXACT_HEAD_REF + + frontend_steps = workflow["jobs"]["frontend"]["steps"] + steps_by_name = {step.get("name"): step for step in frontend_steps} + + browser_step = steps_by_name["Run Playwright browser acceptance"] + browser_command = browser_step["run"] + assert "pnpm run test:e2e" in browser_command + browser_env = browser_step.get("env") or {} + assert "LIVE_BASE_URL" not in browser_env + assert "RUN_LIVE_E2E" not in browser_env + + evidence_step = steps_by_name["Upload Playwright browser evidence"] + assert evidence_step["uses"] == PLAYWRIGHT_UPLOAD_PIN + assert evidence_step["if"] == "${{ always() }}" + evidence_name = evidence_step["with"]["name"] + assert EXACT_HEAD_REF in evidence_name + evidence_paths = evidence_step["with"]["path"] + assert "frontend/playwright-report/" in evidence_paths + assert "frontend/test-results/" in evidence_paths diff --git a/frontend/src/lib/data-evidence-snapshot-e2e-fixture.test.ts b/frontend/src/lib/data-evidence-snapshot-e2e-fixture.test.ts new file mode 100644 index 000000000..5e4dc530d --- /dev/null +++ b/frontend/src/lib/data-evidence-snapshot-e2e-fixture.test.ts @@ -0,0 +1,56 @@ +import type { Page, Route } from '@playwright/test'; +import { describe, expect, it, vi } from 'vitest'; + +import { mockDashboardApi } from '../../tests/e2e/helpers'; + +type RouteHandler = (route: Route) => Promise; + +describe('mockDashboardApi Data evidence snapshot', () => { + it('serves a redacted verifier-ready snapshot instead of falling through to 404', async () => { + let routeHandler: RouteHandler | undefined; + const page = { + route: vi.fn(async (_pattern: string, handler: RouteHandler) => { + routeHandler = handler; + }), + } as unknown as Page; + + await mockDashboardApi(page); + expect(routeHandler).toBeDefined(); + + const fulfill = vi.fn(async () => undefined); + const route = { + request: () => ({ + url: () => 'https://naruon.test/api/data/quality-surface/evidence-snapshot', + method: () => 'GET', + }), + fulfill, + } as unknown as Route; + + await routeHandler!(route); + + expect(fulfill).toHaveBeenCalledTimes(1); + const response = fulfill.mock.calls[0]?.[0] as { + status?: number; + contentType?: string; + body?: string; + }; + expect(response.status).toBe(200); + expect(response.contentType).toBe('application/json'); + + const body = JSON.parse(response.body ?? '{}') as Record; + expect(body.snapshot_version).toBe('data_quality_evidence_snapshot.v1'); + expect(body.digest_algorithm).toBe('sha256'); + expect(body.snapshot_digest).toMatch(/^[0-9a-f]{64}$/u); + expect(body.privacy_redaction_policy).toMatchObject({ + raw_content_exposed: false, + stable_identifiers_exposed: false, + provider_credentials_exposed: false, + }); + expect(body.validation_status).toMatchObject({ status_code: 'ready' }); + expect(body.verification_handoff).toMatchObject({ + accepted_input: 'data_quality_evidence_snapshot.v1 JSON', + digest_algorithm: 'sha256', + success_exit_code: 0, + }); + }); +}); diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index d98fac63d..8a58bbc75 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -745,6 +745,51 @@ const dataQualitySurface = { ], }; +const dataEvidenceSnapshot = { + snapshot_version: 'data_quality_evidence_snapshot.v1', + generated_at: '2026-05-28T05:47:00Z', + audit_event: 'data.quality_surface.evidence_snapshot.viewed', + scope_label: 'playwright_dashboard_fixture', + snapshot_digest: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + digest_algorithm: 'sha256', + canonical_payload_fields: [], + privacy_redaction_policy: { + raw_content_exposed: false, + stable_identifiers_exposed: false, + provider_credentials_exposed: false, + redacted_fields: [], + allowed_sample_fields: [], + }, + validation_status: { + status_code: 'ready', + display_name: 'Ready', + detail_text: 'Playwright evidence snapshot is redacted and verifier-ready.', + provider_write_executed: false, + }, + verification_handoff: { + handoff_text: 'Verify the copied Playwright snapshot JSON before sharing diligence materials.', + verifier_command: 'python scripts/verify_evidence_snapshot.py snapshot.json', + accepted_input: 'data_quality_evidence_snapshot.v1 JSON', + digest_algorithm: 'sha256', + excluded_digest_fields: ['snapshot_digest'], + success_exit_code: 0, + failure_exit_codes: { invalid_digest: 2 }, + provider_write_executed: false, + }, + parser_manifest_summary: [], + content_graph_evidence_samples: [], + knowledge_graph_evidence_samples: [], + evidence_packet_checklist: [], + data_room_package_manifest: [], + diligence_exception_register: [], + diligence_risk_matrix: [], + diligence_close_artifact_review_queue: [], + diligence_close_owner_handoff_queue: [], + diligence_close_traceability_map: [], + diligence_close_decision_summary: null, + diligence_close_proof_plan: [], +}; + const accountConfig = { user_id: 'default', smtp_server: 'smtp.example.com', @@ -876,6 +921,11 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string, return; } + if (path === '/api/data/quality-surface/evidence-snapshot' && request.method() === 'GET') { + await fulfillJson(route, dataEvidenceSnapshot); + return; + } + if ( path === '/api/data/documents/doc_repository_ready/webdav-materialization-intent' && request.method() === 'POST'