diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index 9b4682d..909a800 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -145,6 +145,9 @@ jobs: ACCOUNT_HISTORY_TARGET_ID: ${{ matrix.target.id }} ACCOUNT_HISTORY_EXPECTED_SCOPE: ${{ matrix.target.label }} GOOGLE_CLOUD_PROJECT: ${{ env.GCP_PROJECT_ID }} + ACCOUNT_FACTS_SYNC_ENABLED: ${{ vars.ACCOUNT_FACTS_SYNC_ENABLED }} + ACCOUNT_FACTS_SYNC_URL: ${{ vars.ACCOUNT_FACTS_SYNC_URL }} + ACCOUNT_FACTS_SYNC_TOKEN: ${{ vars.ACCOUNT_FACTS_SYNC_ENABLED == 'true' && secrets.ACCOUNT_FACTS_SYNC_TOKEN || '' }} run: uv run --no-sync python scripts/record_daily_account_snapshot.py - name: Publish daily runtime projection diff --git a/docs/account_snapshot_history.md b/docs/account_snapshot_history.md index 5cd8317..a246408 100644 --- a/docs/account_snapshot_history.md +++ b/docs/account_snapshot_history.md @@ -13,6 +13,8 @@ `PAPER` 只表示这份清单配置的范围,不能据此推断券商账户身份。脚本再要求期望 scope 精确为 `PAPER`,服务根必须是没有 userinfo、query、fragment 和额外 path 的 HTTPS `*.run.app`,并且只 GET 该源站的 `/account-snapshot`。GCS 前缀最后一段必须是 `account_snapshots`,不能落在 execution report 路径上。缺任何一项就失败,不补默认值。 +可选 QRS 发布仍默认关闭;只有 `ACCOUNT_FACTS_SYNC_ENABLED` 精确为 `true` 时才使用 `ACCOUNT_FACTS_SYNC_URL` 与专用 `ACCOUNT_FACTS_SYNC_TOKEN`。URL 必须是 HTTPS 的精确 `/api/account-facts/sync`,不接受 userinfo、端口、query、fragment 或其他路径;POST 不跟随重定向,设置超时并限制响应体大小。token 只作为该 workflow step 的环境变量和 Authorization header 使用,不打印。 + OIDC 使用 heartbeat 里已经配置的 gcloud:`gcloud auth print-identity-token --audiences=<服务根> --quiet`。stdout 只留在内存,不打印,也不放进参数;失败只报短类别。HTTP 有超时、不跟随重定向、不重试。观察时钟在响应收齐之后读取。 ## 保存什么 @@ -23,6 +25,8 @@ OIDC 使用 heartbeat 里已经配置的 gcloud:`gcloud auth print-identity-to 路径是 `{prefix}/{target_id}/{source_binding_id}/{YYYY-MM-DD}.json`。目标与来源绑定分成不同路径段,不把两段来源拼进同一个对象。`create_text` 只创建:已有对象时结果是 `already_recorded`,不覆盖、不重新拉取。存储结果不明则停止,不写占位点,也不重试。错误输出只有短类别。 +仅当本次 `create_text` 明确返回新建成功时,脚本才把完全相同的历史 JSON body POST 到 QRS。`already_recorded` 明确跳过发布,不能把这次新读取的内容冒充为已保存对象;`store_unknown` 不 POST。QRS 发布状态与历史记录状态分开输出:发布拒绝或结果未知不会撤销已写入历史;未知 POST 不自动重试。QRS `ok=true` 且回读的目标、观察日、观察结束时间匹配,只表示接收端确认保存,不证明页面已经展示或数据完成物理账户身份核验。接收端按其可信配置绑定目标与来源,调用方不传账户 key 或身份结论。 + 这份记录不是 TWR,不是收益率,也不授予 live 权限。 ## main 上的 PAPER 镜像暂存 @@ -33,4 +37,4 @@ OIDC 使用 heartbeat 里已经配置的 gcloud:`gcloud auth print-identity-to ## 尚未启用 -本轮没有打开 GitHub variable,没有新增 secret 或 IAM,也没有真实采样。以后若要启用,需要先确认 paper 服务上的部署版本、来源读取权限、bucket 保留策略和上述变量,并读回第一份对象。在那之前,仓库里的步骤保持关闭。 +本轮没有打开 GitHub variable,没有新增 secret 或 IAM,也没有真实采样。本次代码不配置生产 QRS URL/token,也不改变该关闭状态。以后若要启用,需要先确认 paper 服务上的部署版本、来源读取权限、bucket 保留策略和上述变量,并读回第一份对象。在那之前,仓库里的步骤保持关闭。 diff --git a/scripts/record_daily_account_snapshot.py b/scripts/record_daily_account_snapshot.py index 639540c..dc449cb 100644 --- a/scripts/record_daily_account_snapshot.py +++ b/scripts/record_daily_account_snapshot.py @@ -26,7 +26,12 @@ OBSERVATION_WINDOW = timedelta(minutes=15) HTTP_TIMEOUT_SECONDS = 20 MAX_RESPONSE_BYTES = 256 * 1024 +MAX_ACCOUNT_FACTS_SYNC_BODY_BYTES = 64 * 1024 +MAX_ACCOUNT_FACTS_SYNC_RESPONSE_BYTES = 64 * 1024 +ACCOUNT_FACTS_SYNC_PATH = "/api/account-facts/sync" +ACCOUNT_FACTS_SYNC_TIMEOUT_SECONDS = 20 _RUN_APP_HOST = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+run\.app$") +_HTTPS_HOST = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") _BUCKET = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$") _TARGET_ID = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") _PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$") @@ -42,6 +47,8 @@ class DailyAccountRecordResult: status: str category: str = "" + publish_status: str = "disabled" + publish_category: str = "" class _Rejected(Exception): @@ -65,11 +72,12 @@ def record_daily_account_snapshot( http_get: Callable[..., Any], open_store: Callable[[str], Any], now_reader: Callable[[], datetime], + http_post: Callable[..., Any] | None = None, ) -> DailyAccountRecordResult: """Validate one snapshot response and create the first object for that day.""" if str(env.get("ACCOUNT_HISTORY_RECORDING_ENABLED") or "").strip() != "true": - return DailyAccountRecordResult("disabled") + return DailyAccountRecordResult("disabled", publish_status="disabled") try: config = _config(env) except _Rejected as rejected: @@ -98,12 +106,151 @@ def record_daily_account_snapshot( try: created = open_store(config.project_id).create_text(uri, body, "application/json") except Exception: - return DailyAccountRecordResult("error", "store_unknown") + return DailyAccountRecordResult( + "error", "store_unknown", "skipped_store_unknown" + ) if created is True: - return DailyAccountRecordResult("recorded") + publish_status, publish_category = _publish_account_facts( + env, body, http_post=http_post or _http_post + ) + return DailyAccountRecordResult( + "recorded", "", publish_status, publish_category + ) if created is False: - return DailyAccountRecordResult("already_recorded") - return DailyAccountRecordResult("error", "store_unknown") + return DailyAccountRecordResult( + "already_recorded", "", "skipped_already_recorded" + ) + return DailyAccountRecordResult( + "error", "store_unknown", "skipped_store_unknown" + ) + + +def _publish_account_facts( + env: Mapping[str, str], + body: str, + *, + http_post: Callable[..., Any], +) -> tuple[str, str]: + if str(env.get("ACCOUNT_FACTS_SYNC_ENABLED") or "").strip() != "true": + return "disabled", "" + try: + url = _account_facts_sync_url(str(env.get("ACCOUNT_FACTS_SYNC_URL") or "")) + token = str(env.get("ACCOUNT_FACTS_SYNC_TOKEN") or "") + if not token or token != token.strip(): + raise _Rejected("qrs_config_invalid") + except _Rejected as rejected: + return "rejected", rejected.category + + encoded_body = body.encode("utf-8") + if len(encoded_body) > MAX_ACCOUNT_FACTS_SYNC_BODY_BYTES: + return "rejected", "qrs_payload_too_large" + try: + response = http_post( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + data=encoded_body, + timeout=ACCOUNT_FACTS_SYNC_TIMEOUT_SECONDS, + allow_redirects=False, + stream=True, + ) + except Exception: + return "unknown", "qrs_request_unknown" + + try: + status_code = getattr(response, "status_code", None) + if not isinstance(status_code, int): + return "unknown", "qrs_response_invalid" + if getattr(response, "is_redirect", False) or 300 <= status_code < 400: + return "rejected", "qrs_redirect_rejected" + if 400 <= status_code < 500: + return "rejected", "qrs_http_rejected" + if status_code >= 500 or not 200 <= status_code < 300: + return "unknown", "qrs_response_unknown" + try: + payload = _bounded_response_json( + response, MAX_ACCOUNT_FACTS_SYNC_RESPONSE_BYTES + ) + except _Rejected as rejected: + return "unknown", rejected.category + if isinstance(payload, dict) and payload.get("ok") is False: + return "rejected", "qrs_application_rejected" + try: + sent = json.loads(body) + except json.JSONDecodeError: + return "unknown", "qrs_request_body_invalid" + if ( + isinstance(payload, dict) + and payload.get("ok") is True + and payload.get("stored") is True + and payload.get("target_id") == sent.get("target_id") + and payload.get("observation_date") == sent.get("observation_date") + and payload.get("observed_finished_at") + == sent.get("observed_finished_at") + ): + return "published", "" + return "unknown", "qrs_response_unconfirmed" + finally: + close = getattr(response, "close", None) + if callable(close): + try: + close() + except Exception: + pass + + +def _account_facts_sync_url(value: str) -> str: + try: + parsed = urlsplit(value.strip()) + port = parsed.port + except ValueError: + raise _Rejected("qrs_config_invalid") from None + host = parsed.hostname or "" + if ( + parsed.scheme != "https" + or parsed.username + or parsed.password + or port is not None + or parsed.query + or parsed.fragment + or parsed.path != ACCOUNT_FACTS_SYNC_PATH + or _HTTPS_HOST.fullmatch(host) is None + ): + raise _Rejected("qrs_config_invalid") + return f"https://{host}{ACCOUNT_FACTS_SYNC_PATH}" + + +def _bounded_response_json(response: Any, max_bytes: int) -> Any: + chunks: list[bytes] = [] + total = 0 + iterator = getattr(response, "iter_content", None) + if callable(iterator): + try: + for chunk in iterator(chunk_size=8192): + if not chunk: + continue + if not isinstance(chunk, bytes): + raise _Rejected("qrs_response_invalid") + total += len(chunk) + if total > max_bytes: + raise _Rejected("qrs_response_too_large") + chunks.append(chunk) + except _Rejected: + raise + except Exception: + raise _Rejected("qrs_response_invalid") from None + content = b"".join(chunks) + else: + content = getattr(response, "content", b"") + if not isinstance(content, bytes) or len(content) > max_bytes: + raise _Rejected("qrs_response_too_large") + try: + return json.loads(content) + except (UnicodeDecodeError, json.JSONDecodeError): + raise _Rejected("qrs_response_invalid") from None def _config(env: Mapping[str, str]) -> _Config: @@ -323,6 +470,27 @@ def _http_get(url: str, *, headers: Mapping[str, str], timeout: float): return requests.get(url, headers=dict(headers), timeout=timeout, allow_redirects=False) +def _http_post( + url: str, + *, + headers: Mapping[str, str], + data: bytes, + timeout: float, + allow_redirects: bool, + stream: bool, +): + import requests + + return requests.post( + url, + headers=dict(headers), + data=data, + timeout=timeout, + allow_redirects=allow_redirects, + stream=stream, + ) + + def _open_store(project_id: str): from quant_platform_kit.cloud import get_object_store @@ -335,6 +503,7 @@ def main( environ: Mapping[str, str] | None = None, fetch_id_token: Callable[[str], str] | None = None, http_get: Callable[..., Any] | None = None, + http_post: Callable[..., Any] | None = None, open_store: Callable[[str], Any] | None = None, now_reader: Callable[[], datetime] | None = None, ) -> int: @@ -348,13 +517,19 @@ def main( os.environ if environ is None else environ, fetch_id_token=fetch_id_token or _fetch_id_token, http_get=http_get or _http_get, + http_post=http_post or _http_post, open_store=open_store or _open_store, now_reader=now_reader or (lambda: datetime.now(timezone.utc)), ) - if result.status == "error": - print(f"error: {result.category}") + record_part = ( + f"record=error:{result.category}" + if result.status == "error" + else f"record={result.status}" + ) + publish_part = f"account_facts_publish={result.publish_status}" + print(f"{record_part} {publish_part}") + if result.status == "error" or result.publish_status in {"rejected", "unknown"}: return 1 - print(result.status) return 0 diff --git a/tests/test_daily_account_snapshot.py b/tests/test_daily_account_snapshot.py index eed33b9..6833a39 100644 --- a/tests/test_daily_account_snapshot.py +++ b/tests/test_daily_account_snapshot.py @@ -23,7 +23,9 @@ BINDING_B = "b" * 64 SERVICE = "https://longbridge-quant-paper-service-ab12.asia-east1.run.app" PREFIX = "gs://acct-history/account_snapshots" +QRS_SYNC_URL = "https://qrs.example.test/api/account-facts/sync" SECRET_TOKEN = "synthetic-oidc-token" +QRS_TOKEN = "synthetic-qrs-token" LEAK = "raw-secret-value" @@ -92,14 +94,30 @@ def __init__(self, status_code, payload=None, content=None): content = json.dumps(payload if payload is not None else _payload()).encode() self.content = content + def iter_content(self, chunk_size=8192): + yield self.content + + def close(self): + pass + class _Spies: - def __init__(self, response=None, created=True, explode_store=False): + def __init__(self, response=None, created=True, explode_store=False, post_response=None): self.calls = [] self.response = response or _Response(200) self.created = created self.explode_store = explode_store self.stored = [] + self.post_response = post_response or _Response( + 200, + { + "ok": True, + "stored": True, + "target_id": "paper", + "observation_date": "2026-09-28", + "observed_finished_at": (NOW - timedelta(minutes=1)).isoformat(), + }, + ) def fetch_id_token(self, audience): self.calls.append(("token", audience)) @@ -111,6 +129,12 @@ def http_get(self, url, *, headers, timeout): raise self.response return self.response + def http_post(self, url, *, headers, data, timeout, allow_redirects, stream): + self.calls.append(("post", url, headers, data, timeout, allow_redirects, stream)) + if isinstance(self.post_response, Exception): + raise self.post_response + return self.post_response + def open_store(self, project_id): self.calls.append(("store", project_id)) if self.explode_store: @@ -134,6 +158,7 @@ def _record(env=None, spies=None, now=NOW, now_reader=None): env if env is not None else _env(), fetch_id_token=spies.fetch_id_token, http_get=spies.http_get, + http_post=spies.http_post, open_store=spies.open_store, now_reader=now_reader or (lambda: now), ) @@ -282,6 +307,219 @@ def test_record_stores_only_whitelisted_multi_currency_fields(): assert "net_assets_total" not in saved +def test_publishes_exact_created_history_body_once_to_qrs(): + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ) + ) + + assert result.status == "recorded" + assert result.publish_status == "published" + assert [name for name, *_ in spies.calls].count("http") == 1 + posts = [call for call in spies.calls if call[0] == "post"] + assert len(posts) == 1 + _, url, headers, body, timeout, allow_redirects, stream = posts[0] + assert url == QRS_SYNC_URL + assert headers == { + "Authorization": f"Bearer {QRS_TOKEN}", + "Accept": "application/json", + "Content-Type": "application/json", + } + assert body.decode("utf-8") == spies.stored[0][1] + assert timeout > 0 + assert allow_redirects is False + assert stream is True + posted_body = body.decode("utf-8") + assert SECRET_TOKEN not in posted_body + assert QRS_TOKEN not in posted_body + assert LEAK not in posted_body + + +def test_qrs_publish_is_off_by_default(): + result, spies = _record() + + assert result.status == "recorded" + assert result.publish_status == "disabled" + assert [name for name, *_ in spies.calls].count("post") == 0 + + +def test_store_unknown_never_posts_to_qrs(): + spies = _Spies(created=None) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.category == "store_unknown" + assert [name for name, *_ in spies.calls].count("post") == 0 + + +def test_already_recorded_does_not_publish_newly_fetched_body(): + spies = _Spies(created=False) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "already_recorded" + assert result.publish_status == "skipped_already_recorded" + assert [name for name, *_ in spies.calls].count("post") == 0 + + +def test_qrs_redirect_is_not_followed_or_treated_as_published(): + spies = _Spies(post_response=_Response(302, {"Location": "https://other.example.test"})) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "recorded" + assert result.publish_status == "rejected" + assert [name for name, *_ in spies.calls].count("post") == 1 + assert spies.calls[-1][5] is False + + +def test_qrs_timeout_is_unknown_and_never_retried(): + spies = _Spies(post_response=TimeoutError(f"{QRS_TOKEN} {LEAK}")) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "recorded" + assert result.publish_status == "unknown" + assert [name for name, *_ in spies.calls].count("post") == 1 + assert QRS_TOKEN not in result.publish_category + assert LEAK not in result.publish_category + + +def test_qrs_error_response_is_sanitized_and_record_stays_recorded(): + spies = _Spies(post_response=_Response(401, {"error": f"bad token {QRS_TOKEN} {LEAK}"})) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "recorded" + assert result.publish_status == "rejected" + assert result.publish_category == "qrs_http_rejected" + assert QRS_TOKEN not in result.publish_category + assert LEAK not in result.publish_category + + +def test_qrs_oversized_response_is_unknown_without_retry(): + spies = _Spies(post_response=_Response(200, content=b"x" * (64 * 1024 + 1))) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "recorded" + assert result.publish_status == "unknown" + assert result.publish_category == "qrs_response_too_large" + assert [name for name, *_ in spies.calls].count("post") == 1 + + +def test_qrs_oversized_payload_is_not_sent(): + large_payload = _payload( + cash=[ + { + "currency": "USD", + "available_cash": "1" * (64 * 1024), + "frozen_cash": "0", + "settling_cash": "0", + } + ] + ) + spies = _Spies(response=_Response(200, large_payload)) + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + + assert result.status == "recorded" + assert result.publish_status == "rejected" + assert result.publish_category == "qrs_payload_too_large" + assert [name for name, *_ in spies.calls].count("post") == 0 + + +def test_cli_reports_qrs_failure_without_erasing_record_success(capsys): + spies = _Spies(post_response=TimeoutError(f"{QRS_TOKEN} {LEAK}")) + code = main( + [], + environ=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=QRS_SYNC_URL, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + fetch_id_token=spies.fetch_id_token, + http_get=spies.http_get, + http_post=spies.http_post, + open_store=spies.open_store, + now_reader=lambda: NOW, + ) + + output = capsys.readouterr().out + assert code == 1 + assert output.strip() == "record=recorded account_facts_publish=unknown" + assert QRS_TOKEN not in output + assert LEAK not in output + + +def test_qrs_sync_url_must_be_exact_https_endpoint(): + for bad_url in ( + "http://qrs.example.test/api/account-facts/sync", + "https://qrs.example.test/api/account-facts/sync/", + "https://qrs.example.test/api/account-facts/sync?next=https://evil.test", + "https://user:pass@qrs.example.test/api/account-facts/sync", + "https://qrs.example.test:443/api/account-facts/sync", + "https://qrs.example.test/other", + ): + spies = _Spies() + result, spies = _record( + env=_env( + ACCOUNT_FACTS_SYNC_ENABLED="true", + ACCOUNT_FACTS_SYNC_URL=bad_url, + ACCOUNT_FACTS_SYNC_TOKEN=QRS_TOKEN, + ), + spies=spies, + ) + assert result.status == "recorded" + assert result.publish_status == "rejected" + assert [name for name, *_ in spies.calls].count("post") == 0 + + def test_new_source_or_utc_day_uses_a_new_object_segment(): late = datetime(2026, 9, 28, 0, 5, tzinfo=timezone.utc) previous_start = datetime(2026, 9, 27, 23, 55, tzinfo=timezone.utc) @@ -343,7 +581,7 @@ def test_cli_disabled_and_success_use_injected_dependencies(capsys): now_reader=lambda: NOW, ) assert disabled == 0 - assert capsys.readouterr().out.strip() == "disabled" + assert capsys.readouterr().out.strip() == "record=disabled account_facts_publish=disabled" spies = _Spies() code = main( @@ -356,7 +594,7 @@ def test_cli_disabled_and_success_use_injected_dependencies(capsys): ) captured = capsys.readouterr() assert code == 0 - assert captured.out.strip() == "recorded" + assert captured.out.strip() == "record=recorded account_facts_publish=disabled" assert SECRET_TOKEN not in captured.out @@ -370,7 +608,7 @@ def test_cli_error_is_a_short_category(capsys): now_reader=lambda: NOW, ) assert code == 1 - assert capsys.readouterr().out.strip() == "error: config_invalid" + assert capsys.readouterr().out.strip() == "record=error:config_invalid account_facts_publish=disabled" def test_gcloud_token_wrapper_success_failure_and_timeout(monkeypatch, capsys): @@ -457,7 +695,7 @@ def succeed(args, **kwargs): ) captured = capsys.readouterr() assert code == 0 - assert captured.out.strip() == "recorded" + assert captured.out.strip() == "record=recorded account_facts_publish=disabled" assert LEAK not in captured.out assert LEAK not in captured.err assert spies.calls[0][2]["Authorization"] == "Bearer synthetic-oidc-token" @@ -547,7 +785,7 @@ def test_malformed_url_cli_stays_a_short_category(capsys): ) captured = capsys.readouterr() assert code == 1 - assert captured.out.strip() == "error: config_invalid" + assert captured.out.strip() == "record=error:config_invalid account_facts_publish=disabled" assert "Traceback" not in captured.err assert "Port out of range" not in captured.err assert "IPv6" not in captured.err