From 38ae2973c9e9ce241600d334616191d462656460 Mon Sep 17 00:00:00 2001 From: Li SiYi Date: Tue, 13 Oct 2026 12:25:01 +0000 Subject: [PATCH] feat: add safe read-only account facts identity diagnosis --- .../account-facts-identity-diagnose.yml | 54 +++ docs/digest_candidates_producer.zh-CN.md | 8 + scripts/diagnose_account_facts_identity.py | 203 +++++++++ tests/test_diagnose_account_facts_identity.py | 412 ++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 .github/workflows/account-facts-identity-diagnose.yml create mode 100644 scripts/diagnose_account_facts_identity.py create mode 100644 tests/test_diagnose_account_facts_identity.py diff --git a/.github/workflows/account-facts-identity-diagnose.yml b/.github/workflows/account-facts-identity-diagnose.yml new file mode 100644 index 0000000..446a1a5 --- /dev/null +++ b/.github/workflows/account-facts-identity-diagnose.yml @@ -0,0 +1,54 @@ +name: Diagnose Account Facts Identity (read only) + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + diagnose: + name: Read identity for ${{ matrix.target }} + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + target: [primary-live, additional-1, additional-2, additional-3] + env: + IBKR_ACCOUNT_FACTS_PROJECT_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_PROJECT_ID }} + IBKR_ACCOUNT_FACTS_TARGET: ${{ matrix.target }} + IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }} + IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }} + IBKR_ACCOUNT_FACTS_SERVICE_NAME: ${{ secrets.IBKR_ACCOUNT_FACTS_SERVICE_NAME }} + IBKR_ACCOUNT_FACTS_RUNTIME_REVISION: ${{ secrets.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION }} + IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE }} + IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON }} + IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR: ${{ secrets.IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR }} + IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON }} + # Existing platform region; not inferred from a report or a public example. + CLOUD_RUN_REGION: us-central1 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Reject invalid slot or protected configuration without cloud reads + run: python3 scripts/diagnose_account_facts_identity.py --preflight + + - name: Authenticate with the existing identity + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + service_account: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + + - name: Set up read-only cloud commands + uses: google-github-actions/setup-gcloud@v3 + + - name: Read report and service revision equality + run: python3 scripts/diagnose_account_facts_identity.py diff --git a/docs/digest_candidates_producer.zh-CN.md b/docs/digest_candidates_producer.zh-CN.md index 6f726a9..a18e111 100644 --- a/docs/digest_candidates_producer.zh-CN.md +++ b/docs/digest_candidates_producer.zh-CN.md @@ -121,6 +121,14 @@ publisher 遇到既有身份失配时保留首行 `skipped:runtime_target_mismat `mismatched_fields` 仅覆盖原拒绝顺序中第一失败检查段:先比较部署/账户身份,只有通过后才比较 receipt revision;更后的账户快照、时点等检查保持原顺序。该列表不是所有字段的完整差异证明。元数据只添加到身份失配的 `skipped` 结果,不进入成功的 account-facts payload,也不改变接受/拒绝决定;真实根因与恢复仍需对应私有事实及自然运行验收。 +### 四槽位只读身份诊断 + +`.github/workflows/account-facts-identity-diagnose.yml` 仅允许在 `main` 手动运行,固定检查 `primary-live` 与三个 additional 槽位。先执行无网络配置预检,再复用现有 WIF;只读取受保护 prefix 下的最新报告及期望 service 的 Cloud Run metadata,不调用 publisher、Telegram、交易接口或写入 GCS,也不上传报告/artifact。原纯投影 guard、拒绝顺序和生产配置保持。 + +`scripts/diagnose_account_facts_identity.py` 输出固定原因码、失配字段名、guard/freshness 状态、报告/配置/ready/receipt revision 的 equality 布尔及完整合法的 source SHA;缺身份为 `null`。`identity_verified` 仅表示原投影 guard 通过,不等于独立发布批准、依赖安装证明或自然候选恢复。36 小时/未来 5 分钟 freshness 边界沿用 publisher;退出 0 还需要新鲜报告和 ready revision 相等,失配/读取不可用退出 1,但仍输出脱敏诊断。 + +Cloud Run 查询 region 明确沿用现有平台 `us-central1`,不从报告或公开 synthetic manifest 推断;独立调用时缺少/非法 `CLOUD_RUN_REGION` 则不查询服务、metadata unavailable。即使报告与当前服务相等,也不能据此更新期望 revision 或批准身份。该诊断不公开配置原值、账户/目标/service、URI、revision 值、金额、异常内容或 gcloud stderr;最新报告不是所有历史报告的覆盖证明,镜像/安装/config/risk 指纹未取得时继续 UNKNOWN。 + ## 如何注入 QRS(人工,不自动改生产 Environment) 中央文档:QuantRuntimeSettings `docs/digest-candidates-wiring.zh-CN.md`(PR #586)。 diff --git a/scripts/diagnose_account_facts_identity.py b/scripts/diagnose_account_facts_identity.py new file mode 100644 index 0000000..cbe1e66 --- /dev/null +++ b/scripts/diagnose_account_facts_identity.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Read protected report identity and Cloud Run metadata without publishing.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta, timezone +from typing import Any + +try: + from scripts import publish_account_facts_from_report as publisher +except ModuleNotFoundError: + import publish_account_facts_from_report as publisher # type: ignore[no-redef] + + +SCHEMA = "ibkr.account_facts_identity_diagnostic.v1" +_REASONS = frozenset({ + "target_disabled", "target_config_unavailable", "target_config_invalid", + "target_config_valid", "report_listing_failed", "report_unavailable", + "report_read_failed", "runtime_report_invalid", "expected_target_invalid", + "report_provenance_invalid", "runtime_target_mismatch", "account_facts_invalid", + "account_identity_mismatch", "observation_invalid", "identity_verified", + "diagnostic_error", "arguments_invalid", +}) +_EQUALITY_FIELDS = ( + "report_equals_configured", "configured_equals_ready", "report_equals_ready", + "ready_equals_latest_created", "receipt_equals_report", +) + + +def _result(*, source_sha: object = None, reason: str, status: str = "unavailable") -> dict[str, Any]: + return { + "schema_version": SCHEMA, + "source_sha": source_sha if isinstance(source_sha, str) and re.fullmatch(r"[0-9a-fA-F]{40}", source_sha) else None, + "status": status, + "reason": reason if isinstance(reason, str) and reason in _REASONS else "diagnostic_error", + "mismatched_fields": [], + "projection_passed": None, + "freshness_status": "unknown", + "cloud_run_metadata_status": "unavailable", + "revision_equalities": dict.fromkeys(_EQUALITY_FIELDS), + } + + +def resolve_expected() -> dict[str, Any]: + """Validate a fixed slot before any cloud authentication or report read.""" + target = os.environ.get("IBKR_ACCOUNT_FACTS_TARGET", "") + if target in publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS: + selected = publisher.additional_target_environment(target) + elif target == publisher.IBKR_ACCOUNT_FACTS_PRIMARY_TARGET: + selected = os.environ + else: + raise publisher._ProjectionError("target_disabled") + + def setting(name: str) -> str: + value = publisher._required_private_setting(name) if selected is os.environ else selected[name] + if not publisher._single_line_text(value): + raise publisher._ProjectionError("target_config_invalid") + return value + + try: + selector = publisher._selector(json.loads(setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON"))) + except (json.JSONDecodeError, TypeError): + raise publisher._ProjectionError("target_config_invalid") from None + if len(selector) != 1 or publisher._ACCOUNT_ID.fullmatch(selector[0]) is None: + raise publisher._ProjectionError("target_config_invalid") + expected = { + "target_id": setting("IBKR_ACCOUNT_FACTS_TARGET_ID"), + "expected_report_prefix": setting("IBKR_ACCOUNT_FACTS_REPORT_PREFIX"), + "expected_project_id": setting("IBKR_ACCOUNT_FACTS_PROJECT_ID"), + "expected_service_name": setting("IBKR_ACCOUNT_FACTS_SERVICE_NAME"), + "expected_runtime_revision": setting("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION"), + "expected_account_scope": setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE"), + "expected_account_selector": selector, + "expected_deployment_selector": setting("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR"), + } + if publisher._TARGET_ID.fullmatch(expected["target_id"]) is None: + raise publisher._ProjectionError("target_config_invalid") + try: + prefix = expected["expected_report_prefix"] + publisher._source_report_uri(prefix.rstrip("/") + "/preflight.json", prefix) + except publisher._ProjectionError: + raise publisher._ProjectionError("target_config_invalid") from None + return expected + + +def _equal(left: object, right: object) -> bool | None: + first, second = publisher._exact_text(left), publisher._exact_text(right) + return first == second if first and second else None + + +def diagnose_identity( + report: Mapping[str, Any], *, expected: Mapping[str, Any], source_report_uri: str, + now: datetime, cloud_run_metadata: object = None, source_sha: object = None, +) -> dict[str, Any]: + """Pure diagnostic: preserve the projection guard and discard all private values.""" + projected = publisher.project_ibkr_account_facts_history( + report, source_report_uri=source_report_uri, **expected, + ) + passed = projected.get("schema_version") == publisher.HISTORY_SCHEMA + result = _result(source_sha=source_sha, status="diagnosed", + reason="identity_verified" if passed else projected.get("reason", "diagnostic_error")) + result["projection_passed"] = passed + if result["reason"] == "runtime_target_mismatch": + result["mismatched_fields"] = publisher._safe_mismatched_fields(projected.get("mismatched_fields")) + + summary = report.get("summary") if isinstance(report, Mapping) else None + facts = summary.get("account_facts") if isinstance(summary, Mapping) else None + try: + observed = publisher._observed_timestamp(facts.get("observed_at") if isinstance(facts, Mapping) else None) + if now.tzinfo is None or now.utcoffset() is None: + raise publisher._ProjectionError("observation_invalid") + current = now.astimezone(timezone.utc) + stale = observed < current - publisher.IBKR_ACCOUNT_FACTS_MAX_AGE or observed > current + timedelta(minutes=5) + result["freshness_status"] = "stale" if stale else "fresh" + except publisher._ProjectionError: + pass + + diagnostics = report.get("diagnostics") if isinstance(report, Mapping) else None + receipt = report.get("runtime_release_receipt") if isinstance(report, Mapping) else None + revision = diagnostics.get("runtime_revision") if isinstance(diagnostics, Mapping) else None + receipt_revision = receipt.get("runtime_revision") if isinstance(receipt, Mapping) else None + equalities = result["revision_equalities"] + equalities["report_equals_configured"] = _equal(revision, expected["expected_runtime_revision"]) + equalities["receipt_equals_report"] = _equal(receipt_revision, revision) + if cloud_run_metadata is not None: + metadata = cloud_run_metadata.get("metadata") if isinstance(cloud_run_metadata, Mapping) else None + status = cloud_run_metadata.get("status") if isinstance(cloud_run_metadata, Mapping) else None + if ( + not isinstance(metadata, Mapping) or not isinstance(status, Mapping) + or _equal(metadata.get("name"), expected["expected_service_name"]) is not True + ): + result["cloud_run_metadata_status"] = "invalid" + else: + result["cloud_run_metadata_status"] = "read" + ready = status.get("latestReadyRevisionName") + equalities["configured_equals_ready"] = _equal(expected["expected_runtime_revision"], ready) + equalities["report_equals_ready"] = _equal(revision, ready) + equalities["ready_equals_latest_created"] = _equal(ready, status.get("latestCreatedRevisionName")) + return result + + +def _read_cloud_run_metadata(expected: Mapping[str, Any]) -> object: + # The explicit existing regional contract is independent of the report. + # Missing region remains unknown; never search other services or regions. + region = os.environ.get("CLOUD_RUN_REGION", "") + if re.fullmatch(r"[a-z]+-[a-z]+[0-9]+", region) is None: + return None + try: + result = subprocess.run( + ("gcloud", "run", "services", "describe", expected["expected_service_name"], + "--project", expected["expected_project_id"], "--region", region, "--format=json"), + capture_output=True, text=True, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode: + return None + try: + return json.loads(result.stdout) + except (json.JSONDecodeError, TypeError): + return None + + +def main(argv: Sequence[str] | None = None, *, now: datetime | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + source_sha = os.environ.get("GITHUB_SHA") + if args not in ([], ["--preflight"]): + print(json.dumps(_result(source_sha=source_sha, reason="arguments_invalid"), sort_keys=True)) + return 1 + try: + expected = resolve_expected() + if args: + print(json.dumps(_result(source_sha=source_sha, status="preflight", reason="target_config_valid"), sort_keys=True)) + return 0 + current = now if now is not None else datetime.now(timezone.utc) + uri = publisher._latest_report_uri(prefix=expected["expected_report_prefix"], + project_id=expected["expected_project_id"], now=current) + report = publisher._load_gcs_report(uri, project_id=expected["expected_project_id"]) + metadata = _read_cloud_run_metadata(expected) + result = diagnose_identity(report, expected=expected, source_report_uri=uri, + now=current, cloud_run_metadata=metadata, source_sha=source_sha) + except publisher._ProjectionError as exc: + result = _result(source_sha=source_sha, reason=exc.reason) + except Exception: + # No exception type, subprocess stderr, URI, or private values are emitted. + result = _result(source_sha=source_sha, reason="diagnostic_error") + print(json.dumps(result, sort_keys=True)) + return 0 if ( + result["projection_passed"] is True and result["freshness_status"] == "fresh" + and result["cloud_run_metadata_status"] == "read" + and result["revision_equalities"]["configured_equals_ready"] is True + and result["revision_equalities"]["report_equals_ready"] is True + ) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_diagnose_account_facts_identity.py b/tests/test_diagnose_account_facts_identity.py new file mode 100644 index 0000000..f815dd3 --- /dev/null +++ b/tests/test_diagnose_account_facts_identity.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from scripts import diagnose_account_facts_identity as diagnostic +from scripts import publish_account_facts_from_report as publisher + + +NOW = datetime(2026, 10, 13, 12, tzinfo=timezone.utc) +SHA = "a" * 40 +PRIVATE = "private-marker-never-public" + + +@pytest.fixture(autouse=True) +def no_external_calls(monkeypatch): + def forbidden(*_args, **_kwargs): + pytest.fail("synthetic diagnostic must not make external calls or publish") + + monkeypatch.setattr(publisher.subprocess, "run", forbidden) + monkeypatch.setattr(publisher, "publish_ibkr_account_facts_history", forbidden) + monkeypatch.setattr(publisher, "main", forbidden) + monkeypatch.setattr(publisher, "build_opener", forbidden) + + +def _environment(monkeypatch, *, target="primary-live"): + settings = { + "IBKR_ACCOUNT_FACTS_TARGET": target, + "IBKR_ACCOUNT_FACTS_PROJECT_ID": PRIVATE + "-project", + "IBKR_ACCOUNT_FACTS_REPORT_PREFIX": "gs://" + PRIVATE + "/reports", + "IBKR_ACCOUNT_FACTS_TARGET_ID": PRIVATE + "-target", + "IBKR_ACCOUNT_FACTS_SERVICE_NAME": PRIVATE + "-service", + "IBKR_ACCOUNT_FACTS_RUNTIME_REVISION": PRIVATE + "-revision", + "IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE": PRIVATE + "-scope", + "IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON": '["U00000001"]', + "IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR": PRIVATE + "-deployment", + "CLOUD_RUN_REGION": "us-central1", + "GITHUB_SHA": SHA, + } + for key, value in settings.items(): + monkeypatch.setenv(key, value) + additional = { + f"additional-{i}": { + "project_id": f"{PRIVATE}-project-{i}", + "report_prefix": f"gs://{PRIVATE}/reports-{i}", + "target_id": f"{PRIVATE}-target-{i}", + "service_name": f"{PRIVATE}-service-{i}", + "runtime_revision": f"{PRIVATE}-revision-{i}", + "account_scope": f"{PRIVATE}-scope-{i}", + "account_selector": [f"U0000001{i}"], + "deployment_selector": f"{PRIVATE}-deployment-{i}", + } + for i in range(1, 4) + } + monkeypatch.setenv(publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, json.dumps(additional)) + return settings, additional + + +def _report(expected): + return { + "schema_version": "runtime_report.v1", + "platform": "interactive_brokers", + "deploy_target": "cloud_run", + "project_id": expected["expected_project_id"], + "service_name": expected["expected_service_name"], + "account_scope": expected["expected_account_scope"], + "runtime_target": { + "account_selector": list(expected["expected_account_selector"]), + "deployment_selector": expected["expected_deployment_selector"], + }, + "diagnostics": {"runtime_revision": expected["expected_runtime_revision"]}, + "runtime_release_receipt": {"runtime_revision": expected["expected_runtime_revision"]}, + "started_at": (NOW - timedelta(seconds=2)).isoformat(), + "finished_at": NOW.isoformat(), + "summary": {"account_facts": { + "schema_version": publisher.SNAPSHOT_SCHEMA, + "account_ids": list(expected["expected_account_selector"]), + "currency": "USD", + "observed_at": (NOW - timedelta(seconds=1)).isoformat(), + "net_assets": "12345.6700", + "cash": [{"currency": "USD", "cash_balance": "100.00", "source_tag": "CashBalance"}], + }}, + "private_free_text": PRIVATE, + } + + +def _metadata(expected): + return { + "metadata": {"name": expected["expected_service_name"]}, + "status": { + "latestReadyRevisionName": expected["expected_runtime_revision"], + "latestCreatedRevisionName": expected["expected_runtime_revision"], + }, + "spec": {"private_free_text": PRIVATE}, + } + + +def _diagnose(report, expected, **kwargs): + return diagnostic.diagnose_identity( + report, expected=expected, + source_report_uri=expected["expected_report_prefix"] + "/2026-10/20261013T115958Z.json", + now=NOW, source_sha=SHA, **kwargs, + ) + + +@pytest.mark.parametrize("target", ["primary-live", "additional-1", "additional-2", "additional-3"]) +def test_valid_slots_use_original_protected_config_without_environment_replacement(monkeypatch, target): + settings, additional = _environment(monkeypatch, target=target) + expected = diagnostic.resolve_expected() + source = additional.get(target) + assert expected["expected_project_id"] == (source["project_id"] if source else settings["IBKR_ACCOUNT_FACTS_PROJECT_ID"]) + assert expected["expected_report_prefix"] == (source["report_prefix"] if source else settings["IBKR_ACCOUNT_FACTS_REPORT_PREFIX"]) + assert diagnostic.os.environ["IBKR_ACCOUNT_FACTS_REPORT_PREFIX"] == settings["IBKR_ACCOUNT_FACTS_REPORT_PREFIX"] + result = _diagnose(_report(expected), expected, cloud_run_metadata=_metadata(expected)) + assert result["reason"] == "identity_verified" + assert result["projection_passed"] is True + assert result["freshness_status"] == "fresh" + assert result["cloud_run_metadata_status"] == "read" + assert all(value is True for value in result["revision_equalities"].values()) + assert PRIVATE not in json.dumps(result) + assert "U000000" not in json.dumps(result) + assert "12345" not in json.dumps(result) + + +@pytest.mark.parametrize("target", ["", "additional-4", "disabled", "ingress-diagnostic", PRIVATE]) +def test_unknown_slot_fails_before_cloud_or_report_read(monkeypatch, capsys, target): + _environment(monkeypatch, target=target) + assert diagnostic.main([]) == 1 + result = json.loads(capsys.readouterr().out) + assert result["reason"] == "target_disabled" + assert result["status"] == "unavailable" + assert PRIVATE not in json.dumps(result) + + +@pytest.mark.parametrize("failure", ["missing", "malformed_additional", "collision", "bad_prefix", "bad_selector", "multiline", "bad_target_id"]) +def test_bad_config_fails_in_network_free_preflight(monkeypatch, capsys, failure): + _, additional = _environment(monkeypatch) + if failure == "missing": + monkeypatch.delenv("IBKR_ACCOUNT_FACTS_PROJECT_ID") + elif failure == "malformed_additional": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "additional-1") + monkeypatch.setenv(publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, "{bad" + PRIVATE) + elif failure == "collision": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "additional-1") + additional["additional-2"]["account_selector"] = ["U00000001"] + monkeypatch.setenv(publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, json.dumps(additional)) + elif failure == "bad_prefix": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "https://" + PRIVATE) + elif failure == "bad_selector": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["default"]') + elif failure == "multiline": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SERVICE_NAME", PRIVATE + "\nservice") + else: + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "Uppercase") + assert diagnostic.main(["--preflight"]) == 1 + result = json.loads(capsys.readouterr().out) + assert result["reason"] == ("target_config_unavailable" if failure == "missing" else "target_config_invalid") + assert PRIVATE not in json.dumps(result) + + +def test_preflight_success_is_not_identity_or_runtime_verification(monkeypatch, capsys): + _environment(monkeypatch) + assert diagnostic.main(["--preflight"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "preflight" + assert result["reason"] == "target_config_valid" + assert result["projection_passed"] is None + assert all(value is None for value in result["revision_equalities"].values()) + + +@pytest.mark.parametrize("field", publisher._RUNTIME_TARGET_MISMATCH_FIELDS) +def test_original_guard_rejects_each_mismatch_without_private_values(monkeypatch, field): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + report = _report(expected) + if field == "runtime_revision": + report["diagnostics"][field] = PRIVATE + "-different" + elif field == "runtime_release_receipt.runtime_revision": + report["runtime_release_receipt"]["runtime_revision"] = PRIVATE + "-different" + elif field in {"deployment_selector", "account_selector"}: + report["runtime_target"][field] = ["U00000099"] if field == "account_selector" else PRIVATE + "-different" + else: + report[field] = PRIVATE + "-different" + result = _diagnose(report, expected, cloud_run_metadata=_metadata(expected)) + assert result["reason"] == "runtime_target_mismatch" + assert result["mismatched_fields"] == [field] + assert result["projection_passed"] is False + assert PRIVATE not in json.dumps(result) + + +def test_first_guard_failure_and_legacy_receipt_contract_are_preserved(monkeypatch): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + report = _report(expected) + report["service_name"] = PRIVATE + "-different" + report["runtime_release_receipt"]["runtime_revision"] = PRIVATE + "-different" + assert _diagnose(report, expected)["mismatched_fields"] == ["service_name"] + report = _report(expected) + report["runtime_release_receipt"] = {"attestation_state": "legacy_unattested"} + result = _diagnose(report, expected, cloud_run_metadata=_metadata(expected)) + assert result["projection_passed"] is True + assert result["revision_equalities"]["receipt_equals_report"] is None + + +@pytest.mark.parametrize("hours,expected_status", [(36, "fresh"), (36.0001, "stale"), (-1 / 12, "fresh"), (-0.1, "stale")]) +def test_freshness_uses_existing_36_hour_and_five_minute_bounds(monkeypatch, hours, expected_status): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + report = _report(expected) + observed = NOW - timedelta(hours=hours) + report["started_at"] = (observed - timedelta(seconds=1)).isoformat() + report["finished_at"] = (observed + timedelta(seconds=1)).isoformat() + report["summary"]["account_facts"]["observed_at"] = observed.isoformat() + result = _diagnose(report, expected) + assert result["projection_passed"] is True + assert result["freshness_status"] == expected_status + + +def test_report_and_config_revision_drift_are_equality_only(monkeypatch): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + report = _report(expected) + cloud = _metadata(expected) + report["diagnostics"]["runtime_revision"] = PRIVATE + "-new" + report["runtime_release_receipt"]["runtime_revision"] = PRIVATE + "-new" + cloud["status"]["latestReadyRevisionName"] = PRIVATE + "-new" + cloud["status"]["latestCreatedRevisionName"] = PRIVATE + "-new" + result = _diagnose(report, expected, cloud_run_metadata=cloud) + assert result["revision_equalities"]["report_equals_ready"] is True + assert result["revision_equalities"]["configured_equals_ready"] is False + assert result["revision_equalities"]["report_equals_configured"] is False + assert result["projection_passed"] is False + assert PRIVATE not in json.dumps(result) + + +@pytest.mark.parametrize("metadata", [None, [], {}, {"metadata": {"name": PRIVATE}}, {"metadata": {"name": PRIVATE + "-service"}, "status": {}}]) +def test_unavailable_or_incomplete_metadata_does_not_create_revision_proof(monkeypatch, metadata): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + result = _diagnose(_report(expected), expected, cloud_run_metadata=metadata) + assert result["revision_equalities"]["configured_equals_ready"] is None + assert result["revision_equalities"]["report_equals_ready"] is None + assert PRIVATE not in json.dumps(result) + + +@pytest.mark.parametrize("sha", [PRIVATE, "a" * 39, "g" * 40, {"nested": PRIVATE}, None]) +def test_source_sha_only_accepts_full_hex(monkeypatch, sha): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + result = diagnostic.diagnose_identity(_report(expected), expected=expected, + source_report_uri=expected["expected_report_prefix"] + "/r.json", now=NOW, source_sha=sha) + assert result["source_sha"] is None + assert PRIVATE not in json.dumps(result) + + +@pytest.mark.parametrize("failure", ["listing", "reading", "cloud", "cloud_json", "unexpected"]) +def test_cli_external_failures_are_redacted_and_never_publish(monkeypatch, capsys, failure): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + report = _report(expected) + commands = [] + + def fake_run(command, **kwargs): + commands.append(command) + assert kwargs["capture_output"] is True + if command[1:3] == ("storage", "ls"): + if failure == "listing": + return subprocess.CompletedProcess(command, 1, "", PRIVATE) + return subprocess.CompletedProcess(command, 0, expected["expected_report_prefix"] + "/2026-10/20261013T115958Z.json\n", "") + if command[1:3] == ("storage", "cat"): + if failure == "reading": + return subprocess.CompletedProcess(command, 1, "", PRIVATE) + if failure == "unexpected": + raise RuntimeError(PRIVATE) + return subprocess.CompletedProcess(command, 0, json.dumps(report), "") + assert command[1:4] == ("run", "services", "describe") + return subprocess.CompletedProcess(command, 1 if failure == "cloud" else 0, + PRIVATE if failure == "cloud_json" else json.dumps(_metadata(expected)), PRIVATE) + + monkeypatch.setattr(publisher.subprocess, "run", fake_run) + assert diagnostic.main([]) == 1 + captured = capsys.readouterr() + assert PRIVATE not in captured.out + captured.err + result = json.loads(captured.out) + assert result["status"] in {"diagnosed", "unavailable"} + if failure in {"cloud", "cloud_json"}: + assert result["projection_passed"] is True + else: + assert result["projection_passed"] is None + assert all(cmd[0] == "gcloud" for cmd in commands) + + +@pytest.mark.parametrize("target", ["primary-live", "additional-1", "additional-2", "additional-3"]) +@pytest.mark.parametrize("age", [1, 37]) +def test_full_cli_uses_selected_read_commands_and_requires_fresh_identity(monkeypatch, capsys, target, age): + _environment(monkeypatch, target=target) + expected = diagnostic.resolve_expected() + report = _report(expected) + observed = NOW - timedelta(hours=age) + report["started_at"] = (observed - timedelta(seconds=1)).isoformat() + report["finished_at"] = (observed + timedelta(seconds=1)).isoformat() + report["summary"]["account_facts"]["observed_at"] = observed.isoformat() + commands = [] + + def fake_run(command, **kwargs): + commands.append(command) + assert command[command.index("--project") + 1] == expected["expected_project_id"] + if command[1:3] == ("storage", "ls"): + return subprocess.CompletedProcess(command, 0, expected["expected_report_prefix"] + "/2026-10/20261013T110000Z.json\n", "") + if command[1:3] == ("storage", "cat"): + return subprocess.CompletedProcess(command, 0, json.dumps(report), "") + assert command[1:4] == ("run", "services", "describe") + assert command[4] == expected["expected_service_name"] + assert command[command.index("--region") + 1] == "us-central1" + return subprocess.CompletedProcess(command, 0, json.dumps(_metadata(expected)), "") + + monkeypatch.setattr(publisher.subprocess, "run", fake_run) + assert diagnostic.main([], now=NOW) == (0 if age == 1 else 1) + captured = capsys.readouterr() + assert PRIVATE not in captured.out + captured.err + assert captured.err == "" + result = json.loads(captured.out) + assert result["projection_passed"] is True + assert result["freshness_status"] == ("fresh" if age == 1 else "stale") + assert len(commands) == 4 # Two bounded month listings, one report read, one service read. + + +def test_unclassified_projection_metadata_cannot_leak_private_reason_or_fields(monkeypatch): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + monkeypatch.setattr(publisher, "project_ibkr_account_facts_history", lambda *args, **kwargs: { + "status": "skipped", "reason": PRIVATE, "mismatched_fields": [PRIVATE], + }) + result = _diagnose(_report(expected), expected) + assert result["reason"] == "diagnostic_error" + assert result["mismatched_fields"] == [] + assert PRIVATE not in json.dumps(result) + + +@pytest.mark.parametrize("region", [PRIVATE, "--option", "us-central1\n", ""]) +def test_invalid_region_cannot_trigger_unscoped_metadata_read(monkeypatch, region): + _environment(monkeypatch) + monkeypatch.setenv("CLOUD_RUN_REGION", region) + assert diagnostic._read_cloud_run_metadata(diagnostic.resolve_expected()) is None + + +def test_cloud_run_os_error_keeps_read_report_guard_facts(monkeypatch, capsys): + _environment(monkeypatch) + expected = diagnostic.resolve_expected() + monkeypatch.setattr(publisher, "_latest_report_uri", lambda **kwargs: expected["expected_report_prefix"] + "/r.json") + monkeypatch.setattr(publisher, "_load_gcs_report", lambda *args, **kwargs: _report(expected)) + def unavailable(*args, **kwargs): + raise OSError(PRIVATE) + monkeypatch.setattr(publisher.subprocess, "run", unavailable) + assert diagnostic.main([], now=NOW) == 1 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["projection_passed"] is True + assert result["cloud_run_metadata_status"] == "unavailable" + assert PRIVATE not in captured.out + captured.err + + +def test_missing_region_never_infers_it_from_report_or_queries_cloud_run(monkeypatch, capsys): + _environment(monkeypatch) + monkeypatch.delenv("CLOUD_RUN_REGION") + expected = diagnostic.resolve_expected() + report = _report(expected) + report["cloud_run_region"] = "arbitrary-region" + monkeypatch.setattr(publisher, "_latest_report_uri", lambda **kwargs: expected["expected_report_prefix"] + "/r.json") + monkeypatch.setattr(publisher, "_load_gcs_report", lambda *args, **kwargs: report) + assert diagnostic.main([]) == 1 + result = json.loads(capsys.readouterr().out) + assert result["cloud_run_metadata_status"] == "unavailable" + assert result["revision_equalities"]["report_equals_ready"] is None + + +def test_workflow_is_main_manual_only_and_has_no_write_or_notification_channel(): + workflow = Path(".github/workflows/account-facts-identity-diagnose.yml").read_text() + assert "workflow_dispatch:" in workflow + assert "github.ref == 'refs/heads/main'" in workflow + assert "schedule:" not in workflow + assert "SYNC_TOKEN" not in workflow + assert "TELEGRAM" not in workflow + assert "upload-artifact" not in workflow + assert "contents: read" in workflow + assert workflow.index("scripts/diagnose_account_facts_identity.py --preflight") < workflow.index("google-github-actions/auth") + assert all(target in workflow for target in ("primary-live", "additional-1", "additional-2", "additional-3")) + + +def test_import_is_inert_in_fresh_interpreter(monkeypatch): + # Use the unpatched executable only for an isolated stdlib import probe. + code = ''' +import socket, subprocess, urllib.request +def forbidden(*args, **kwargs): + raise AssertionError("external call during import") +socket.socket = forbidden +socket.getaddrinfo = forbidden +subprocess.run = forbidden +urllib.request.urlopen = forbidden +urllib.request.build_opener = forbidden +import scripts.diagnose_account_facts_identity +print("inert import passed") +''' + monkeypatch.undo() + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "inert import passed"