diff --git a/Makefile b/Makefile index ce2cf0a..59ce28f 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ test: lint test-suite ## Lint + the dependency-free suite (runs on macOS or Linu test-suite: ## rigforge test suite: unit + black-box, every CPU/OS profile simulated bash tests/run.sh + bash tests/test-contract-credentials.sh bash tests/test-access-token-readiness.sh test-e2e: ## Full end-to-end run in disposable Linux containers (needs Docker) diff --git a/tests/README.md b/tests/README.md index 6315ec3..7193a86 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,6 +3,9 @@ RigForge is one self-contained script, so its tests are layered by how much they exercise for real, from a dependency-free suite that runs anywhere up to a real-hardware gate that actually compiles XMRig and mines. Each layer covers what the one below it has to stub. +`make test-suite` requires both the Bash suite and the Python-dependent contract credential regression. +The kcov container measures the Bash suite only; the credential regression always runs in the +GitHub Test suite job, including argument inspection, password rotation and EXIT restoration. > The split exists so CI proves everything it can on a GitHub runner, while the things a runner > physically can't do (compile XMRig, reserve HugePages, write MSRs, set the governor, hash) are @@ -12,7 +15,7 @@ compiles XMRig and mines. Each layer covers what the one below it has to stub. | Layer | File | Runs | What it proves | How to run | |---|---|---|---|---| -| **Unit + black-box suite** | [`run.sh`](run.sh) | Any host (macOS/Linux), no Docker. **In CI.** | Config parsing, the XMRig-config generation matrix (every CPU/OS profile, simulated via PATH stubs), GRUB/HugePages math, the command surface, tune search, doctor: everything that doesn't need a real `/etc` or real hardware. The bulk of coverage. | `make test` (lint + suite) or `bash tests/run.sh` | +| **Unit + black-box suite** | [`run.sh`](run.sh) + [`test-contract-credentials.sh`](test-contract-credentials.sh) | Any host (macOS/Linux), no Docker; credential regression needs Python 3. **In CI.** | Config parsing, the XMRig-config generation matrix (every CPU/OS profile, simulated via PATH stubs), GRUB/HugePages math, the command surface, tune search, doctor: everything that doesn't need a real `/etc` or real hardware. The bulk of coverage. | `make test` (lint + suite) or `make test-suite` | | **Linux container e2e** | [`e2e/linux.sh`](e2e/linux.sh) → [`e2e/in-container.sh`](e2e/in-container.sh) | Disposable Ubuntu container, **needs Docker**. **In CI.** | The genuine Linux deploy path against a real (throwaway) `/etc` with real GNU tools (`sed -i`, `tee`, `envsubst`) + idempotency on re-run. Only the heavy/privileged bits (compile, package install, `systemctl`/`mount`) are stubbed. A second pass re-runs the container with `RIGFORGE_APPLIANCE=1` (#348) and proves the appliance contracts on that real `/etc`: units land in `/run/systemd/system`, no package installs, no fstab/limits.conf/logrotate writes, every enable carries `--runtime`. | `make test-e2e` | | **Native macOS e2e** | [`e2e/macos.sh`](e2e/macos.sh) | A real Mac, **manual, local only. Not in CI** (macOS is deprecated, #493). | The macOS deploy path with genuine BSD tools the Linux CI can only stub: BSD `sed`, the macOS config profile, `mac_*` process control (real `nohup` + PID file), the launchd login agent, `backup`/`restore`. | `make test-e2e-macos` | | **Coverage gate** | [`coverage.sh`](coverage.sh) | kcov in **Docker**. **In CI.** | Line coverage of `rigforge.sh` + `util/proposed-grub.sh` by running `run.sh` under kcov; enforces the committed floor ([`coverage-floor.txt`](coverage-floor.txt)) plus a patch-coverage gate (diff-cover) on changed lines. | `make coverage` | @@ -37,9 +40,14 @@ without a successful refresh and CPU begin/end from the same process fails diagn No arguments, process names or payloads are exported. Five-second observations record timer/service state and the direct feed for five minutes around the apply; the changed ceiling must still appear within 90 seconds. -Control-phase setup passes the temporary write token through stdin to jq's `--rawfile` -with a constant program; requests pass it through curl's stdin configuration. Both keep the -token out of process arguments and logs, while the EXIT trap restores the original config. Each POST uses its own securely allocated response file, +Control and access-token requests pass bearer tokens through curl's stdin configuration. +Control setup, access-token setup and stratum password setup/rotation use stdin with constant jq programs; +the derived read bearer uses Python's standard-library HMAC with its key on stdin. +These paths keep credentials out of process arguments and logs; the EXIT trap restores the original config. +A failed control revocation request records only curl's numeric exit status and control-service +activity; a failed wrong-password gate reuses fixed-schema XMRig state and classified log counts. +These diagnostics preserve the failing verdict and never export config, log text or credentials. +Each POST uses its own securely allocated response file, removed after reading the change identifier. The drop-in is written in a private staging file and published atomically with `link` only after the write succeeds. A partial write fails setup and cleanup removes the staging file. Publication refuses diff --git a/tests/e2e-pithead-tokens.sh b/tests/e2e-pithead-tokens.sh index ab0fa12..f22b18e 100644 --- a/tests/e2e-pithead-tokens.sh +++ b/tests/e2e-pithead-tokens.sh @@ -32,12 +32,12 @@ _access_tokens_ready() { # phase_access_tokens() { phase "access-tokens — a non-master ACCESS_TOKENS entry authenticates :8081 and :8082 (#516)" - local master bench cur new port api_port resp code cid st waited=0 body bread + local master bench cur new port api_port resp code cid st waited=0 body bread curl_rc=0 control_active=0 master=$(head -c 32 /dev/urandom | xxd -p -c 256) bench=$(head -c 32 /dev/urandom | xxd -p -c 256) cur=$(jq -r '.DONATION // 1' "$CFG") new=$(((cur + 1) % 101)) - set_cfg ".api=\"enabled\" | .control=\"enabled\" | .ACCESS_TOKEN=\"$master\" | .ACCESS_TOKENS={\"bench\":\"$bench\"} | .api_allow_from=\"127.0.0.1/32\"" soft || + printf '{"master":"%s","bench":"%s"}' "$master" "$bench" | set_cfg '.api="enabled" | .control="enabled" | .ACCESS_TOKEN=$tokens[0].master | .ACCESS_TOKENS={"bench":$tokens[0].bench} | .api_allow_from="127.0.0.1/32"' soft --slurpfile tokens /dev/stdin || bad "could not enable api+control with a named ACCESS_TOKENS entry" port=$(jq -r '.control_port // 8082' "$CFG") api_port=$(jq -r '.api_port // 8081' "$CFG") @@ -47,26 +47,26 @@ phase_access_tokens() { # :8080 stays master-only — a named entry must not reach xmrig's own API. The refusal code is a # present-but-wrong Bearer, not a missing one, and varies by xmrig version (same reasoning as # e2e-pithead.sh's phase_worker_api restricted-PUT check); != 200 is the contract. - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -H "Authorization: Bearer $bench" http://127.0.0.1:8080/2/summary 2>/dev/null || true) + code=$(_control_curl "$bench" -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:8080/2/summary 2>/dev/null || true) [ -n "$code" ] && [ "$code" != 200 ] && ok "the bench entry is refused on :8080 (master-only, $code, #516)" || bad ":8080 answered $code to a non-master entry (expected refusal)" # :8081 — the sister API accepts the bench entry raw, and its own derived read bearer. - body=$(curl -fsS --max-time 10 -H "Authorization: Bearer $bench" "http://127.0.0.1:$(jq -r '.api_port // 8081' "$CFG")/health" 2>/dev/null || true) + body=$(_control_curl "$bench" -fsS --max-time 10 "http://127.0.0.1:$(jq -r '.api_port // 8081' "$CFG")/health" 2>/dev/null || true) printf '%s' "$body" | jq -e . >/dev/null 2>&1 && ok "a non-master ACCESS_TOKENS entry authenticates :8081 raw (#516)" || bad ":8081 refused the bench entry raw" - bread=$(printf 'rigforge:api-read:v1' | openssl dgst -sha256 -hmac "$bench" | awk '{print $NF}') - body=$(curl -fsS --max-time 10 -H "Authorization: Bearer $bread" "http://127.0.0.1:$(jq -r '.api_port // 8081' "$CFG")/health" 2>/dev/null || true) + bread=$(printf '%s' "$bench" | python3 -c 'import hmac,sys; print(hmac.new(sys.stdin.buffer.read(), b"rigforge:api-read:v1", "sha256").hexdigest())') + body=$(_control_curl "$bread" -fsS --max-time 10 "http://127.0.0.1:$(jq -r '.api_port // 8081' "$CFG")/health" 2>/dev/null || true) printf '%s' "$body" | jq -e . >/dev/null 2>&1 && ok "the bench entry's derived read bearer authenticates :8081 too (#516)" || bad ":8081 refused the bench entry's derived read bearer" # :8082 — a full apply round trip authenticated with the bench entry, never the master. resp="$(mktemp)" - code=$(curl -s -o "$resp" -w '%{http_code}' --max-time 10 -H "Authorization: Bearer $bench" \ + code=$(_control_curl "$bench" -s -o "$resp" -w '%{http_code}' --max-time 10 \ -H "Content-Type: application/json" -d "{\"DONATION\": $new}" "http://127.0.0.1:$port/apply" 2>/dev/null || true) cid=$(jq -r '.change_id // empty' "$resp" 2>/dev/null || true) rm -f "$resp" [ "$code" = 202 ] && [ -n "$cid" ] && ok "a non-master ACCESS_TOKENS entry authenticates :8082's POST /apply (#516, change_id=$cid)" || bad ":8082 POST /apply with the bench entry returned HTTP '$code' (expected 202)" while [ "$waited" -lt 300 ]; do - body=$(curl -fsS --max-time 5 -H "Authorization: Bearer $bench" "http://127.0.0.1:$port/status?change_id=$cid" 2>/dev/null || true) + body=$(_control_curl "$bench" -fsS --max-time 5 "http://127.0.0.1:$port/status?change_id=$cid" 2>/dev/null || true) st=$(printf '%s' "$body" | jq -r '.status // empty' 2>/dev/null || true) case "$st" in applied | rejected | rolled_back | failed) break ;; esac sleep 5 @@ -79,10 +79,16 @@ phase_access_tokens() { set_cfg '.ACCESS_TOKENS={}' soft || bad "could not revoke the bench ACCESS_TOKENS entry" _access_tokens_ready api "$api_port" revocation || return 1 _access_tokens_ready control "$port" revocation || return 1 - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -H "Authorization: Bearer $bench" "http://127.0.0.1:$(jq -r '.api_port // 8081' "$CFG")/health" 2>/dev/null || true) + code=$(_control_curl "$bench" -s -o /dev/null -w '%{http_code}' --max-time 10 "http://127.0.0.1:$api_port/health" 2>/dev/null || true) [ "$code" = 401 ] && ok "a revoked ACCESS_TOKENS entry stops authenticating :8081 (#516)" || bad "revoked entry still answered $code on :8081" - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H "Authorization: Bearer $bench" -H 'Content-Type: application/json' -d '{"DONATION":1}' "http://127.0.0.1:$port/apply" 2>/dev/null || true) - [ "$code" = 401 ] && ok "a revoked ACCESS_TOKENS entry stops authenticating :8082 (#516)" || bad "revoked entry still answered $code on :8082" + code=$(_control_curl "$bench" -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' -d '{"DONATION":1}' "http://127.0.0.1:$port/apply" 2>/dev/null) || curl_rc=$? + if [ "$code" = 401 ]; then + ok "a revoked ACCESS_TOKENS entry stops authenticating :8082 (#516)" + else + systemctl is-active --quiet rigforge-control.service && control_active=1 || true + printf 'access-token-diagnostic: control_transport_rc=%s control_active=%s\n' "$curl_rc" "$control_active" + bad "revoked entry still answered $code on :8082" + fi # No further restoration here: _cleanup's EXIT trap restores DONATION, the tokens and the # control state from the snapshot, same convention as phase_control. } diff --git a/tests/e2e-pithead.sh b/tests/e2e-pithead.sh index 7ce4532..c77f4cc 100644 --- a/tests/e2e-pithead.sh +++ b/tests/e2e-pithead.sh @@ -436,7 +436,7 @@ phase_stratum_auth() { if grep -qi 'permission denied\|login error' "$WLOG" 2>/dev/null; then ok "wrong pass: rejected by the proxy" else - bad "wrong pass: no rejection within 60s" + _connect_bad "wrong pass: no rejection within 60s" fi if grep -q 'new job from' "$WLOG" 2>/dev/null; then bad "wrong pass: worker still received jobs (auth not enforced?)" diff --git a/tests/test-access-token-readiness.sh b/tests/test-access-token-readiness.sh index 93a969b..3293112 100644 --- a/tests/test-access-token-readiness.sh +++ b/tests/test-access-token-readiness.sh @@ -6,7 +6,8 @@ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) TASK_TMP=$(mktemp -d) trap 'rm -rf "$TASK_TMP"' EXIT source "$ROOT/tests/e2e-pithead-tokens.sh" -for fn in _restore_xmrig _cleanup snapshot_config; do +source "$ROOT/tests/e2e-pithead-control.sh" +for fn in _restore_xmrig _cleanup snapshot_config set_cfg; do eval "$(sed -n "/^$fn()/,/^}/p" "$ROOT/tests/e2e-pithead.sh")" done check() { [ "$1" = "$2" ] || { @@ -24,11 +25,7 @@ bad() { refresh_profile_finish() { :; } rigforge_stub() { :; } systemctl() { return 0; } -set_cfg() { - local next - next=$(jq "$1" "$CFG") - printf '%s\n' "$next" >"$CFG" -} + curl() { local url="${*: -1}" output='' bearer=0 body=0 n=0 arg printf '%s\n' "$*" >>"$CASE_DIR/argv" @@ -36,8 +33,14 @@ curl() { arg=$1 shift case "$arg" in - -H) + --config) + [ "$1" = - ] || return 99 + shift + [ -n "$(cat)" ] || return 99 bearer=1 + ;; + -H) + [[ "$1" != Authorization:* ]] || return 99 shift ;; -o) diff --git a/tests/test-contract-credentials.sh b/tests/test-contract-credentials.sh new file mode 100644 index 0000000..1f66ab9 --- /dev/null +++ b/tests/test-contract-credentials.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Hardware-free argv, authentication and restoration regression for #559. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +if [ "$#" = 0 ]; then + TEST_CREDENTIAL_DIR=$(mktemp -d) + export TEST_CREDENTIAL_DIR + trap 'rm -rf "$TEST_CREDENTIAL_DIR"' EXIT + for mode in tokens stratum edit-failed apply-failed tokens-failed stratum-failed; do + if bash "$0" "$mode" >"$TEST_CREDENTIAL_DIR/$mode.log" 2>&1; then + case "$mode" in *-failed) + echo "$mode unexpectedly succeeded" + exit 1 + ;; + esac + else + case "$mode" in *-failed) ;; *) + cat "$TEST_CREDENTIAL_DIR/$mode.log" + exit 1 + ;; + esac + fi + case "$mode" in + edit-failed | apply-failed) + grep -q 'could not enable api+control' "$TEST_CREDENTIAL_DIR/$mode.log" + [ ! -s "$TEST_CREDENTIAL_DIR/$mode.requests" ] + ;; + esac + case "$mode" in + tokens-failed) + grep -q '^access-token-diagnostic: control_transport_rc=7 control_active=1$' "$TEST_CREDENTIAL_DIR/$mode.log" + grep -q 'revoked entry still answered 000' "$TEST_CREDENTIAL_DIR/$mode.log" + ;; + stratum-failed) + grep -q '^xmrig-diagnostic: .*network_errors=1 .*auth_errors=0 ' "$TEST_CREDENTIAL_DIR/$mode.log" + grep -q 'wrong pass: no rejection within 60s' "$TEST_CREDENTIAL_DIR/$mode.log" + ;; + esac + cmp "$TEST_CREDENTIAL_DIR/$mode.json" "$TEST_CREDENTIAL_DIR/$mode.original" + done + ! grep -qE 'a{64}|b{64}|stratum-secret' "$TEST_CREDENTIAL_DIR"/*.log + printf 'contract credential regressions: PASS\n' + exit 0 +fi +mode="$1" +CFG="$TEST_CREDENTIAL_DIR/$mode.json" +WLOG="$TEST_CREDENTIAL_DIR/worker.log" +# shellcheck disable=SC2034 # consumed by extracted editor/cleanup functions +RIGFORGE=fixture_rigforge PITHEAD_URL=fixture.invalid:3333 +# shellcheck disable=SC2034 # consumed by extracted cleanup function +HAMMER_PIDS='' RIG_LOCK_HOLDER="$TEST_CREDENTIAL_DIR/holder" +E2E_STRATUM_PASS=$'stratum-secret"\\\nsecond line' +master_fixture=$(printf 'a%.0s' {1..64}) +bench_fixture=$(printf 'b%.0s' {1..64}) +read_fixture=5ce653a2e6f6e9ece58f79eedbfcaf87ab80b78b0b0019dd340a758f0badded9 +printf '{"DONATION":1,"pools":[{"pass":"original"}],"keep":true}\n' >"$CFG" +cp "$CFG" "$TEST_CREDENTIAL_DIR/$mode.original" +: >"$WLOG" +: >"$TEST_CREDENTIAL_DIR/$mode.requests" +: >"$TEST_CREDENTIAL_DIR/$mode.edits" +phase() { :; } +ok() { printf 'ok\n' >>"$TEST_CREDENTIAL_DIR/$mode.ok"; } +bad() { + echo "$1" >&2 + exit 1 +} +die() { + echo "$1" >&2 + exit 2 +} +skip() { bad 'unexpected skip'; } +sleep() { :; } +refresh_profile_finish() { :; } +_restore_xmrig() { :; } +systemctl() { + case "$1" in + is-active) return 0 ;; + show) printf 'ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n' ;; + *) return 1 ;; + esac +} +# Run the actual editor, phases and EXIT restoration without preflight or hardware. +eval "$(sed -n '/^set_cfg()/,/^}/p; /^_cleanup()/,/^}/p; /^snapshot_config()/,/^}/p; /^phase_stratum_auth()/,/^}/p' "$ROOT/tests/e2e-pithead.sh")" +eval "$(sed -n '/^_xmrig_diagnostics()/,/^}/p; /^_connect_bad()/,/^}/p' "$ROOT/tests/e2e-control-replay.sh")" +source "$ROOT/tests/e2e-pithead-control.sh" +source "$ROOT/tests/e2e-pithead-tokens.sh" +check_args() { + local arg + for arg in "$@"; do + case "$arg" in + *"$master_fixture"* | *"$bench_fixture"* | *"$read_fixture"* | *stratum-secret*) bad 'credential appeared in process arguments' ;; + esac + done +} +jq() { + check_args "$@" + [ "$mode" != edit-failed ] || [ "${1:-}" != --slurpfile ] || return 1 + command jq "$@" +} +python3() { + check_args "$@" + command python3 "$@" +} +openssl() { + check_args "$@" + bad 'unexpected openssl invocation' +} +xxd() { + cat >/dev/null + # xxd runs in command substitutions: persist the count outside the subshell. + if [ -e "$TEST_CREDENTIAL_DIR/$mode.generated" ]; then + printf '%s\n' "$bench_fixture" + else + : >"$TEST_CREDENTIAL_DIR/$mode.generated" + printf '%s\n' "$master_fixture" + fi +} +fixture_rigforge() { + if [ "$1" = restart ]; then + if command jq -e '.pools[0].pass == "wrong-114"' "$CFG" >/dev/null; then + if [ "$mode" = stratum-failed ]; then + printf 'connect error: stratum-secret\n' >"$WLOG" + else + printf 'login error\n' >"$WLOG" + fi + else + printf 'new job from fixture\n' >"$WLOG" + fi + return + fi + [ "$1" = apply ] + cmp -s "$CFG" "$SAVED_CFG" && return 0 # restoration + [ "$mode" != apply-failed ] || return 1 + if [[ "$mode" = stratum* ]]; then + # Compare private stdin to the edited password, including quotes, slash and newline. + if ! command jq -e '.pools[0].pass == "wrong-114"' "$CFG" >/dev/null; then + printf '%s' "$E2E_STRATUM_PASS" | command jq -e --rawfile pass /dev/stdin '.pools[0].pass == $pass and .keep' "$CFG" >/dev/null + fi + else + command jq -e '.ACCESS_TOKEN == ("a" * 64) and .api == "enabled" and .control == "enabled" and .api_allow_from == "127.0.0.1/32" and .keep and (.ACCESS_TOKENS == {"bench":("b" * 64)} or .ACCESS_TOKENS == {})' "$CFG" >/dev/null + fi + echo edit >>"$TEST_CREDENTIAL_DIR/$mode.edits" +} +wait_for_job() { grep -q 'new job from' "$WLOG"; } +curl() { + check_args "$@" + if [ "${1:-}" = -q ] && [ "${4:-}" = -s ]; then + printf 401 # Bearer-free readiness is separate from the seven auth assertions. + return + fi + local config arg url='' response='' count expected + config=$(cat) + count=$(wc -l <"$TEST_CREDENTIAL_DIR/$mode.requests" | tr -d ' ') + echo request >>"$TEST_CREDENTIAL_DIR/$mode.requests" + expected="$bench_fixture" + [ "$count" != 2 ] || expected="$read_fixture" + [ "$config" = "header = \"Authorization: Bearer $expected\"" ] || bad 'wrong bearer transport or derivation' + while [ "$#" -gt 0 ]; do + arg="$1" + shift + case "$arg" in -o) + response="$1" + shift + ;; + http://*) url="$arg" ;; esac + done + case "$count:$url" in + 0:*/2/summary) printf 401 ;; + 1:*/health | 2:*/health) printf '{}' ;; + 3:*/apply) + printf '{"change_id":"fixture"}' >"$response" + printf 202 + ;; + 4:*/status?change_id=fixture) printf '{"status":"applied"}' ;; + 5:*/health | 6:*/apply) + command jq -e '.ACCESS_TOKENS == {}' "$CFG" >/dev/null + if [ "$mode:$count" = tokens-failed:6 ]; then + printf 000 + return 7 + fi + printf 401 + ;; + *) bad 'unexpected request sequence' ;; + esac +} +snapshot_config +if [[ "$mode" = stratum* ]]; then + phase_stratum_auth + [ "$(wc -l <"$TEST_CREDENTIAL_DIR/$mode.edits" | tr -d ' ')" = 3 ] + [ "$(wc -l <"$TEST_CREDENTIAL_DIR/$mode.ok" | tr -d ' ')" = 4 ] +else + phase_access_tokens + [ "$(wc -l <"$TEST_CREDENTIAL_DIR/$mode.requests" | tr -d ' ')" = 7 ] + [ "$(wc -l <"$TEST_CREDENTIAL_DIR/$mode.ok" | tr -d ' ')" = 7 ] +fi +_cleanup +trap - EXIT