diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index d7b72d9..5593d63 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -22,21 +22,17 @@ on: type: boolean default: false account_facts_target: - description: "Publish one fresh archived account-facts report for the selected target." + description: "Optional account-facts action; disabled by default." required: false type: choice default: disabled options: - disabled - live-u16608560 + - ingress-diagnostic schedule: - cron: "20 22 * * *" -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false @@ -44,12 +40,16 @@ concurrency: jobs: heartbeat: name: Check execution report heartbeat + if: ${{ github.event_name != 'workflow_dispatch' || inputs.account_facts_target != 'ingress-diagnostic' }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read id-token: write env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com RUNTIME_HEARTBEAT_NAME: InteractiveBrokersPlatform RUNTIME_HEARTBEAT_REPORT_PLATFORM: interactive_brokers RUNTIME_HEARTBEAT_REQUIRED_SERVICES: ${{ secrets.RUNTIME_HEARTBEAT_REQUIRED_SERVICES }} @@ -135,3 +135,20 @@ jobs: - name: Send configured daily dry-run digest if: ${{ always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest) }} run: uv run --no-sync python scripts/daily_dry_run_digest.py + + account-facts-ingress-diagnostic: + name: Verify account-facts ingress authentication only + if: ${{ github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'ingress-diagnostic' }} + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + env: + IBKR_ACCOUNT_FACTS_TARGET: ${{ inputs.account_facts_target }} + IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Send one fixed account-facts ingress diagnostic + run: python3 scripts/publish_account_facts_from_report.py diff --git a/scripts/publish_account_facts_from_report.py b/scripts/publish_account_facts_from_report.py index 3cb8f98..a1fb509 100644 --- a/scripts/publish_account_facts_from_report.py +++ b/scripts/publish_account_facts_from_report.py @@ -27,6 +27,9 @@ SOURCE_BINDING_KIND = "deployment_runtime_account" IBKR_ACCOUNT_FACTS_SYNC_TOKEN_ENV = "IBKR_ACCOUNT_FACTS_SYNC_TOKEN" IBKR_ACCOUNT_FACTS_SYNC_URL = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/account-facts/sync" +IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET = "ingress-diagnostic" +_INGRESS_DIAGNOSTIC_BODY = b"{}" +_INGRESS_DIAGNOSTIC_ERROR = "invalid_account_facts_history" _REPORT_RUN_ID = re.compile(r"^\d{8}T\d{6}Z\.json$") _ACCOUNT_ID = re.compile(r"^(?:U|DU)\d+$") _ALLOWED_CASH_TAGS = frozenset( @@ -35,6 +38,41 @@ _CURRENCY = re.compile(r"^[A-Z]{3}$") _DECIMAL_TEXT = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?$") _TARGET_ID = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") +_QRS_ACCOUNT_FACTS_ERROR_CODES = frozenset( + { + "account_facts_account_unattributed", + "account_facts_bindings_missing", + "account_facts_caller_identity_forbidden", + "account_facts_disabled", + "account_facts_identity_mismatch", + "account_facts_store_unavailable", + "account_facts_sync_token_ambiguous", + "account_facts_sync_token_invalid", + "account_facts_sync_token_not_configured", + "account_facts_sync_token_platform_mismatch", + "account_facts_target_mismatch", + "account_facts_binding_mismatch", + "account_facts_observation_window", + "account_facts_future_observation", + "invalid_account_facts_account", + "invalid_account_facts_balances", + "invalid_account_facts_bindings", + "invalid_account_facts_cash", + "invalid_account_facts_date", + "invalid_account_facts_history", + "invalid_account_facts_money_magnitude", + "invalid_account_facts_money_scale", + "invalid_account_facts_scope", + "invalid_account_facts_source_binding", + "invalid_account_facts_target", + "invalid_account_facts_time", + "account_facts_stored_invalid", + "duplicate_account_facts_binding", + "invalid_account_facts_stored", + "unsupported_account_facts_action", + } +) +_HTTP_ERROR_BODY_LIMIT = 16 * 1024 class _ProjectionError(ValueError): @@ -253,14 +291,201 @@ def publish_ibkr_account_facts_history( method="POST", ) opener = build_opener(_NoRedirect()) - with opener.open(request, timeout=15) as response: - if not 200 <= response.status < 300: - raise _ProjectionError("publish_failed") + try: + with opener.open(request, timeout=15) as response: + if not 200 <= response.status < 300: + return _publish_failed( + stage="http_response", + category="http_status", + http_status=_numeric_http_status(response.status), + qrs_error_code="unknown", + ) + except HTTPError as exc: + return _publish_failed( + stage="http_response", + category="http_error", + http_status=_numeric_http_status(exc.code), + qrs_error_code=_safe_qrs_error_code(exc), + ) + except TimeoutError: + return _publish_failed(stage="request", category="timeout") + except URLError: + return _publish_failed(stage="request", category="url_error") + except OSError: + return _publish_failed(stage="request", category="transport_error") + except Exception: + return _publish_failed(stage="unknown", category="unknown") return {"status": "published"} except _ProjectionError as exc: return {"status": "skipped", "reason": exc.reason} - except (HTTPError, URLError, TimeoutError, OSError): - return {"status": "skipped", "reason": "publish_failed"} + + +def _numeric_http_status(value: object) -> int | None: + if isinstance(value, int) and not isinstance(value, bool) and 100 <= value <= 599: + return value + return None + + +def _safe_qrs_error_code(error: HTTPError) -> str: + try: + body = error.read(_HTTP_ERROR_BODY_LIMIT + 1) + except Exception: + return "unknown" + return _safe_qrs_error_code_from_body(body) + + +def _safe_qrs_error_code_from_body(body: object) -> str: + if not isinstance(body, bytes) or len(body) > _HTTP_ERROR_BODY_LIMIT: + return "unknown" + try: + payload = json.loads(body) + except Exception: + return "unknown" + code = payload.get("error") if isinstance(payload, Mapping) else None + return code if isinstance(code, str) and code in _QRS_ACCOUNT_FACTS_ERROR_CODES else "unknown" + + +def _publish_failed( + *, + stage: str, + category: str, + http_status: int | None = None, + qrs_error_code: str = "unknown", +) -> dict[str, Any]: + return { + "status": "skipped", + "reason": "publish_failed", + "diagnostics": { + "stage": stage, + "category": category, + "http_status": http_status, + "qrs_error_code": qrs_error_code, + "outcome": "unknown", + }, + } + + +def _ingress_diagnostic_result( + *, + http_status: int | None, + qrs_error_code: str, + category: str = "http_error", + stage: str = "http_response", +) -> dict[str, Any]: + verified = http_status == 400 and qrs_error_code == _INGRESS_DIAGNOSTIC_ERROR + return { + "status": "verified" if verified else "skipped", + "reason": ( + "ingress_authentication_and_schema_rejection_verified" + if verified + else "ingress_diagnostic_unverified" + ), + "diagnostics": { + "stage": stage, + "category": category, + "http_status": http_status, + "qrs_error_code": qrs_error_code, + "outcome": "rejected_before_storage" if verified else "unknown", + }, + } + + +def diagnose_account_facts_ingress(*, sync_token: str) -> dict[str, Any]: + """Send one fixed empty schema probe to verify protected QRS ingress only.""" + if not _text(sync_token): + return {"status": "skipped", "reason": "publish_auth_unavailable"} + request = Request( + IBKR_ACCOUNT_FACTS_SYNC_URL, + data=_INGRESS_DIAGNOSTIC_BODY, + headers={ + "Authorization": f"Bearer {_text(sync_token)}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + opener = build_opener(_NoRedirect()) + with opener.open(request, timeout=15) as response: + status = _numeric_http_status(getattr(response, "status", None)) + if status != 400: + return _ingress_diagnostic_result( + http_status=status, + qrs_error_code="unknown", + category="http_status", + ) + try: + body = response.read(_HTTP_ERROR_BODY_LIMIT + 1) + except Exception: + body = None + return _ingress_diagnostic_result( + http_status=status, + qrs_error_code=_safe_qrs_error_code_from_body(body), + category="http_status", + ) + except HTTPError as exc: + status = _numeric_http_status(exc.code) + code = _safe_qrs_error_code(exc) + return _ingress_diagnostic_result(http_status=status, qrs_error_code=code) + except TimeoutError: + return _ingress_diagnostic_result( + http_status=None, + qrs_error_code="unknown", + category="timeout", + stage="request", + ) + except URLError: + return _ingress_diagnostic_result( + http_status=None, + qrs_error_code="unknown", + category="url_error", + stage="request", + ) + except OSError: + return _ingress_diagnostic_result( + http_status=None, + qrs_error_code="unknown", + category="transport_error", + stage="request", + ) + except Exception: + return _ingress_diagnostic_result( + http_status=None, + qrs_error_code="unknown", + category="unknown", + stage="unknown", + ) + + +def _format_cli_result(result: Mapping[str, Any]) -> str: + status = result.get("status") if result.get("status") in {"published", "verified", "skipped"} else "skipped" + reason = result.get("reason") if isinstance(result.get("reason"), str) else "unknown" + diagnostic = result.get("diagnostics") + if not isinstance(diagnostic, Mapping): + return f"{status}:{reason}" + stage = ( + diagnostic.get("stage") + if diagnostic.get("stage") in {"http_response", "request", "unknown"} + else "unknown" + ) + category = ( + diagnostic.get("category") + if diagnostic.get("category") in { + "http_status", "http_error", "timeout", "url_error", "transport_error", "unknown" + } + else "unknown" + ) + http_status = _numeric_http_status(diagnostic.get("http_status")) + qrs_error_code = diagnostic.get("qrs_error_code") + if not isinstance(qrs_error_code, str) or qrs_error_code not in _QRS_ACCOUNT_FACTS_ERROR_CODES: + qrs_error_code = "unknown" + outcome = diagnostic.get("outcome") + if outcome not in {"rejected_before_storage", "unknown"}: + outcome = "unknown" + return ( + f"{status}:{reason}:stage={stage}:category={category}" + f":http_status={http_status if http_status is not None else 'none'}" + f":qrs_error_code={qrs_error_code}:outcome={outcome}" + ) class _NoRedirect(HTTPRedirectHandler): @@ -322,6 +547,12 @@ def main() -> int: """Workflow-only, explicitly enabled single-report publisher.""" try: target = _text(os.environ.get("IBKR_ACCOUNT_FACTS_TARGET")) + if target == IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET: + result = diagnose_account_facts_ingress( + sync_token=os.environ.get(IBKR_ACCOUNT_FACTS_SYNC_TOKEN_ENV, "") + ) + print(_format_cli_result(result)) + return 0 if result.get("status") == "verified" else 1 if target != "live-u16608560": print("skipped:target_disabled") return 0 @@ -357,7 +588,7 @@ def main() -> int: sync_token=os.environ.get(IBKR_ACCOUNT_FACTS_SYNC_TOKEN_ENV, ""), **expected, ) - print(f"{result['status']}:{result.get('reason', 'ok')}") + print(_format_cli_result(result)) return 0 if result["status"] == "published" else 1 except _ProjectionError as exc: print(f"skipped:{exc.reason}") @@ -470,7 +701,11 @@ def _project_ibkr_account_facts_history( } -__all__ = ["project_ibkr_account_facts_history", "publish_ibkr_account_facts_history"] +__all__ = [ + "diagnose_account_facts_ingress", + "project_ibkr_account_facts_history", + "publish_ibkr_account_facts_history", +] if __name__ == "__main__": diff --git a/tests/test_publish_ibkr_account_facts.py b/tests/test_publish_ibkr_account_facts.py index 87ad35b..ad04706 100644 --- a/tests/test_publish_ibkr_account_facts.py +++ b/tests/test_publish_ibkr_account_facts.py @@ -2,6 +2,11 @@ import json from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest import scripts.publish_account_facts_from_report as publisher from scripts.publish_account_facts_from_report import ( @@ -266,6 +271,383 @@ def test_publisher_rejects_stale_or_missing_facts_without_http(monkeypatch): assert wrong_endpoint == {"status": "skipped", "reason": "publish_target_invalid"} +@pytest.mark.parametrize( + ("status", "body_error", "expected_error"), + [ + (401, "account_facts_sync_token_invalid", "account_facts_sync_token_invalid"), + (409, "account_facts_bindings_missing", "account_facts_bindings_missing"), + (503, "account_facts_store_unavailable", "account_facts_store_unavailable"), + (409, "UNAPPROVED_ERROR_SENTINEL", "unknown"), + ], +) +def test_publisher_retains_http_status_and_allowlisted_qrs_error_without_retry( + monkeypatch, status, body_error, expected_error +): + observed = {"calls": 0} + + class Opener: + def open(self, request, *, timeout): + observed["calls"] += 1 + raise HTTPError( + request.full_url, + status, + "private response text", + {}, + BytesIO(json.dumps({"error": body_error}).encode()), + ) + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + result = publisher.publish_ibkr_account_facts_history( + _report(), + now=datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc), + source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", + sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, + sync_token="dedicated-test-token", + target_id="ibkr-u16608560", + expected_report_prefix="gs://qsl-runtime-reports/ibkr", + expected_project_id="qsl-prod", + expected_service_name="interactive-brokers-quant-live-u16608560-service", + expected_runtime_revision="service-00369-88c", + expected_account_scope="live-u16608560", + expected_account_selector=["U16608560"], + expected_deployment_selector="live-u16608560", + ) + + assert result == { + "status": "skipped", + "reason": "publish_failed", + "diagnostics": { + "stage": "http_response", + "category": "http_error", + "http_status": status, + "qrs_error_code": expected_error, + "outcome": "unknown", + }, + } + assert observed["calls"] == 1 + + +@pytest.mark.parametrize( + ("error", "category", "stage"), + [ + (TimeoutError("TIMEOUT_SENTINEL"), "timeout", "request"), + (URLError("URL_REASON_SENTINEL"), "url_error", "request"), + (RuntimeError("UNKNOWN_TRANSPORT_SENTINEL"), "unknown", "unknown"), + ], +) +def test_publisher_marks_transport_outcome_unknown_without_exposing_exception( + monkeypatch, error, category, stage +): + class Opener: + def open(self, *_args, **_kwargs): + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + result = publisher.publish_ibkr_account_facts_history( + _report(), + now=datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc), + source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", + sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, + sync_token="dedicated-test-token", + target_id="ibkr-u16608560", + expected_report_prefix="gs://qsl-runtime-reports/ibkr", + expected_project_id="qsl-prod", + expected_service_name="interactive-brokers-quant-live-u16608560-service", + expected_runtime_revision="service-00369-88c", + expected_account_scope="live-u16608560", + expected_account_selector=["U16608560"], + expected_deployment_selector="live-u16608560", + ) + + assert result == { + "status": "skipped", + "reason": "publish_failed", + "diagnostics": { + "stage": stage, + "category": category, + "http_status": None, + "qrs_error_code": "unknown", + "outcome": "unknown", + }, + } + assert "SENTINEL" not in repr(result) + + +def test_cli_prints_allowlisted_http_diagnostics_without_private_response_data( + monkeypatch, capsys +): + now = datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc) + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + return now if tz is not None else now.replace(tzinfo=None) + + class Opener: + def open(self, request, *, timeout): + raise HTTPError( + "https://endpoint.invalid/URL_SENTINEL", + 503, + "MESSAGE_SENTINEL", + {"X-Debug": "HEADER_SENTINEL"}, + BytesIO( + b'{"error":"account_facts_store_unavailable",' + b'"debug":"BODY_SENTINEL"}' + ), + ) + + monkeypatch.setattr(publisher, "datetime", FrozenDateTime) + report = _report() + report["project_id"] = "interactivebrokersquant" + monkeypatch.setattr( + publisher, + "_latest_report_uri", + lambda **_kwargs: ( + "gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/" + "tqqq_growth_income/live-u16608560/2026-09/20260930T010000Z.json" + ), + ) + monkeypatch.setattr(publisher, "_load_gcs_report", lambda *_args, **_kwargs: report) + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "live-u16608560") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "service-00369-88c") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_URL", publisher.IBKR_ACCOUNT_FACTS_SYNC_URL) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "TOKEN_SENTINEL") + + assert publisher.main() == 1 + output = capsys.readouterr().out + assert output.strip() == ( + "skipped:publish_failed:stage=http_response:category=http_error:" + "http_status=503:qrs_error_code=account_facts_store_unavailable:outcome=unknown" + ) + for sentinel in ( + "URL_SENTINEL", "MESSAGE_SENTINEL", "HEADER_SENTINEL", "BODY_SENTINEL", "TOKEN_SENTINEL" + ): + assert sentinel not in output + + +def test_default_cli_target_makes_no_api_or_report_calls(monkeypatch, capsys): + monkeypatch.delenv("IBKR_ACCOUNT_FACTS_TARGET", raising=False) + monkeypatch.setattr( + publisher, + "build_opener", + lambda *_args: (_ for _ in ()).throw(AssertionError("must not call API")), + ) + monkeypatch.setattr( + publisher, + "_latest_report_uri", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not list GCS")), + ) + monkeypatch.setattr( + publisher, + "_load_gcs_report", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not read GCS")), + ) + + assert publisher.main() == 0 + assert capsys.readouterr().out.strip() == "skipped:target_disabled" + + +def test_ingress_diagnostic_posts_fixed_empty_body_once_without_gcs(monkeypatch, capsys): + observed = {"calls": 0} + + class Response: + status = 400 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, limit): + observed["read_limit"] = limit + return b'{"error":"invalid_account_facts_history"}' + + class Opener: + def open(self, request, *, timeout): + observed["calls"] += 1 + observed["request"] = request + observed["timeout"] = timeout + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + monkeypatch.setattr( + publisher, + "_latest_report_uri", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("diagnostic must not list GCS")), + ) + monkeypatch.setattr( + publisher, + "_load_gcs_report", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("diagnostic must not read GCS")), + ) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "TOKEN_SENTINEL") + monkeypatch.delenv("IBKR_ACCOUNT_FACTS_SYNC_URL", raising=False) + + assert publisher.main() == 0 + output = capsys.readouterr().out + assert output.strip() == ( + "verified:ingress_authentication_and_schema_rejection_verified:" + "stage=http_response:category=http_status:http_status=400:" + "qrs_error_code=invalid_account_facts_history:outcome=rejected_before_storage" + ) + request = observed["request"] + assert request.full_url == publisher.IBKR_ACCOUNT_FACTS_SYNC_URL + assert request.data == b"{}" + assert request.get_method() == "POST" + assert request.get_header("Authorization") == "Bearer TOKEN_SENTINEL" + assert request.get_header("Content-type") == "application/json" + assert observed["calls"] == 1 + assert observed["timeout"] == 15 + assert "TOKEN_SENTINEL" not in output + + +@pytest.mark.parametrize( + ("response_status", "body_error", "exception", "expected_status", "expected_reason", "expected_code"), + [ + (None, "account_facts_sync_token_invalid", None, "skipped", "ingress_diagnostic_unverified", "account_facts_sync_token_invalid"), + (None, "invalid_account_facts_history", None, "verified", "ingress_authentication_and_schema_rejection_verified", "invalid_account_facts_history"), + (None, "UNSAFE_ERROR_SENTINEL", None, "skipped", "ingress_diagnostic_unverified", "unknown"), + (200, None, None, "skipped", "ingress_diagnostic_unverified", "unknown"), + (None, None, TimeoutError("TIMEOUT_SENTINEL"), "skipped", "ingress_diagnostic_unverified", "unknown"), + ], +) +def test_ingress_diagnostic_verifies_only_exact_qrs_schema_rejection( + monkeypatch, response_status, body_error, exception, expected_status, expected_reason, expected_code +): + class Response: + status = response_status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit): + return json.dumps({"error": body_error}).encode() if body_error else b"" + + class Opener: + def open(self, request, *, timeout): + assert request.data == b"{}" + if exception is not None: + raise exception + if response_status is None: + raise HTTPError( + request.full_url, + 400 if body_error != "account_facts_sync_token_invalid" else 401, + "private response message", + {}, + BytesIO(json.dumps({"error": body_error}).encode()), + ) + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + result = publisher.diagnose_account_facts_ingress(sync_token="diagnostic-token") + assert result["status"] == expected_status + assert result["reason"] == expected_reason + assert result["diagnostics"]["qrs_error_code"] == expected_code + assert result["diagnostics"]["outcome"] == ( + "rejected_before_storage" if expected_status == "verified" else "unknown" + ) + assert "SENTINEL" not in repr(result) + + +def test_ingress_diagnostic_rejects_missing_token_without_api_call(monkeypatch): + monkeypatch.setattr( + publisher, + "build_opener", + lambda *_args: (_ for _ in ()).throw(AssertionError("must not call API")), + ) + assert publisher.diagnose_account_facts_ingress(sync_token="") == { + "status": "skipped", + "reason": "publish_auth_unavailable", + } + + +def test_ingress_diagnostic_does_not_follow_redirect(monkeypatch): + observed = {"calls": 0} + + class Opener: + def open(self, request, *, timeout): + observed["calls"] += 1 + raise HTTPError( + request.full_url, + 302, + "redirect response", + {"Location": "https://other.example/redirect"}, + BytesIO(b'{"error":"invalid_account_facts_history"}'), + ) + + def build_opener(handler): + assert isinstance(handler, publisher._NoRedirect) + return Opener() + + monkeypatch.setattr(publisher, "build_opener", build_opener) + result = publisher.diagnose_account_facts_ingress(sync_token="diagnostic-token") + assert result["status"] == "skipped" + assert result["diagnostics"] == { + "stage": "http_response", + "category": "http_error", + "http_status": 302, + "qrs_error_code": "invalid_account_facts_history", + "outcome": "unknown", + } + assert observed["calls"] == 1 + + +def test_ingress_diagnostic_cli_redacts_http_exception(monkeypatch, capsys): + class Opener: + def open(self, *_args, **_kwargs): + raise HTTPError( + "https://endpoint.invalid/URL_SENTINEL", + 401, + "MESSAGE_SENTINEL", + {"X-Debug": "HEADER_SENTINEL"}, + BytesIO( + b'{"error":"account_facts_sync_token_invalid",' + b'"debug":"BODY_SENTINEL"}' + ), + ) + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "TOKEN_SENTINEL") + + assert publisher.main() == 1 + output = capsys.readouterr().out + assert output.strip() == ( + "skipped:ingress_diagnostic_unverified:stage=http_response:category=http_error:" + "http_status=401:qrs_error_code=account_facts_sync_token_invalid:outcome=unknown" + ) + for sentinel in ( + "URL_SENTINEL", "MESSAGE_SENTINEL", "HEADER_SENTINEL", "BODY_SENTINEL", "TOKEN_SENTINEL" + ): + assert sentinel not in output + + +def test_workflow_ingress_diagnostic_is_manual_and_isolated_from_heartbeat(): + workflow = Path(__file__).parents[1] / ".github/workflows/execution-report-heartbeat.yml" + source = workflow.read_text() + assert "default: disabled" in source + assert "- ingress-diagnostic" in source + assert "inputs.account_facts_target != 'ingress-diagnostic'" in source + diagnostic_job = source.split(" account-facts-ingress-diagnostic:", 1)[1] + assert "inputs.account_facts_target == 'ingress-diagnostic'" in diagnostic_job + assert "contents: read" in diagnostic_job + assert "id-token: write" not in diagnostic_job + assert "google-github-actions" not in diagnostic_job + assert "setup-uv" not in diagnostic_job + assert "uv sync" not in diagnostic_job + assert "GCP_" not in diagnostic_job + assert "RUNTIME_HEARTBEAT_" not in diagnostic_job + assert "IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }}" in diagnostic_job + assert "run: python3 scripts/publish_account_facts_from_report.py" in diagnostic_job + + def test_latest_report_listing_is_confined_to_exact_prefix(monkeypatch): seen = []