strix/tests/test_cloud_idempotency.py
alex s de730119f0
feat(cli): strix cloud — managed platform CLI (login, scans, billing, and the rest of the API) (#1177)
* feat(cli): add strix login for managed platform sign-in (device flow)

* feat(cli): add --scopes flag to strix login

* docs: document strix login and managed billing in README, AGENTS, docs, and managed skill

* fix(cli): handle malformed login responses and credential file failures

* fix(cli): reject sign-in responses without an API token

* feat(login): interactive workspace and scope selection with presets

* fix(login): reject malformed API token values in sign-in responses

* fix(login): skip the scope prompt when stdin is not a terminal

* fix(login): tolerate malformed selection containers and remove unreadable credential files on logout

* fix(login): treat overflowing timing values as invalid

* fix(login): show the configured platform host in the sign-in banner

* fix(login): bound device flow timing values and clean up unreplaced secret temp files

* feat(cli): add the strix cloud command surface for the managed platform

* feat(cli): manage workspaces and hosted onboarding links from strix cloud

* fix(cli): report a leftover temporary secret file instead of hiding it

* feat(cli): pass a Stripe payment method to the top-up wallet client

* docs(cloud): recommend the Stripe agent wallet as the default payment path

* fix(cloud): preserve API auth during MPP payment

* fix(cloud): drop knowledge query and settings commands removed from the API

* fix(cloud): align agent commands with API contracts

* fix(cloud): send required PR review integration fields

* fix(cloud): preserve scopes when switching workspaces

* fix(cloud): make session command help non-destructive

* feat(cloud): improve human navigation and output

* feat(cli): add native shell completions

* feat(cloud): tailor human list and detail views

* feat(cloud): upload local source for managed scans

* fix(cloud): infer scan type from local targets

* Add agent-friendly managed cloud CLI

* Harden cloud CLI type boundaries

* Clarify cloud test user MFA options

* Correct cloud vulnerability status guidance

* Clarify chat file path handling

* Allow signed storage upload URLs

* Fix provider token request handling

* Improve cloud CLI human list views

* Make cloud CLI workflows actionable and safe

* Make cloud workspace switching session-safe

* Preserve CLI session metadata in JSON output

* Remove preview protection bypass plumbing from cloud CLI
2026-09-01 14:39:34 -04:00

203 lines
6.6 KiB
Python

"""Durable retry behavior for managed scan-launch commands."""
from __future__ import annotations
import json
from typing import Any
import pytest
from strix.interface import cloud
from strix.interface.cloud import http, runner
from strix.interface.completions import completion_candidates
class FakeResponse:
def __init__(self, payload: Any, *, status_code: int = 200) -> None:
self._payload = payload
self.status_code = status_code
self.headers = {"content-type": "application/json"}
self.text = json.dumps(payload)
self.closed = False
def json(self) -> Any:
return self._payload
def close(self) -> None:
self.closed = True
@pytest.fixture(autouse=True)
def _token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_API_TOKEN", "idempotency-test-token")
monkeypatch.setattr(runner.time, "sleep", lambda _seconds: None)
def test_scan_start_generates_and_sends_one_stable_key(
monkeypatch: pytest.MonkeyPatch,
capsys: Any,
) -> None:
seen: list[dict[str, Any]] = []
monkeypatch.setattr(runner, "uuid4", lambda: "generated-key")
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
seen.append(kwargs)
return FakeResponse({"scan_id": "scan-1", "status": "running"})
monkeypatch.setattr(http, "request", request)
assert cloud.run_cloud(["scans", "start", "--domain-ids", "domain-1", "--json"]) == 0
assert json.loads(capsys.readouterr().out)["scan_id"] == "scan-1"
assert len(seen) == 1
assert seen[0]["idempotency_key"] == "generated-key"
assert seen[0]["body"]["engagement_type"] == "live_test"
def test_exact_transport_retry_reuses_key_and_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: list[tuple[str, dict[str, Any]]] = []
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
seen.append((kwargs["idempotency_key"], kwargs["body"]))
if len(seen) == 1:
raise http.CloudTransportError("response lost")
return FakeResponse({"scan_id": "scan-1", "status": "running"})
monkeypatch.setattr(http, "request", request)
command = [
"scans",
"start",
"--domain-ids",
"domain-1",
"--idempotency-key",
"retry-key",
"--json",
]
assert cloud.run_cloud(command) == 0
assert len(seen) == 2
assert seen[0] == seen[1]
assert seen[0][0] == "retry-key"
@pytest.mark.parametrize(
"payload,status",
[
({"code": "idempotency_request_in_progress", "retry_safe": True}, 409),
({"code": "idempotency_outcome_unknown", "retry_safe": True}, 503),
({"detail": "gateway unavailable"}, 502),
({"detail": "rate limited"}, 429),
],
)
def test_retryable_responses_are_closed_and_replayed(
payload: dict[str, Any],
status: int,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = FakeResponse(payload, status_code=status)
responses = iter((first, FakeResponse({"scan_id": "scan-1", "status": "running"})))
keys: list[str] = []
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
keys.append(kwargs["idempotency_key"])
return next(responses)
monkeypatch.setattr(http, "request", request)
assert (
cloud.run_cloud(
[
"scans",
"rerun",
"scan-old",
"--idempotency-key",
"same-key",
"--json",
]
)
== 0
)
assert keys == ["same-key", "same-key"]
assert first.closed is True
def test_terminal_key_conflict_is_not_retried(
monkeypatch: pytest.MonkeyPatch,
capsys: Any,
) -> None:
calls = 0
def request(_method: str, _path: str, **_kwargs: Any) -> FakeResponse:
nonlocal calls
calls += 1
return FakeResponse(
{
"detail": "key belongs to another request",
"code": "idempotency_key_conflict",
"terminal": True,
},
status_code=409,
)
monkeypatch.setattr(http, "request", request)
assert (
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "conflict", "--json"])
== http.EXIT_ERROR
)
assert calls == 1
assert json.loads(capsys.readouterr().out)["code"] == "idempotency_key_conflict"
def test_exhausted_ambiguous_launch_reports_safe_recovery_key(
monkeypatch: pytest.MonkeyPatch,
capsys: Any,
) -> None:
monkeypatch.setattr(
http,
"request",
lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudTransportError("response lost")),
)
assert (
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "recover-me", "--json"])
== http.EXIT_ERROR
)
payload = json.loads(capsys.readouterr().out)
assert payload["idempotency_key"] == "recover-me"
assert payload["retry_safe"] is True
assert payload["retry_same_request"] is True
assert "--idempotency-key recover-me" in payload["error"]
@pytest.mark.parametrize("key", ["", " white", "bad key", "x\nheader", "x" * 201])
def test_invalid_idempotency_key_is_usage_error_before_request(
key: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request"))
assert (
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", key, "--json"])
== http.EXIT_USAGE
)
def test_idempotency_flag_is_completed_only_for_keyed_commands() -> None:
assert "--idempotency-key" in completion_candidates(["cloud", "scans", "start", "--idemp"])
assert "--idempotency-key" in completion_candidates(
["cloud", "scans", "rerun", "scan-1", "--idemp"]
)
assert "--idempotency-key" not in completion_candidates(["cloud", "scans", "list", "--idemp"])
assert "--idempotency-key" in completion_candidates(["cloud", "schedules", "create", "--idemp"])
assert "--idempotency-key" in completion_candidates(
["cloud", "schedules", "trigger", "schedule-1", "--idemp"]
)
def test_http_client_places_key_in_the_header(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, Any] = {}
def request(_method: str, _url: str, **kwargs: Any) -> FakeResponse:
seen.update(kwargs)
return FakeResponse({"ok": True})
monkeypatch.setattr(http.requests, "request", request)
http.request("POST", "/scans", body={}, idempotency_key="header-key")
assert seen["headers"]["Idempotency-Key"] == "header-key"
assert seen["headers"]["Authorization"] == "Bearer idempotency-test-token"