mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 00:22:28 +00:00
0928584625
The license-server broker now serves stable and rc manifest slots (pulse-pro side). The Pro self-updater passes channel=rc for rc-channel installs so they track the RC slot; stable installs keep the unchanged default URL, which older brokers also understand. The client-side guard that refuses a prerelease pin on the stable channel stays as the backstop for a drifted or single-manifest broker. Contract updated under deployment-installability; fixture now pins the channel query behavior.
524 lines
20 KiB
Go
524 lines
20 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
|
|
dockerBlock map[string]any // broker docker block; nil for the default digest-pinned one
|
|
|
|
brokerCalls int
|
|
tarballCalls int
|
|
sshsigCalls int
|
|
channelQueries []string
|
|
}
|
|
|
|
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())
|
|
}
|
|
f.channelQueries = append(f.channelQueries, r.URL.Query().Get("channel"))
|
|
|
|
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"
|
|
}
|
|
dockerBlock := f.dockerBlock
|
|
if dockerBlock == nil {
|
|
dockerBlock = defaultBrokerDockerBlock(f.version)
|
|
}
|
|
resp := map[string]any{
|
|
"release": map[string]any{
|
|
"version": f.version,
|
|
"prerelease": f.prerelease,
|
|
},
|
|
"artifacts": []any{artifact},
|
|
"docker": dockerBlock,
|
|
}
|
|
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
|
|
}
|
|
|
|
// defaultBrokerDockerBlock mirrors the license server's docker block: a
|
|
// digest-pinned private image ref inside ready-to-run compose commands (the
|
|
// broker never emits mutable-tag commands; see pro_downloads_test.go in
|
|
// pulse-pro).
|
|
func defaultBrokerDockerBlock(version string) map[string]any {
|
|
digest := "sha256:" + strings.Repeat("ab", 32)
|
|
pinnedRef := "registry.pulserelay.pro/pulse/pulse-pro@" + digest
|
|
return map[string]any{
|
|
"registry": "registry.pulserelay.pro",
|
|
"image": "registry.pulserelay.pro/pulse/pulse-pro",
|
|
"tag": "v" + strings.TrimPrefix(version, "v"),
|
|
"image_digest": digest,
|
|
"login_command": "printf '%s' '<activation-key>' | docker login registry.pulserelay.pro -u 'lic_test' --password-stdin",
|
|
"compose_pull_command": "PULSE_IMAGE='" + pinnedRef + "' docker compose pull",
|
|
"compose_up_command": "PULSE_IMAGE='" + pinnedRef + "' docker compose up -d",
|
|
}
|
|
}
|
|
|
|
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 leaves the broker channel parameter unset", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.0.5", false)
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable"); err != nil {
|
|
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
|
}
|
|
if len(fixture.channelQueries) == 0 {
|
|
t.Fatal("expected the update check to query the download broker")
|
|
}
|
|
// The stable default stays implicit so the request is byte-identical
|
|
// for brokers that predate the dual-channel parameter.
|
|
if got := fixture.channelQueries[len(fixture.channelQueries)-1]; got != "" {
|
|
t.Fatalf("stable check sent channel=%q, want no channel parameter", got)
|
|
}
|
|
})
|
|
|
|
t.Run("rc channel requests the broker rc slot", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc"); err != nil {
|
|
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
|
}
|
|
if len(fixture.channelQueries) == 0 {
|
|
t.Fatal("expected the update check to query the download broker")
|
|
}
|
|
if got := fixture.channelQueries[len(fixture.channelQueries)-1]; got != "rc" {
|
|
t.Fatalf("rc check sent channel=%q, want rc", got)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCheckProUpdatesDockerCommands proves a Docker deployment of the Pro
|
|
// binary gets the broker's digest-pinned image commands on the update check
|
|
// (its only sane update path: the self-updater refuses to run in a container,
|
|
// and the community `docker pull rcourtman/pulse` guidance would silently
|
|
// downgrade the install to the community build).
|
|
func TestCheckProUpdatesDockerCommands(t *testing.T) {
|
|
setupProUpdateTest(t, "6.0.0")
|
|
|
|
dockerVersionInfo := func(version string) *VersionInfo {
|
|
return &VersionInfo{Version: version, Build: "release", Runtime: "go", IsDocker: true, DeploymentType: "docker"}
|
|
}
|
|
|
|
t.Run("docker deployment gets digest-pinned commands", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.0.5", false)
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
currentVer, err := ParseVersion("6.0.0")
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion: %v", err)
|
|
}
|
|
info, err := manager.checkProUpdates(context.Background(), "stable", dockerVersionInfo("6.0.0"), currentVer)
|
|
if err != nil {
|
|
t.Fatalf("checkProUpdates: %v", err)
|
|
}
|
|
if !info.Available {
|
|
t.Fatalf("expected update to be available, got %+v", info)
|
|
}
|
|
cmds := info.DockerUpdate
|
|
if cmds == nil {
|
|
t.Fatal("expected DockerUpdate commands for a Pro docker deployment")
|
|
}
|
|
if cmds.Version != "v6.0.5" {
|
|
t.Fatalf("DockerUpdate.Version = %q, want v6.0.5", cmds.Version)
|
|
}
|
|
pinnedRef := cmds.Image + "@" + cmds.ImageDigest
|
|
if !strings.Contains(cmds.ComposePullCommand, pinnedRef) || !strings.Contains(cmds.ComposeUpCommand, pinnedRef) {
|
|
t.Fatalf("compose commands must reference the digest-pinned image %q, got pull=%q up=%q", pinnedRef, cmds.ComposePullCommand, cmds.ComposeUpCommand)
|
|
}
|
|
if strings.Contains(cmds.ComposePullCommand, "rcourtman/pulse") {
|
|
t.Fatalf("Pro docker commands must never reference the community image, got %q", cmds.ComposePullCommand)
|
|
}
|
|
// The frontend consumes this via /api/updates/check; pin the wire key.
|
|
encoded, err := json.Marshal(info)
|
|
if err != nil {
|
|
t.Fatalf("marshal UpdateInfo: %v", err)
|
|
}
|
|
if !strings.Contains(string(encoded), `"dockerUpdate"`) {
|
|
t.Fatalf("UpdateInfo JSON must carry dockerUpdate, got %s", encoded)
|
|
}
|
|
})
|
|
|
|
t.Run("commands attach even when already up to date", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.0.0", false)
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
currentVer, err := ParseVersion("6.0.0")
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion: %v", err)
|
|
}
|
|
info, err := manager.checkProUpdates(context.Background(), "stable", dockerVersionInfo("6.0.0"), currentVer)
|
|
if err != nil {
|
|
t.Fatalf("checkProUpdates: %v", err)
|
|
}
|
|
if info.Available {
|
|
t.Fatalf("expected no update at the same version, got %+v", info)
|
|
}
|
|
if info.DockerUpdate == nil {
|
|
t.Fatal("expected DockerUpdate commands to ride along even when up to date")
|
|
}
|
|
})
|
|
|
|
t.Run("non-docker deployment gets no docker commands", 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 !info.Available {
|
|
t.Fatalf("expected update to be available, got %+v", info)
|
|
}
|
|
if info.DockerUpdate != nil {
|
|
t.Fatalf("non-docker deployments must not carry docker commands, got %+v", info.DockerUpdate)
|
|
}
|
|
})
|
|
|
|
t.Run("stable channel prerelease pin withholds commands with the version", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
currentVer, err := ParseVersion("6.0.0")
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion: %v", err)
|
|
}
|
|
info, err := manager.checkProUpdates(context.Background(), "stable", dockerVersionInfo("6.0.0"), currentVer)
|
|
if err != nil {
|
|
t.Fatalf("checkProUpdates: %v", err)
|
|
}
|
|
if info.Available || info.DockerUpdate != nil {
|
|
t.Fatalf("stable channel must withhold prerelease docker commands, got %+v", info)
|
|
}
|
|
})
|
|
|
|
t.Run("fails closed on a mutable-tag broker block", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.0.5", false)
|
|
fixture.dockerBlock = map[string]any{
|
|
"registry": "registry.pulserelay.pro",
|
|
"image": "registry.pulserelay.pro/pulse/pulse-pro",
|
|
"tag": "v6.0.5",
|
|
"compose_pull_command": "PULSE_IMAGE='registry.pulserelay.pro/pulse/pulse-pro:v6.0.5' docker compose pull",
|
|
"compose_up_command": "PULSE_IMAGE='registry.pulserelay.pro/pulse/pulse-pro:v6.0.5' docker compose up -d",
|
|
}
|
|
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
|
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
|
|
|
currentVer, err := ParseVersion("6.0.0")
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion: %v", err)
|
|
}
|
|
info, err := manager.checkProUpdates(context.Background(), "stable", dockerVersionInfo("6.0.0"), currentVer)
|
|
if err != nil {
|
|
t.Fatalf("checkProUpdates: %v", err)
|
|
}
|
|
if info.DockerUpdate != nil {
|
|
t.Fatalf("a broker block without a digest-pinned ref must be dropped, got %+v", info.DockerUpdate)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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 pre-install validation stage (dummy tarball has no pulse binary)")
|
|
}
|
|
if !strings.Contains(err.Error(), "pulse binary not found") {
|
|
t.Fatalf("expected failure at the validation 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("corrupt pulse binary fails the pre-install self-test", func(t *testing.T) {
|
|
fixture := newProBrokerFixture(t, "6.0.5", false)
|
|
fixture.tarball = buildTarballWithPulseBinary(t, []byte{0x00, 0x01, 0x02, 0x03})
|
|
digest := sha256.Sum256(fixture.tarball)
|
|
fixture.sha256Hex = hex.EncodeToString(digest[:])
|
|
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(), "self-test") {
|
|
t.Fatalf("expected the pre-install self-test to reject the corrupt binary, got: %v", err)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|