mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-04 13:52:24 +00:00
fix(updates): report installability-aware update checks
Combine the protected schema-v18 sender with the exact-archive updater. Require installable archives in positive fixtures, classify missing archives and unavailable Pro checks without claiming an offer, and avoid caching an unactivated Pro result. Record the contract and exact combined-source browser receipt. Change-source: pulse-maintainer
This commit is contained in:
parent
6d7e3342e5
commit
3a362f0afb
5 changed files with 115 additions and 32 deletions
|
|
@ -4065,6 +4065,15 @@ telemetry exports only that record at schema v18. An update discovery failure
|
|||
that records nothing is a regression, because a failed check never reaches the
|
||||
update history and is otherwise invisible in the fleet (#2285). Proof:
|
||||
`internal/updates/issue2285_update_check_observation_test.go`.
|
||||
The observation describes installability, not merely version ordering. Positive
|
||||
release fixtures must include the exact server archive; a newer release that
|
||||
lacks that archive is not offered and records `metadata_error`, rather than
|
||||
`up_to_date` or `available`. A compiled Pro install without usable broker
|
||||
activation credentials returns its existing operator warning but records
|
||||
`skipped`, never `up_to_date`; this unavailable result must not be cached across
|
||||
a later activation. A stable-channel Pro check against a prerelease-only broker
|
||||
pin records `no_release`. No version, URL, credential or warning text enters
|
||||
the telemetry observation.
|
||||
Those same workflows must also fetch and dispatch the governed release branch
|
||||
derived from release-control metadata instead of hardcoding `pulse/v6`,
|
||||
`pulse/v6-release`, `main`, or any later branch literal inline; when a stable
|
||||
|
|
|
|||
|
|
@ -1,36 +1,28 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "b1e75fb9e4693c5706b2242760d6277e5291e7f9",
|
||||
"verified_at": "2026-09-27T22:55:00Z",
|
||||
"base_sha": "6d7e3342e56a4f8f2b411acb3df8dc7777d4ec7f",
|
||||
"verified_at": "2026-09-27T23:52:13Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/api/settings.ts"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/settings.ts": "4172b94c268be6a0ea000e73d155e540f11776e1c59325250e13716722be5aeb"
|
||||
},
|
||||
"routes": ["/settings/system-general", "/docs/PRIVACY"],
|
||||
"routes": ["/tmp/telemetry-schema-2285.html"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 768
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
{"width": 1280, "height": 800},
|
||||
{"width": 390, "height": 844}
|
||||
],
|
||||
"states": [
|
||||
"Local build of this branch signed in with a test account: General settings telemetry card rendered with the Preview payload control",
|
||||
"Telemetry payload preview opened: schema_version 18 with update_channel stable, update_check_outcome skipped (source build, recorded by the background update checker) and update_available false, all three keys present",
|
||||
"Preview at 390px: payload block visible and scrolls within its own box with no page-level horizontal overflow",
|
||||
"Shipped privacy document: schema version 18 disclosure, Update channel, Update check outcome and Update available rows, and the dated schema 18 changelog row present",
|
||||
"Privacy document at 390px: new rows render in the existing table, which scrolls within its container like the neighbouring rows, with no page-level horizontal overflow",
|
||||
"Desktop and phone screenshots inspected for placement, clipping and overflow"
|
||||
"Production GeneralSettingsPanel mounted in an isolated Vite fixture with a synthetic schema-v18 telemetry-preview API response",
|
||||
"Preview payload displayed stable-channel skipped, metadata_error, and available outcomes with corresponding false, false, and true update_available booleans",
|
||||
"The payload block stayed inside the card; the metadata_error JSON scrolled within the block at 390px without page-level horizontal overflow",
|
||||
"Desktop and phone screenshots for all three outcomes were inspected for placement, clipping and scrolling"
|
||||
],
|
||||
"interactions": [
|
||||
"Signed in through the login form",
|
||||
"Navigated to /settings/system-general and clicked Preview payload",
|
||||
"Parsed the rendered payload JSON and checked the schema 18 fields and closed values",
|
||||
"Resized to 390x844 and rechecked the preview and page overflow",
|
||||
"Navigated to /docs/PRIVACY and scrolled the Update check outcome row into view at desktop and 390px widths"
|
||||
]
|
||||
"Clicked Preview payload and then Refresh payload twice per viewport, replacing the synthetic API outcome on each request",
|
||||
"Parsed the rendered payload JSON and asserted schema_version, update_channel, update_check_outcome and update_available",
|
||||
"Checked page and preview scroll widths at both viewports and checked for uncaught page errors"
|
||||
],
|
||||
"command": "pulse-worker-browser tmp/browser-proof/telemetry-schema-2285.cjs (Playwright 1.56.1, Chromium 141.0.7390.37)",
|
||||
"notes": "Offline production-component fixture with synthetic API data, not a full application login, installed sender, receiver or release acceptance. The browser result and six screenshots are retained under tmp/browser-proof in this assigned workspace."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -33,10 +34,22 @@ func issue2285Releases(t *testing.T, releases ...ReleaseInfo) string {
|
|||
return string(body)
|
||||
}
|
||||
|
||||
func issue2285InstallableRelease(t *testing.T, tag string, prerelease bool, published time.Time) ReleaseInfo {
|
||||
t.Helper()
|
||||
asset, ok := updateReleaseAssetForRuntime(tag)
|
||||
if !ok {
|
||||
t.Skip("no server release archive for this architecture")
|
||||
}
|
||||
return ReleaseInfo{
|
||||
TagName: tag, Prerelease: prerelease, PublishedAt: published,
|
||||
Assets: []ReleaseAsset{asset},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) {
|
||||
setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond)
|
||||
stable := ReleaseInfo{TagName: "v6.4.5", PublishedAt: time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)}
|
||||
preview := ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true, PublishedAt: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)}
|
||||
stable := issue2285InstallableRelease(t, "v6.4.5", false, time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC))
|
||||
preview := issue2285InstallableRelease(t, "v6.4.6-rc.1", true, time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
|
@ -51,6 +64,7 @@ func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) {
|
|||
{name: "update offered", current: "6.4.1", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true},
|
||||
{name: "already current", current: "6.4.5", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeUpToDate},
|
||||
{name: "preview channel offered newer prerelease", current: "6.4.5", channel: "rc", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true},
|
||||
{name: "newer release missing exact archive", current: "6.4.1", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, ReleaseInfo{TagName: stable.TagName, Assets: []ReleaseAsset{{Name: "pulse-agent-v6.4.5-linux-amd64.tar.gz", BrowserDownloadURL: "https://example.invalid/agent"}}}), wantOutcome: UpdateCheckOutcomeMetadataError},
|
||||
{name: "no stable release for channel", current: "6.4.5-rc.3", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, preview), wantOutcome: UpdateCheckOutcomeNoRelease},
|
||||
{name: "malformed metadata", current: "6.4.1", channel: "stable", status: http.StatusOK, body: `{"message":"not a list"}`, wantOutcome: UpdateCheckOutcomeMetadataError, wantErr: true},
|
||||
{name: "server error", current: "6.4.1", channel: "stable", status: http.StatusBadGateway, body: `bad gateway`, wantOutcome: UpdateCheckOutcomeNetworkError, wantErr: true},
|
||||
|
|
@ -64,10 +78,13 @@ func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) {
|
|||
t.Fatalf("before any check LastUpdateCheck = %+v, want not_checked on %s", got, tc.channel)
|
||||
}
|
||||
|
||||
_, err := manager.CheckForUpdates(context.Background())
|
||||
info, err := manager.CheckForUpdates(context.Background())
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("CheckForUpdates error = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if err == nil && (info.Available != tc.wantAvailable || (tc.wantAvailable && info.DownloadURL == "") || (tc.name == "newer release missing exact archive" && info.DownloadURL != "")) {
|
||||
t.Fatalf("CheckForUpdates result = %+v, want available %v with matching archive only", info, tc.wantAvailable)
|
||||
}
|
||||
got := manager.LastUpdateCheck()
|
||||
if got.Outcome != tc.wantOutcome || got.Available != tc.wantAvailable || got.Channel != tc.channel || got.CheckedAt.IsZero() {
|
||||
t.Fatalf("LastUpdateCheck = %+v, want outcome %s available %v on %s", got, tc.wantOutcome, tc.wantAvailable, tc.channel)
|
||||
|
|
@ -80,8 +97,8 @@ func TestIssue2285PreviewOfOtherChannelDoesNotReplaceObservation(t *testing.T) {
|
|||
setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond)
|
||||
withBuildVersion(t, "6.4.5")
|
||||
issue2285Server(t, http.StatusOK, issue2285Releases(t,
|
||||
ReleaseInfo{TagName: "v6.4.5"},
|
||||
ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true},
|
||||
issue2285InstallableRelease(t, "v6.4.5", false, time.Time{}),
|
||||
issue2285InstallableRelease(t, "v6.4.6-rc.1", true, time.Time{}),
|
||||
))
|
||||
manager := NewManager(&config.Config{UpdateChannel: "stable"})
|
||||
|
||||
|
|
@ -95,14 +112,61 @@ func TestIssue2285PreviewOfOtherChannelDoesNotReplaceObservation(t *testing.T) {
|
|||
// The settings UI can preview the preview channel before saving it. That
|
||||
// offer is not what this install is being offered, so it must not leak
|
||||
// into the observation telemetry reports.
|
||||
if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc"); err != nil {
|
||||
previewInfo, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc")
|
||||
if err != nil {
|
||||
t.Fatalf("preview-channel check: %v", err)
|
||||
}
|
||||
if !previewInfo.Available || previewInfo.DownloadURL == "" {
|
||||
t.Fatalf("preview-channel check = %+v, want installable preview offer", previewInfo)
|
||||
}
|
||||
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeUpToDate || got.Available || got.Channel != "stable" {
|
||||
t.Fatalf("after previewing rc LastUpdateCheck = %+v, want stable up_to_date unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue2285UnactivatedProCheckIsNotUpToDateOrCached(t *testing.T) {
|
||||
setupProUpdateTest(t, "6.0.0")
|
||||
manager := NewManager(&config.Config{UpdateChannel: "stable", DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(func() (ProUpdateCredentials, bool) {
|
||||
return ProUpdateCredentials{}, false
|
||||
})
|
||||
|
||||
info, err := manager.CheckForUpdates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unactivated Pro check: %v", err)
|
||||
}
|
||||
if info.Available || !strings.Contains(info.Warning, "Update checks are unavailable") {
|
||||
t.Fatalf("unactivated Pro result = %+v, want unavailable warning without an offer", info)
|
||||
}
|
||||
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeSkipped || got.Available || got.Channel != "stable" {
|
||||
t.Fatalf("unactivated Pro observation = %+v, want stable skipped without offer", got)
|
||||
}
|
||||
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
info, err = manager.CheckForUpdates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("activated Pro check: %v", err)
|
||||
}
|
||||
if fixture.brokerCalls != 1 || !info.Available {
|
||||
t.Fatalf("activated Pro result = %+v, broker calls = %d; want fresh offer", info, fixture.brokerCalls)
|
||||
}
|
||||
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeAvailable || !got.Available {
|
||||
t.Fatalf("activated Pro observation = %+v, want available", got)
|
||||
}
|
||||
|
||||
previewPin := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
||||
stableManager := NewManager(&config.Config{UpdateChannel: "stable", DataPath: t.TempDir()})
|
||||
stableManager.SetProUpdateCredentialSource(previewPin.credentialSource())
|
||||
info, err = stableManager.CheckForUpdates(context.Background())
|
||||
if err != nil || info.Available || previewPin.brokerCalls != 1 {
|
||||
t.Fatalf("stable Pro check against preview pin = %+v, err=%v, broker calls=%d", info, err, previewPin.brokerCalls)
|
||||
}
|
||||
if got := stableManager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeNoRelease || got.Available {
|
||||
t.Fatalf("stable Pro preview-pin observation = %+v, want no_release", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue2285SourceBuildReportsSkipped(t *testing.T) {
|
||||
withBuildVersion(t, "6.4.0")
|
||||
markerPath := "BUILD_FROM_SOURCE"
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ type UpdateInfo struct {
|
|||
// compiled Pro binary, which cannot self-update in a container and must
|
||||
// never be pointed at the community rcourtman/pulse image.
|
||||
DockerUpdate *DockerUpdateCommands `json:"dockerUpdate,omitempty"`
|
||||
// checkOutcome is only for the content-free observation. It distinguishes a
|
||||
// check that could not run from a completed check with no update; it is not
|
||||
// part of the update API response.
|
||||
checkOutcome string
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -453,8 +457,14 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
|
|||
m.updateStatus("error", 0, "Failed to check for Pulse Pro updates", proErr)
|
||||
return nil, proErr
|
||||
}
|
||||
m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available)
|
||||
if useCache {
|
||||
outcome := availabilityOutcome(info.Available)
|
||||
if info.checkOutcome != "" {
|
||||
outcome = info.checkOutcome
|
||||
}
|
||||
m.recordUpdateCheck(channel, effectiveChannel, outcome, info.Available)
|
||||
// A missing activation can be repaired without restarting Pulse. Do not
|
||||
// cache its unavailable result across the next credentialed check.
|
||||
if useCache && info.checkOutcome != UpdateCheckOutcomeSkipped {
|
||||
m.statusMu.Lock()
|
||||
m.checkCache[channel] = info
|
||||
m.cacheTime[channel] = time.Now()
|
||||
|
|
@ -566,7 +576,13 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
|
|||
}
|
||||
|
||||
info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major)
|
||||
m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available)
|
||||
checkOutcome := availabilityOutcome(info.Available)
|
||||
if latestVer.IsNewerThan(currentVer) && downloadURL == "" {
|
||||
// Metadata for a newer release without this binary's exact archive is
|
||||
// not an offer and is not evidence that the install is up to date.
|
||||
checkOutcome = UpdateCheckOutcomeMetadataError
|
||||
}
|
||||
m.recordUpdateCheck(channel, effectiveChannel, checkOutcome, info.Available)
|
||||
|
||||
// Cache the result (only if using saved channel)
|
||||
if useCache {
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ func (m *Manager) checkProUpdates(ctx context.Context, channel string, currentIn
|
|||
CurrentVersion: currentInfo.Version,
|
||||
LatestVersion: currentInfo.Version,
|
||||
Warning: "Update checks are unavailable: " + errProUpdateNotActivated().Error(),
|
||||
checkOutcome: UpdateCheckOutcomeSkipped,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +334,7 @@ func (m *Manager) checkProUpdates(ctx context.Context, channel string, currentIn
|
|||
CurrentVersion: currentInfo.Version,
|
||||
LatestVersion: currentInfo.Version,
|
||||
Warning: fmt.Sprintf("The private Pulse Pro release channel currently serves prerelease %s; stable-channel installs skip prereleases.", manifest.Release.Version),
|
||||
checkOutcome: UpdateCheckOutcomeNoRelease,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue