diff --git a/docs/API.md b/docs/API.md index 46d3d3cbec..1b6696de72 100644 --- a/docs/API.md +++ b/docs/API.md @@ -34,7 +34,7 @@ X-API-Token: Then make a read-only request (curl 7.76 or later): ```bash -curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \ +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ http://127.0.0.1:7655/api/state/summary ``` @@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled; do not use `--insecure`. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public and does not verify authentication. `--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. +403, while retaining the error response. Keep `--disable` first: it ignores +local curl configuration that could otherwise enable credential-bearing trace output. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include: - `settings:write` - `agent:config:read` - `agent:manage` +- `actions:plan`, `actions:approve`, `actions:execute` +- `audit:read` Endpoints that require admin access are noted below. @@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -CLI adapter: +Use the private header file prepared in [Authentication](#-authentication), +with `settings:read` on the token: + ```bash -PULSE_API_TOKEN=your-token pulse fleet connections \ - --api-url http://localhost:7655 +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/connections ``` ### Unified Action Planning -`POST /api/actions/plan` -Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`. -This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything. +The existing action API separates planning, approval and execution. Run these +steps separately, inspecting each response before moving on; do not paste the +whole sequence as an unattended recovery script. Use the private header file +from [Authentication](#-authentication), not a token in a command or environment +assignment. For remote access, replace the loopback origin with your Pulse HTTPS +URL and keep certificate verification enabled. -`POST /api/actions/{id}/decision` -Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action. +| Endpoint | Token scope and access | +| --- | --- | +| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. | +| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. | +| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. | +| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. | +| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. | +| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. | -`POST /api/actions/{id}/execute` -Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint. +The legacy `ai:execute` scope also permits planning, approval and execution, +but use the narrower action scopes when possible. Approval and execution tokens +must be bound to an authorised user; a scope alone does not bypass approval +policy, separation of duties or step-up requirements. Audit reads through a +browser session additionally require admin access. A `monitoring:read` token is +not an action-control or audit token. + +1. **Inspect capabilities.** Choose the canonical resource `id` from + `GET /api/resources`, not a display name or a VM number. Replace `vm:42` + throughout these examples and URL-encode it in request paths (`vm%3A42` here). + An empty capabilities list means there is nothing to plan for that resource. -CLI adapter: ```bash -PULSE_API_TOKEN=your-token pulse actions capabilities \ - --api-url http://localhost:7655 \ - --resource-id vm:42 - -PULSE_API_TOKEN=your-token pulse actions plan \ - --api-url http://localhost:7655 \ - --request-id agent-run-123 \ - --resource-id vm:42 \ - --capability restart \ - --param mode=graceful \ - --reason "Recover after confirmed outage" \ - --requested-by agent:oncall-helper - -PULSE_API_TOKEN=your-token pulse actions decide \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --outcome approved \ - --reason "Inside maintenance window" - -PULSE_API_TOKEN=your-token pulse actions execute \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --reason "Execute approved recovery" - -PULSE_API_TOKEN=your-token pulse actions audit \ - --api-url http://localhost:7655 \ - --resource-id vm:42 \ - --limit 10 - -PULSE_API_TOKEN=your-token pulse actions events \ - --api-url http://localhost:7655 \ - --action-id act_... +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 ``` -Request: -```json +2. **Plan only.** Use an advertised capability and its actual parameter schema; + `restart` and `mode=graceful` are examples, not universal capabilities. Choose + a request ID for this specific intent. Pulse derives the actor from the + authenticated credential, not a caller-supplied `requestedBy` value. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/plan <<'JSON' { - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "resourceId": "vm:42", "capabilityName": "restart", "params": { "mode": "graceful" }, - "reason": "Recover after confirmed outage", - "requestedBy": "agent:oncall-helper" + "reason": "Recover after confirmed outage" } +JSON ``` +Inspect the returned approval policy, blast radius, expiry and preflight checks. +Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...` +below with those returned values. Planning is not proof that execution is safe +or currently available. + +3. **Decide only if approval is required.** Use an authorised approver's private + header file. Approve only the reviewed plan; stop on a refusal or stale-plan + response rather than weakening the policy or creating another recovery action. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +{ + "outcome": "approved", + "reason": "Inside maintenance window", + "planHash": "sha256:..." +} +JSON +``` + +4. **Execute separately.** Only after reviewing a successful decision, or an + approval-free executable plan, use the executor's authorised header file: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +{ + "reason": "Execute approved recovery", + "planHash": "sha256:..." +} +JSON +``` + +If the response is lost or times out, inspect the existing action and target +before retrying: losing the connection does not prove that execution stopped. +An HTTP success is not a substitute for checking the terminal result and the +resource's observed state. + +5. **Read the audit and events**, using a private header file with audit access: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +``` + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/actions/act_.../events +``` + +When audit logging is unavailable, use Pulse's action detail in the signed-in +UI to inspect the existing action; do not infer success or repeat execution +from an unavailable audit response. + Response: ```json { "actionId": "act_...", - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "allowed": true, "requiresApproval": true, "approvalPolicy": "admin", diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 46d3d3cbec..1b6696de72 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -34,7 +34,7 @@ X-API-Token: Then make a read-only request (curl 7.76 or later): ```bash -curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \ +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ http://127.0.0.1:7655/api/state/summary ``` @@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled; do not use `--insecure`. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public and does not verify authentication. `--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. +403, while retaining the error response. Keep `--disable` first: it ignores +local curl configuration that could otherwise enable credential-bearing trace output. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include: - `settings:write` - `agent:config:read` - `agent:manage` +- `actions:plan`, `actions:approve`, `actions:execute` +- `audit:read` Endpoints that require admin access are noted below. @@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -CLI adapter: +Use the private header file prepared in [Authentication](#-authentication), +with `settings:read` on the token: + ```bash -PULSE_API_TOKEN=your-token pulse fleet connections \ - --api-url http://localhost:7655 +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/connections ``` ### Unified Action Planning -`POST /api/actions/plan` -Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`. -This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything. +The existing action API separates planning, approval and execution. Run these +steps separately, inspecting each response before moving on; do not paste the +whole sequence as an unattended recovery script. Use the private header file +from [Authentication](#-authentication), not a token in a command or environment +assignment. For remote access, replace the loopback origin with your Pulse HTTPS +URL and keep certificate verification enabled. -`POST /api/actions/{id}/decision` -Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action. +| Endpoint | Token scope and access | +| --- | --- | +| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. | +| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. | +| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. | +| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. | +| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. | +| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. | -`POST /api/actions/{id}/execute` -Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint. +The legacy `ai:execute` scope also permits planning, approval and execution, +but use the narrower action scopes when possible. Approval and execution tokens +must be bound to an authorised user; a scope alone does not bypass approval +policy, separation of duties or step-up requirements. Audit reads through a +browser session additionally require admin access. A `monitoring:read` token is +not an action-control or audit token. + +1. **Inspect capabilities.** Choose the canonical resource `id` from + `GET /api/resources`, not a display name or a VM number. Replace `vm:42` + throughout these examples and URL-encode it in request paths (`vm%3A42` here). + An empty capabilities list means there is nothing to plan for that resource. -CLI adapter: ```bash -PULSE_API_TOKEN=your-token pulse actions capabilities \ - --api-url http://localhost:7655 \ - --resource-id vm:42 - -PULSE_API_TOKEN=your-token pulse actions plan \ - --api-url http://localhost:7655 \ - --request-id agent-run-123 \ - --resource-id vm:42 \ - --capability restart \ - --param mode=graceful \ - --reason "Recover after confirmed outage" \ - --requested-by agent:oncall-helper - -PULSE_API_TOKEN=your-token pulse actions decide \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --outcome approved \ - --reason "Inside maintenance window" - -PULSE_API_TOKEN=your-token pulse actions execute \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --reason "Execute approved recovery" - -PULSE_API_TOKEN=your-token pulse actions audit \ - --api-url http://localhost:7655 \ - --resource-id vm:42 \ - --limit 10 - -PULSE_API_TOKEN=your-token pulse actions events \ - --api-url http://localhost:7655 \ - --action-id act_... +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 ``` -Request: -```json +2. **Plan only.** Use an advertised capability and its actual parameter schema; + `restart` and `mode=graceful` are examples, not universal capabilities. Choose + a request ID for this specific intent. Pulse derives the actor from the + authenticated credential, not a caller-supplied `requestedBy` value. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/plan <<'JSON' { - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "resourceId": "vm:42", "capabilityName": "restart", "params": { "mode": "graceful" }, - "reason": "Recover after confirmed outage", - "requestedBy": "agent:oncall-helper" + "reason": "Recover after confirmed outage" } +JSON ``` +Inspect the returned approval policy, blast radius, expiry and preflight checks. +Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...` +below with those returned values. Planning is not proof that execution is safe +or currently available. + +3. **Decide only if approval is required.** Use an authorised approver's private + header file. Approve only the reviewed plan; stop on a refusal or stale-plan + response rather than weakening the policy or creating another recovery action. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +{ + "outcome": "approved", + "reason": "Inside maintenance window", + "planHash": "sha256:..." +} +JSON +``` + +4. **Execute separately.** Only after reviewing a successful decision, or an + approval-free executable plan, use the executor's authorised header file: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +{ + "reason": "Execute approved recovery", + "planHash": "sha256:..." +} +JSON +``` + +If the response is lost or times out, inspect the existing action and target +before retrying: losing the connection does not prove that execution stopped. +An HTTP success is not a substitute for checking the terminal result and the +resource's observed state. + +5. **Read the audit and events**, using a private header file with audit access: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +``` + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/actions/act_.../events +``` + +When audit logging is unavailable, use Pulse's action detail in the signed-in +UI to inspect the existing action; do not infer success or repeat execution +from an unavailable audit response. + Response: ```json { "actionId": "act_...", - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "allowed": true, "requiresApproval": true, "approvalPolicy": "admin", diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py index 0b9ab7adf7..b085fc5d51 100644 --- a/scripts/tests/test_api_auth_docs.py +++ b/scripts/tests/test_api_auth_docs.py @@ -37,17 +37,50 @@ def commands() -> tuple[str, str]: return preparation, request +def recipe(path: str) -> str: + matches = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + if path in block] + if len(matches) != 1: + raise AssertionError(f"expected one executable recipe for {path}") + return matches[0] + + +# Independently expected HTTP operations: extracting the shell from the guide +# ensures the fixture tests what readers copy, not a second hand-written client. +FLEET_ACTION_REQUESTS = ( + ("GET", "/api/connections", None), + ("GET", "/api/agent/resource-capabilities/vm%3A42", None), + ("POST", "/api/actions/plan", { + "requestId": "manual-recovery-123", "resourceId": "vm:42", + "capabilityName": "restart", "params": {"mode": "graceful"}, + "reason": "Recover after confirmed outage", + }), + ("POST", "/api/actions/act_.../decision", { + "outcome": "approved", "reason": "Inside maintenance window", "planHash": "sha256:...", + }), + ("POST", "/api/actions/act_.../execute", { + "reason": "Execute approved recovery", "planHash": "sha256:...", + }), + ("GET", "/api/audit/actions?resourceId=vm%3A42&limit=10", None), + ("GET", "/api/audit/actions/act_.../events", None), +) + + @contextmanager def recording_server(status: int = 200): requests = [] class Handler(BaseHTTPRequestHandler): - def do_GET(self): - requests.append((self.path, dict(self.headers))) + def record_request(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + requests.append((self.path, dict(self.headers), self.command, body)) self.send_response(status) self.end_headers() self.wfile.write(b'{"fixture":true}\n') + do_GET = record_request + do_POST = record_request + def log_message(self, *_args): pass @@ -73,6 +106,30 @@ class APIAuthDocsTest(unittest.TestCase): self.assertIn("monitoring:read", section) self.assertIn("Do not paste tokens", section) + def test_all_shell_recipes_keep_credentials_out_of_arguments_and_defaults(self): + bash = "\n".join(re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)) + self.assertNotRegex(bash, r"(?:\b\w*TOKEN=|X-API-Token:|Authorization:|Bearer\s)") + self.assertNotRegex(bash, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") + requests = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + if block.startswith("curl ")] + self.assertEqual(len(requests), 8) + for request in requests: + self.assertTrue(request.startswith("curl --disable --fail-with-body ")) + self.assertIn('--header "@$HOME/.config/pulse/api-header"', request) + if "--request POST" in request: + self.assertIn("--data-binary @-", request) + self.assertIn("<<'JSON'", request) + + def test_action_guidance_preserves_scope_and_review_boundaries(self): + guide = DOC.read_text() + for scope in ("monitoring:read", "settings:read", "actions:plan", "actions:approve", + "actions:execute", "audit:read"): + self.assertIn(scope, guide) + for boundary in ("steps separately", "bound to an authorised user", "licensed audit logging", + "separation of duties", "step-up", "losing the connection", + "terminal result", "planHash", "URL-encode"): + self.assertIn(boundary, guide) + def test_shipped_copy_is_identical(self): self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/API.md").read_bytes()) @@ -97,8 +154,9 @@ class APIAuthDocsTest(unittest.TestCase): self.assertEqual(stat.S_IMODE(header.stat().st_mode), 0o600) self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "") - def run_documented_request(self, home: Path, header_text: str, port: int): - _, request = commands() + def run_documented_request(self, home: Path, header_text: str, port: int, request=None): + if request is None: + _, request = commands() header = home / ".config/pulse/api-header" header.parent.mkdir(parents=True, exist_ok=True) header.write_text(header_text + "\n") @@ -116,14 +174,26 @@ class APIAuthDocsTest(unittest.TestCase): ) recorder.chmod(0o700) receipt = home / "argv.json" + trace = home / "curl-trace.txt" + # An existing curl configuration must not turn safe argv into a trace + # containing the header file's credential, or inject another header. + (home / ".curlrc").write_text( + f'header = "X-Curlrc-Injected: yes"\nverbose\ntrace-ascii = "{trace}"\n' + ) env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}", + CURL_HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt)) + for key in list(env): + if key.lower().endswith("_proxy"): + del env[key] request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10) argv = json.loads(receipt.read_text()) + self.assertEqual(argv[0], "--disable", "curl defaults must be disabled by the first option") self.assertNotIn(TEST_TOKEN, " ".join(argv)) self.assertIn("@" + str(header), argv) self.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr) + self.assertFalse(trace.exists(), "local curl configuration must not create a credential trace") return result def test_header_file_sends_each_supported_header_without_exposing_argv(self): @@ -134,6 +204,41 @@ class APIAuthDocsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr.decode()) self.assertEqual(requests[-1][0], "/api/state/summary") self.assertEqual(requests[-1][1][key], value) + self.assertNotIn("X-Curlrc-Injected", requests[-1][1]) + + def test_fleet_action_recipes_send_expected_methods_paths_and_json(self): + with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests): + for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)): + for method, path, body in FLEET_ACTION_REQUESTS: + with self.subTest(header=key, method=method, path=path): + before = len(requests) + result = self.run_documented_request(Path(temporary), f"{key}: {value}", port, + recipe(path)) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(len(requests), before + 1, "each step sends only its own request") + sent_path, headers, sent_method, sent_body = requests[-1] + self.assertEqual((sent_method, sent_path), (method, path)) + self.assertEqual(headers[key], value) + other_key = "Authorization" if key == "X-API-Token" else "X-API-Token" + self.assertNotIn(other_key, headers) + self.assertNotIn("X-Curlrc-Injected", headers) + if body is None: + self.assertEqual(sent_body, b"") + else: + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(json.loads(sent_body), body) + + def test_each_fleet_action_recipe_surfaces_auth_errors_without_following_steps(self): + for status in (401, 403): + with tempfile.TemporaryDirectory() as temporary, recording_server(status) as (port, requests): + for _method, path, _body in FLEET_ACTION_REQUESTS: + with self.subTest(status=status, path=path): + before = len(requests) + result = self.run_documented_request(Path(temporary), "X-API-Token: " + TEST_TOKEN, + port, recipe(path)) + self.assertEqual(result.returncode, 22, result.stderr.decode()) + self.assertIn(b'{"fixture":true}', result.stdout) + self.assertEqual(len(requests), before + 1) def test_http_auth_errors_fail_instead_of_looking_successful(self): for status in (401, 403):