Pulse/scripts/installtests/pulse_auto_update_test.go
rcourtman a2c101379a Guard stable updater from prerelease tags
Refuse prerelease-shaped tags and explicit GitHub prerelease responses in the unattended stable updater before installer invocation.

Add installability tests and proof routing for the auto-update prerelease refusal guard.
2026-04-25 23:49:27 +01:00

253 lines
7.2 KiB
Go

package installtests
import (
"os/exec"
"strings"
"testing"
)
// 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; }
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, "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, "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",
"prerelease": false,
"name": "Pulse v5.1.28"
}
EOF
}
` + extractAutoUpdateFunction(t, "is_prerelease_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)
}
}