diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60717cfbb11..9c3cc365a49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,35 @@ jobs: if-no-files-found: error include-hidden-files: true + python-coverage: + name: python / measured coverage floors + needs: ci-config + if: needs.ci-config.outputs.python == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + fetch-depth: 0 + - uses: ./.github/actions/docker-mirror + - uses: ./.github/actions/python-workspace + - name: Measure workspace and required PostgreSQL controls and enforce floors + run: make test-coverage + - name: Publish per-source line/branch totals and skip reasons + if: always() + run: | + python3 scripts/test_summary.py test-results/coverage >> "$GITHUB_STEP_SUMMARY" + python3 scripts/coverage_report.py test-results/coverage/coverage.json \ + --output test-results/coverage/totals.json >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: always() + with: + name: workspace-line-branch-coverage + path: test-results/coverage/ + if-no-files-found: error + include-hidden-files: true + windows-portability: name: windows / compose portability needs: ci-config @@ -369,6 +398,10 @@ jobs: id: observatory_install run: npm ci + - name: Install authoritative API for the method/schema drift gate + if: needs.ci-config.outputs.frontend == 'true' + uses: ./.github/actions/python-workspace + - name: Check formatting with Prettier if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npx prettier --check . @@ -387,12 +420,32 @@ jobs: - name: Build production bundle if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} + id: observatory_build run: npm run build - name: Run tests if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npm test + - name: Install pinned browser driver and Chromium + if: ${{ !cancelled() && steps.observatory_build.outcome == 'success' }} + # Disposable CI-only driver, kept out of the product dependency graph. + run: | # zizmor: ignore[adhoc-packages] + npm install --global agent-browser@0.38.1 + agent-browser install --with-deps + - name: Smoke the built Node app against the controlled authenticated API + if: ${{ !cancelled() && steps.observatory_build.outcome == 'success' }} + env: + SMOKE_ARTIFACT_DIR: ${{ github.workspace }}/test-results/browser + run: node scripts/browser-smoke.mjs + - name: Upload built browser smoke evidence + if: always() && needs.ci-config.outputs.frontend == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: built-observatory-browser-smoke + path: test-results/browser/ + if-no-files-found: error + - name: Install writer dependencies if: ${{ !cancelled() && needs.ci-config.outputs.writer == 'true' }} id: writer_install @@ -448,6 +501,7 @@ jobs: - python-quality - python-core-tests - python-package-tests + - python-coverage - windows-portability - frontend steps: @@ -459,6 +513,7 @@ jobs: PYTHON_QUALITY: ${{ needs.python-quality.result }} PYTHON_CORE_TESTS: ${{ needs.python-core-tests.result }} PYTHON_PACKAGE_TESTS: ${{ needs.python-package-tests.result }} + PYTHON_COVERAGE: ${{ needs.python-coverage.result }} WINDOWS_PORTABILITY: ${{ needs.windows-portability.result }} FRONTEND: ${{ needs.frontend.result }} SELECT_PYTHON: ${{ needs.ci-config.outputs.python }} @@ -480,6 +535,7 @@ jobs: echo "| python / core tests | ${PYTHON_CORE_TESTS} |" echo "| integration / Windows contracts | ${WINDOWS_PORTABILITY} |" echo "| python / package tests | ${PYTHON_PACKAGE_TESTS} |" + echo "| python / line and branch floors | ${PYTHON_COVERAGE} |" echo "| behaviour / JavaScript | ${FRONTEND} |" } >> "$GITHUB_STEP_SUMMARY" @@ -491,7 +547,7 @@ jobs: if [ "$SELECT_PYTHON" = true ]; then expected_python=success; fi if [ "$SELECT_GROUPS" != '[]' ]; then expected_groups=success; fi if [ "$SELECT_FRONTEND" = true ] || [ "$SELECT_WRITER" = true ] || [ "$SELECT_PLUGIN" = true ]; then expected_node=success; fi - for result in "$INSTALLED_PROVIDER_ARTIFACTS" "$PYTHON_CORE_TESTS" "$WINDOWS_PORTABILITY"; do + for result in "$INSTALLED_PROVIDER_ARTIFACTS" "$PYTHON_CORE_TESTS" "$PYTHON_COVERAGE" "$WINDOWS_PORTABILITY"; do test "$result" = "$expected_python" done test "$PYTHON_PACKAGE_TESTS" = "$expected_groups" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 393081b0ebe..7ebc951f30b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -57,6 +57,9 @@ jobs: - name: Run named behavioral contracts if: ${{ !cancelled() && steps.workspace.outcome == 'success' }} run: uv run --locked python scripts/run_integration.py + - name: Publish designated suite execution and skip reasons + if: always() + run: python3 scripts/test_summary.py test-results/integration >> "$GITHUB_STEP_SUMMARY" - name: Upload required suite results if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f diff --git a/Makefile b/Makefile index b25f37716df..eaed08cf60d 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,9 @@ LANE ?= all BASE ?= origin/main PYMDX_DOCS_DIR ?= docs-site PYMDX_DOCS_PORT ?= 3000 +COVERAGE_DIR ?= test-results/coverage -.PHONY: setup setup-js install test \ +.PHONY: setup setup-js install test test-coverage \ dagster superset hub minio pgweb trino nessie grafana prometheus api hasura openmetadata catalog docs-open \ check lint lint-sql lint-python format-python typecheck-python \ dependency-refresh dependency-refresh-check \ @@ -56,6 +57,21 @@ install: test: uv run --locked pytest +test-coverage: + @mkdir -p $(COVERAGE_DIR) + @args=(--cov=src/phlo --cov=scripts); \ + for source in packages/*/src; do args+=("--cov=$$source"); done; \ + COVERAGE_FILE=$(COVERAGE_DIR)/.coverage uv run --locked pytest -m 'not integration' \ + "$${args[@]}" --cov-branch --cov-report=json:$(COVERAGE_DIR)/coverage.json \ + --cov-report= --junitxml=$(COVERAGE_DIR)/results.xml --tb=short -ra || exit $$?; \ + COVERAGE_FILE=$(COVERAGE_DIR)/.coverage uv run --locked python -m pytest \ + -p scripts.ci_required -o addopts= -m integration \ + packages/phlo-api/tests/test_operation_controls_postgres.py \ + "$${args[@]}" --cov-append --cov-branch \ + --cov-report=json:$(COVERAGE_DIR)/coverage.json --cov-report= \ + --junitxml=$(COVERAGE_DIR)/postgres.xml --tb=short -ra + python3 scripts/coverage_report.py $(COVERAGE_DIR)/coverage.json --output $(COVERAGE_DIR)/totals.json + test-core-regression: uv run --locked pytest $(CORE_REGRESSION_TEST_PATHS) -m core_regression $(CORE_REGRESSION_PYTEST_ARGS) diff --git a/conftest.py b/conftest.py index 164a2399153..f74a77f7544 100644 --- a/conftest.py +++ b/conftest.py @@ -85,6 +85,19 @@ def _register_workspace_plugins() -> None: # Import fixtures from phlo_testing - these are auto-discovered by pytest +def pytest_collection_modifyitems(items): + """Service integration directories must not leak into the default unit lane.""" + missing = [ + item.nodeid + for item in items + if "integration" in item.path.parts and not item.get_closest_marker("integration") + ] + if missing: + raise pytest.UsageError( + "Integration paths require the integration marker:\n" + "\n".join(missing) + ) + + @pytest.fixture(autouse=True) def reset_discovery_caches(): """Keep mocked entry points and manifest inputs isolated between tests.""" diff --git a/docs/contributing/testing-gates.md b/docs/contributing/testing-gates.md new file mode 100644 index 00000000000..48176e75fc4 --- /dev/null +++ b/docs/contributing/testing-gates.md @@ -0,0 +1,135 @@ +# Test and contract gates + +## Coverage and required lanes + +`make test-coverage` runs the complete non-integration workspace with branch +coverage, then appends the required PostgreSQL operation-control suite before +checking floors. Docker is required: the suite provisions disposable PostgreSQL +and rejects skips through `scripts.ci_required`. Its public lifecycle tests cover +shared claims, replay, evidence resolution, namespace isolation, advisory locks, +and bounded rate storage. It measures `src/phlo`, `scripts`, and every +`packages/*/src` tree. +`scripts/coverage_report.py` publishes separate line and branch percentages and +checks each against `tests/coverage-floors.json`. A new source tree without a +measurement or floor fails. The existing package-specific coverage collection +remains in CI. + +The floors use a fresh workspace measurement, not the historical September +aggregate. They are rounded down to one decimal place to avoid display-rounding +failures. The JSON artifact contains the unrounded measurements. A floor is a +minimum, not a claim that uncovered code is safe. + +Quality benchmark tests run every worker with real Parquet and DuckDB data and +check emitted counts, late failure positions, merge-key checksums, and module +provenance. They include a partial final file and a singleton final delete batch. +The comparison test launches real before/after worker processes. These are +correctness checks, not speed or memory thresholds. + +`scripts/test_summary.py` publishes executed, skipped, failed, and errored test +counts from JUnit reports, together with skip and xfail reasons. Ordinary +non-integration runs can contain optional skips. Named required lanes cannot: +the existing `scripts.ci_required` plugin rejects skips, xfails, and empty +execution. `test_missing_minio_cannot_pass_required_lane` runs a real MinIO +integration selection against a reserved, non-listening port and verifies both +the failing exit status and the published skip reason. + +The required contracts in `scripts/run_integration.py` are Iceberg, Dagster, +API integration, MinIO migration, dbt quality, schema generation, schema +conversion, and ingestion. They do not claim complete external-service coverage. + +The [fix regression-test-or-justification item proposed in PR1109](https://github.com/phlohouse/phlo/blob/ac75026a89985d009bcb3430830e1b892af67fa4/.github/PULL_REQUEST_TEMPLATE.md#L18-L20) +belongs to the single PR template. This change does not add a competing template +or alter repository settings. + +## Surviving clock, oracle, and taxonomy findings + +The three cited idempotency tests now synchronise with entered and release +events. Asset metadata deadline tests block a worker on an event instead of +guessing when a sleep finishes. Query and service timeout tests await a pending +future that the production deadline cancels. The Loki heartbeat test waits for +the fetch to start instead of a scheduling delay. + +Remaining positive sleeps in API tests poll actual query-worker completion, +Dagster subprocess execution, or a disposable service. Root lifecycle tests +wait for real child-process exits and signal handling. Zero-duration async +sleeps yield control or construct an awaitable fake and do not delay a test. +Those external boundaries remain clock-driven. The inventory command is +`rg 'sleep\(' packages/phlo-api/tests tests`. + +The MinIO suite no longer tests calls made only to its own `MagicMock`, or +literal policy dictionaries that never reach production. Production contributor +and migration tests still exercise configuration, credentials, and storage +operations. Real-service tests remain integration-marked. + +Collection rejects tests under an `integration` directory without an integration +marker. Restore reconciliation and supported-version upgrade tests use injected +in-process providers, so they live under `tests/operations`, not a service lane. +`test_integration_directory_requires_marker` proves both rejected and accepted +collection. + +## Built browser and schema checks + +From `packages/phlo-observatory/src/phlo_observatory`, `npm run build` followed +by `node scripts/browser-smoke.mjs` starts the bundled Node server and a +controlled HTTP API. The smoke navigates the real application, executes SQL +through CodeMirror, checks results, and verifies permission denial removes stale +results. The HTTP fixture verifies the caller bearer and staging environment. +CI uploads screenshots and sanitised request evidence. This is not live +FastAPI, Dagster, PostgreSQL, or Trino acceptance. + +The existing rendered accessibility and keyboard lineage tests remain the +accessibility checks. `route-behaviour.test.tsx` adds pending mutation, completed +result, permission failure, empty result, and registered loading/error boundaries. +The browser covers actual navigation and the real editor. + +`contracts.test.mjs` generates OpenAPI from the installed FastAPI application. +`scripts/client-contracts.mjs` discovers actual production `phloApi` calls and +evaluates their actual Zod response parsers. It compares HTTP methods and response +types, required fields, unions, enums, and nested arrays/objects. JSON-text calls +use their production inner schemas after verifying the real `jsonText` helper +decodes JSON and rejects parse failures. Tests also execute that production +transform. CSV and NDJSON require string response schemas; no-content responses +must have no body. An OpenAPI default annotation does not make a field required. +Negative tests remove GET, rename required fields in ordinary and JSON-text +responses, and break text or no-content declarations. URL-only checks and +duplicated hand-written fixture schemas are not the authority. Untyped API +responses remain failures until API and product owners align their contracts. + +## Retained Node runtime and handler ownership + +The owner-approved #998 scope retains the authenticated TanStack Start Node +runtime. ADR 0032's historical pure-SPA target does not require deleting working +identity forwarding. Phlo API owns authorisation, provider access, queries, +governance, and backend state. Node handlers validate inputs, forward the user's +identity, parse responses, and assemble presentation data only. + +The current 66 production handlers all belong to that API-adapter category. +There are no UI-only server handlers or approved duplicate backend handlers. +Each named handler below stays in its API adapter module. Legacy consumer +migration and URL retirement belong to #1035, not this guard. + +| Adapter file | Count | Handlers retained as authenticated API adapters | +|---|---:|---| +| admin.ts | 7 | `getAdminIdentity`, `changeMemberRoles`, `createInvitation`, `createServiceAccount`, `revokeServiceAccount`, `getAuditLog`, `exportAuditLog` | +| assets.ts | 12 | `getAssetPreview`, `rollbackAssetSnapshot`, `getAssetList`, `getAssetDetail`, `startAssetExactRowCount`, `getMaterializationEstimate`, `materializeAsset`, `backfillAsset`, `createAuditProposal`, `getAuditProposal`, `testAuditProposal`, `publishAuditProposal` | +| branches.ts | 9 | `getBranchesPage`, `getWapRuns`, `getBranchDetail`, `createBranch`, `deleteBranch`, `runBranchChecks`, `rebaseBranch`, `trialMerge`, `signAndMerge` | +| core.ts | 2 | `getShell`, `getOverview` | +| datasets.ts | 1 | `getDatasetInventory` | +| incidents.ts | 10 | `getSchemaDecisions`, `recordSchemaDecision`, `getIncidentList`, `getIncidentDetail`, `createIncident`, `updateIncident`, `setIncidentSubscription`, `createFollowUp`, `updateFollowUp`, `resolveIncident` | +| pipelines.ts | 7 | `getPipelineList`, `getPipelineJob`, `getRunLogPage`, `launchJob`, `changeSchedule`, `cancelRun`, `retryRun` | +| query.ts | 11 | `getQueryHistory`, `getQueryWorkspace`, `submitQuery`, `explainQuery`, `getQuerySession`, `getQueryIncidentTargets`, `pinQueryToIncident`, `cancelQuery`, `downloadQueryCsv`, `saveQuery`, `deleteSavedQuery` | +| reports.ts | 1 | `getRunReport` | +| settings.ts | 2 | `getSettings`, `saveSettings` | +| staging.ts | 4 | `getStagingOverview`, `runStagingChecks`, `resyncStaging`, `promoteStaging` | + +`scripts/adapter-boundary.mjs` inspects production TypeScript with the TypeScript +AST. It rejects provider/database dependencies, I/O outside the authenticated +transport, shared backend credentials/configuration, SQL in adapters, +noncanonical endpoints, and server functions outside the API adapter directory. +Negative tests insert prohibited calls. The guard constrains code boundaries; +it does not prove arbitrary new algorithms have no backend semantics. + +`forwarding.test.ts` uses a real HTTP listener to prove caller bearer precedence, +trusted forwarded bearer fallback, absence of a configured shared-token bypass, +method/body/environment preservation, HTTP failures, unavailable API failures, +and rejection of cross-environment responses. diff --git a/packages/phlo-api/tests/test_loki_api.py b/packages/phlo-api/tests/test_loki_api.py index 338f58f7dd6..4b1a82a24ce 100644 --- a/packages/phlo-api/tests/test_loki_api.py +++ b/packages/phlo-api/tests/test_loki_api.py @@ -166,8 +166,10 @@ async def fetch_entries(**_: object) -> LogQueryResult: @pytest.mark.anyio async def test_run_log_regex_timeout_does_not_stall_heartbeat_request(monkeypatch) -> None: secret_message = "a" * 30 + "!" + fetched = asyncio.Event() async def fetch_entries(**_: object) -> LogQueryResult: + fetched.set() return LogQueryResult( entries=[LogEntry(timestamp="1", level="info", message=secret_message, metadata={})], has_more=False, @@ -187,7 +189,7 @@ async def heartbeat() -> dict[str, bool]: filter_request = asyncio.create_task( client.get("/api/loki/runs/run-1", params={"regex": r"(a+)+$"}) ) - await asyncio.sleep(0.02) + await asyncio.wait_for(fetched.wait(), timeout=5) heartbeat_response = await client.get("/heartbeat") filter_response = await filter_request diff --git a/packages/phlo-api/tests/test_observatory_api.py b/packages/phlo-api/tests/test_observatory_api.py index 92c761034ed..2b1af20e605 100644 --- a/packages/phlo-api/tests/test_observatory_api.py +++ b/packages/phlo-api/tests/test_observatory_api.py @@ -1984,13 +1984,16 @@ def test_observatory_concurrent_idempotency_endpoint_executes_provider_once( ) provider_call_count = 0 provider_lock = threading.Lock() + entered = asyncio.Event() + release = asyncio.Event() async def fake_materialize(asset_id: str, payload, dagster_url: str | None = None): nonlocal provider_call_count with provider_lock: provider_call_count += 1 # Hold the claim open so the contender observes the pending state. - await asyncio.sleep(0.4) + entered.set() + await release.wait() return { "operation": "materialize_asset", "dry_run": payload.dry_run, @@ -2038,7 +2041,13 @@ async def call_once() -> httpx.Response: ) async def main() -> tuple[httpx.Response, httpx.Response]: - return await asyncio.gather(call_once(), call_once()) + first = asyncio.create_task(call_once()) + await asyncio.wait_for(entered.wait(), timeout=5) + try: + second = await call_once() + finally: + release.set() + return await first, second try: r1, r2 = asyncio.run(main()) @@ -3636,6 +3645,72 @@ def test_observatory_operations_classify_terminal_wap_reports( assert wap["health"]["state"] == health_state +def test_maintenance_operations_and_search_read_isolated_telemetry( + monkeypatch, tmp_path, observatory_loaders +) -> None: + from phlo.capabilities import DefaultMaintenanceReadModel + from phlo.capabilities.specs import MaintenanceReadModelSpec + + telemetry = tmp_path / "events.jsonl" + telemetry.write_text( + "\n".join( + json.dumps( + { + "event_type": "telemetry.log", + "name": "iceberg.maintenance.complete", + "timestamp": "2026-06-17T10:00:00+00:00", + "tags": {"operation": "expire_snapshots", "namespace": "raw", "ref": ref}, + "payload": {"status": "succeeded", "duration_seconds": 12.5, "dry_run": True}, + } + ) + for ref in ("main", "other") + ) + + "\n" + ) + monkeypatch.setenv("PHLO_PROJECT_PATH", str(tmp_path)) + monkeypatch.setenv("PHLO_TELEMETRY_PATH", str(telemetry)) + registry = CapabilityRegistry() + registry.register( + "maintenance_read_model", + MaintenanceReadModelSpec(name="default", provider=DefaultMaintenanceReadModel()), + ) + observatory_loaders(capability_registry=registry, assets=[], tables=[], services=[], logs=[]) + observatory._clear_read_model_cache() + client = authenticated_client("admin") + + response = client.get("/api/observatory/operations") + assert response.status_code == 200 + operations = {item["id"]: item for item in response.json()["items"]} + assert set(operations) == {"expire_snapshots:raw:main", "expire_snapshots:raw:other"} + main = operations["expire_snapshots:raw:main"] + assert main["status"] == "succeeded" and main["health"]["state"] == "ok" + assert main["completed_at"] == "2026-06-17T10:00:00+00:00" + assert main["duration_seconds"] == 12.5 + assert main["target"]["id"] == "main" + assert main["metadata"]["dry_run"] is True + assert "completed_at" not in main["metadata"] + + overview = client.get("/api/observatory/overview") + assert overview.status_code == 200 + assert {item["id"] for item in overview.json()["events"]} == { + "operation:expire_snapshots:raw:main", + "operation:expire_snapshots:raw:other", + } + + search = client.get("/api/observatory/search", params={"q": "raw:main"}) + assert search.status_code == 200 + assert search.json()["items"] == [ + { + "id": "operation:expire_snapshots:raw:main", + "label": "expire_snapshots", + "kind": "operation", + "summary": "maintenance · succeeded", + "href": "/operations?operationId=expire_snapshots%3Araw%3Amain", + "metadata": {}, + } + ] + + def test_observatory_branch_detail_uses_wap_report_tables_when_catalog_tables_missing( monkeypatch, tmp_path: Path ) -> None: @@ -3662,13 +3737,25 @@ def test_observatory_branch_detail_uses_wap_report_tables_when_catalog_tables_mi ), encoding="utf-8", ) + (reports_dir / "other.json").write_text( + json.dumps( + { + "run_id": "other", + "status": "promoted", + "branch": "other-branch", + "tables": ["private.other_table"], + "updated_at": "2026-06-17T11:00:00+00:00", + } + ), + encoding="utf-8", + ) monkeypatch.setattr( "phlo_api.observatory_api.observatory._load_branches", lambda: [ ObservatoryBranch( id="pipeline-run-1", name="pipeline-run-1", - metadata={"changed": 1, "tables": 1}, + metadata={"changed": 1, "tables": 1, "ahead": "unavailable", "behind": 5}, ) ], ) @@ -3677,8 +3764,10 @@ def test_observatory_branch_detail_uses_wap_report_tables_when_catalog_tables_mi payload = authenticated_client("admin").get("/api/observatory/branches/pipeline-run-1").json() - assert payload["tables"][0]["id"] == "analytics.orders" + assert [table["id"] for table in payload["tables"]] == ["analytics.orders"] assert payload["contents"][0]["label"] == "orders" + assert payload["compare"]["ahead"] == 0 + assert payload["compare"]["behind"] == 5 def test_observatory_branch_actions_use_registered_catalog_provider( diff --git a/packages/phlo-api/tests/test_observatory_run_action_contract.py b/packages/phlo-api/tests/test_observatory_run_action_contract.py index 4727294520a..6eb19896f8d 100644 --- a/packages/phlo-api/tests/test_observatory_run_action_contract.py +++ b/packages/phlo-api/tests/test_observatory_run_action_contract.py @@ -313,10 +313,13 @@ def test_pending_key_conflicts_without_duplicate_execution(monkeypatch, tmp_path import httpx calls: list[str] = [] + entered = asyncio.Event() + release = asyncio.Event() async def retry_run(run_id: str, payload: Any) -> dict[str, Any]: calls.append(run_id) - await asyncio.sleep(0.4) + entered.set() + await release.wait() return _retry_provider_result("run-new-456") provider = _fake_provider(retry_run=retry_run) @@ -330,8 +333,13 @@ async def call_once() -> httpx.Response: return await client.post(RETRY_URL, json=body, headers=OPERATE_HEADERS) async def main() -> tuple[httpx.Response, httpx.Response]: - first, second = await asyncio.gather(call_once(), call_once()) - return first, second + first = asyncio.create_task(call_once()) + await asyncio.wait_for(entered.wait(), timeout=5) + try: + second = await call_once() + finally: + release.set() + return await first, second r1, r2 = asyncio.run(main()) diff --git a/packages/phlo-api/tests/test_observatory_surfaces.py b/packages/phlo-api/tests/test_observatory_surfaces.py index 97965e4a315..79f9c13d7c4 100644 --- a/packages/phlo-api/tests/test_observatory_surfaces.py +++ b/packages/phlo-api/tests/test_observatory_surfaces.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections.abc import Callable + import pytest from security_test_support import authenticated_client @@ -43,13 +45,19 @@ async def fake_get_runs() -> list[dict[str, object]]: assert runs[0].metadata == {"source": "dagster"} -def test_observatory_runs_endpoint_returns_provider_neutral_shape() -> None: +@pytest.mark.parametrize("next_cursor", [None, "next-page"]) +def test_observatory_runs_endpoint_returns_provider_neutral_shape( + observatory_loaders: Callable[..., dict[str, object]], next_cursor: str | None +) -> None: + observatory_loaders(runs=([], next_cursor)) response = authenticated_client("admin").get("/api/observatory/runs") assert response.status_code == 200 payload = response.json() - assert set(payload) == {"items"} - assert isinstance(payload["items"], list) + expected: dict[str, object] = {"items": []} + if next_cursor is not None: + expected["next_cursor"] = next_cursor + assert payload == expected @pytest.mark.parametrize( diff --git a/packages/phlo-api/tests/test_operation_controls.py b/packages/phlo-api/tests/test_operation_controls.py index 81df8d2e21e..6b9d9cf588b 100644 --- a/packages/phlo-api/tests/test_operation_controls.py +++ b/packages/phlo-api/tests/test_operation_controls.py @@ -145,10 +145,13 @@ def test_concurrent_idempotency_async_executes_provider_once(monkeypatch, tmp_pa """Two concurrent async calls invoke the provider exactly once.""" monkeypatch.setenv("PHLO_PROJECT_PATH", str(tmp_path)) provider_calls: list[int] = [] + entered = asyncio.Event() + release = asyncio.Event() async def execute() -> dict[str, Any]: provider_calls.append(1) - await asyncio.sleep(0.3) + entered.set() + await release.wait() return {"ok": True, "run_id": "run-async-1"} async def caller() -> dict[str, Any]: @@ -160,7 +163,15 @@ async def caller() -> dict[str, Any]: ) async def main() -> tuple[Any, Any]: - return await asyncio.gather(caller(), caller(), return_exceptions=True) + first = asyncio.create_task(caller()) + await asyncio.wait_for(entered.wait(), timeout=5) + try: + second = await caller() + except IdempotencyConflict as exc: + second = exc + finally: + release.set() + return await first, second r1, r2 = asyncio.run(main()) diff --git a/packages/phlo-api/tests/test_operation_controls_postgres.py b/packages/phlo-api/tests/test_operation_controls_postgres.py index 86b9ee0221f..b4fa2ca3877 100644 --- a/packages/phlo-api/tests/test_operation_controls_postgres.py +++ b/packages/phlo-api/tests/test_operation_controls_postgres.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import multiprocessing from concurrent.futures import ThreadPoolExecutor from dataclasses import replace @@ -16,8 +17,13 @@ from testcontainers.postgres import PostgresContainer from phlo.operations.journal import OperationJournalEntry, OperationJournalState +from phlo.plugins.observatory_settings import StorageUnavailableError from phlo_api.api.operation_controls import resolve_idempotency_claim -from phlo_postgres.operation_controls import RATE_BUCKET_CAP, PostgresOperationControls +from phlo_postgres.operation_controls import ( + RATE_BUCKET_CAP, + OperationExclusionConflict, + PostgresOperationControls, +) from process_test_support import process_operation_control_app @@ -192,6 +198,70 @@ def test_shared_journal_blocks_unknown_until_reconciled(postgres_dsn): assert stores[0].claim(replace(entry, operation_id="second")) +def test_shared_claim_resolution_and_namespace_isolation(postgres_dsn): + namespace = str(uuid4()) + store = PostgresOperationControls(postgres_dsn, namespace) + replica = PostgresOperationControls(postgres_dsn, namespace) + other = PostgresOperationControls(postgres_dsn, namespace + "-other") + store.initialize() + assert store.idempotency_target("missing", "restore") is None + assert store.read("missing") is None + assert not store.transition("missing", OperationJournalState.SUCCEEDED) + assert store.claim_idempotency("key", "restore", "original", "resource") == ( + True, + "pending", + "", + ) + assert replica.idempotency_target("key", "restore") == "original" + assert ( + replica.claim_idempotency("key", "restore", "changed", "resource")[1] == "target_mismatch" + ) + assert replica.claim_idempotency("key", "restore", "original", "resource")[1] == "pending" + assert ( + replica.claim_idempotency("new", "restore", "different", "resource")[1] + == "operation_pending" + ) + assert other.claim_idempotency("key", "restore", "original", "resource")[0] + store.finish_idempotency("key", "restore", "original", "unknown") + assert ( + replica.claim_idempotency("new", "restore", "different", "resource")[1] + == "operation_unknown" + ) + evidence = {"provider_lookup": "not applied"} + replica.resolve_idempotency( + "key", "restore", "original", "safe_to_retry", "operator", evidence, None + ) + replica.resolve_idempotency( + "key", "restore", "original", "safe_to_retry", "operator", evidence, None + ) + with pytest.raises(ValueError, match="only pending or unknown"): + replica.resolve_idempotency( + "key", "restore", "original", "safe_to_retry", "other", evidence, None + ) + assert store.claim_idempotency("key", "restore", "original", "resource")[0] + store.finish_idempotency("key", "restore", "original", "completed", {"accepted": True}) + claimed, state, response = replica.claim_idempotency("key", "restore", "original", "resource") + assert (claimed, state) == (False, "completed") + assert json.loads(response) == {"accepted": True} + with pytest.raises(StorageUnavailableError, match="claim changed"): + replica.finish_idempotency("key", "restore", "original", "completed") + assert store.claim_idempotency("resolved", "restore", "second")[0] + store.resolve_idempotency( + "resolved", "restore", "second", "succeeded", "operator", evidence, {"restored": True} + ) + claimed, state, response = replica.claim_idempotency("resolved", "restore", "second") + assert (claimed, state) == (False, "completed") + assert json.loads(response) == {"restored": True} + with store.exclusion("workflow", "resource"): + with pytest.raises(OperationExclusionConflict): + with replica.exclusion("workflow", "resource"): + pytest.fail("independent connection bypassed the exclusion") + with other.exclusion("workflow", "resource"): + pass + with replica.exclusion("workflow", "resource"): + pass + + def test_postgres_rate_storage_is_bounded_and_expired_buckets_are_reclaimed(postgres_dsn): namespace = str(uuid4()) store = PostgresOperationControls(postgres_dsn, namespace) diff --git a/packages/phlo-api/tests/test_operational_observations.py b/packages/phlo-api/tests/test_operational_observations.py index d3c603a3f82..81f06a1ef8f 100644 --- a/packages/phlo-api/tests/test_operational_observations.py +++ b/packages/phlo-api/tests/test_operational_observations.py @@ -744,7 +744,7 @@ async def locations(): async def probe(name, url): if name == "slow": - await asyncio.sleep(1) + await asyncio.Future() return v1.ServiceSnapshot( id=name, status="healthy", diff --git a/packages/phlo-api/tests/test_v1_api.py b/packages/phlo-api/tests/test_v1_api.py index 393a60152b8..14438833b19 100644 --- a/packages/phlo-api/tests/test_v1_api.py +++ b/packages/phlo-api/tests/test_v1_api.py @@ -5,7 +5,7 @@ import asyncio import itertools import json -import time +import threading from datetime import UTC, datetime from typing import Any @@ -1181,15 +1181,20 @@ def test_asset_list_page_stats_have_a_whole_operation_deadline(client, monkeypat async def graphql(*args, **kwargs): return _asset_inventory_response(nodes) + release = threading.Event() + def slow_table_metadata(name, ref): - time.sleep(0.05) + assert release.wait(timeout=5) return {"row_count": 42, "size_bytes": 2048} monkeypatch.setattr(v1_assets, "graphql_request", graphql) monkeypatch.setattr(v1_assets, "_iceberg_asset_metadata", slow_table_metadata) monkeypatch.setattr(v1_assets, "_ASSET_PAGE_STATS_TIMEOUT_SECONDS", 0.01) - response = http.get("/api/v1/assets?env=prod&limit=9") + try: + response = http.get("/api/v1/assets?env=prod&limit=9") + finally: + release.set() assert response.status_code == 200, response.text payload = response.json() diff --git a/packages/phlo-api/tests/test_v1_asset_row_count.py b/packages/phlo-api/tests/test_v1_asset_row_count.py index f6827693e66..645c34b235d 100644 --- a/packages/phlo-api/tests/test_v1_asset_row_count.py +++ b/packages/phlo-api/tests/test_v1_asset_row_count.py @@ -4,6 +4,7 @@ import asyncio import json +import threading import time from fastapi import FastAPI, Request @@ -201,8 +202,10 @@ async def asset_nodes(*_args, **_kwargs): } ] + release = threading.Event() + def delayed_snapshot_lookup(*_args): - time.sleep(0.1) + assert release.wait(timeout=5) return "42" def unexpected_count(*_args, **_kwargs): @@ -213,8 +216,14 @@ def unexpected_count(*_args, **_kwargs): monkeypatch.setattr(v1_assets, "_EXACT_COUNT_SNAPSHOT_LOOKUP_TIMEOUT_SECONDS", 0.01) monkeypatch.setattr(v1_assets, "start_exact_asset_count", unexpected_count) + async def request_and_release(): + try: + await v1_assets.v1_asset_exact_row_count(request, "orders", "prod") + finally: + release.set() + with raises(BackendUnavailableError, match="snapshot identity is unavailable"): - asyncio.run(v1_assets.v1_asset_exact_row_count(request, "orders", "prod")) + asyncio.run(request_and_release()) def test_snapshot_identity_preserves_the_current_iceberg_id(monkeypatch: MonkeyPatch): diff --git a/packages/phlo-api/tests/test_v1_query_api.py b/packages/phlo-api/tests/test_v1_query_api.py index 7cfa4266901..98799829f54 100644 --- a/packages/phlo-api/tests/test_v1_query_api.py +++ b/packages/phlo-api/tests/test_v1_query_api.py @@ -257,7 +257,7 @@ def test_catalog_role_probe_timeout_cancels_pending_work_without_hiding_tables(m async def execute(sql, **kwargs): try: - await asyncio.sleep(10) + await asyncio.Future() finally: cancelled.append(sql) diff --git a/packages/phlo-clickhouse/tests/test_clickhouse_resource.py b/packages/phlo-clickhouse/tests/test_clickhouse_resource.py index 7bd1054dafb..958d2d1524a 100644 --- a/packages/phlo-clickhouse/tests/test_clickhouse_resource.py +++ b/packages/phlo-clickhouse/tests/test_clickhouse_resource.py @@ -4,10 +4,12 @@ flags, including that snapshots are not supported for ClickHouse. """ +import re from types import SimpleNamespace from unittest.mock import MagicMock import pyarrow as pa +import pytest from phlo_clickhouse.resource import CLICKHOUSE_QUERY_ENGINE_SUPPORT, ClickHouseResource @@ -175,15 +177,22 @@ def test_append_parquet_targets_resolved_namespace(monkeypatch): assert result["rows_inserted"] == 1 -def test_query_preview_preserves_columns_and_lookahead(): +@pytest.mark.parametrize("relation,first_id", [("events", 7), ("analytics.events", 41)]) +def test_query_preview_preserves_columns_and_lookahead(relation, first_id): resource, client = _recording_resource() - client.query.return_value = SimpleNamespace( - column_names=["id", "name"], - column_types=["Int64", "String"], - result_rows=[(7, "first"), (11, "second"), (18, "third")], - ) - page = resource.preview("events", schema="raw", limit=2, offset=4) + + def query(sql): + database = re.search(r"FROM `([^`]+)`", sql).group(1) + start = {"raw": 7, "analytics": 41}[database] + return SimpleNamespace( + column_names=["id", "name"], + column_types=["Int64", "String"], + result_rows=[(start, "first"), (start + 4, "second"), (start + 11, "third")], + ) + + client.query.side_effect = query + page = resource.preview(relation, schema="raw", limit=2, offset=4) assert page.columns == ["id", "name"] assert page.column_types == ["Int64", "String"] - assert page.rows == [{"id": 7, "name": "first"}, {"id": 11, "name": "second"}] + assert page.rows == [{"id": first_id, "name": "first"}, {"id": first_id + 4, "name": "second"}] assert page.has_more is True diff --git a/packages/phlo-dlt/tests/test_pandera_checks.py b/packages/phlo-dlt/tests/test_pandera_checks.py index 353509f3450..78ce26a4ddd 100644 --- a/packages/phlo-dlt/tests/test_pandera_checks.py +++ b/packages/phlo-dlt/tests/test_pandera_checks.py @@ -58,6 +58,12 @@ def test_evaluate_pandera_contract_parquet_files_combines_file_set(tmp_path) -> assert evaluation.failed_count == 0 +@pytest.mark.parametrize("mode", ["materialized", "full", "sample"]) +def test_empty_staged_file_set_cannot_pass_validation(mode) -> None: + with pytest.raises(FileNotFoundError, match="Missing parquet_paths"): + evaluate_pandera_contract_parquet_files([], schema_class=MultiFileSchema, mode=mode) + + def test_evaluate_pandera_contract_backfills_missing_nullable_columns() -> None: evaluation = evaluate_pandera_contract( pd.DataFrame([{"id": 1}]), diff --git a/packages/phlo-hasura/tests/test_hasura.py b/packages/phlo-hasura/tests/test_hasura.py index 1ecebaefc4d..176cd915720 100644 --- a/packages/phlo-hasura/tests/test_hasura.py +++ b/packages/phlo-hasura/tests/test_hasura.py @@ -245,6 +245,27 @@ def test_get_tracked_tables(self, mock_request, sample_metadata): assert "api" in tracked assert "glucose_readings" in tracked["api"] + @patch("phlo_hasura.client.requests.request") + def test_get_tables_filters_source_and_schema(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "sources": [ + {"name": "other", "tables": [{"table": {"schema": "api", "name": "hidden"}}]}, + { + "name": "default", + "tables": [ + {"table": {"schema": "public", "name": "unrelated"}}, + {"table": {"schema": "api", "name": "orders"}}, + ], + }, + ] + } + mock_request.return_value = mock_response + client = HasuraClient(admin_secret="test-secret") + assert client.get_tables("api") == ["orders"] + assert client.get_tables("missing") == [] + @patch("phlo_hasura.client.requests.request") def test_request_error_handling(self, mock_request): """Should handle API errors.""" diff --git a/packages/phlo-minio/tests/test_integration_minio.py b/packages/phlo-minio/tests/test_integration_minio.py index 24a3f13a902..f187f042d77 100644 --- a/packages/phlo-minio/tests/test_integration_minio.py +++ b/packages/phlo-minio/tests/test_integration_minio.py @@ -8,7 +8,6 @@ import os import tempfile from pathlib import Path -from unittest.mock import MagicMock import pytest @@ -91,123 +90,6 @@ def test_minio_endpoint_format(self): assert len(endpoint) > 0 -# ============================================================================= -# Bucket Policy Tests (Unit) -# ============================================================================= - - -class TestBucketPolicies: - """Test bucket policy generation.""" - - def test_public_read_policy_structure(self): - """Test public read bucket policy structure.""" - # Standard MinIO/S3 public read policy - policy = { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "PublicRead", - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": ["arn:aws:s3:::test-bucket/*"], - } - ], - } - - assert policy["Version"] == "2012-10-17" - assert len(policy["Statement"]) == 1 - assert policy["Statement"][0]["Effect"] == "Allow" # type: ignore[index] - - def test_read_write_policy_structure(self): - """Test read-write bucket policy structure.""" - policy = { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ReadWrite", - "Effect": "Allow", - "Principal": {"AWS": ["arn:aws:iam::123456789:user/dagster"]}, - "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], - "Resource": ["arn:aws:s3:::warehouse/*"], - } - ], - } - - assert "s3:PutObject" in policy["Statement"][0]["Action"] # type: ignore[index] - assert "s3:GetObject" in policy["Statement"][0]["Action"] # type: ignore[index] - - -# ============================================================================= -# MinIO Client Tests (Mocked) -# ============================================================================= - - -class TestMinioClientMocked: - """Test MinIO client operations with mocks.""" - - def test_bucket_creation_mocked(self): - """Test bucket creation with mocked client.""" - mock_client = MagicMock() - mock_client.bucket_exists.return_value = False - - bucket_name = "test-bucket" - - # Simulate bucket creation - if not mock_client.bucket_exists(bucket_name): - mock_client.make_bucket(bucket_name) - - mock_client.make_bucket.assert_called_once_with(bucket_name) - - def test_file_upload_mocked(self): - """Test file upload with mocked client.""" - mock_client = MagicMock() - - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(b"test content") - temp_path = f.name - - try: - mock_client.fput_object( - bucket_name="test-bucket", - object_name="test.txt", - file_path=temp_path, - ) - - mock_client.fput_object.assert_called_once() - finally: - os.unlink(temp_path) - - def test_file_download_mocked(self, tmp_path): - """Test file download with mocked client.""" - mock_client = MagicMock() - - download_path = Path(tmp_path) / "downloaded.txt" - - mock_client.fget_object( - bucket_name="test-bucket", - object_name="test.txt", - file_path=str(download_path), - ) - - mock_client.fget_object.assert_called_once() - - def test_list_objects_mocked(self): - """Test listing objects with mocked client.""" - mock_client = MagicMock() - - mock_objects = [ - MagicMock(object_name="file1.parquet"), - MagicMock(object_name="file2.parquet"), - MagicMock(object_name="subdir/file3.parquet"), - ] - mock_client.list_objects.return_value = mock_objects - - objects = list(mock_client.list_objects("test-bucket", prefix="")) - - assert len(objects) == 3 - - # ============================================================================= # Functional Integration Tests (Real MinIO if available) # ============================================================================= diff --git a/packages/phlo-observatory/src/phlo_observatory/knip.jsonc b/packages/phlo-observatory/src/phlo_observatory/knip.jsonc index fbc2296f737..245db6ae7c9 100644 --- a/packages/phlo-observatory/src/phlo_observatory/knip.jsonc +++ b/packages/phlo-observatory/src/phlo_observatory/knip.jsonc @@ -6,10 +6,13 @@ "entry": [ // Manual smoke check documented in README.md, run with `node` against a live app. "scripts/verify-api-client.mjs", + "scripts/browser-smoke.mjs", ], "ignoreBinaries": [ // Optional local browser driver the manual smoke check shells out to. "agent-browser", + // The contract gate evaluates the authoritative Python API, not a snapshot. + "uv", ], "ignoreDependencies": [ // Pins the axe rule set that vitest-axe runs in the accessibility tests. diff --git a/packages/phlo-observatory/src/phlo_observatory/scripts/adapter-boundary.mjs b/packages/phlo-observatory/src/phlo_observatory/scripts/adapter-boundary.mjs new file mode 100644 index 00000000000..4ab3c63ab2b --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/scripts/adapter-boundary.mjs @@ -0,0 +1,106 @@ +/** Enforces the API-only Node adapter boundary, not a pure-SPA runtime. */ +import { readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' + +export function checkSource(source, filename) { + const tree = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ) + const errors = [] + const adapter = filename.includes('/lib/data/api/') + const transport = filename.endsWith('/lib/data/api/client.ts') + function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + const name = node.moduleSpecifier?.text + if ( + name && + (name.startsWith('node:') || + /^(fs|http|https|net|tls|child_process|pg|postgres|mysql|better-sqlite3|sqlite3|@aws-sdk|@dagster|trino|nessie|minio)(\/|$)/.test( + name, + )) + ) { + errors.push(`Provider/database access is prohibited: ${name}`) + } + if ( + adapter && + name && + !name.startsWith('.') && + ![ + 'zod', + '@tanstack/react-start', + '@tanstack/react-start/server', + ].includes(name) + ) { + errors.push(`Backend dependency outside API adapters: ${name}`) + } + if (adapter && name?.startsWith('../')) + errors.push( + 'Adapters must not import a parallel backend implementation', + ) + } + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + const name = node.expression.getText(tree) + if ( + /\b(require|import)$/.test(name) || + (/\b(fetch|XMLHttpRequest|WebSocket)$/.test(name) && !transport) + ) + errors.push( + `Only the authenticated API transport may perform I/O: ${name}`, + ) + if (name === 'createServerFn' && !adapter) + errors.push( + 'Server responsibilities belong in authenticated API adapters', + ) + if (name === 'phloApi' && node.arguments?.[0]) { + const path = node.arguments[0].getText(tree) + // Dynamic pagination's API base is independently resolved by the drift gate. + if (!path.includes('api/v1/') && !path.includes('${base}')) + errors.push(`Noncanonical backend endpoint: ${path}`) + } + } + if ( + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + /^process\.env(?:\.|\[)/.test(node.getText(tree)) + ) { + if (!transport || node.getText(tree) !== 'process.env.PHLO_API_URL') + errors.push( + 'Adapters must not resolve provider configuration or shared credentials', + ) + } + if ( + ts.isStringLiteralLike(node) && + /\b(SELECT\s+.+\s+FROM|INSERT\s+INTO|CREATE\s+TABLE|UPDATE\s+.+\s+SET)\b/i.test( + node.text, + ) && + adapter + ) { + errors.push( + 'Backend SQL/source-of-truth logic is prohibited in API adapters', + ) + } + ts.forEachChild(node, visit) + } + visit(tree) + return errors +} + +export function checkTree(directory) { + const errors = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) errors.push(...checkTree(path)) + else if (/\.tsx?$/.test(path) && !/\.(test|spec)\.tsx?$/.test(path)) { + errors.push( + ...checkSource(readFileSync(path, 'utf8'), path).map( + (error) => `${path}: ${error}`, + ), + ) + } + } + return errors +} diff --git a/packages/phlo-observatory/src/phlo_observatory/scripts/api-fixture.mjs b/packages/phlo-observatory/src/phlo_observatory/scripts/api-fixture.mjs new file mode 100644 index 00000000000..38f548ef0c3 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/scripts/api-fixture.mjs @@ -0,0 +1,134 @@ +/** Controlled HTTP fixture for the built-app smoke, never live-service evidence. */ +import { createServer } from 'node:http' + +export function startApiFixture() { + const requests = [] + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://fixture') + const env = url.searchParams.get('env') ?? 'prod' + let body = '' + for await (const chunk of req) body += chunk + requests.push({ + method: req.method, + path: url.pathname, + env, + authorization: req.headers.authorization, + body, + }) + res.setHeader('content-type', 'application/json') + if (req.headers.authorization !== 'Bearer browser-smoke-user') { + res + .writeHead(401) + .end(JSON.stringify({ detail: 'User identity required' })) + return + } + const page = { env, items: [], next_cursor: null } + const ref = env === 'staging' ? 'candidate' : 'main' + const overview = { + env, + asset_count: env === 'staging' ? 7 : 3, + materialized_asset_count: 0, + latest_materialization_at: null, + incident_counts: {}, + freshness_counts: { + fresh: 0, + stale: 0, + unknown: env === 'staging' ? 7 : 3, + }, + run_status_counts: {}, + run_history_truncated: false, + quality_checks: { + status: 'unknown', + counts: null, + reason: 'No checks observed', + }, + } + const responses = { + '/api/v1/me': { + subject: 'browser-smoke', + email: null, + principal_type: 'user', + roles: ['reader'], + permissions: { staging: ['service.read', 'run.read'] }, + }, + '/api/v1/overview': overview, + '/api/v1/services': { + ...page, + dagster_location: 'fixture-location', + nessie_ref: ref, + }, + '/api/v1/sources': page, + '/api/v1/layers': page, + '/api/v1/runs': page, + '/api/v1/incidents': page, + '/api/v1/assets': page, + '/api/v1/jobs': page, + '/api/v1/query/catalog': { + env, + nessie_ref: ref, + engine: 'trino', + catalogs: [ + { + name: 'lake', + truncated: false, + schemas: [{ name: 'raw', tables: ['orders'] }], + }, + ], + }, + '/api/v1/query/refs': { + env, + items: [{ env, name: ref, catalog: 'lake' }], + }, + '/api/v1/query/engines': { + env, + items: [{ id: 'trino', status: 'configured' }], + }, + '/api/v1/queries/saved': page, + '/api/v1/queries': page, + } + if (url.pathname === '/api/v1/queries' && req.method === 'POST') { + const input = JSON.parse(body) + if (input.sql.includes('denied')) { + res.writeHead(403).end(JSON.stringify({ detail: 'Denied query' })) + return + } + res.writeHead(201).end( + JSON.stringify({ + id: 'query-7', + env, + nessie_ref: ref, + engine: 'trino', + evidence_available: true, + status: 'completed', + sql_hash: 'smoke-hash', + created_at: '2026-10-10T10:00:00Z', + updated_at: '2026-10-10T10:00:01Z', + result: { + columns: [{ name: 'answer', type: 'bigint' }], + rows: [{ answer: 7 }], + has_more: false, + }, + error: null, + }), + ) + return + } + const response = responses[url.pathname] + if (!response) { + res + .writeHead(404) + .end( + JSON.stringify({ detail: `Unimplemented fixture ${url.pathname}` }), + ) + return + } + res.end(JSON.stringify(response)) + }) + return new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve({ server, requests, url: `http://127.0.0.1:${address.port}` }) + }) + }) +} diff --git a/packages/phlo-observatory/src/phlo_observatory/scripts/browser-smoke.mjs b/packages/phlo-observatory/src/phlo_observatory/scripts/browser-smoke.mjs new file mode 100644 index 00000000000..76254254cdf --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/scripts/browser-smoke.mjs @@ -0,0 +1,163 @@ +/** Repeatable authenticated smoke against the production Node bundle and HTTP fixture. */ +import assert from 'node:assert/strict' +import { execFile, spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { startApiFixture } from './api-fixture.mjs' + +const session = `smoke-${process.pid}` +const artifactDir = resolve( + process.env.SMOKE_ARTIFACT_DIR ?? '../../../../.amp/in/artifacts/998-browser', +) +mkdirSync(artifactDir, { recursive: true }) +const fixture = await startApiFixture() +const port = process.env.SMOKE_PORT ?? '3109' +const base = `http://127.0.0.1:${port}` +const app = spawn(process.execPath, ['.output/server/index.mjs'], { + env: { + ...process.env, + PORT: port, + HOST: '127.0.0.1', + PHLO_API_URL: fixture.url, + }, + stdio: ['ignore', 'pipe', 'pipe'], +}) +let logs = '' +app.stdout.on('data', (data) => { + logs += data +}) +app.stderr.on('data', (data) => { + logs += data +}) + +async function browser(...args) { + const { stdout } = await promisify(execFile)( + 'agent-browser', + ['--session', session, ...args, '--json'], + { + encoding: 'utf8', + timeout: 40_000, + }, + ) + const result = JSON.parse(stdout) + assert(result.success, result.error) + return result.data +} +async function capture(name) { + await browser('screenshot', resolve(artifactDir, `${name}.png`)) +} + +try { + let ready = false + for (let attempt = 0; attempt < 100; attempt++) { + if (app.exitCode !== null) throw new Error(`Built app exited: ${logs}`) + try { + await fetch(base) + ready = true + break + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + assert(ready, 'Built app did not listen') + await browser( + '--headers', + JSON.stringify({ Authorization: 'Bearer browser-smoke-user' }), + 'open', + `${base}/?env=staging`, + ) + await browser('set', 'viewport', '1280', '800', '2') + await browser('wait', '--text', '0 of 7 fresh') + assert.equal((await browser('get', 'text', 'h1')).text, 'Overview') + assert.match((await browser('get', 'text', 'main')).text, /0 of 7 fresh/) + await capture('01-startup') + // Navigate through the real application link, not a direct route load. + await browser( + 'click', + 'nav[aria-label="Primary navigation"] a[href="/query?env=staging"]', + ) + await browser('wait', '.cm-content') + assert.match((await browser('get', 'url')).url, /\/query\?env=staging/) + await browser('click', '.cm-content') + await browser('press', 'Control+A') + await browser('press', 'Backspace') + await browser('type', '.cm-content', 'SELECT 7 AS answer') + await browser('find', 'role', 'button', 'click', '--name', 'Run', '--exact') + await browser('wait', 'table[aria-label="Query results"] tbody') + assert.equal( + ( + await browser( + 'eval', + `document.querySelector('table[aria-label="Query results"] tbody').rows.length`, + ) + ).result, + 1, + ) + assert.match( + (await browser('get', 'text', 'table[aria-label="Query results"]')).text, + /answer bigint\s+1\s+7/i, + ) + await capture('02-query') + await browser('click', '.cm-content') + await browser('press', 'Control+A') + await browser('press', 'Backspace') + await browser('type', '.cm-content', 'SELECT denied') + await browser('find', 'role', 'button', 'click', '--name', 'Run', '--exact') + await browser('wait', '--text', 'Your account does not have permission') + assert.equal( + ( + await browser( + 'eval', + `document.querySelectorAll('table[aria-label="Query results"]').length`, + ) + ).result, + 0, + ) + await capture('03-permission-error') + const mutations = fixture.requests.filter( + (req) => req.method === 'POST' && req.path === '/api/v1/queries', + ) + assert.equal(mutations.length, 2) + assert( + mutations.every( + (req) => + req.authorization === 'Bearer browser-smoke-user' && + req.env === 'staging', + ), + ) + assert.equal(JSON.parse(mutations[0].body).sql, 'SELECT 7 AS answer') + assert.equal(JSON.parse(mutations[1].body).sql, 'SELECT denied') + writeFileSync( + resolve(artifactDir, 'results.json'), + JSON.stringify( + { + status: 'passed', + checks: [ + 'built startup', + 'navigation preserving staging', + 'query result', + 'permission failure without stale results', + 'Node bearer identity', + ], + requests: fixture.requests.map( + ({ authorization: _token, ...rest }) => rest, + ), + }, + null, + 2, + ), + ) + console.log( + 'PASS: built Node app startup, navigation, query, permission failure, staging and caller identity', + ) +} finally { + try { + await browser('close') + } finally { + app.kill('SIGTERM') + fixture.server.closeAllConnections() + await new Promise((resolve) => fixture.server.close(resolve)) + writeFileSync(resolve(artifactDir, 'app.log'), logs) + } +} diff --git a/packages/phlo-observatory/src/phlo_observatory/scripts/client-contracts.mjs b/packages/phlo-observatory/src/phlo_observatory/scripts/client-contracts.mjs new file mode 100644 index 00000000000..b432245b8a5 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/scripts/client-contracts.mjs @@ -0,0 +1,346 @@ +/** Discovers real endpoint methods and evaluates their production Zod parsers. */ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve, dirname } from 'node:path' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { z } from 'zod' + +const require = createRequire(import.meta.url) +const apiDir = resolve( + dirname(new URL(import.meta.url).pathname), + '../src/lib/data/api', +) +const normalize = (path) => + `/${path + .replace(/^\//, '') + .split('?')[0] + .replace(/\{[^}]*\}/g, '{}')}` + +function alternatives(node, tree) { + if (ts.isStringLiteralLike(node)) return [node.text] + if (ts.isConditionalExpression(node)) + return [ + ...alternatives(node.whenTrue, tree), + ...alternatives(node.whenFalse, tree), + ] + if (ts.isIdentifier(node) && tree) { + const declarations = [] + function find(candidate) { + if ( + ts.isVariableDeclaration(candidate) && + candidate.name.getText(tree) === node.text && + candidate.initializer + ) + declarations.push(candidate.initializer) + ts.forEachChild(candidate, find) + } + find(tree) + if (declarations.length === 1) return alternatives(declarations[0], tree) + } + if (ts.isTemplateExpression(node)) { + let paths = [node.head.text] + for (const span of node.templateSpans) { + let values = ['{}'] + if (ts.isConditionalExpression(span.expression)) + values = alternatives(span.expression, tree) + else if (ts.isIdentifier(span.expression)) { + try { + values = alternatives(span.expression, tree) + } catch { + /* Path parameters remain placeholders. */ + } + } + paths = paths.flatMap((path) => + values.map((value) => path + value + span.literal.text), + ) + } + return paths + } + throw new Error(`Unresolved API expression: ${node.getText()}`) +} + +export function discoverContracts() { + const cache = new Map() + function load(filename) { + if (cache.has(filename)) return cache.get(filename) + const source = readFileSync(filename, 'utf8') + const tree = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ) + const entries = [] + let jsonTextDefinition + function visit(node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(tree) === 'jsonText' + ) + jsonTextDefinition = node.initializer + if ( + ts.isCallExpression(node) && + node.expression.getText(tree) === 'phloApi' + ) { + const [path, parser, options] = node.arguments + let schema = parser + assert(path && schema, 'API call needs a response parser') + const props = + options && ts.isObjectLiteralExpression(options) + ? options.properties + : [] + const method = props.find((p) => p.name?.getText(tree) === 'method') + const methods = method + ? alternatives(method.initializer) + : [ + props.some((p) => p.name?.getText(tree) === 'body') + ? 'POST' + : 'GET', + ] + const paths = alternatives(path, tree).map(normalize) + let text = props.some( + (p) => + p.name?.getText(tree) === 'responseType' && + p.initializer?.getText(tree) === "'text'", + ) + if ( + ts.isCallExpression(schema) && + schema.expression.getText(tree) === 'jsonText' + ) { + assert( + text && schema.arguments.length === 1, + 'Unexpected JSON-text parser call', + ) + const helper = jsonTextDefinition + assert( + helper && + ts.isArrowFunction(helper) && + ts.isCallExpression(helper.body), + 'JSON-text helper changed', + ) + const transform = helper.body.arguments[0] + assert( + helper.body.expression.getText(tree) === 'z.string().transform' && + ts.isArrowFunction(transform) && + ts.isBlock(transform.body), + 'JSON-text helper must decode a string', + ) + const statement = transform.body.statements[0] + assert( + transform.body.statements.length === 1 && + ts.isTryStatement(statement) && + !statement.finallyBlock, + 'JSON-text helper has unchecked branches', + ) + const accepted = statement.tryBlock.statements + const rejected = statement.catchClause?.block.statements + const schemaName = helper.parameters[0].name.getText(tree) + const textName = transform.parameters[0].name.getText(tree) + const contextName = transform.parameters[1].name.getText(tree) + assert( + accepted.length === 1 && + ts.isReturnStatement(accepted[0]) && + accepted[0].expression?.getText(tree).replace(/\s/g, '') === + `${schemaName}.parse(JSON.parse(${textName}))`, + 'JSON-text helper must apply its actual inner schema to parsed JSON', + ) + const rejection = rejected?.[0].expression + assert( + rejected?.length === 2 && + rejection && + ts.isCallExpression(rejection) && + rejection.expression.getText(tree) === + `${contextName}.addIssue` && + rejection.arguments[0]?.properties.some( + (p) => + p.name?.getText(tree) === 'code' && + p.initializer?.text === 'custom', + ) && + ts.isReturnStatement(rejected[1]) && + rejected[1].expression?.getText(tree) === 'z.NEVER', + 'JSON-text helper must reject invalid JSON and schema failures', + ) + // The real helper JSON-decodes text before applying this production parser. + schema = schema.arguments[0] + text = false + } + // Conditional path/method pairs use the same condition in saveQuery. + for (const [i, route] of paths.entries()) { + const verb = methods.length === paths.length ? methods[i] : methods[0] + entries.push( + `{path:${JSON.stringify(route)},method:${JSON.stringify(verb.toLowerCase())},text:${text},schema:${schema.getText(tree)},parser:${parser.getText(tree)}}`, + ) + } + } + ts.forEachChild(node, visit) + } + visit(tree) + const module = { exports: {} } + cache.set(filename, module.exports) + const code = ts.transpileModule( + `${source}\nexport const __contracts = [${entries.join(',')}];`, + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText + const serverFn = () => { + const builder = { + inputValidator: () => builder, + validator: () => builder, + handler: (fn) => fn, + } + return builder + } + runInNewContext( + code, + { + module, + exports: module.exports, + require: (name) => { + if (name === '@tanstack/react-start') + return { createServerFn: serverFn, createServerOnlyFn: (fn) => fn } + if (name === '@tanstack/react-start/server') + return { getRequestHeader: () => undefined } + if (name.startsWith('.')) + return load(resolve(dirname(filename), `${name}.ts`)) + return require(name) + }, + }, + { filename }, + ) + return module.exports + } + return readdirSync(apiDir) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) + .flatMap((f) => + load(resolve(apiDir, f)).__contracts.map((c) => ({ ...c, file: f })), + ) +} + +function dereference(schema, document) { + if (!schema.$ref) return schema + return schema.$ref + .split('/') + .slice(1) + .reduce((node, key) => node[key], document) +} + +function compatible(api, client, document, location) { + api = dereference(api, document) + if (api.anyOf) { + for (const option of api.anyOf) + compatible(option, client, document, location) + return + } + if (client.anyOf) { + assert( + client.anyOf.some((option) => { + try { + compatible(api, option, document, location) + return true + } catch { + return false + } + }), + `${location}: incompatible union`, + ) + return + } + if (client.type) { + const accepted = Array.isArray(client.type) ? client.type : [client.type] + assert( + accepted.includes(api.type) || + (api.type === 'integer' && accepted.includes('number')), + `${location}: ${api.type} not ${client.type}`, + ) + } + if (client.enum) + assert( + api.enum?.every((value) => client.enum.includes(value)), + `${location}: enum drift`, + ) + if (client.const !== undefined) + assert( + api.const === client.const || + (api.enum?.length === 1 && api.enum[0] === client.const), + `${location}: constant drift`, + ) + for (const key of client.required ?? []) { + assert( + api.properties?.[key] && api.required?.includes(key), + `${location}: API no longer requires ${key}`, + ) + } + for (const [key, value] of Object.entries(client.properties ?? {})) { + if (api.properties?.[key]) + compatible(api.properties[key], value, document, `${location}.${key}`) + } + if (client.items) + compatible(api.items ?? {}, client.items, document, `${location}[]`) +} + +export function checkContract(document, contract) { + const path = Object.keys(document.paths).find( + (path) => normalize(path) === contract.path, + ) + assert(path, `${contract.file}: API route missing ${contract.path}`) + const operation = document.paths[path][contract.method] + assert( + operation, + `${contract.file}: API method missing ${contract.method.toUpperCase()} ${path}`, + ) + const successes = Object.entries(operation.responses).filter(([status]) => + /^2\d\d$/.test(status), + ) + assert(successes.length, `${path}: no success response`) + const client = z.toJSONSchema(contract.schema, { + io: 'input', + unrepresentable: 'any', + }) + for (const [status, response] of successes) { + if (contract.text) { + const output = z.toJSONSchema(contract.schema, { + io: 'output', + unrepresentable: 'any', + }) + assert( + client.type === 'string' && output.type === 'string', + `${path}: unrecognised text parser`, + ) + if (status === '204') { + assert( + !response.content, + `${path}: no-content response unexpectedly has a body`, + ) + continue + } + const api = Object.values(response.content ?? {}).find( + (media) => media.schema?.type === 'string', + )?.schema + assert(api, `${path}: API text success response has no string schema`) + compatible( + api, + client, + document, + `${contract.method.toUpperCase()} ${path}`, + ) + continue + } + const api = response.content?.['application/json']?.schema + assert( + api && Object.keys(api).length, + `${path}: API success response has no schema`, + ) + compatible( + api, + client, + document, + `${contract.method.toUpperCase()} ${path}`, + ) + } +} diff --git a/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/boundary.test.mjs b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/boundary.test.mjs new file mode 100644 index 00000000000..926df096be7 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/boundary.test.mjs @@ -0,0 +1,34 @@ +/** Negative regressions for provider I/O and duplicate backend responsibilities. */ +import { describe, expect, it } from 'vitest' +import { + checkSource, + checkTree, +} from '../../../../scripts/adapter-boundary.mjs' + +describe('the retained Node API adapter boundary', () => { + it('permits current production code but not provider/database/source-of-truth access', () => { + expect(checkTree('src')).toEqual([]) + const file = '/src/lib/data/api/new-adapter.ts' + for (const source of [ + "import { Pool } from 'pg'; new Pool().query('SELECT * FROM runs')", + "fetch('http://dagster/graphql', {method: 'POST'})", + 'const token = process.env.PHLO_SERVICE_TOKEN', + "phloApi('http://nessie/api/v2/trees', schema)", + "const sql = 'INSERT INTO query_sessions VALUES ($1)'", + "const pg = await import('postgres')", + "const token = process.env['PHLO_SERVICE_TOKEN']", + "import backend from '../provider'", + ]) + expect(checkSource(source, file), source).not.toEqual([]) + expect( + checkSource("require('pg')", '/src/lib/data/api/client.ts'), + ).not.toEqual([]) + expect( + checkSource( + 'createServerFn({method: "POST"}).handler(() => ({}))', + '/src/server/duplicate.ts', + ), + ).not.toEqual([]) + expect(checkSource("phloApi('api/v1/runs', runSchema)", file)).toEqual([]) + }) +}) diff --git a/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/contracts.test.mjs b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/contracts.test.mjs new file mode 100644 index 00000000000..30120d6a927 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/contracts.test.mjs @@ -0,0 +1,115 @@ +/** Checks actual client methods/parsers against freshly generated FastAPI OpenAPI. */ +import { execFileSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { + checkContract, + discoverContracts, +} from '../../../../scripts/client-contracts.mjs' + +const document = JSON.parse( + execFileSync( + 'uv', + [ + 'run', + '--locked', + 'python', + '-c', + 'import json; from phlo_api.main import app; print(json.dumps(app.openapi()))', + ], + { cwd: '../../../..', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }, + ), +) +const contracts = discoverContracts() + +describe('API/client method and response drift', () => { + it('checks every discovered production phloApi call against real OpenAPI', () => { + expect(contracts.length).toBeGreaterThan(60) + const failures = [] + for (const contract of contracts) { + try { + checkContract(document, contract) + } catch (error) { + failures.push(String(error)) + } + } + expect(failures).toEqual([]) + }) + + it('rejects an incompatible method and a renamed required API field', () => { + const overview = contracts.find((c) => c.path === '/api/v1/overview') + expect(overview).toBeDefined() + const wrongMethod = structuredClone(document) + wrongMethod.paths['/api/v1/overview'].post = + wrongMethod.paths['/api/v1/overview'].get + delete wrongMethod.paths['/api/v1/overview'].get + expect(() => checkContract(wrongMethod, overview)).toThrow( + 'API method missing GET', + ) + const wrongField = structuredClone(document) + const model = wrongField.components.schemas.OverviewResponse + model.properties.assets_total = model.properties.asset_count + delete model.properties.asset_count + model.required = model.required.map((key) => + key === 'asset_count' ? 'assets_total' : key, + ) + expect(() => checkContract(wrongField, overview)).toThrow( + 'API no longer requires asset_count', + ) + const defaultOnly = structuredClone(document) + const optionalCount = defaultOnly.components.schemas.OverviewResponse + optionalCount.properties.asset_count.default = 0 + optionalCount.required = optionalCount.required.filter( + (key) => key !== 'asset_count', + ) + expect(() => checkContract(defaultOnly, overview)).toThrow( + 'API no longer requires asset_count', + ) + }) + + it('rejects required-field drift inside production JSON-text transforms', () => { + const createIncident = contracts.find( + (c) => c.path === '/api/v1/incidents' && c.method === 'post', + ) + expect(createIncident).toBeDefined() + // Execute the actual production transform, including both rejection paths. + expect(createIncident.parser.safeParse('{').success).toBe(false) + expect(createIncident.parser.safeParse('{}').success).toBe(false) + const wrongField = structuredClone(document) + const model = wrongField.components.schemas.IncidentView + model.properties.incident_id = model.properties.id + delete model.properties.id + model.required = model.required.map((key) => + key === 'id' ? 'incident_id' : key, + ) + expect(() => checkContract(wrongField, createIncident)).toThrow( + 'API no longer requires id', + ) + }) + + it('rejects incompatible CSV, NDJSON, and no-content declarations', () => { + const textContracts = contracts.filter((c) => c.text) + expect(textContracts.length).toBeGreaterThanOrEqual(3) + for (const contract of textContracts) { + const changed = structuredClone(document) + const path = Object.keys(changed.paths).find( + (path) => path.replace(/\{[^}]*\}/g, '{}') === contract.path, + ) + const responses = changed.paths[path][contract.method].responses + if (responses['204']) { + responses['204'].content = { + 'application/json': { schema: { type: 'object' } }, + } + expect(() => checkContract(changed, contract)).toThrow( + 'no-content response unexpectedly has a body', + ) + } else { + for (const response of Object.values(responses)) + for (const media of Object.values(response.content ?? {})) + if (media.schema?.type === 'string') media.schema.type = 'object' + expect(() => checkContract(changed, contract)).toThrow( + 'API text success response has no string schema', + ) + } + } + }) +}) diff --git a/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/forwarding.test.ts b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/forwarding.test.ts new file mode 100644 index 00000000000..4a045f05056 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/src/lib/data/api/forwarding.test.ts @@ -0,0 +1,119 @@ +/** Exercises the Node transport against a real HTTP boundary with request-scoped identity. */ +import { createServer } from 'node:http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { overviewSchema, phloApi } from './client' + +const requestHeaders = vi.hoisted(() => new Map()) +vi.mock('@tanstack/react-start', () => ({ + createServerOnlyFn: (fn: unknown) => fn, +})) +vi.mock('@tanstack/react-start/server', () => ({ + getRequestHeader: (name: string) => requestHeaders.get(name), +})) +afterEach(() => { + requestHeaders.clear() + vi.unstubAllEnvs() +}) + +describe('authenticated Node forwarding', () => { + it('uses each caller identity and exact mutation intent, never a configured shared token', async () => { + const received: Array<{ + authorization?: string + path?: string + method?: string + body: string + }> = [] + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) body += chunk + received.push({ + authorization: req.headers.authorization, + path: req.url, + method: req.method, + body, + }) + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ env: 'staging', accepted: true })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('No HTTP listener') + vi.stubEnv('PHLO_API_URL', `http://127.0.0.1:${address.port}`) + vi.stubEnv('PHLO_API_TOKEN', 'shared-token-must-not-be-used') + const schema = z.object({ + env: z.literal('staging'), + accepted: z.boolean(), + }) + try { + requestHeaders.set('authorization', 'Bearer alice') + requestHeaders.set('x-auth-request-access-token', 'proxy-token') + await expect( + phloApi('/api/v1/queries?env=staging', schema, { + env: 'staging', + method: 'POST', + body: { sql: 'SELECT 7' }, + idempotencyKey: 'once', + }), + ).resolves.toEqual({ env: 'staging', accepted: true }) + requestHeaders.delete('authorization') + await phloApi('/api/v1/overview?env=staging', schema, { env: 'staging' }) + requestHeaders.clear() + await phloApi('/api/v1/overview?env=staging', schema) + expect(received).toEqual([ + { + authorization: 'Bearer alice', + path: '/api/v1/queries?env=staging', + method: 'POST', + body: '{"sql":"SELECT 7"}', + }, + { + authorization: 'Bearer proxy-token', + path: '/api/v1/overview?env=staging', + method: 'GET', + body: '', + }, + { + authorization: undefined, + path: '/api/v1/overview?env=staging', + method: 'GET', + body: '', + }, + ]) + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ) + } + }) + + it('preserves permission/backend failures and rejects cross-environment payloads at the actual transport', async () => { + vi.stubEnv('PHLO_API_URL', 'http://api.invalid') + const fetch = vi.spyOn(globalThis, 'fetch') + try { + for (const status of [401, 403, 503]) { + fetch.mockResolvedValueOnce( + Response.json({ detail: 'private' }, { status }), + ) + await expect( + phloApi('api/v1/overview?env=staging', overviewSchema, { + env: 'staging', + }), + ).rejects.toMatchObject({ status, name: 'PhloApiError' }) + } + fetch.mockResolvedValueOnce(Response.json({ env: 'prod' })) + await expect( + phloApi('api/v1/overview?env=staging', z.object({ env: z.string() }), { + env: 'staging', + }), + ).rejects.toThrow('different environment') + fetch.mockRejectedValueOnce(new Error('private network detail')) + await expect(phloApi('api/v1/overview', overviewSchema)).rejects.toThrow( + 'Phlo API is unreachable.', + ) + } finally { + fetch.mockRestore() + } + }) +}) diff --git a/packages/phlo-observatory/src/phlo_observatory/src/routes/route-behaviour.test.tsx b/packages/phlo-observatory/src/phlo_observatory/src/routes/route-behaviour.test.tsx new file mode 100644 index 00000000000..30636407ce6 --- /dev/null +++ b/packages/phlo-observatory/src/phlo_observatory/src/routes/route-behaviour.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +/** Exercises the real query route and its shared pending/error boundary. */ +import * as React from 'react' +import { act, cleanup, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Route } from './_app/query' +import { Route as ShellRoute } from './_app' +import type { QuerySession } from '@/lib/data/api/query' +import type * as QueryApi from '@/lib/data/api/query' +import { PhloApiError } from '@/lib/data/api/client' +import { clearQueryWorkspaces } from '@/lib/query-workspace' + +const submit = vi.hoisted(() => vi.fn()) +const state = { + loader: { + catalog: { + env: 'staging', + nessie_ref: 'candidate', + engine: 'trino', + catalogs: [], + }, + refs: [{ name: 'candidate' }], + engines: [{ id: 'trino', status: 'configured' }], + saved: [], + }, +} +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (options: { component?: React.ComponentType }) => ({ + options, + useLoaderData: () => state.loader, + useSearch: () => ({ env: 'staging', sql: 'SELECT 7 AS answer' }), + }), + getRouteApi: () => ({ + useLoaderData: () => ({ + me: { principal_type: 'user', subject: 'route-test' }, + }), + }), + useRouter: () => ({ invalidate: () => Promise.resolve() }), + Link: ({ to, children }: { to?: string; children: React.ReactNode }) => ( + {children} + ), +})) +vi.mock('@/lib/data/api/query', async (importOriginal) => ({ + ...(await importOriginal()), + submitQuery: submit, +})) +// CodeMirror's real DOM/input path is exercised by the built-browser smoke. +vi.mock('@/components/query/sql-editor', () => ({ + SqlEditor: ({ + value, + onChange, + }: { + value: string + onChange: (value: string) => void + }) => ( +