Files
pulse/internal/updates/manager_edition_test.go
T
rcourtman 93bccaff18 Update the Pro binary in-app from the license server download broker
The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.

Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.

The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.

Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
2026-07-08 23:39:37 +01:00

93 lines
3.6 KiB
Go

package updates
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
)
// TestApplyUpdateProEditionGate verifies Guard 2 of the Pro download/update
// spec: the separately compiled Pro binary must never install the public
// community build. Without an activation the Pro path refuses with the manual
// portal fallback; with an activation it only accepts the broker-intent URL
// for its own license server; the community edition is untouched by the gate.
func TestApplyUpdateProEditionGate(t *testing.T) {
// Allow an arbitrary download host (validateApplyDownloadURL) and force
// Docker detection off, so the flow deterministically reaches the edition
// gate whether the test runs on a dev host or inside a CI container.
t.Setenv("PULSE_UPDATE_SERVER", "http://example.invalid")
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
// A local server that always 404s keeps the community path hermetic: it
// gets past the edition gate and then fails fast on download without
// touching the real network.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
}))
defer server.Close()
cfg := &config.Config{DataPath: t.TempDir()}
manager := NewManager(cfg)
downloadURL := server.URL + "/pulse-v6.0.0-linux-amd64.tar.gz"
t.Run("pro edition without activation refuses with portal fallback", func(t *testing.T) {
edition.SetEdition(edition.Pro)
t.Cleanup(func() { edition.SetEdition(edition.Community) })
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
if err == nil {
t.Fatal("expected ApplyUpdate to refuse the unactivated Pro edition, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "activated license") {
t.Fatalf("Pro edition error must explain the activation requirement, got: %v", err)
}
if !strings.Contains(msg, "pulserelay.pro/download.html") {
t.Fatalf("Pro edition error must point at the download portal fallback, got: %v", err)
}
if !strings.Contains(msg, "install.sh --archive") {
t.Fatalf("Pro edition error must mention the archive install path, got: %v", err)
}
})
t.Run("pro edition rejects community download URLs", func(t *testing.T) {
edition.SetEdition(edition.Pro)
t.Cleanup(func() { edition.SetEdition(edition.Community) })
manager.SetProUpdateCredentialSource(func() (ProUpdateCredentials, bool) {
return ProUpdateCredentials{
LicenseServerURL: "https://license.example.invalid",
InstallationToken: "pit_live_test",
InstanceFingerprint: "fp-test",
}, true
})
t.Cleanup(func() { manager.SetProUpdateCredentialSource(nil) })
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
if err == nil {
t.Fatal("expected ApplyUpdate to reject a community download URL on the Pro edition, got nil")
}
if !strings.Contains(err.Error(), "invalid download URL") {
t.Fatalf("Pro edition must reject non-broker download URLs, got: %v", err)
}
})
t.Run("community edition proceeds past the edition gate", func(t *testing.T) {
edition.SetEdition(edition.Community)
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
// The community path still fails (the local server 404s), but it must
// NOT be blocked by the Pro edition gate.
if err != nil && strings.Contains(err.Error(), "activated license") {
t.Fatalf("community edition must not hit the Pro edition gate, got: %v", err)
}
})
}