Files
pulse/internal/updates/manager_pro_update_test.go
T
rcourtman 313552debc Pin the OAuth Tailscale path in the update-demo workflow test
TestUpdateDemoWorkflowUsesGovernedNetworkPath still asserted the retired
authkey: TS_AUTHKEY line; 0a9a29d63 moved update-demo-server.yml to the
OAuth client (TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET, tag:infra), so the
test now pins those lines instead.
2026-07-08 23:23:20 +01:00

311 lines
11 KiB
Go

package updates
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
)
// proBrokerFixture stands in for the license server download broker plus the
// signed R2 URLs it hands out: /v1/downloads/pulse-pro returns the manifest,
// and the artifact/.sshsig endpoints simulate the presigned object URLs.
type proBrokerFixture struct {
t *testing.T
server *httptest.Server
version string
prerelease bool
tarball []byte
sha256Hex string // manifest hash; may deliberately mismatch the tarball
omitSSHSig bool // drop the sshsig_url from the manifest
brokerCalls int
tarballCalls int
sshsigCalls int
}
func newProBrokerFixture(t *testing.T, version string, prerelease bool) *proBrokerFixture {
t.Helper()
f := &proBrokerFixture{t: t, version: version, prerelease: prerelease}
f.tarball = buildDummyTarball(t)
digest := sha256.Sum256(f.tarball)
f.sha256Hex = hex.EncodeToString(digest[:])
mux := http.NewServeMux()
mux.HandleFunc("/v1/downloads/pulse-pro", func(w http.ResponseWriter, r *http.Request) {
f.brokerCalls++
if got := r.Header.Get("Authorization"); got != "Bearer pit_live_test" {
t.Errorf("broker got Authorization %q, want installation token bearer", got)
w.WriteHeader(http.StatusUnauthorized)
return
}
if got := r.Header.Get("X-Pulse-Instance-Fingerprint"); got != "fp-test" {
t.Errorf("broker got fingerprint %q, want fp-test", got)
w.WriteHeader(http.StatusForbidden)
return
}
if got := r.URL.Query().Get("target"); got != proUpdateTarget() {
t.Errorf("broker got target %q, want %q", got, proUpdateTarget())
}
artifact := map[string]any{
"target": proUpdateTarget(),
"filename": fmt.Sprintf("pulse-pro-v%s-%s.tar.gz", f.version, proUpdateTarget()),
"sha256": f.sha256Hex,
"download_url": f.server.URL + "/r2/pulse-pro.tar.gz?X-Amz-Signature=signed",
}
if !f.omitSSHSig {
artifact["sshsig_url"] = f.server.URL + "/r2/pulse-pro.tar.gz.sshsig?X-Amz-Signature=signed"
}
resp := map[string]any{
"release": map[string]any{
"version": f.version,
"prerelease": f.prerelease,
},
"artifacts": []any{artifact},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
t.Errorf("encode broker response: %v", err)
}
})
mux.HandleFunc("/r2/pulse-pro.tar.gz", func(w http.ResponseWriter, r *http.Request) {
f.tarballCalls++
if r.URL.Query().Get("X-Amz-Signature") == "" {
t.Error("artifact download must use the presigned URL from the broker manifest")
}
if _, err := w.Write(f.tarball); err != nil {
t.Errorf("write tarball: %v", err)
}
})
mux.HandleFunc("/r2/pulse-pro.tar.gz.sshsig", func(w http.ResponseWriter, r *http.Request) {
f.sshsigCalls++
if r.URL.Query().Get("X-Amz-Signature") == "" {
t.Error("signature download must use the presigned URL from the broker manifest")
}
if _, err := w.Write([]byte("dummy-sshsig")); err != nil {
t.Errorf("write sshsig: %v", err)
}
})
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
func (f *proBrokerFixture) credentialSource() func() (ProUpdateCredentials, bool) {
return func() (ProUpdateCredentials, bool) {
return ProUpdateCredentials{
LicenseServerURL: f.server.URL,
InstallationToken: "pit_live_test",
InstanceFingerprint: "fp-test",
}, true
}
}
func setupProUpdateTest(t *testing.T, currentVersion string) {
t.Helper()
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
oldBuildVersion := BuildVersion
BuildVersion = currentVersion
t.Cleanup(func() { BuildVersion = oldBuildVersion })
edition.SetEdition(edition.Pro)
t.Cleanup(func() { edition.SetEdition(edition.Community) })
}
// TestCheckForUpdatesProUsesBroker proves the Pro binary's update check reads
// the license server download broker, never GitHub: the offered version is the
// broker's pinned private release and the download URL is the broker intent
// URL, not a public release asset.
func TestCheckForUpdatesProUsesBroker(t *testing.T) {
setupProUpdateTest(t, "6.0.0")
t.Run("offers the broker-pinned release", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.0.5", false)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
if err != nil {
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
}
if fixture.brokerCalls == 0 {
t.Fatal("expected the update check to query the download broker")
}
if !info.Available {
t.Fatalf("expected update 6.0.0 → 6.0.5 to be available, got %+v", info)
}
if info.LatestVersion != "6.0.5" {
t.Fatalf("LatestVersion = %q, want 6.0.5", info.LatestVersion)
}
if !strings.Contains(info.DownloadURL, proDownloadBrokerPath) {
t.Fatalf("DownloadURL %q must be the broker intent URL", info.DownloadURL)
}
if !strings.Contains(info.DownloadURL, "version=v6.0.5") {
t.Fatalf("DownloadURL %q must carry the target version for the apply channel guard", info.DownloadURL)
}
// The API handler runs the shared channel guard on this URL before
// readiness checks; it must be able to infer the version from it.
target, err := ValidateApplyTargetVersion("stable", info.DownloadURL)
if err != nil {
t.Fatalf("ValidateApplyTargetVersion on the broker intent URL: %v", err)
}
if target != "v6.0.5" {
t.Fatalf("inferred target version = %q, want v6.0.5", target)
}
})
t.Run("stable channel skips a prerelease broker pin", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
if err != nil {
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
}
if info.Available {
t.Fatalf("stable channel must not offer prerelease %q, got %+v", "6.1.0-rc.1", info)
}
if !strings.Contains(info.Warning, "prerelease") {
t.Fatalf("expected a prerelease-pin warning, got %q", info.Warning)
}
})
t.Run("rc channel offers a prerelease broker pin", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc")
if err != nil {
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
}
if !info.Available || !info.IsPrerelease {
t.Fatalf("rc channel should offer prerelease 6.1.0-rc.1, got %+v", info)
}
})
t.Run("without activation reports unavailable with guidance", func(t *testing.T) {
manager := NewManager(&config.Config{DataPath: t.TempDir()})
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
if err != nil {
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
}
if info.Available {
t.Fatalf("unactivated Pro must not offer updates, got %+v", info)
}
if !strings.Contains(info.Warning, "activated license") {
t.Fatalf("expected activation guidance in warning, got %q", info.Warning)
}
})
}
// TestApplyUpdateProDownloadsFromBroker proves the Pro apply path resolves
// fresh signed URLs from the broker, downloads the private archive, verifies
// its .sshsig against the pinned key path and its sha256 against the manifest,
// and never fetches a public community asset. The dummy tarball deliberately
// contains no pulse binary, so a "pulse binary not found" failure at the apply
// stage is the proof that every Pro-specific stage before it succeeded.
func TestApplyUpdateProDownloadsFromBroker(t *testing.T) {
setupProUpdateTest(t, "6.0.0")
origVerify := verifyReleaseSignatureFunc
verifyReleaseSignatureFunc = func(ctx context.Context, targetPath, signaturePath string) error {
return nil
}
t.Cleanup(func() { verifyReleaseSignatureFunc = origVerify })
t.Run("full flow through verification and extraction", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.0.5", false)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
if err != nil {
t.Fatalf("proUpdateApplyURL: %v", err)
}
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
if err == nil {
t.Fatal("expected apply to fail at the binary-install stage (dummy tarball has no pulse binary)")
}
if !strings.Contains(err.Error(), "pulse binary not found") {
t.Fatalf("expected failure at the apply stage after all Pro verification stages passed, got: %v", err)
}
if fixture.brokerCalls == 0 {
t.Fatal("apply must re-resolve signed URLs from the broker")
}
if fixture.tarballCalls != 1 {
t.Fatalf("expected exactly one artifact download, got %d", fixture.tarballCalls)
}
if fixture.sshsigCalls != 1 {
t.Fatalf("expected exactly one signature download, got %d", fixture.sshsigCalls)
}
})
t.Run("manifest hash mismatch fails closed", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.0.5", false)
fixture.sha256Hex = strings.Repeat("0", 64)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
if err != nil {
t.Fatalf("proUpdateApplyURL: %v", err)
}
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
if err == nil || !strings.Contains(err.Error(), "checksum verification failed") {
t.Fatalf("expected checksum failure, got: %v", err)
}
})
t.Run("missing signature URL fails closed", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.0.5", false)
fixture.omitSSHSig = true
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
if err != nil {
t.Fatalf("proUpdateApplyURL: %v", err)
}
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
if err == nil || !strings.Contains(err.Error(), ".sshsig") {
t.Fatalf("expected missing-signature refusal, got: %v", err)
}
})
t.Run("stable channel refuses a prerelease broker pin at apply time", func(t *testing.T) {
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
manager := NewManager(&config.Config{DataPath: t.TempDir()})
manager.SetProUpdateCredentialSource(fixture.credentialSource())
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.1.0-rc.1")
if err != nil {
t.Fatalf("proUpdateApplyURL: %v", err)
}
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
if err == nil || !strings.Contains(err.Error(), "stable channel cannot install prerelease builds") {
t.Fatalf("expected the stable-channel guard, got: %v", err)
}
})
}