mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Merge reviewed private bounded API guidance
Integrate exact candidate aece6b5217. Retained installer/vet and scoped Docs receipts cover the unchanged documentation recipes and tests; no runtime or product-surface change.
Change-source: pulse-maintainer
This commit is contained in:
+91
-33
@@ -17,7 +17,8 @@ secret does not appear in shell history or process arguments.
|
||||
|
||||
On a trusted machine, as the account running curl, prepare the file and open
|
||||
it in an editor. This preserves an existing file, restricts its directory and
|
||||
stops before editing if preparation fails or a credential path is a symlink:
|
||||
stops before editing if preparation fails or a credential path is symlinked or
|
||||
not a regular file:
|
||||
|
||||
```bash
|
||||
(
|
||||
@@ -29,6 +30,10 @@ stops before editing if preparation fails or a credential path is a symlink:
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$auth_file" ] && [ ! -f "$auth_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$auth_dir"
|
||||
chmod 700 "$auth_dir"
|
||||
touch "$auth_file"
|
||||
@@ -43,20 +48,82 @@ In the editor, save just this line, replacing `<token>` with the API token:
|
||||
X-API-Token: <token>
|
||||
```
|
||||
|
||||
Then make a read-only request (curl 7.76 or later):
|
||||
Define this helper in the same Bash session (curl 7.76 or later). The examples
|
||||
on this page use it to make **one request**, print only the HTTP status and save
|
||||
each response to a new owner-only file. This keeps resource names, audit details
|
||||
and credential-bearing error bodies out of terminal output and recordings.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/state/summary
|
||||
pulse_api() (
|
||||
set -eu
|
||||
umask 077
|
||||
if [ "$#" -ne 2 ]; then
|
||||
printf 'Usage: pulse_api GET|POST /api/path\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
method=$1
|
||||
api_path=$2
|
||||
case "$api_path" in
|
||||
/api/*) ;;
|
||||
*) printf 'Use an API path on this Pulse instance, not a full URL.\n' >&2; exit 2 ;;
|
||||
esac
|
||||
case "$method" in
|
||||
GET) set -- ;;
|
||||
POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;;
|
||||
*) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;;
|
||||
esac
|
||||
auth_dir="$HOME/.config/pulse"
|
||||
auth_file="$auth_dir/api-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ] || [ ! -f "$auth_file" ]; then
|
||||
printf 'Prepare a regular, private header file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$auth_dir" || exit "$?"
|
||||
chmod 600 "$auth_file" || exit "$?"
|
||||
result_file=$(mktemp "$auth_dir/api-response.XXXXXX") || exit "$?"
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--proto '=http' --noproxy 127.0.0.1 --connect-timeout 5 --max-time 20 \
|
||||
--header "@$auth_file" --output "$result_file" --write-out '%{http_code}' \
|
||||
"$@" "http://127.0.0.1:7655$api_path") || curl_exit=$?
|
||||
printf 'HTTP %s\nPrivate response: %s\n' "$status" "$result_file"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
case "$status" in
|
||||
2??) ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
)
|
||||
```
|
||||
|
||||
This loopback URL applies only when curl runs on the Pulse host. For remote
|
||||
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. Keep `--disable` first: it ignores
|
||||
local curl configuration that could otherwise enable credential-bearing trace output.
|
||||
Then make a read-only request with a `monitoring:read` token:
|
||||
|
||||
```bash
|
||||
pulse_api GET /api/state/summary
|
||||
```
|
||||
|
||||
The default loopback origin applies only when curl runs on the Pulse host and
|
||||
explicitly bypasses proxy environment settings. For remote access, replace
|
||||
`http://127.0.0.1:7655` **in the helper** with your Pulse HTTPS origin, and replace
|
||||
`--proto '=http' --noproxy 127.0.0.1` with `--proto '=https'`. Keep certificate
|
||||
verification enabled; for a private CA, add `--cacert` with a CA file you
|
||||
verified separately, never `--insecure`. Do not put credentials in the URL.
|
||||
|
||||
Open the reported response file privately to inspect it, including on failure;
|
||||
share only the relevant redacted error, not the whole response or file. Each
|
||||
call preserves earlier responses. HTTP 401 and 403 return curl's non-zero exit
|
||||
and retain the error body; redirects are not followed and are not success.
|
||||
The connection limit is five seconds and the whole request limit is twenty
|
||||
seconds. A partial file after a transport error is not a complete result.
|
||||
No request is retried automatically, especially a POST whose outcome is
|
||||
uncertain: inspect the existing action and target before deciding what to do.
|
||||
A 2xx response is only HTTP success, not proof that an action completed.
|
||||
|
||||
Keep `--disable` first: it ignores local curl configuration that could enable
|
||||
trace output, bypass TLS or follow redirects. On an authentication-enabled
|
||||
instance, the protected summary checks token access; `/api/health` is public
|
||||
and does not verify authentication. The helper is local example code, not an
|
||||
installed Pulse command; define it again in a new shell before using these
|
||||
examples. Dispose of response files under your normal private-data policy.
|
||||
|
||||
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
|
||||
@@ -67,7 +134,7 @@ needed in Pulse's API Access settings.
|
||||
### Bearer Token
|
||||
|
||||
The same API token can use a Bearer header instead. Replace the header file's
|
||||
line in the editor with the following, then use the same curl command above;
|
||||
line in the editor with the following, then use the same helper above;
|
||||
do not send both authentication headers:
|
||||
|
||||
```text
|
||||
@@ -326,12 +393,11 @@ 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.
|
||||
|
||||
Use the private header file prepared in [Authentication](#-authentication),
|
||||
Define the private-response helper in [Authentication](#-authentication),
|
||||
with `settings:read` on the token:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/connections
|
||||
pulse_api GET /api/connections
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
@@ -339,9 +405,10 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
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.
|
||||
and `pulse_api` helper from [Authentication](#-authentication), not a token in a
|
||||
command or environment assignment. Inspect each reported private response file
|
||||
before proceeding. For remote access, change the helper as described there,
|
||||
using your Pulse HTTPS origin with certificate verification enabled.
|
||||
|
||||
| Endpoint | Token scope and access |
|
||||
| --- | --- |
|
||||
@@ -365,8 +432,7 @@ not an action-control or audit token.
|
||||
An empty capabilities list means there is nothing to plan for that resource.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
|
||||
pulse_api GET /api/agent/resource-capabilities/vm%3A42
|
||||
```
|
||||
|
||||
2. **Plan only.** Use an advertised capability and its actual parameter schema;
|
||||
@@ -375,9 +441,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
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'
|
||||
pulse_api POST /api/actions/plan <<'JSON'
|
||||
{
|
||||
"requestId": "manual-recovery-123",
|
||||
"resourceId": "vm:42",
|
||||
@@ -398,9 +462,7 @@ or currently available.
|
||||
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'
|
||||
pulse_api POST /api/actions/act_.../decision <<'JSON'
|
||||
{
|
||||
"outcome": "approved",
|
||||
"reason": "Inside maintenance window",
|
||||
@@ -413,9 +475,7 @@ JSON
|
||||
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'
|
||||
pulse_api POST /api/actions/act_.../execute <<'JSON'
|
||||
{
|
||||
"reason": "Execute approved recovery",
|
||||
"planHash": "sha256:..."
|
||||
@@ -431,13 +491,11 @@ 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'
|
||||
pulse_api GET '/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
|
||||
pulse_api GET /api/audit/actions/act_.../events
|
||||
```
|
||||
|
||||
When audit logging is unavailable, use Pulse's action detail in the signed-in
|
||||
|
||||
@@ -17,7 +17,8 @@ secret does not appear in shell history or process arguments.
|
||||
|
||||
On a trusted machine, as the account running curl, prepare the file and open
|
||||
it in an editor. This preserves an existing file, restricts its directory and
|
||||
stops before editing if preparation fails or a credential path is a symlink:
|
||||
stops before editing if preparation fails or a credential path is symlinked or
|
||||
not a regular file:
|
||||
|
||||
```bash
|
||||
(
|
||||
@@ -29,6 +30,10 @@ stops before editing if preparation fails or a credential path is a symlink:
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$auth_file" ] && [ ! -f "$auth_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$auth_dir"
|
||||
chmod 700 "$auth_dir"
|
||||
touch "$auth_file"
|
||||
@@ -43,20 +48,82 @@ In the editor, save just this line, replacing `<token>` with the API token:
|
||||
X-API-Token: <token>
|
||||
```
|
||||
|
||||
Then make a read-only request (curl 7.76 or later):
|
||||
Define this helper in the same Bash session (curl 7.76 or later). The examples
|
||||
on this page use it to make **one request**, print only the HTTP status and save
|
||||
each response to a new owner-only file. This keeps resource names, audit details
|
||||
and credential-bearing error bodies out of terminal output and recordings.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/state/summary
|
||||
pulse_api() (
|
||||
set -eu
|
||||
umask 077
|
||||
if [ "$#" -ne 2 ]; then
|
||||
printf 'Usage: pulse_api GET|POST /api/path\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
method=$1
|
||||
api_path=$2
|
||||
case "$api_path" in
|
||||
/api/*) ;;
|
||||
*) printf 'Use an API path on this Pulse instance, not a full URL.\n' >&2; exit 2 ;;
|
||||
esac
|
||||
case "$method" in
|
||||
GET) set -- ;;
|
||||
POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;;
|
||||
*) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;;
|
||||
esac
|
||||
auth_dir="$HOME/.config/pulse"
|
||||
auth_file="$auth_dir/api-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ] || [ ! -f "$auth_file" ]; then
|
||||
printf 'Prepare a regular, private header file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$auth_dir" || exit "$?"
|
||||
chmod 600 "$auth_file" || exit "$?"
|
||||
result_file=$(mktemp "$auth_dir/api-response.XXXXXX") || exit "$?"
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--proto '=http' --noproxy 127.0.0.1 --connect-timeout 5 --max-time 20 \
|
||||
--header "@$auth_file" --output "$result_file" --write-out '%{http_code}' \
|
||||
"$@" "http://127.0.0.1:7655$api_path") || curl_exit=$?
|
||||
printf 'HTTP %s\nPrivate response: %s\n' "$status" "$result_file"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
case "$status" in
|
||||
2??) ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
)
|
||||
```
|
||||
|
||||
This loopback URL applies only when curl runs on the Pulse host. For remote
|
||||
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. Keep `--disable` first: it ignores
|
||||
local curl configuration that could otherwise enable credential-bearing trace output.
|
||||
Then make a read-only request with a `monitoring:read` token:
|
||||
|
||||
```bash
|
||||
pulse_api GET /api/state/summary
|
||||
```
|
||||
|
||||
The default loopback origin applies only when curl runs on the Pulse host and
|
||||
explicitly bypasses proxy environment settings. For remote access, replace
|
||||
`http://127.0.0.1:7655` **in the helper** with your Pulse HTTPS origin, and replace
|
||||
`--proto '=http' --noproxy 127.0.0.1` with `--proto '=https'`. Keep certificate
|
||||
verification enabled; for a private CA, add `--cacert` with a CA file you
|
||||
verified separately, never `--insecure`. Do not put credentials in the URL.
|
||||
|
||||
Open the reported response file privately to inspect it, including on failure;
|
||||
share only the relevant redacted error, not the whole response or file. Each
|
||||
call preserves earlier responses. HTTP 401 and 403 return curl's non-zero exit
|
||||
and retain the error body; redirects are not followed and are not success.
|
||||
The connection limit is five seconds and the whole request limit is twenty
|
||||
seconds. A partial file after a transport error is not a complete result.
|
||||
No request is retried automatically, especially a POST whose outcome is
|
||||
uncertain: inspect the existing action and target before deciding what to do.
|
||||
A 2xx response is only HTTP success, not proof that an action completed.
|
||||
|
||||
Keep `--disable` first: it ignores local curl configuration that could enable
|
||||
trace output, bypass TLS or follow redirects. On an authentication-enabled
|
||||
instance, the protected summary checks token access; `/api/health` is public
|
||||
and does not verify authentication. The helper is local example code, not an
|
||||
installed Pulse command; define it again in a new shell before using these
|
||||
examples. Dispose of response files under your normal private-data policy.
|
||||
|
||||
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
|
||||
@@ -67,7 +134,7 @@ needed in Pulse's API Access settings.
|
||||
### Bearer Token
|
||||
|
||||
The same API token can use a Bearer header instead. Replace the header file's
|
||||
line in the editor with the following, then use the same curl command above;
|
||||
line in the editor with the following, then use the same helper above;
|
||||
do not send both authentication headers:
|
||||
|
||||
```text
|
||||
@@ -326,12 +393,11 @@ 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.
|
||||
|
||||
Use the private header file prepared in [Authentication](#-authentication),
|
||||
Define the private-response helper in [Authentication](#-authentication),
|
||||
with `settings:read` on the token:
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/connections
|
||||
pulse_api GET /api/connections
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
@@ -339,9 +405,10 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
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.
|
||||
and `pulse_api` helper from [Authentication](#-authentication), not a token in a
|
||||
command or environment assignment. Inspect each reported private response file
|
||||
before proceeding. For remote access, change the helper as described there,
|
||||
using your Pulse HTTPS origin with certificate verification enabled.
|
||||
|
||||
| Endpoint | Token scope and access |
|
||||
| --- | --- |
|
||||
@@ -365,8 +432,7 @@ not an action-control or audit token.
|
||||
An empty capabilities list means there is nothing to plan for that resource.
|
||||
|
||||
```bash
|
||||
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
|
||||
pulse_api GET /api/agent/resource-capabilities/vm%3A42
|
||||
```
|
||||
|
||||
2. **Plan only.** Use an advertised capability and its actual parameter schema;
|
||||
@@ -375,9 +441,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||
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'
|
||||
pulse_api POST /api/actions/plan <<'JSON'
|
||||
{
|
||||
"requestId": "manual-recovery-123",
|
||||
"resourceId": "vm:42",
|
||||
@@ -398,9 +462,7 @@ or currently available.
|
||||
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'
|
||||
pulse_api POST /api/actions/act_.../decision <<'JSON'
|
||||
{
|
||||
"outcome": "approved",
|
||||
"reason": "Inside maintenance window",
|
||||
@@ -413,9 +475,7 @@ JSON
|
||||
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'
|
||||
pulse_api POST /api/actions/act_.../execute <<'JSON'
|
||||
{
|
||||
"reason": "Execute approved recovery",
|
||||
"planHash": "sha256:..."
|
||||
@@ -431,13 +491,11 @@ 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'
|
||||
pulse_api GET '/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
|
||||
pulse_api GET /api/audit/actions/act_.../events
|
||||
```
|
||||
|
||||
When audit logging is unavailable, use Pulse's action detail in the signed-in
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Execute the actual reference recipes against synthetic peers only. In
|
||||
// particular, action examples must not acquire an automatic retry or emit a
|
||||
// private response just because a POST failed or its response was incomplete.
|
||||
func pulseAPIRecipes(doc string) (preparation, helper string, calls []string, err error) {
|
||||
for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) {
|
||||
block := match[1]
|
||||
switch {
|
||||
case strings.Contains(block, "api-header") && strings.Contains(block, "vi "):
|
||||
preparation = block
|
||||
case strings.HasPrefix(block, "pulse_api() ("):
|
||||
helper = block
|
||||
case strings.HasPrefix(block, "pulse_api "):
|
||||
calls = append(calls, block)
|
||||
case strings.Contains(block, "curl "):
|
||||
return "", "", nil, fmt.Errorf("request bypasses the private-response helper")
|
||||
}
|
||||
}
|
||||
if preparation == "" || helper == "" || len(calls) != 8 {
|
||||
err = fmt.Errorf("missing credential/helper recipe or request examples (got %d)", len(calls))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func pulseAPIReference(t *testing.T) (string, string, []string) {
|
||||
t.Helper()
|
||||
doc, err := os.ReadFile(repoFile("docs", "API.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "API.md"))
|
||||
if err != nil || string(doc) != string(mirror) {
|
||||
t.Fatal("shipped API reference differs from the tested recipes")
|
||||
}
|
||||
prep, helper, calls, err := pulseAPIRecipes(string(doc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return prep, helper, calls
|
||||
}
|
||||
|
||||
func runPulseAPIRecipe(t *testing.T, script, home, bin string, extraEnv ...string) (string, int) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", script)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"),
|
||||
"http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=")
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("API recipe exceeded the test watchdog; its own deadline did not complete")
|
||||
}
|
||||
if err == nil {
|
||||
return string(out), 0
|
||||
}
|
||||
if failure, ok := err.(*exec.ExitError); ok {
|
||||
return string(out), failure.ExitCode()
|
||||
}
|
||||
t.Fatal(err)
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func TestPulseAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) {
|
||||
prep, _, _ := pulseAPIReference(t)
|
||||
for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "dangling symlink", "non-regular", "mkdir failure", "chmod failure", "touch failure", "editor failure"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
home, bin := t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
file := filepath.Join(dir, "api-header")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "new" || mode == "mkdir failure" {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "existing" {
|
||||
if err := os.WriteFile(file, []byte("synthetic-existing-header"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "non-regular" {
|
||||
if err := os.Mkdir(file, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
target := filepath.Join(home, "unchanged")
|
||||
if strings.Contains(mode, "symlink") {
|
||||
link := file
|
||||
if mode == "config symlink" || mode == "directory symlink" {
|
||||
link = dir
|
||||
if mode == "config symlink" {
|
||||
link = filepath.Join(home, ".config")
|
||||
}
|
||||
if err := os.Remove(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "config symlink" {
|
||||
if err := os.Remove(link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(target, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if mode == "file symlink" {
|
||||
if err := os.WriteFile(target, []byte("synthetic-existing-header"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
edited := filepath.Join(home, "editor-started")
|
||||
editor := "#!/bin/sh\nprintf started > \"$API_EDITED\"\n"
|
||||
if mode == "editor failure" {
|
||||
editor += "exit 7\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "vi"), []byte(editor), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasSuffix(mode, " failure") && mode != "editor failure" {
|
||||
name := strings.TrimSuffix(mode, " failure")
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 7\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
output, exit := runPulseAPIRecipe(t, prep, home, bin, "API_EDITED="+edited)
|
||||
_, editorErr := os.Stat(edited)
|
||||
if mode == "new" || mode == "existing" {
|
||||
if exit != 0 || editorErr != nil {
|
||||
t.Fatalf("safe preparation failed: exit %d", exit)
|
||||
}
|
||||
for path, perm := range map[string]os.FileMode{dir: 0700, file: 0600} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != perm {
|
||||
t.Fatal("credential paths are not private")
|
||||
}
|
||||
}
|
||||
if mode == "existing" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil || string(data) != "synthetic-existing-header" {
|
||||
t.Fatal("preparation did not preserve the existing header")
|
||||
}
|
||||
}
|
||||
} else if exit == 0 || (mode != "editor failure" && !os.IsNotExist(editorErr)) {
|
||||
t.Fatal("unsafe or failed preparation reached the editor or succeeded")
|
||||
}
|
||||
if mode == "file symlink" {
|
||||
data, err := os.ReadFile(target)
|
||||
info, statErr := os.Stat(target)
|
||||
if err != nil || statErr != nil || string(data) != "synthetic-existing-header" || info.Mode().Perm() != 0644 {
|
||||
t.Fatal("preparation modified the symlink target")
|
||||
}
|
||||
}
|
||||
if mode == "config symlink" || mode == "directory symlink" {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil || info.Mode().Perm() != 0755 {
|
||||
t.Fatal("preparation changed the target directory")
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "synthetic-existing-header") {
|
||||
t.Fatal("preparation disclosed a header")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulseAPIGuidanceHelperStopsBeforeCurlOnUnsafeFilesOrPreparation(t *testing.T) {
|
||||
_, helper, _ := pulseAPIReference(t)
|
||||
for _, mode := range []string{"missing", "file symlink", "config symlink", "directory symlink", "non-regular", "chmod failure", "mktemp failure", "unsupported method", "full URL", "extra argument"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
home, bin := t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "api-header")
|
||||
if mode != "missing" && mode != "file symlink" && mode != "non-regular" {
|
||||
if err := os.WriteFile(file, []byte("synthetic-header"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "file symlink" {
|
||||
if err := os.Symlink(filepath.Join(home, "absent"), file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "config symlink" || mode == "directory symlink" {
|
||||
link := dir
|
||||
if mode == "config symlink" {
|
||||
link = filepath.Join(home, ".config")
|
||||
}
|
||||
if err := os.Rename(link, link+"-retained"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(link+"-retained", link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "non-regular" {
|
||||
if err := os.Mkdir(file, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
called := filepath.Join(home, "curl-called")
|
||||
if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nprintf started > \"$API_CALLED\"\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasSuffix(mode, " failure") {
|
||||
if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 7\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
call := "pulse_api GET /api/state/summary"
|
||||
switch mode {
|
||||
case "unsupported method":
|
||||
call = "pulse_api DELETE /api/actions/act_example"
|
||||
case "full URL":
|
||||
call = "pulse_api GET https://not-this-instance.invalid/api/state"
|
||||
case "extra argument":
|
||||
call += " --location"
|
||||
}
|
||||
// Bash disables implicit errexit inside functions used as conditions.
|
||||
// The documented helper must still stop on chmod/mktemp failure.
|
||||
script := helper + "\nif " + call + "; then exit 0; else exit $?; fi\n"
|
||||
_, exit := runPulseAPIRecipe(t, script, home, bin, "API_CALLED="+called)
|
||||
_, err := os.Stat(called)
|
||||
if exit == 0 || !os.IsNotExist(err) {
|
||||
t.Fatal("invalid input or failed preparation reached curl")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type apiRecipeRequest struct{ method, uri, token, contentType, body string }
|
||||
|
||||
func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing.T) {
|
||||
_, helper, calls := pulseAPIReference(t)
|
||||
paths := []string{"/api/state/summary", "/api/connections", "/api/agent/resource-capabilities/vm%3A42", "/api/actions/plan", "/api/actions/act_.../decision", "/api/actions/act_.../execute", "/api/audit/actions?resourceId=vm%3A42&limit=10", "/api/audit/actions/act_.../events"}
|
||||
for index, call := range calls {
|
||||
for _, scenario := range []string{"success", "401", "403", "redirect", "partial", "untrusted HTTPS", "trusted HTTPS"} {
|
||||
t.Run(fmt.Sprintf("request-%d/%s", index, scenario), func(t *testing.T) {
|
||||
home, bin := t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const token = "synthetic-fixture-secret"
|
||||
const response = `{"token":"synthetic-fixture-secret","resource":"private-fixture-name","audit":"private-fixture-event"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: "+token+"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retained := filepath.Join(dir, "api-response.retained")
|
||||
if err := os.WriteFile(retained, []byte("previous-result"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capture, trace := filepath.Join(home, "argv"), filepath.Join(home, "trace")
|
||||
realCurl, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
t.Fatal("curl is required to verify copied API requests")
|
||||
}
|
||||
wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$API_ARGV\"\nexec \"$API_CURL\" \"$@\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan apiRecipeRequest, 4)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requests <- apiRecipeRequest{r.Method, r.URL.RequestURI(), r.Header.Get("X-API-Token"), r.Header.Get("Content-Type"), string(body)}
|
||||
switch scenario {
|
||||
case "401":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case "403":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
case "redirect":
|
||||
w.Header().Set("Location", "/should-not-receive-header")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
case "partial":
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(response)+100))
|
||||
}
|
||||
_, _ = io.WriteString(w, response)
|
||||
})
|
||||
var server *httptest.Server
|
||||
recipe := helper
|
||||
if strings.Contains(scenario, "HTTPS") {
|
||||
server = httptest.NewUnstartedServer(handler)
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
server.StartTLS()
|
||||
recipe = strings.Replace(recipe, "--proto '=http' --noproxy 127.0.0.1", "--proto '=https'", 1)
|
||||
if scenario == "trusted HTTPS" {
|
||||
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1)
|
||||
}
|
||||
} else {
|
||||
server = httptest.NewServer(handler)
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1)
|
||||
extra := []string{"API_ARGV=" + capture, "API_CURL=" + realCurl}
|
||||
if !strings.Contains(scenario, "HTTPS") {
|
||||
// The local recipe must not send credentials to an ambient proxy.
|
||||
extra = append(extra, "http_proxy=http://127.0.0.1:1", "ALL_PROXY=http://127.0.0.1:1")
|
||||
}
|
||||
output, exit := runPulseAPIRecipe(t, recipe+"\n"+call, home, bin, extra...)
|
||||
wantExit := map[string]int{"success": 0, "401": 22, "403": 22, "redirect": 1, "partial": 18, "untrusted HTTPS": 60, "trusted HTTPS": 0}[scenario]
|
||||
if exit != wantExit {
|
||||
t.Fatalf("request exit %d, want %d", exit, wantExit)
|
||||
}
|
||||
argv, err := os.ReadFile(capture)
|
||||
if err != nil || !strings.HasPrefix(string(argv), "--disable\x00") {
|
||||
t.Fatal("curl did not ignore configuration first")
|
||||
}
|
||||
for _, sensitive := range []string{token, "private-fixture-name", "private-fixture-event"} {
|
||||
if strings.Contains(output, sensitive) || strings.Contains(string(argv), sensitive) {
|
||||
t.Fatal("request printed private credentials or response data")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(trace); !os.IsNotExist(err) {
|
||||
t.Fatal("local curl configuration enabled tracing")
|
||||
}
|
||||
responses, err := filepath.Glob(filepath.Join(dir, "api-response.??????"))
|
||||
if err != nil || len(responses) != 1 || !strings.Contains(output, responses[0]) {
|
||||
t.Fatal("response was not saved to one new, reported private file")
|
||||
}
|
||||
for path, perm := range map[string]os.FileMode{dir: 0700, filepath.Join(dir, "api-header"): 0600, responses[0]: 0600} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != perm {
|
||||
t.Fatal("request paths are not private")
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(retained); err != nil || string(data) != "previous-result" {
|
||||
t.Fatal("request replaced an earlier response")
|
||||
}
|
||||
if scenario == "untrusted HTTPS" {
|
||||
if len(requests) != 0 {
|
||||
t.Fatal("untrusted TLS peer received credentials")
|
||||
}
|
||||
} else {
|
||||
if len(requests) != 1 {
|
||||
t.Fatal("request was missing, redirected or retried")
|
||||
}
|
||||
got := <-requests
|
||||
method := "GET"
|
||||
contentType, body := "", ""
|
||||
if index >= 3 && index <= 5 {
|
||||
method, contentType = "POST", "application/json"
|
||||
_, body, _ = strings.Cut(call, "<<'JSON'\n")
|
||||
body = strings.TrimSuffix(body, "JSON\n")
|
||||
}
|
||||
if got != (apiRecipeRequest{method, paths[index], token, contentType, body}) {
|
||||
t.Fatal("request method, endpoint, private header or JSON body differs from the example")
|
||||
}
|
||||
if data, err := os.ReadFile(responses[0]); err != nil || string(data) != response {
|
||||
t.Fatal("HTTP response, including failure or partial body, was not retained privately")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulseAPIGuidanceTimedOutPOSTIsNotRepeated(t *testing.T) {
|
||||
_, helper, calls := pulseAPIReference(t)
|
||||
home, bin := t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: synthetic-timeout-secret\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan struct{}, 4)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
requests <- struct{}{}
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = io.WriteString(w, "private-partial-response")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
if !strings.Contains(helper, "--connect-timeout 5 --max-time 20") {
|
||||
t.Fatal("documented request deadlines are missing")
|
||||
}
|
||||
// Use the actual documented twenty-second limit, not a test-only shortened
|
||||
// deadline. An execution endpoint can have acted even when this read fails.
|
||||
helper = strings.Replace(helper, "http://127.0.0.1:7655", server.URL, 1)
|
||||
start := time.Now()
|
||||
output, exit := runPulseAPIRecipe(t, helper+"\n"+calls[5], home, bin)
|
||||
if exit != 28 || time.Since(start) > 25*time.Second || len(requests) != 1 {
|
||||
t.Fatal("POST did not stop at its request deadline or was retried")
|
||||
}
|
||||
if strings.Contains(output, "private-partial-response") || strings.Contains(output, "synthetic-timeout-secret") {
|
||||
t.Fatal("timed-out POST disclosed a private body or credential")
|
||||
}
|
||||
responses, err := filepath.Glob(filepath.Join(dir, "api-response.??????"))
|
||||
if err != nil || len(responses) != 1 {
|
||||
t.Fatal("timed-out POST did not retain its response")
|
||||
}
|
||||
if data, err := os.ReadFile(responses[0]); err != nil || string(data) != "private-partial-response" {
|
||||
t.Fatal("partial response was not retained for private reconciliation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulseAPIGuidanceExactParentPrintsPrivateResponse(t *testing.T) {
|
||||
inputs := os.Getenv("PULSE_PROOF_INPUTS")
|
||||
if inputs == "" {
|
||||
t.Skip("exact-parent input is provided by the candidate source proof")
|
||||
}
|
||||
old, err := os.ReadFile(filepath.Join(inputs, "api-parent.md"))
|
||||
if os.IsNotExist(err) {
|
||||
t.Skip("unrelated proof input does not supply the exact parent")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request string
|
||||
for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(string(old), -1) {
|
||||
if strings.Contains(match[1], "/api/state/summary") && strings.Contains(match[1], "curl ") {
|
||||
request = match[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if request == "" {
|
||||
t.Fatal("exact old reference summary command is missing")
|
||||
}
|
||||
home, bin := t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: synthetic-parent-secret\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const response = "synthetic-parent-secret private-parent-resource"
|
||||
requests := make(chan struct{}, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests <- struct{}{}
|
||||
if r.Header.Get("X-API-Token") != "synthetic-parent-secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
_, _ = io.WriteString(w, response)
|
||||
}))
|
||||
defer server.Close()
|
||||
request = strings.Replace(request, "http://127.0.0.1:7655", server.URL, 1)
|
||||
output, exit := runPulseAPIRecipe(t, request, home, bin)
|
||||
if exit != 0 || len(requests) != 1 || !strings.Contains(output, response) {
|
||||
t.Fatal("exact parent did not reproduce the private-response console exposure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulseAPIGuidanceRejectsExactOldReference(t *testing.T) {
|
||||
inputs := os.Getenv("PULSE_PROOF_INPUTS")
|
||||
if inputs == "" {
|
||||
t.Skip("exact-parent input is provided by the candidate source proof")
|
||||
}
|
||||
old, err := os.ReadFile(filepath.Join(inputs, "api-parent.md"))
|
||||
if os.IsNotExist(err) {
|
||||
t.Skip("unrelated proof input does not supply the exact parent")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := pulseAPIRecipes(string(old)); err == nil {
|
||||
t.Fatal("old direct, unbounded console-response examples were accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user