mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-11-02 08:41:03 +00:00
0b3261e1aa
The NAS suite exhausts its aggregate alarm after fifteen successful controls on macOS Intel. Run each discovered NAS and updater control under its existing bound, retaining the native phase's ten-minute alarm and all installer operation deadlines. Keep required controls mandatory and reject skipped, absent or failed execution. Exercise discovery and false-pass rejection with real Python controls, including a newly added method.
1315 lines
53 KiB
Go
1315 lines
53 KiB
Go
package installtests
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The native CI runner is checked without host-manager access. These controls
|
|
// exercise its exact recipe, admission, signature/archive and recovery wiring;
|
|
// the hosted runner still supplies the actual systemd/real-Pulse verdict.
|
|
func TestAutoUpdateNativeSystemdHarnessContracts(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_systemd.py"), "-v")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("native updater harness controls: %v\n%s", err, out)
|
|
}
|
|
results, err := nativeHarnessControlResults(string(out))
|
|
if err != nil {
|
|
t.Fatalf("incomplete native-harness result inventory: %v\n%s", err, out)
|
|
}
|
|
// The Linux server's ELF staging cannot execute on a macOS agent runner.
|
|
// Its connected check must pass on both Linux architectures; macOS skips
|
|
// only that check, never the portable signing/admission/recovery oracles.
|
|
for _, name := range []string{
|
|
"test_prepare_owns_generated_signer_mode_before_first_real_signature",
|
|
"test_signer_creation_refuses_adoption_wrong_type_owner_or_failed_mode",
|
|
"test_real_verifier_accepts_fixture_and_refuses_tampering",
|
|
"test_phase_cannot_accept_wrong_bytes_state_mask_persistence_or_backup",
|
|
"test_managed_process_identity_follows_served_readiness_and_rejects_residuals",
|
|
"test_runtime_readback_rejects_post_comparison_drift",
|
|
"test_primary_command_survives_successful_or_failed_cleanup",
|
|
"test_primary_timeout_is_not_replaced_by_cleanup_exit",
|
|
"test_assertion_and_cleanup_only_errors_do_not_invent_primary_command",
|
|
"test_successful_receipt_retains_no_failure_record",
|
|
"test_connected_linux_control_is_required_without_macho_or_validator_bypass",
|
|
} {
|
|
if results[name] != "ok" {
|
|
t.Fatalf("required portable native-harness control %s did not pass:\n%s", name, out)
|
|
}
|
|
}
|
|
const connected = "test_connected_signed_archive_and_recovery_controls_without_host_manager"
|
|
verdict := "ok"
|
|
if runtime.GOOS != "linux" {
|
|
verdict = "skipped 'Linux server ELF execution; portable controls remain required on macOS'"
|
|
}
|
|
if results[connected] != verdict {
|
|
t.Fatalf("required Linux connected control has wrong platform verdict:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// unittest writes its result to stderr while the native recipe prints phase
|
|
// markers to stdout. CombinedOutput can put those markers between the method
|
|
// header and its verdict. Require a complete, unique verdict rather than a
|
|
// single adjacent header/"ok" string; do not mistake a phase for completion.
|
|
func nativeHarnessControlResults(output string) (map[string]string, error) {
|
|
results := make(map[string]string)
|
|
current := ""
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if name, tail, found := strings.Cut(line, " (__main__.NativeContracts."); found && strings.HasPrefix(name, "test_") {
|
|
if current != "" {
|
|
return nil, fmt.Errorf("missing verdict for %s", current)
|
|
}
|
|
if _, duplicate := results[name]; duplicate {
|
|
return nil, fmt.Errorf("duplicate result for %s", name)
|
|
}
|
|
verdict, exact := strings.CutPrefix(tail, name+") ... ")
|
|
if !exact {
|
|
return nil, fmt.Errorf("mismatched method identity for %s", name)
|
|
}
|
|
current = name
|
|
line = verdict
|
|
}
|
|
if current != "" && (line == "ok" || strings.HasPrefix(line, "skipped ")) {
|
|
results[current] = line
|
|
current = ""
|
|
}
|
|
}
|
|
if current != "" {
|
|
return nil, fmt.Errorf("missing verdict for %s", current)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func TestAutoUpdateNativeControlResultsRetainInterleavedPhaseVerdicts(t *testing.T) {
|
|
const header = "test_fixture (__main__.NativeContracts.test_fixture) ... "
|
|
for _, verdict := range []string{"ok", "skipped 'Linux-only fixture'"} {
|
|
for _, phases := range []string{"", "NATIVE_UPDATER_PHASE=forward\nNATIVE_UPDATER_PHASE=manual-reverse\n"} {
|
|
out := header + phases + verdict + "\n\nRan 1 test\n\nOK\n"
|
|
results, err := nativeHarnessControlResults(out)
|
|
if err != nil || len(results) != 1 || results["test_fixture"] != verdict {
|
|
t.Fatalf("lost exact verdict %q with phase output %q: %v, %v", verdict, phases, results, err)
|
|
}
|
|
}
|
|
}
|
|
for _, out := range []string{
|
|
header + "NATIVE_UPDATER_PHASE=forward\n\nOK\n",
|
|
header + "ok\n" + header + "ok\n",
|
|
header + "NATIVE_UPDATER_PHASE=forward\n" + "test_next (__main__.NativeContracts.test_next) ... ok\n",
|
|
"test_fixture (__main__.NativeContracts.test_other) ... ok\n",
|
|
} {
|
|
if _, err := nativeHarnessControlResults(out); err == nil {
|
|
t.Fatalf("accepted incomplete/duplicate/mismatched method results: %q", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The standalone helper passes an explicit name to the installer. Exercise
|
|
// that producer and its real entry/handoff, not just installer discovery.
|
|
func TestAutoUpdateServiceDiscoveryAndInstallerHandoff(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_server_service_discovery.py"), "-v",
|
|
"ServiceDiscovery.test_fresh_host_no_matching_unit_files_is_an_empty_inventory",
|
|
"ServiceDiscovery.test_first_inventory_failure_is_not_replaced_by_silent_absence",
|
|
"ServiceDiscovery.test_only_silent_exit_one_is_admitted_as_absence",
|
|
"ServiceDiscovery.test_blank_line_output_is_not_a_silent_failed_inventory",
|
|
"ServiceDiscovery.test_automatic_helper_preserves_exact_names_and_explicit_instances",
|
|
"ServiceDiscovery.test_automatic_helper_cannot_short_read_the_installer_handoff",
|
|
"ServiceDiscovery.test_automatic_helper_refuses_before_backup_or_installer_even_in_or_list",
|
|
"ServiceDiscovery.test_automatic_helper_timeout_stops_before_service_observation",
|
|
// A fresh host has no Pulse unit files and systemd then exits 1 with no output; the
|
|
// updater's own discovery must read that as absence, not as a failed inventory.
|
|
"ServiceDiscovery.test_fresh_host_no_matching_unit_files_is_an_empty_inventory",
|
|
"ServiceDiscovery.test_real_systemctl_on_a_host_without_pulse_units_selects_the_default")
|
|
cmd.Env = append(os.Environ(),
|
|
"PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh"),
|
|
"PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("unattended service discovery/installer handoff controls: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
func TestAutoUpdateDownloadFailures(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "UpdateDownloads")
|
|
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("complete updater download-failure controls: %v\n%s", err, out)
|
|
}
|
|
for _, name := range []string{
|
|
"test_both_complete_downloads_keep_the_exact_signed_installer_handoff",
|
|
"test_partial_failed_downloads_never_execute_or_mutate_the_install",
|
|
} {
|
|
if !strings.Contains(string(out), fmt.Sprintf("%s (", name)) {
|
|
t.Fatalf("missing updater download control %s:\n%s", name, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Service-state admission is independent of the signed/metadata transaction
|
|
// matrix below; no native service or published input is used by this fixture.
|
|
func TestAutoUpdateServiceStateFailsClosed(t *testing.T) {
|
|
runPythonFixtureControls(t, repoFile("scripts", "tests", "test_auto_update_service_state.py"), "UpdateServiceState", []string{
|
|
"test_manager_diagnostics_refuse_initial_update_and_restart",
|
|
"test_manager_diagnostics_after_install_retain_backup_without_mutation",
|
|
"test_manager_diagnostics_after_stop_retain_backup_without_restart",
|
|
"test_success_cannot_claim_unverified_running_executable",
|
|
"test_rollback_keeps_backup_until_restored_running_executable_matches",
|
|
"test_early_collector_failure_survives_later_success_and_drains_installer",
|
|
"test_unterminated_final_installer_line_is_collected_and_its_failure_retained",
|
|
}, 45*time.Second, "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
|
|
}
|
|
|
|
// Keep the full real-sink matrix mandatory, but give it its own existing-size
|
|
// bound rather than exhausting the service-state suite's manager deadlines.
|
|
func TestAutoUpdateLogSinkFailures(t *testing.T) {
|
|
runPythonFixtureControls(t, repoFile("scripts", "tests", "test_auto_update_service_state.py"), "UpdateLogSinks", []string{
|
|
"test_logger_double_keeps_complete_argument_frames_and_exit_sequence",
|
|
"test_real_log_retains_each_sink_failure_in_conditional_calls",
|
|
"test_real_logger_failure_drains_installer_and_preserves_recovery",
|
|
}, 45*time.Second, "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
|
|
}
|
|
|
|
// A real owned process retains its old executable after its disk path changes.
|
|
// This tests Linux process identity, not a native systemd/application journey.
|
|
func TestAutoUpdateRunningExecutableIdentity(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("Linux /proc executable identity; portable transaction controls remain required")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "RunningExecutable")
|
|
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("running executable identity controls: %v\n%s", err, out)
|
|
}
|
|
for _, name := range []string{
|
|
"test_actual_process_not_replaced_disk_path_is_checked",
|
|
"test_complete_stable_main_pid_tuple_is_required",
|
|
"test_manager_diagnostics_refuse_either_running_identity_read",
|
|
"test_identity_read_timeout_is_bounded_and_owned_process_is_reaped",
|
|
} {
|
|
passed := false
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if strings.HasPrefix(line, name+" (") && strings.HasSuffix(line, " ... ok") {
|
|
passed = true
|
|
}
|
|
}
|
|
if !passed {
|
|
t.Fatalf("required running executable control %s did not pass:\n%s", name, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractAutoUpdateServiceFunctions(t *testing.T) string {
|
|
t.Helper()
|
|
var functions []string
|
|
for _, name := range []string{"read_update_service_state", "start_update_service_if_stopped", "stop_update_service_for_rollback", "wait_for_service_active", "ensure_service_restarted"} {
|
|
functions = append(functions, extractAutoUpdateFunction(t, name))
|
|
}
|
|
// These pure-shell controls have simulated service state and script
|
|
// executables, not a managed process. The registered Python controls
|
|
// exercise real owned /proc entries and fail-closed transaction wiring;
|
|
// containing source CI separately checks actual systemd/Pulse processes.
|
|
functions = append(functions, "verify_update_running_executable() { return 0; }")
|
|
return strings.Join(functions, "\n")
|
|
}
|
|
|
|
// TestIsPrereleaseTagRecognizesPrereleases asserts that any semver tag with
|
|
// a hyphen after the patch component is flagged as a prerelease. This is the
|
|
// pattern used for Pulse RCs (`-rc.N`), betas (`-beta.N`), alphas, nightlies,
|
|
// etc. The unattended updater must refuse these on the stable channel.
|
|
func TestIsPrereleaseTagRecognizesPrereleases(t *testing.T) {
|
|
cases := []string{
|
|
"v6.0.0-rc.2",
|
|
"v5.1.28-rc.3",
|
|
"v5.1.28-beta.1",
|
|
"v5.1.28-alpha.2",
|
|
"v5.1.28-nightly",
|
|
"v5.1.28-pre.1",
|
|
"6.0.0-rc.2",
|
|
"5.1.28-beta",
|
|
}
|
|
|
|
for _, tag := range cases {
|
|
tag := tag
|
|
t.Run(tag, func(t *testing.T) {
|
|
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
is_prerelease_tag "` + tag + `"
|
|
echo $?
|
|
`
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "0" {
|
|
t.Fatalf("is_prerelease_tag %q returned %q, want 0 (is prerelease)", tag, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsPrereleaseTagAcceptsStableTags asserts that plain MAJOR.MINOR.PATCH
|
|
// tags - the only thing the unattended stable updater should ever accept -
|
|
// are not flagged.
|
|
func TestIsPrereleaseTagAcceptsStableTags(t *testing.T) {
|
|
cases := []string{
|
|
"v5.1.28",
|
|
"v5.1.27",
|
|
"v6.0.0",
|
|
"v6.1.0",
|
|
"5.1.28",
|
|
"6.0.0",
|
|
}
|
|
|
|
for _, tag := range cases {
|
|
tag := tag
|
|
t.Run(tag, func(t *testing.T) {
|
|
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
is_prerelease_tag "` + tag + `"
|
|
echo $?
|
|
`
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "1" {
|
|
t.Fatalf("is_prerelease_tag %q returned %q, want 1 (stable)", tag, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsPrereleaseTagFailsClosed asserts that empty or malformed input is
|
|
// treated as a prerelease - callers should refuse to act on it rather than
|
|
// proceed with an unrecognized tag.
|
|
func TestIsPrereleaseTagFailsClosed(t *testing.T) {
|
|
cases := []string{
|
|
"",
|
|
"latest",
|
|
"v5",
|
|
"v5.1",
|
|
"garbage",
|
|
"main",
|
|
}
|
|
|
|
for _, tag := range cases {
|
|
tag := tag
|
|
name := tag
|
|
if name == "" {
|
|
name = "empty"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
is_prerelease_tag "` + tag + `"
|
|
echo $?
|
|
`
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "0" {
|
|
t.Fatalf("is_prerelease_tag %q returned %q, want 0 (fail closed)", tag, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPerformUpdateRefusesPrereleaseTag asserts the defense-in-depth guard
|
|
// at the perform_update entry: even if every caller above this point thinks
|
|
// the tag is safe, perform_update itself refuses prerelease-shaped tags
|
|
// before touching the installer. This is the last line of defense against
|
|
// the 2026-04-16 incident recurring.
|
|
func TestPerformUpdateRefusesPrereleaseTag(t *testing.T) {
|
|
script := `
|
|
set -u
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
INSTALL_DIR="/tmp/pulse-nonexistent-test-install"
|
|
log() { echo "[$1] $2"; }
|
|
detect_service_name() { echo pulse; }
|
|
get_current_version() { echo v5.1.27; }
|
|
# Pure-function manager doubles stay in this shell; the Python fixture uses
|
|
# real timeout with an external confined manager to test actual deadlines.
|
|
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
|
|
systemctl() { return 0; }
|
|
# If any of these get called, the guard has failed.
|
|
curl() { echo "FAIL: curl invoked during refused update"; exit 99; }
|
|
mktemp() { echo "FAIL: mktemp invoked during refused update"; exit 99; }
|
|
verify_release_signature() { echo "FAIL: signature verify invoked"; exit 99; }
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
|
|
` + extractAutoUpdateFunction(t, "perform_update") + `
|
|
if perform_update v6.0.0-rc.2; then
|
|
echo "ACCEPTED"
|
|
else
|
|
echo "REFUSED"
|
|
fi
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, "REFUSED") {
|
|
t.Fatalf("perform_update did not refuse prerelease tag:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "FAIL:") {
|
|
t.Fatalf("perform_update invoked installer machinery despite refusal:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Refusing to install prerelease") {
|
|
t.Fatalf("perform_update did not log refusal reason:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestGetLatestStableVersionRefusesPrereleaseFlag asserts that even if the
|
|
// tag name looks stable, a `"prerelease": true` flag in the API response
|
|
// causes the function to return empty. This was the suspected trigger for
|
|
// the 2026-04-16 jump from 5.1.27 to 6.0.0-rc.2.
|
|
func TestGetLatestStableVersionRefusesPrereleaseFlag(t *testing.T) {
|
|
script := `
|
|
set -u
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
log() { echo "[$1] $2" >&2; }
|
|
# Stub curl: respond with JSON where prerelease=true.
|
|
curl() {
|
|
# Drain flags; we don't care what was requested.
|
|
cat <<'EOF'
|
|
{
|
|
"tag_name": "v5.1.28",
|
|
"prerelease": true,
|
|
"name": "Pulse v5.1.28"
|
|
}
|
|
EOF
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "version_greater_than") + `
|
|
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
|
|
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
result=$(get_latest_stable_version)
|
|
echo "RESULT=[$result]"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, "RESULT=[]") {
|
|
t.Fatalf("get_latest_stable_version returned a tag despite prerelease=true:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestGetLatestStableVersionRefusesPrereleaseShapedTag asserts the shape
|
|
// check: if the API says `prerelease=false` but the tag itself is clearly a
|
|
// prerelease (e.g. during a brief window where the flag was miswritten),
|
|
// the function still refuses.
|
|
func TestGetLatestStableVersionRefusesPrereleaseShapedTag(t *testing.T) {
|
|
script := `
|
|
set -u
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
log() { echo "[$1] $2" >&2; }
|
|
curl() {
|
|
cat <<'EOF'
|
|
{
|
|
"tag_name": "v6.0.0-rc.2",
|
|
"prerelease": false,
|
|
"name": "Pulse v6.0.0-rc.2"
|
|
}
|
|
EOF
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "version_greater_than") + `
|
|
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
|
|
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
result=$(get_latest_stable_version)
|
|
echo "RESULT=[$result]"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, "RESULT=[]") {
|
|
t.Fatalf("get_latest_stable_version returned prerelease-shaped tag v6.0.0-rc.2 despite flag=false:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestGetLatestStableVersionAcceptsStable asserts the happy path: a stable
|
|
// tag with prerelease=false flows through unchanged.
|
|
func TestGetLatestStableVersionAcceptsStable(t *testing.T) {
|
|
script := `
|
|
set -u
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
log() { echo "[$1] $2" >&2; }
|
|
curl() {
|
|
cat <<'EOF'
|
|
{
|
|
"tag_name": "v5.1.28",
|
|
"draft": false,
|
|
"prerelease": false,
|
|
"name": "Pulse v5.1.28"
|
|
}
|
|
EOF
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "version_greater_than") + `
|
|
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
|
|
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
result=$(get_latest_stable_version)
|
|
echo "RESULT=[$result]"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, "RESULT=[v5.1.28]") {
|
|
t.Fatalf("get_latest_stable_version did not return expected stable tag:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestGetLatestStableVersionPrefersHighestVersion asserts that the updater
|
|
// selects the highest stable version from the release list rather than the
|
|
// most recently created one. This repo interleaves v5-line maintenance
|
|
// releases with v6 releases (v5.1.36 was created the day before v6.0.5), so
|
|
// GitHub's created_at ordering — and its /releases/latest pointer — can name
|
|
// an older version line, which would strand v6 installs until the next v6
|
|
// release ships. Draft releases, metadata-flagged prereleases, and
|
|
// prerelease- or non-semver-shaped tags (helm-chart-*) must all be ignored.
|
|
func TestGetLatestStableVersionPrefersHighestVersion(t *testing.T) {
|
|
script := `
|
|
set -u
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
log() { echo "[$1] $2" >&2; }
|
|
# Stub curl: the /releases list in GitHub's created_at ordering, with the
|
|
# v5-line release first. Any fallback call (e.g. /releases/latest) would
|
|
# return the same list and must not be needed for selection.
|
|
curl() {
|
|
cat <<'EOF'
|
|
[
|
|
{
|
|
"tag_name": "v5.1.37",
|
|
"draft": false,
|
|
"prerelease": false
|
|
},
|
|
{
|
|
"tag_name": "v9.9.9",
|
|
"draft": true,
|
|
"prerelease": false
|
|
},
|
|
{
|
|
"tag_name": "helm-chart-6.0.5",
|
|
"draft": false,
|
|
"prerelease": true
|
|
},
|
|
{
|
|
"tag_name": "v6.0.5",
|
|
"draft": false,
|
|
"prerelease": false
|
|
},
|
|
{
|
|
"tag_name": "v6.0.5-rc.4",
|
|
"draft": false,
|
|
"prerelease": true
|
|
},
|
|
{
|
|
"tag_name": "v6.0.4",
|
|
"draft": false,
|
|
"prerelease": false
|
|
}
|
|
]
|
|
EOF
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "version_greater_than") + `
|
|
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
|
|
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
result=$(get_latest_stable_version)
|
|
echo "RESULT=[$result]"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, "RESULT=[v6.0.5]") {
|
|
t.Fatalf("get_latest_stable_version = %s, want v6.0.5 (highest stable, not most recently created)", got)
|
|
}
|
|
}
|
|
|
|
// TestInstalledBinaryIsPulseProGuard proves the unattended updater's Pro
|
|
// guard: a binary whose --version reports "Pulse Pro" is detected (so main()
|
|
// exits before any community download can replace it), while the community
|
|
// "Pulse vX" binary is not flagged. The community auto-update flow must never
|
|
// reinstall the public build over a paid Pro runtime.
|
|
func TestInstalledBinaryIsPulseProGuard(t *testing.T) {
|
|
fn := extractAutoUpdateFunction(t, "installed_binary_is_pulse_pro")
|
|
|
|
cases := []struct {
|
|
name string
|
|
versionLine string
|
|
wantPro bool
|
|
}{
|
|
{"pro binary detected", "Pulse Pro v6.0.5", true},
|
|
{"community binary not flagged", "Pulse v6.0.5", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
installDir := t.TempDir()
|
|
binDir := filepath.Join(installDir, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir bin: %v", err)
|
|
}
|
|
stub := "#!/bin/bash\necho \"" + tc.versionLine + "\"\n"
|
|
if err := os.WriteFile(filepath.Join(binDir, "pulse"), []byte(stub), 0o755); err != nil {
|
|
t.Fatalf("write pulse stub: %v", err)
|
|
}
|
|
|
|
script := fn + "\nINSTALL_DIR=" + installDir + "\nif installed_binary_is_pulse_pro; then echo pro; else echo community; fi\n"
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := strings.TrimSpace(string(out))
|
|
want := "community"
|
|
if tc.wantPro {
|
|
want = "pro"
|
|
}
|
|
if got != want {
|
|
t.Fatalf("installed_binary_is_pulse_pro on %q = %q, want %q", tc.versionLine, got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("missing binary is unknown", func(t *testing.T) {
|
|
script := fn + "\nINSTALL_DIR=" + t.TempDir() + "\nif installed_binary_is_pulse_pro; then echo pro; else echo STATUS=$?; fi\n"
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "STATUS=2" {
|
|
t.Fatalf("missing binary must not establish community edition, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPerformUpdateRestartsServiceWhenInstallerFails asserts the #1630
|
|
// guarantee: when the downloaded installer exits non-zero (for example a
|
|
// write to a read-only path aborting it under errexit) after it has already
|
|
// stopped the service, perform_update must restore the backup AND leave the
|
|
// service running. The generated pulse-update.service gates on
|
|
// ExecCondition=systemctl is-active, so a service left stopped would also
|
|
// disable every future unattended run.
|
|
func TestPerformUpdateRestartsServiceWhenInstallerFails(t *testing.T) {
|
|
script := `
|
|
set -uo pipefail
|
|
TMP=$(mktemp -d)
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
INSTALL_DIR="$TMP/opt/pulse"
|
|
CONFIG_DIR="$TMP/etc/pulse"
|
|
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR"
|
|
printf 'v5.1.24\n' > "$INSTALL_DIR/VERSION"
|
|
printf '#!/usr/bin/env bash\necho v5.1.24\n' > "$INSTALL_DIR/bin/pulse"
|
|
chmod +x "$INSTALL_DIR/bin/pulse"
|
|
export INSTALL_DIR
|
|
|
|
log() { echo "[$1] ${*:2}"; }
|
|
detect_service_name() { echo pulse; }
|
|
get_current_version() { tr -d '\r\n' < "$INSTALL_DIR/VERSION"; }
|
|
verify_release_signature() { return 0; }
|
|
sleep() { :; }
|
|
|
|
# curl writes the installer / signature to the -o target; the fake installer
|
|
# fails outright, like the real one aborting on a read-only filesystem after
|
|
# it has stopped the service.
|
|
curl() {
|
|
local out="" prev="" arg
|
|
for arg in "$@"; do
|
|
if [[ "$prev" == "-o" ]]; then out="$arg"; fi
|
|
prev="$arg"
|
|
done
|
|
if [[ -n "$out" ]]; then
|
|
case "$out" in
|
|
*.sig.*) printf 'dummy-signature\n' > "$out" ;;
|
|
*) printf '#!/usr/bin/env bash\necho inactive > "${PULSE_INSTALL_DIR}/service-state"\nexit 1\n' > "$out" ;;
|
|
esac
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Service is running at the was-active capture, then down (the installer
|
|
# stopped it before failing) until an explicit start/restart.
|
|
IS_ACTIVE_CALLS=0
|
|
printf 'active\n' > "$INSTALL_DIR/service-state"
|
|
SERVICE_UP="no"
|
|
STARTS=0
|
|
# Pure-function manager doubles stay in this shell; the Python fixture uses
|
|
# real timeout with an external confined manager to test actual deadlines.
|
|
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
|
|
systemctl() {
|
|
case "$1" in
|
|
show) printf 'LoadState=loaded\nActiveState=%s\n' "$(cat "$INSTALL_DIR/service-state")"; return 0 ;;
|
|
is-active)
|
|
((IS_ACTIVE_CALLS += 1))
|
|
if (( IS_ACTIVE_CALLS == 1 )); then return 0; fi
|
|
[[ "$SERVICE_UP" == "yes" ]] && return 0 || return 1
|
|
;;
|
|
start|restart)
|
|
((STARTS += 1))
|
|
SERVICE_UP="yes"
|
|
printf 'active\n' > "$INSTALL_DIR/service-state"
|
|
return 0
|
|
;;
|
|
stop) SERVICE_UP="no"; printf 'inactive\n' > "$INSTALL_DIR/service-state"; return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
|
|
` + extractAutoUpdateServiceFunctions(t) + `
|
|
` + extractAutoUpdateFunction(t, "download_update_asset") + `
|
|
` + extractAutoUpdateFunction(t, "perform_update") + `
|
|
if perform_update v5.1.25; then
|
|
echo "RESULT:succeeded"
|
|
else
|
|
echo "RESULT:failed"
|
|
fi
|
|
echo "STARTS:$STARTS"
|
|
echo "SERVICE:$SERVICE_UP"
|
|
echo "VERSION:$(tr -d '\r\n' < "$INSTALL_DIR/VERSION")"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{"RESULT:failed", "SERVICE:yes", "VERSION:v5.1.24"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("missing %q in perform_update installer-failure output:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "STARTS:0") {
|
|
t.Fatalf("service was never restarted after installer failure:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestEnsureServiceRestartedHonorsPriorServiceState asserts the RETURN-trap
|
|
// backstop behind the #1630 fix: a service that was inactive before the
|
|
// update is left alone, and a service that was active is started again.
|
|
func TestEnsureServiceRestartedHonorsPriorServiceState(t *testing.T) {
|
|
script := `
|
|
set -uo pipefail
|
|
SERVICE_UP="no"
|
|
STARTS=0
|
|
# Pure-function manager doubles stay in this shell; the Python fixture uses
|
|
# real timeout with an external confined manager to test actual deadlines.
|
|
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
|
|
systemctl() {
|
|
case "$1" in
|
|
show) printf 'LoadState=loaded\n'; if [[ "$SERVICE_UP" == yes ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi; return 0 ;;
|
|
is-active) [[ "$SERVICE_UP" == "yes" ]] && return 0 || return 1 ;;
|
|
start|restart) ((STARTS += 1)); SERVICE_UP="yes"; return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
sleep() { :; }
|
|
log() { echo "[$1] ${*:2}"; }
|
|
` + extractAutoUpdateServiceFunctions(t) + `
|
|
ensure_service_restarted pulse false || echo "INACTIVE_PATH_FAILED"
|
|
echo "STARTS_AFTER_INACTIVE:$STARTS"
|
|
ensure_service_restarted pulse true || echo "ACTIVE_PATH_FAILED"
|
|
echo "STARTS_AFTER_ACTIVE:$STARTS"
|
|
echo "SERVICE:$SERVICE_UP"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{"STARTS_AFTER_INACTIVE:0", "STARTS_AFTER_ACTIVE:1", "SERVICE:yes"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("missing %q in ensure_service_restarted output:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, reject := range []string{"INACTIVE_PATH_FAILED", "ACTIVE_PATH_FAILED"} {
|
|
if strings.Contains(got, reject) {
|
|
t.Fatalf("ensure_service_restarted must always return 0 (it runs in a RETURN trap under set -e):\n%s", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPerformUpdateDoesNotLeakReturnTrap asserts the #2128 regression: a
|
|
// RETURN trap is not scoped to the function that installs it, so
|
|
// perform_update's trap must disarm itself on its first invocation. If it
|
|
// leaks, it re-runs when a later function returns; the function-local
|
|
// installer_tmp/signature_tmp are gone by then, and under `set -u` the updater
|
|
// aborts with "installer_tmp: unbound variable" after a *successful* update,
|
|
// leaving pulse-update.service failed even though the install worked.
|
|
func TestPerformUpdateDoesNotLeakReturnTrap(t *testing.T) {
|
|
script := `
|
|
set -uo pipefail
|
|
TMP=$(mktemp -d)
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
GITHUB_REPO="rcourtman/Pulse"
|
|
INSTALL_DIR="$TMP/opt/pulse"
|
|
CONFIG_DIR="$TMP/etc/pulse"
|
|
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR"
|
|
printf 'v5.1.24\n' > "$INSTALL_DIR/VERSION"
|
|
printf '#!/usr/bin/env bash\necho v5.1.24\n' > "$INSTALL_DIR/bin/pulse"
|
|
chmod +x "$INSTALL_DIR/bin/pulse"
|
|
export INSTALL_DIR
|
|
export FAKE_NEW_VERSION="v5.1.25"
|
|
|
|
log() { echo "[$1] ${*:2}"; }
|
|
detect_service_name() { echo pulse; }
|
|
get_current_version() { tr -d '\r\n' < "$INSTALL_DIR/VERSION"; }
|
|
verify_release_signature() { return 0; }
|
|
sleep() { :; }
|
|
|
|
# curl writes the installer / signature to the -o target; the fake installer
|
|
# bumps VERSION to the new version, simulating a successful install.
|
|
curl() {
|
|
local out="" prev="" arg
|
|
for arg in "$@"; do
|
|
if [[ "$prev" == "-o" ]]; then out="$arg"; fi
|
|
prev="$arg"
|
|
done
|
|
if [[ -n "$out" ]]; then
|
|
case "$out" in
|
|
*.sig.*) printf 'dummy-signature\n' > "$out" ;;
|
|
*)
|
|
cat > "$out" <<'INSTALLER'
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "${FAKE_NEW_VERSION}" > "${PULSE_INSTALL_DIR}/VERSION"
|
|
exit 0
|
|
INSTALLER
|
|
;;
|
|
esac
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Pulse was running and stays running, so perform_update succeeds.
|
|
# Pure-function manager doubles stay in this shell; the Python fixture uses
|
|
# real timeout with an external confined manager to test actual deadlines.
|
|
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
|
|
systemctl() {
|
|
if [[ "$1" == show ]]; then printf 'LoadState=loaded\nActiveState=active\n'; fi
|
|
return 0
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
|
|
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
|
|
` + extractAutoUpdateServiceFunctions(t) + `
|
|
` + extractAutoUpdateFunction(t, "download_update_asset") + `
|
|
` + extractAutoUpdateFunction(t, "perform_update") + `
|
|
if perform_update v5.1.25; then echo "RESULT:succeeded"; else echo "RESULT:failed"; fi
|
|
echo "LEAKED:$(trap -p RETURN)"
|
|
# The real caller (main) returns after perform_update; a leaked trap would
|
|
# re-run here with the locals gone.
|
|
caller() { return 0; }
|
|
caller
|
|
echo "AFTER_CALLER"
|
|
`
|
|
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bash: %v\n%s", err, out)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{"RESULT:succeeded", "LEAKED:\n", "AFTER_CALLER"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("missing %q in perform_update success output:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "unbound variable") {
|
|
t.Fatalf("perform_update leaked its RETURN trap; a later function return aborted under set -u:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// Exercise the complete sourced updater in its real conditional-call context.
|
|
// Only downloads and systemd are replaced; file copies, comparisons, renames,
|
|
// executable version reads and SSH signature verification are real and local.
|
|
func TestAutoUpdateBackupTransaction(t *testing.T) {
|
|
if _, err := exec.LookPath("ssh-keygen"); err != nil {
|
|
t.Skip("ssh-keygen unavailable for signed offline installer fixture")
|
|
}
|
|
type scenario struct {
|
|
name, fault, outcome, layout string
|
|
noVersion, retained, success, preflight bool
|
|
}
|
|
cases := []scenario{
|
|
{name: "backup_directory_failure", fault: "backup_dir", preflight: true},
|
|
{name: "binary_snapshot_failure", fault: "backup_binary", preflight: true},
|
|
{name: "version_snapshot_failure", fault: "backup_version", preflight: true},
|
|
{name: "corrupt_snapshot", fault: "backup_corrupt", preflight: true},
|
|
{name: "installer_file_failure", fault: "installer_tmp", preflight: true},
|
|
{name: "signature_file_failure", fault: "signature_tmp", preflight: true},
|
|
{name: "missing_binary", fault: "no_binary", preflight: true},
|
|
{name: "symlink_binary", fault: "binary_symlink", preflight: true},
|
|
{name: "symlink_version", fault: "version_symlink", preflight: true},
|
|
{name: "installer_download_failure", fault: "download_installer", preflight: true},
|
|
{name: "signature_download_failure", fault: "download_signature", preflight: true},
|
|
{name: "invalid_signature", fault: "invalid_signature", preflight: true},
|
|
{name: "installer_failure", outcome: "failure"},
|
|
{name: "wrong_version", outcome: "mismatch"},
|
|
{name: "failed_version_probe", outcome: "probe_failure"},
|
|
{name: "empty_version_probe", outcome: "probe_empty"},
|
|
{name: "unrecognised_version_brand", outcome: "probe_unknown"},
|
|
{name: "different_edition", outcome: "probe_pro"},
|
|
{name: "nonexecutable_replacement", outcome: "probe_nonexec"},
|
|
{name: "missing_replacement", outcome: "probe_missing"},
|
|
{name: "version_on_later_line", outcome: "probe_later_line"},
|
|
{name: "invalid_version_suffix", outcome: "probe_trailing"},
|
|
{name: "valid_build_details", outcome: "probe_details", success: true},
|
|
{name: "valid_unprefixed_version", outcome: "probe_unprefixed", success: true},
|
|
{name: "original_version_absent", outcome: "failure", noVersion: true},
|
|
{name: "extended_metadata", fault: "xattrs", outcome: "failure"},
|
|
{name: "legacy_layout_migration", outcome: "failure", layout: "legacy"},
|
|
{name: "both_binary_paths", outcome: "failure", layout: "both"},
|
|
{name: "new_destination_symlink", outcome: "symlink"},
|
|
{name: "rollback_stop_failure", fault: "stop", outcome: "failure", retained: true},
|
|
{name: "rollback_allocation_failure", fault: "restore_tmp", outcome: "failure", retained: true},
|
|
{name: "rollback_copy_failure", fault: "restore_copy", outcome: "failure", retained: true},
|
|
{name: "corrupt_rollback_copy", fault: "restore_corrupt", outcome: "failure", retained: true},
|
|
{name: "rollback_rename_failure", fault: "restore_rename", outcome: "failure", retained: true},
|
|
{name: "rollback_rename_noop", fault: "restore_noop", outcome: "failure", retained: true},
|
|
{name: "rollback_nonregular_destination", fault: "restore_noop", outcome: "fifo", retained: true},
|
|
{name: "rollback_destination_changed", fault: "restore_changed", outcome: "failure", retained: true},
|
|
{name: "version_restore_failure", fault: "version_rename", outcome: "failure", retained: true},
|
|
{name: "new_destination_directory", outcome: "directory", retained: true},
|
|
{name: "successful_update", success: true},
|
|
}
|
|
t.Run("new_service_failure", func(t *testing.T) {
|
|
runAutoUpdateTransactionScenario(t, "", "service_failure", "", "true", false, false, false, false)
|
|
})
|
|
for _, tc := range cases {
|
|
for _, active := range []string{"true", "false"} {
|
|
t.Run(tc.name+"/prior_active_"+active, func(t *testing.T) {
|
|
runAutoUpdateTransactionScenario(t, tc.fault, tc.outcome, tc.layout, active, tc.noVersion, tc.preflight, tc.retained, tc.success)
|
|
})
|
|
}
|
|
}
|
|
t.Run("restored_service_start_failure", func(t *testing.T) {
|
|
runAutoUpdateTransactionScenario(t, "restart", "failure", "", "true", false, false, true, false)
|
|
})
|
|
t.Run("restored_service_liveness_failure", func(t *testing.T) {
|
|
runAutoUpdateTransactionScenario(t, "restart_liveness", "failure", "", "true", false, false, true, false)
|
|
})
|
|
}
|
|
|
|
func runAutoUpdateTransactionScenario(t *testing.T, fault, outcome, layout, active string, noVersion, preflight, retained, success bool) {
|
|
t.Helper()
|
|
if fault == "xattrs" {
|
|
if _, err := exec.LookPath("python3"); err != nil {
|
|
t.Skip("python3 unavailable for extended metadata fixture")
|
|
}
|
|
}
|
|
updater, err := filepath.Abs(repoFile("scripts", "pulse-auto-update.sh"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
script := `
|
|
set -euo pipefail
|
|
source "$UPDATER_UNDER_TEST"
|
|
# This signed transaction matrix simulates the manager. Running-process
|
|
# identity is exercised by the real owned-process Python controls, not here.
|
|
verify_update_running_executable() { return 0; }
|
|
INSTALL_DIR="$FIXTURE_DIR/install"
|
|
CONFIG_DIR="$FIXTURE_DIR/config"
|
|
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR" "$FIXTURE_DIR/tmp" "$FIXTURE_DIR/original"
|
|
printf 'operator settings unchanged\n' > "$CONFIG_DIR/system.json"
|
|
printf 'local identity unchanged\n' > "$CONFIG_DIR/agent-id"
|
|
printf 'victim unchanged\n' > "$FIXTURE_DIR/victim"
|
|
printf '#!/usr/bin/env bash\nprintf "Pulse v6.4.5\\n"\n' > "$FIXTURE_DIR/original/pulse-bin"
|
|
printf '#!/usr/bin/env bash\nprintf "Pulse v6.4.4\\n"\n' > "$FIXTURE_DIR/original/pulse-legacy"
|
|
printf 'v6.4.5\n' > "$FIXTURE_DIR/original/VERSION"
|
|
chmod 751 "$FIXTURE_DIR/original/pulse-bin" "$FIXTURE_DIR/original/pulse-legacy"
|
|
chmod 640 "$FIXTURE_DIR/original/VERSION"
|
|
case "$LAYOUT" in
|
|
legacy) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/pulse" ;;
|
|
both) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; cp -p "$FIXTURE_DIR/original/pulse-legacy" "$INSTALL_DIR/pulse" ;;
|
|
*) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse" ;;
|
|
esac
|
|
if [[ "$NO_VERSION" == false ]]; then cp -p "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION"; fi
|
|
case "$FAULT" in
|
|
no_binary) rm -f "$INSTALL_DIR/bin/pulse" ;;
|
|
binary_symlink) mv "$INSTALL_DIR/bin/pulse" "$FIXTURE_DIR/original/linked-binary"; ln -s "$FIXTURE_DIR/original/linked-binary" "$INSTALL_DIR/bin/pulse" ;;
|
|
version_symlink) rm "$INSTALL_DIR/VERSION"; ln -s "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION" ;;
|
|
esac
|
|
if [[ "$FAULT" == xattrs ]]; then
|
|
if python3 - "$INSTALL_DIR" <<'METADATA'
|
|
import errno, os, sys
|
|
try:
|
|
for name in ('bin/pulse', 'VERSION'):
|
|
os.setxattr(os.path.join(sys.argv[1], name), 'user.pulse-updater-test', b'original extended metadata')
|
|
except AttributeError:
|
|
sys.exit(77)
|
|
except OSError as exc:
|
|
if exc.errno in (errno.ENOTSUP, errno.EOPNOTSUPP):
|
|
sys.exit(77)
|
|
raise
|
|
METADATA
|
|
then :
|
|
else
|
|
metadata_status=$?
|
|
if [[ "$metadata_status" == 77 ]]; then echo TRANSACTION_METADATA_UNAVAILABLE; exit 0; fi
|
|
exit "$metadata_status"
|
|
fi
|
|
fi
|
|
printf '%s\n' "$PRIOR_ACTIVE" > "$FIXTURE_DIR/active"
|
|
export FIXTURE_DIR OUTCOME FAULT
|
|
cat > "$FIXTURE_DIR/installer" <<'INSTALLER'
|
|
set -euo pipefail
|
|
printf 'executed\n' > "$FIXTURE_DIR/installer-executed"
|
|
printf 'false\n' > "$FIXTURE_DIR/active"
|
|
[[ "$FAULT" != stop ]] || printf 'true\n' > "$FIXTURE_DIR/active"
|
|
mkdir -p "$PULSE_INSTALL_DIR/bin"
|
|
new_version=v6.5.0
|
|
[[ "$OUTCOME" != mismatch ]] || new_version=v6.4.6
|
|
printf '#!/usr/bin/env bash\nprintf "Pulse %s\\n"\n' "$new_version" > "$PULSE_INSTALL_DIR/bin/pulse"
|
|
chmod 700 "$PULSE_INSTALL_DIR/bin/pulse"
|
|
if [[ -f "$PULSE_INSTALL_DIR/pulse" ]]; then printf 'changed legacy binary\n' > "$PULSE_INSTALL_DIR/pulse"; fi
|
|
printf '%s\n' "$new_version" > "$PULSE_INSTALL_DIR/VERSION"
|
|
chmod 600 "$PULSE_INSTALL_DIR/VERSION"
|
|
# Keep the target VERSION sidecar even when the signed installer leaves an
|
|
# unusable executable. The real post-install probe must trigger full rollback.
|
|
case "$OUTCOME" in
|
|
probe_failure) printf '#!/bin/bash\necho "Pulse v6.5.0"; exit 1\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_empty) printf '#!/bin/bash\nexit 0\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_unknown) printf '#!/bin/bash\necho "Other v6.5.0 private-detail"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_pro) printf '#!/bin/bash\necho "Pulse Pro v6.5.0"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_nonexec) chmod 600 "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_missing) rm "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_later_line) printf '#!/bin/bash\nprintf "Other\\nPulse v6.5.0\\n"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_trailing) printf '#!/bin/bash\necho "Pulse v6.5.0-other!"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_details) printf '#!/bin/bash\nprintf "Pulse v6.5.0\\nBuilt: yesterday\\n"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
probe_unprefixed) printf '#!/bin/bash\necho "Pulse 6.5.0"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
|
|
esac
|
|
if [[ "$OUTCOME" == symlink ]]; then
|
|
rm "$PULSE_INSTALL_DIR/bin/pulse"
|
|
ln -s "$FIXTURE_DIR/victim" "$PULSE_INSTALL_DIR/bin/pulse"
|
|
fi
|
|
if [[ "$OUTCOME" == directory ]]; then
|
|
rm "$PULSE_INSTALL_DIR/bin/pulse"
|
|
mkdir "$PULSE_INSTALL_DIR/bin/pulse"
|
|
printf 'unexpected directory\n' > "$PULSE_INSTALL_DIR/bin/pulse/keep"
|
|
fi
|
|
if [[ "$OUTCOME" == fifo ]]; then
|
|
rm "$PULSE_INSTALL_DIR/bin/pulse"
|
|
mkfifo "$PULSE_INSTALL_DIR/bin/pulse"
|
|
fi
|
|
case "$OUTCOME" in failure|symlink|directory|fifo) exit 23 ;; esac
|
|
exit 0
|
|
INSTALLER
|
|
command ssh-keygen -q -t ed25519 -N '' -f "$FIXTURE_DIR/fixture-key"
|
|
PINNED_RELEASE_SSH_PUBLIC_KEY=$(cat "$FIXTURE_DIR/fixture-key.pub")
|
|
command ssh-keygen -Y sign -f "$FIXTURE_DIR/fixture-key" -n pulse-install "$FIXTURE_DIR/installer" >/dev/null 2>&1
|
|
[[ -s "$FIXTURE_DIR/installer.sig" ]]
|
|
[[ "$FAULT" != invalid_signature ]] || printf 'tampered\n' >> "$FIXTURE_DIR/installer"
|
|
log() { printf '[%s] %s\n' "$1" "${*:2}"; }
|
|
detect_service_name() { printf 'pulse\n'; }
|
|
sleep() { :; }
|
|
file_mode() { command stat -c '%a' "$1" 2>/dev/null || command stat -f '%Lp' "$1"; }
|
|
# Keep real crypto but place all temporary files inside this disposable fixture.
|
|
mktemp() {
|
|
local -a args=("$@")
|
|
local template="${args[${#args[@]}-1]}" result
|
|
case "$template:$FAULT" in
|
|
/tmp/pulse-backup.*:backup_dir|/tmp/pulse-update-installer.XXXXXX:installer_tmp|/tmp/pulse-update-installer.sig.*:signature_tmp|*.rollback.*:restore_tmp) return 1 ;;
|
|
esac
|
|
if [[ "$template" == /tmp/* ]]; then args[${#args[@]}-1]="$FIXTURE_DIR/tmp/${template##*/}"; fi
|
|
result=$(command mktemp "${args[@]}") || return
|
|
if [[ "$template" == /tmp/pulse-backup.* ]]; then printf '%s\n' "$result" > "$FIXTURE_DIR/backup-path"; fi
|
|
printf '%s\n' "$result"
|
|
}
|
|
# Record/clean the parent's timestamp backup as well, for the negative control.
|
|
date() { printf 'transaction-%s\n' "${FIXTURE_DIR##*/}"; }
|
|
mkdir() {
|
|
if [[ "${*: -1}" == /tmp/pulse-backup-* ]]; then printf '%s\n' "${*: -1}" > "$FIXTURE_DIR/backup-path"; fi
|
|
command mkdir "$@"
|
|
}
|
|
cp() {
|
|
local src="${@: -2:1}" dest="${@: -1}"
|
|
if [[ "$dest" == *pulse-backup* ]]; then
|
|
case "$FAULT:${src##*/}" in backup_binary:pulse|backup_version:VERSION) return 1 ;; esac
|
|
command cp "$@" || return
|
|
if [[ "$FAULT" == backup_corrupt ]]; then
|
|
[[ -d "$dest" ]] && dest="$dest/${src##*/}"
|
|
printf 'corrupt\n' > "$dest"
|
|
fi
|
|
elif [[ "$src" == *pulse-backup*/* ]]; then
|
|
[[ "$FAULT" != restore_copy ]] || return 1
|
|
command cp "$@" || return
|
|
[[ "$FAULT" != restore_corrupt ]] || printf 'corrupt\n' > "$dest"
|
|
else
|
|
command cp "$@"
|
|
fi
|
|
}
|
|
mv() {
|
|
local dest="${*: -1}"
|
|
if [[ "$FAULT" == restore_rename && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 1; fi
|
|
if [[ "$FAULT" == restore_noop && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 0; fi
|
|
if [[ "$FAULT" == version_rename && "$dest" == "$INSTALL_DIR/VERSION" ]]; then return 1; fi
|
|
command mv "$@" || return
|
|
if [[ "$FAULT" == restore_changed && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then printf 'changed\n' > "$dest"; fi
|
|
}
|
|
curl() {
|
|
local out="" url="" arg
|
|
while (( $# )); do
|
|
arg="$1"; shift
|
|
case "$arg" in -o) out="$1"; shift ;; https://*) url="$arg" ;; esac
|
|
done
|
|
printf '%s\n' "$url" >> "$FIXTURE_DIR/downloads"
|
|
if [[ "$url" == *.sshsig ]]; then
|
|
[[ "$FAULT" != download_signature ]] || return 1
|
|
command cp "$FIXTURE_DIR/installer.sig" "$out"
|
|
else
|
|
[[ "$FAULT" != download_installer ]] || return 1
|
|
command cp "$FIXTURE_DIR/installer" "$out"
|
|
fi
|
|
}
|
|
# Pure-function manager doubles stay in this shell; the Python fixture uses
|
|
# real timeout with an external confined manager to test actual deadlines.
|
|
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
|
|
systemctl() {
|
|
case "$1" in
|
|
show) printf 'LoadState=loaded\n'; if [[ "$(cat "$FIXTURE_DIR/active")" == true ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi ;;
|
|
is-active) [[ "$(cat "$FIXTURE_DIR/active")" == true ]] ;;
|
|
stop)
|
|
printf 'stop\n' >> "$FIXTURE_DIR/service-operations"
|
|
[[ "$FAULT" != stop ]] || return 1
|
|
printf 'false\n' > "$FIXTURE_DIR/active"
|
|
;;
|
|
start|restart)
|
|
printf '%s:%s\n' "$1" "$(get_current_version)" >> "$FIXTURE_DIR/service-operations"
|
|
[[ "$FAULT" != restart ]] || return 1
|
|
if [[ "$OUTCOME" == service_failure && "$(get_current_version)" != v6.4.5 ]]; then return 1; fi
|
|
[[ "$FAULT" == restart_liveness ]] || printf 'true\n' > "$FIXTURE_DIR/active"
|
|
;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
cleanup_fixture() {
|
|
if [[ -f "$FIXTURE_DIR/backup-path" ]]; then
|
|
local old_backup; old_backup=$(cat "$FIXTURE_DIR/backup-path")
|
|
case "$old_backup" in /tmp/pulse-backup-transaction-*) command rm -rf -- "$old_backup" ;; esac
|
|
fi
|
|
}
|
|
trap cleanup_fixture EXIT
|
|
result=failure
|
|
if perform_update v6.5.0; then result=success; fi
|
|
[[ "$result" == "$EXPECTED_RESULT" ]] || { echo "unexpected result: $result"; exit 1; }
|
|
[[ -z "$(trap -p RETURN)" ]] || { echo 'leaked RETURN trap'; exit 1; }
|
|
caller() { :; }; caller
|
|
[[ "$(cat "$CONFIG_DIR/system.json")" == 'operator settings unchanged' ]]
|
|
[[ "$(cat "$CONFIG_DIR/agent-id")" == 'local identity unchanged' ]]
|
|
[[ "$(cat "$FIXTURE_DIR/victim")" == 'victim unchanged' ]]
|
|
if [[ "$PREFLIGHT" == true ]]; then
|
|
[[ ! -f "$FIXTURE_DIR/installer-executed" ]] || { echo 'installer executed without a usable backup/admission'; exit 1; }
|
|
[[ ! -s "$FIXTURE_DIR/service-operations" ]] || { echo 'service mutated on rejected preflight'; exit 1; }
|
|
[[ "$(cat "$FIXTURE_DIR/active")" == "$PRIOR_ACTIVE" ]]
|
|
else
|
|
[[ -f "$FIXTURE_DIR/installer-executed" ]]
|
|
fi
|
|
backup=""; [[ ! -f "$FIXTURE_DIR/backup-path" ]] || backup=$(cat "$FIXTURE_DIR/backup-path")
|
|
if [[ "$RETAINED" == true ]]; then
|
|
[[ -d "$backup" ]] || { echo 'rollback backup lost after failed recovery'; exit 1; }
|
|
[[ "$(file_mode "$backup")" == 700 ]]
|
|
cmp "$FIXTURE_DIR/original/pulse-bin" "$backup/pulse-bin"
|
|
cmp "$FIXTURE_DIR/original/VERSION" "$backup/VERSION"
|
|
else
|
|
[[ -z "$backup" || ! -e "$backup" ]] || { echo 'unneeded backup leaked'; exit 1; }
|
|
fi
|
|
# Incomplete file restoration must not activate an unverified executable.
|
|
case "$FAULT" in restore_tmp|restore_copy|restore_corrupt|restore_rename|restore_noop|restore_changed|version_rename|stop)
|
|
if [[ -f "$FIXTURE_DIR/service-operations" ]]; then
|
|
! grep -Eq '^(start|restart):' "$FIXTURE_DIR/service-operations"
|
|
fi
|
|
;;
|
|
esac
|
|
if [[ "$PREFLIGHT" != true && "$RETAINED" != true && "$EXPECTED_RESULT" != success ]]; then
|
|
case "$LAYOUT" in
|
|
legacy) [[ ! -e "$INSTALL_DIR/bin/pulse" ]]; cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/pulse" ;;
|
|
both) cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; cmp "$FIXTURE_DIR/original/pulse-legacy" "$INSTALL_DIR/pulse" ;;
|
|
*) cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; [[ ! -e "$INSTALL_DIR/pulse" ]] ;;
|
|
esac
|
|
if [[ "$NO_VERSION" == true ]]; then
|
|
[[ ! -e "$INSTALL_DIR/VERSION" ]]
|
|
else
|
|
cmp "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION"
|
|
[[ "$(file_mode "$INSTALL_DIR/VERSION")" == 640 ]]
|
|
fi
|
|
[[ "$(get_current_version)" == v6.4.5 ]]
|
|
binary="$INSTALL_DIR/bin/pulse"; [[ "$LAYOUT" != legacy ]] || binary="$INSTALL_DIR/pulse"
|
|
[[ "$(file_mode "$binary")" == 751 ]]
|
|
[[ "$(cat "$FIXTURE_DIR/active")" == "$PRIOR_ACTIVE" ]]
|
|
fi
|
|
if [[ "$EXPECTED_RESULT" == success ]]; then [[ "$(get_current_version)" == v6.5.0 ]]; fi
|
|
if [[ "$FAULT" == xattrs ]]; then
|
|
python3 - "$INSTALL_DIR" <<'METADATA'
|
|
import os, sys
|
|
for name in ('bin/pulse', 'VERSION'):
|
|
assert os.getxattr(os.path.join(sys.argv[1], name), 'user.pulse-updater-test') == b'original extended metadata'
|
|
METADATA
|
|
fi
|
|
printf 'TRANSACTION_ASSERTIONS_PASSED\n'
|
|
`
|
|
cmd := exec.Command("bash", "-c", script)
|
|
boolString := func(v bool) string {
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
want := "failure"
|
|
if success {
|
|
want = "success"
|
|
}
|
|
cmd.Env = append(os.Environ(), "UPDATER_UNDER_TEST="+updater, "FIXTURE_DIR="+dir,
|
|
"FAULT="+fault, "OUTCOME="+outcome, "LAYOUT="+layout, "PRIOR_ACTIVE="+active,
|
|
"NO_VERSION="+boolString(noVersion), "PREFLIGHT="+boolString(preflight),
|
|
"RETAINED="+boolString(retained), "EXPECTED_RESULT="+want)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("complete updater transaction: %v\n%s", err, out)
|
|
}
|
|
if strings.Contains(string(out), "TRANSACTION_METADATA_UNAVAILABLE") {
|
|
t.Skip("extended metadata unsupported by fixture filesystem")
|
|
}
|
|
if !strings.Contains(string(out), "TRANSACTION_ASSERTIONS_PASSED") {
|
|
t.Fatalf("transaction assertions did not finish:\n%s", out)
|
|
}
|
|
if !preflight {
|
|
installerExit := "0"
|
|
switch outcome {
|
|
case "failure", "symlink", "directory", "fifo":
|
|
installerExit = "23"
|
|
}
|
|
observed := "(installer exit: " + installerExit + "; log collector exit: 0)"
|
|
if !strings.Contains(string(out), observed) {
|
|
t.Fatalf("missing observed pipeline exits %q after signed installer execution:\n%s", observed, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stable admission is object-bound, not a line-oriented tag/flag accumulator.
|
|
// Each fixture runs the real selector with local transport stubs only.
|
|
func TestAutoUpdateReleaseMetadataAdmission(t *testing.T) {
|
|
const stable = `{"tag_name":"v6.5.0","draft":false,"prerelease":false}`
|
|
cases := []struct {
|
|
name, list, latest, want string
|
|
listExit, calls int
|
|
}{
|
|
{"compact list", `[{"tag_name":"v6.4.5","draft":false,"prerelease":false},` + stable + `]`, `{}`, "v6.5.0", 0, 1},
|
|
{"reordered fields", `[{"prerelease":false,"draft":false,"tag_name":"v6.5.0"}]`, `{}`, "v6.5.0", 0, 1},
|
|
{"no sibling maturity borrowing", "[\n{\n\"draft\":true,\n\"prerelease\":false,\n\"tag_name\":\"v9.9.9\"\n},\n{\n\"prerelease\":false,\n\"draft\":false,\n\"tag_name\":\"v6.5.0\"\n}\n]", `{}`, "v6.5.0", 0, 1},
|
|
{"confirmed latest fallback", `[]`, stable, "v6.5.0", 0, 2},
|
|
{"missing draft", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"prerelease\":false\n}", "", 0, 2},
|
|
{"missing prerelease", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"draft\":false\n}", "", 0, 2},
|
|
{"draft latest", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"draft\":true,\n\"prerelease\":false\n}", "", 0, 2},
|
|
{"string flags", `[{"tag_name":"v9.9.9","draft":"false","prerelease":"false"}]`, `{}`, "", 0, 2},
|
|
{"nested flags", `[{"tag_name":"v9.9.9","status":{"draft":false,"prerelease":false}}]`, `{}`, "", 0, 2},
|
|
{"body is not release metadata", `[{"body":{"tag_name":"v9.9.9","draft":false,"prerelease":false}}]`, `{}`, "", 0, 2},
|
|
{"wrong list shape", stable, `{}`, "", 0, 2},
|
|
{"wrong latest shape", `[]`, `[` + stable + `]`, "", 0, 2},
|
|
{"non-object entry", `[` + stable + `,false]`, `{}`, "", 0, 2},
|
|
{"truncated list", `[` + stable + `,`, `{}`, "", 0, 2},
|
|
{"concatenated documents", `[` + stable + `] []`, `{}`, "", 0, 2},
|
|
{"partial transport output", `[` + stable + `]`, `{}`, "", 22, 2},
|
|
{"transport failure with confirmed fallback", `[` + stable + `]`, stable, "v6.5.0", 22, 2},
|
|
{"redirect is not maturity", `[]`, "Location: https://github.com/rcourtman/Pulse/releases/tag/v9.9.9\r\n", "", 0, 2},
|
|
{"multiline tag", `[{"tag_name":"unknown\nv9.9.9","draft":false,"prerelease":false}]`, `{}`, "", 0, 2},
|
|
{"preview and chart tags", `[{"tag_name":"v9.9.9-rc.1","draft":false,"prerelease":false},{"tag_name":"helm-chart-9.9.9","draft":false,"prerelease":false}]`, `{}`, "", 0, 2},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
for name, data := range map[string]string{"list": tc.list, "latest": tc.latest} {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
script := `set -euo pipefail
|
|
GITHUB_REPO=rcourtman/Pulse
|
|
log() { printf '%s\n' "$*" >&2; }
|
|
curl() {
|
|
printf '%s\n' "$*" >> "$FIXTURE_DIR/calls"
|
|
local url="${!#}"
|
|
case "$url" in
|
|
*/releases?per_page=30) cat "$FIXTURE_DIR/list"; return "$LIST_EXIT" ;;
|
|
*/releases/latest) cat "$FIXTURE_DIR/latest" ;;
|
|
*) echo "UNEXPECTED_TRANSPORT" >&2; return 99 ;;
|
|
esac
|
|
}
|
|
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + "\n" +
|
|
extractAutoUpdateFunction(t, "version_greater_than") + "\n" +
|
|
extractAutoUpdateFunction(t, "pick_highest_stable_tag") + "\n" +
|
|
extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
get_latest_stable_version
|
|
`
|
|
cmd := exec.Command("bash", "-c", script)
|
|
listExit := "0"
|
|
if tc.listExit != 0 {
|
|
listExit = "22"
|
|
}
|
|
cmd.Env = append(os.Environ(), "FIXTURE_DIR="+dir, "LIST_EXIT="+listExit)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil || strings.TrimSpace(string(out)) != tc.want {
|
|
t.Fatalf("release selection: %v; got %q, want %q", err, out, tc.want)
|
|
}
|
|
calls, err := os.ReadFile(filepath.Join(dir, "calls"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(calls)), "\n")
|
|
if len(lines) != tc.calls {
|
|
t.Fatalf("transport calls: got %d, want %d: %s", len(lines), tc.calls, calls)
|
|
}
|
|
for _, line := range lines {
|
|
if !strings.HasPrefix(line, "--disable --fail --silent --show-error --connect-timeout 5 --max-time 20 --proto =https https://api.github.com/repos/rcourtman/Pulse/") {
|
|
t.Fatalf("unbounded or ambient-config transport: %s", line)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutoUpdateReleaseMetadataRequiresParserBeforeTransport(t *testing.T) {
|
|
script := `set -euo pipefail
|
|
command() { if [[ "$*" == '-v jq' ]]; then return 1; fi; builtin command "$@"; }
|
|
log() { printf '%s\n' "$*" >&2; }
|
|
curl() { echo UNEXPECTED_TRANSPORT; return 99; }
|
|
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
|
|
result=$(get_latest_stable_version)
|
|
[[ -z "$result" ]] && echo PARSER_REFUSED
|
|
`
|
|
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
|
if err != nil || !strings.Contains(string(out), "PARSER_REFUSED") || strings.Contains(string(out), "UNEXPECTED_TRANSPORT") {
|
|
t.Fatalf("missing parser must stop before discovery: %v\n%s", err, out)
|
|
}
|
|
}
|