mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
93bccaff18
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 inside313552debvia a parallel session committing a shared staged index; this commit completes the wiring they belong to.
93 lines
3.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|