Keep fleet and action API tokens out of shell recipes

Use the existing HTTP routes with private header files, JSON on stdin and curl defaults disabled. Separate plan, decision and execution, explain their real permissions and retain uncertain-execution safety. Extend the executable guide tests to cover all seven recipes, both header formats, auth failures and trace-enabled local curl settings.

Contract-Neutral: API routes, authentication, scopes, action authority and runtime execution are unchanged; only existing guidance and its executable documentation checks change.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-01 07:27:49 +01:00
parent 9bbbc7a817
commit 704f4eeb4e
3 changed files with 321 additions and 106 deletions
+106 -51
View File
@@ -34,7 +34,7 @@ X-API-Token: <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",
+106 -51
View File
@@ -34,7 +34,7 @@ X-API-Token: <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",
+109 -4
View File
@@ -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):