feat: warn when the CLI is older than the newest release on its host #5
Merged@ogirardot wants to merge
feat/staleness-notice into mainMerged as
aa8785956e63.4 files changed
modified
cmd/body_test.go +74 -0 | @@ -209,6 +209,80 @@ func TestMRCommentAnchoredBlankBodyIsRefused(t *testing.T) { | ||
| 209 | 209 | } |
| 210 | 210 | } |
| 211 | 211 | |
| 212 | +// TestIssueCreateBodyDashReadsStdin and TestPRCreateBodyDashReadsStdin close | |
| 213 | +// rickub/rickub#279's acceptance criterion 3: "-b - reaches the API as the stdin | |
| 214 | +// content for BOTH issue create and pr create". The comment commands above | |
| 215 | +// already pin their halves; these pin the two CREATE halves — `pr create -b -` | |
| 216 | +// is the exact command that once stored a literal "-" and discarded an 8.9 KB | |
| 217 | +// description with a success line. Each test fails on a parser that uses the | |
| 218 | +// flag value verbatim (sent == "-"), which is how the pr-create half regressed | |
| 219 | +// before the shared resolver existed. | |
| 220 | +func TestIssueCreateBodyDashReadsStdin(t *testing.T) { | |
| 221 | + var sent string | |
| 222 | + defer withCreateBodyServer(t, &sent, "/issues")() | |
| 223 | + | |
| 224 | + const text = "## Summary\n\nPins the portal gun back on the shelf.\n\n- one\n- two" | |
| 225 | + if _, _, err := executeStdin(t, text+"\n", "issue", "create", "-R", "rick/portal", | |
| 226 | + "-t", "the gun slips", "-b", "-"); err != nil { | |
| 227 | + t.Fatalf("execute: %v", err) | |
| 228 | + } | |
| 229 | + if sent == "-" { | |
| 230 | + t.Fatal(`issue create -b - posted the literal "-" — rickub/rickub#279 has regressed`) | |
| 231 | + } | |
| 232 | + if sent != text { | |
| 233 | + t.Fatalf("posted body = %q, want the stdin text %q", sent, text) | |
| 234 | + } | |
| 235 | +} | |
| 236 | + | |
| 237 | +func TestPRCreateBodyDashReadsStdin(t *testing.T) { | |
| 238 | + var sent string | |
| 239 | + defer withCreateBodyServer(t, &sent, "/merge-requests")() | |
| 240 | + | |
| 241 | + const text = "## What\n\nA long description piped in from a file, the way MR bodies\nare actually written.\n" | |
| 242 | + if _, _, err := executeStdin(t, text, "pr", "create", "-R", "rick/portal", | |
| 243 | + "--base", "main", "--head", "fix/pin-the-gun", "-t", "fix: pin the gun", "-b", "-"); err != nil { | |
| 244 | + t.Fatalf("execute: %v", err) | |
| 245 | + } | |
| 246 | + if sent == "-" { | |
| 247 | + t.Fatal(`pr create -b - posted the literal "-" — rickub/rickub#279 has regressed`) | |
| 248 | + } | |
| 249 | + if sent != strings.TrimRight(text, "\r\n") { | |
| 250 | + t.Fatalf("posted body = %q, want the stdin text %q", sent, text) | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +// withCreateBodyServer stands up a fake that answers a create endpoint (path | |
| 255 | +// suffix) and records the request JSON's body field, wiring the CLI at it. The | |
| 256 | +// suffix keeps the two tests honest about WHICH create they exercised; any | |
| 257 | +// other path fails the test rather than passing as an empty capture. | |
| 258 | +func withCreateBodyServer(t *testing.T, got *string, suffix string) func() { | |
| 259 | + t.Helper() | |
| 260 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 261 | + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, suffix) { | |
| 262 | + t.Errorf("create request used %s %s, want POST …%s", r.Method, r.URL.Path, suffix) | |
| 263 | + } | |
| 264 | + var body struct { | |
| 265 | + Body string `json:"body"` | |
| 266 | + } | |
| 267 | + b, err := io.ReadAll(r.Body) | |
| 268 | + if err != nil { | |
| 269 | + t.Errorf("reading request body: %v", err) | |
| 270 | + } | |
| 271 | + if err := json.Unmarshal(b, &body); err != nil { | |
| 272 | + t.Errorf("request body %q is not the JSON the CLI should send: %v", b, err) | |
| 273 | + } | |
| 274 | + *got = body.Body | |
| 275 | + w.WriteHeader(http.StatusCreated) | |
| 276 | + _ = json.NewEncoder(w).Encode(map[string]any{ | |
| 277 | + "number": 62, "title": "recorded", "state": "open", | |
| 278 | + }) | |
| 279 | + })) | |
| 280 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 281 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 282 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 283 | + return srv.Close | |
| 284 | +} | |
| 285 | + | |
| 212 | 286 | // TestBodyFlagHelpIsIdentical: both commands document the stdin behaviour in the |
| 213 | 287 | // same words, because they read the same constant. Issue #94's criterion 4. |
| 214 | 288 | func TestBodyFlagHelpIsIdentical(t *testing.T) { |
| @@ -209,6 +209,80 @@ func TestMRCommentAnchoredBlankBodyIsRefused(t *testing.T) { | |||
| 209 | } | 209 | } |
| 210 | } | 210 | } |
| 211 | 211 | ||
| 212 | +// TestIssueCreateBodyDashReadsStdin and TestPRCreateBodyDashReadsStdin close | ||
| 213 | +// rickub/rickub#279's acceptance criterion 3: "-b - reaches the API as the stdin | ||
| 214 | +// content for BOTH issue create and pr create". The comment commands above | ||
| 215 | +// already pin their halves; these pin the two CREATE halves — `pr create -b -` | ||
| 216 | +// is the exact command that once stored a literal "-" and discarded an 8.9 KB | ||
| 217 | +// description with a success line. Each test fails on a parser that uses the | ||
| 218 | +// flag value verbatim (sent == "-"), which is how the pr-create half regressed | ||
| 219 | +// before the shared resolver existed. | ||
| 220 | +func TestIssueCreateBodyDashReadsStdin(t *testing.T) { | ||
| 221 | + var sent string | ||
| 222 | + defer withCreateBodyServer(t, &sent, "/issues")() | ||
| 223 | + | ||
| 224 | + const text = "## Summary\n\nPins the portal gun back on the shelf.\n\n- one\n- two" | ||
| 225 | + if _, _, err := executeStdin(t, text+"\n", "issue", "create", "-R", "rick/portal", | ||
| 226 | + "-t", "the gun slips", "-b", "-"); err != nil { | ||
| 227 | + t.Fatalf("execute: %v", err) | ||
| 228 | + } | ||
| 229 | + if sent == "-" { | ||
| 230 | + t.Fatal(`issue create -b - posted the literal "-" — rickub/rickub#279 has regressed`) | ||
| 231 | + } | ||
| 232 | + if sent != text { | ||
| 233 | + t.Fatalf("posted body = %q, want the stdin text %q", sent, text) | ||
| 234 | + } | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +func TestPRCreateBodyDashReadsStdin(t *testing.T) { | ||
| 238 | + var sent string | ||
| 239 | + defer withCreateBodyServer(t, &sent, "/merge-requests")() | ||
| 240 | + | ||
| 241 | + const text = "## What\n\nA long description piped in from a file, the way MR bodies\nare actually written.\n" | ||
| 242 | + if _, _, err := executeStdin(t, text, "pr", "create", "-R", "rick/portal", | ||
| 243 | + "--base", "main", "--head", "fix/pin-the-gun", "-t", "fix: pin the gun", "-b", "-"); err != nil { | ||
| 244 | + t.Fatalf("execute: %v", err) | ||
| 245 | + } | ||
| 246 | + if sent == "-" { | ||
| 247 | + t.Fatal(`pr create -b - posted the literal "-" — rickub/rickub#279 has regressed`) | ||
| 248 | + } | ||
| 249 | + if sent != strings.TrimRight(text, "\r\n") { | ||
| 250 | + t.Fatalf("posted body = %q, want the stdin text %q", sent, text) | ||
| 251 | + } | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +// withCreateBodyServer stands up a fake that answers a create endpoint (path | ||
| 255 | +// suffix) and records the request JSON's body field, wiring the CLI at it. The | ||
| 256 | +// suffix keeps the two tests honest about WHICH create they exercised; any | ||
| 257 | +// other path fails the test rather than passing as an empty capture. | ||
| 258 | +func withCreateBodyServer(t *testing.T, got *string, suffix string) func() { | ||
| 259 | + t.Helper() | ||
| 260 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 261 | + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, suffix) { | ||
| 262 | + t.Errorf("create request used %s %s, want POST …%s", r.Method, r.URL.Path, suffix) | ||
| 263 | + } | ||
| 264 | + var body struct { | ||
| 265 | + Body string `json:"body"` | ||
| 266 | + } | ||
| 267 | + b, err := io.ReadAll(r.Body) | ||
| 268 | + if err != nil { | ||
| 269 | + t.Errorf("reading request body: %v", err) | ||
| 270 | + } | ||
| 271 | + if err := json.Unmarshal(b, &body); err != nil { | ||
| 272 | + t.Errorf("request body %q is not the JSON the CLI should send: %v", b, err) | ||
| 273 | + } | ||
| 274 | + *got = body.Body | ||
| 275 | + w.WriteHeader(http.StatusCreated) | ||
| 276 | + _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| 277 | + "number": 62, "title": "recorded", "state": "open", | ||
| 278 | + }) | ||
| 279 | + })) | ||
| 280 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 281 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 282 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 283 | + return srv.Close | ||
| 284 | +} | ||
| 285 | + | ||
| 212 | // TestBodyFlagHelpIsIdentical: both commands document the stdin behaviour in the | 286 | // TestBodyFlagHelpIsIdentical: both commands document the stdin behaviour in the |
| 213 | // same words, because they read the same constant. Issue #94's criterion 4. | 287 | // same words, because they read the same constant. Issue #94's criterion 4. |
| 214 | func TestBodyFlagHelpIsIdentical(t *testing.T) { | 288 | func TestBodyFlagHelpIsIdentical(t *testing.T) { |
modified
cmd/root.go +8 -0 | @@ -37,6 +37,14 @@ Authenticate once with "rickub auth login", then drive repos, merge requests, | ||
| 37 | 37 | CI runs, orgs, and code browsing from your terminal.`, |
| 38 | 38 | SilenceUsage: true, |
| 39 | 39 | SilenceErrors: true, |
| 40 | + // Every command starts with the (throttled, best-effort) staleness probe of | |
| 41 | + // cmd/update_check.go — a stale CLI describes an old product as current | |
| 42 | + // (rickub/rickub#305), and the notice has to appear on the commands people | |
| 43 | + // actually run, not only on `rickub version`. | |
| 44 | + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { | |
| 45 | + maybeWarnAboutStaleCLI(cmd) | |
| 46 | + return nil | |
| 47 | + }, | |
| 40 | 48 | } |
| 41 | 49 | |
| 42 | 50 | // Execute runs the root command. main() maps the returned error to an exit code. |
| @@ -37,6 +37,14 @@ Authenticate once with "rickub auth login", then drive repos, merge requests, | |||
| 37 | CI runs, orgs, and code browsing from your terminal.`, | 37 | CI runs, orgs, and code browsing from your terminal.`, |
| 38 | SilenceUsage: true, | 38 | SilenceUsage: true, |
| 39 | SilenceErrors: true, | 39 | SilenceErrors: true, |
| 40 | + // Every command starts with the (throttled, best-effort) staleness probe of | ||
| 41 | + // cmd/update_check.go — a stale CLI describes an old product as current | ||
| 42 | + // (rickub/rickub#305), and the notice has to appear on the commands people | ||
| 43 | + // actually run, not only on `rickub version`. | ||
| 44 | + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { | ||
| 45 | + maybeWarnAboutStaleCLI(cmd) | ||
| 46 | + return nil | ||
| 47 | + }, | ||
| 40 | } | 48 | } |
| 41 | 49 | ||
| 42 | // Execute runs the root command. main() maps the returned error to an exit code. | 50 | // Execute runs the root command. main() maps the returned error to an exit code. |
added
cmd/update_check.go +301 -0 | new file mode 100644 | ||
| @@ -0,0 +1,301 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "net/url" | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + "strconv" | |
| 11 | + "strings" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "rickub.com/rickub/cli/internal/config" | |
| 15 | + | |
| 16 | + "github.com/spf13/cobra" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// --- CLI staleness detection (rickub/rickub issue 305) ------------------------- | |
| 20 | +// | |
| 21 | +// A stale CLI does not fail loudly — it describes an OLD version of the product | |
| 22 | +// as if it were current, and everything downstream inherits that: issues filed | |
| 23 | +// against defects already fixed (#294, #279), agents concluding a shipped | |
| 24 | +// feature is missing, hours re-diagnosing what was diagnosed. It is also | |
| 25 | +// self-concealing: "does my CLI support X?" answers truthfully about the BINARY, | |
| 26 | +// which is the wrong question, and nothing prompts you to ask which build you | |
| 27 | +// are holding. | |
| 28 | +// | |
| 29 | +// The honest, minimal signal: the host the CLI already talks to names the newest | |
| 30 | +// rickub/cli release — GET /rickub/cli/releases/latest is a documented 302 whose | |
| 31 | +// Location carries the tag (the same redirect the install script resolves | |
| 32 | +// "latest" with; no token, no HTML scraping). When that release is strictly | |
| 33 | +// NEWER than the running binary, print a one-line notice naming both versions. | |
| 34 | +// | |
| 35 | +// The check is throttled to once per day per host (a stamp beside the config | |
| 36 | +// file), always best-effort — any failure is silent and never blocks the real | |
| 37 | +// command — and skipped entirely for dev builds (Version "dev"), which carry no | |
| 38 | +// release identity to compare. | |
| 39 | + | |
| 40 | +const ( | |
| 41 | + // updateCheckOwner/Repo: the CLI's own distribution repository, served by | |
| 42 | + // every host that mirrors it. A host without it 404s and the check stays | |
| 43 | + // quiet — nothing to compare, nothing to say. | |
| 44 | + updateCheckOwner = "rickub" | |
| 45 | + updateCheckRepo = "cli" | |
| 46 | + // updateCheckTimeout bounds the probe; it runs BEFORE the real command, so | |
| 47 | + // a slow host must not tax every invocation (once a day, this is the ceiling). | |
| 48 | + updateCheckTimeout = 2 * time.Second | |
| 49 | + // updateCheckInterval is how often the probe repeats per host. | |
| 50 | + updateCheckInterval = 24 * time.Hour | |
| 51 | +) | |
| 52 | + | |
| 53 | +// maybeWarnAboutStaleCLI is hooked into the root command's PersistentPreRun: | |
| 54 | +// every command (version included) gets the notice at most once a day. It never | |
| 55 | +// returns an error and never writes to stdout — the notice goes to stderr, so | |
| 56 | +// --json output stays parseable and pipes stay clean. | |
| 57 | +func maybeWarnAboutStaleCLI(cmd *cobra.Command) { | |
| 58 | + if Version == "dev" { | |
| 59 | + return // a build from source has no release identity to compare | |
| 60 | + } | |
| 61 | + cfg, err := loadConfig() | |
| 62 | + if err != nil { | |
| 63 | + return | |
| 64 | + } | |
| 65 | + host := config.ResolveHost(flagHost, cfg) | |
| 66 | + stampPath, err := updateStampPath(host) | |
| 67 | + if err != nil { | |
| 68 | + return | |
| 69 | + } | |
| 70 | + if stampIsFresh(stampPath) { | |
| 71 | + return | |
| 72 | + } | |
| 73 | + // An attempted check (whatever the outcome) suppresses the next for a day: | |
| 74 | + // a host that cannot answer today should not be probed on every command. | |
| 75 | + writeUpdateStamp(stampPath) | |
| 76 | + tag := resolveLatestCLITag(cmd.Context(), host) | |
| 77 | + if !newerThan(tag, Version) { | |
| 78 | + return | |
| 79 | + } | |
| 80 | + base := strings.TrimSuffix(config.NormalizeHost(host), "/") | |
| 81 | + fmt.Fprintf(cmd.ErrOrStderr(), | |
| 82 | + "note: this rickub CLI is %s but the newest release on %s is %s — some bugs you see may already be fixed; upgrade: %s/%s/%s/releases/%s\n", | |
| 83 | + Version, base, tag, base, updateCheckOwner, updateCheckRepo, tag) | |
| 84 | +} | |
| 85 | + | |
| 86 | +// resolveLatestCLITag asks host for the newest rickub/cli release tag via the | |
| 87 | +// documented /releases/latest redirect, returning "" on ANY failure (no | |
| 88 | +// redirect, no releases, unreachable host, unparsable Location). Redirects are | |
| 89 | +// not followed: the Location header is the answer. | |
| 90 | +func resolveLatestCLITag(ctx context.Context, host string) string { | |
| 91 | + base := strings.TrimSuffix(config.NormalizeHost(host), "/") | |
| 92 | + if base == "" { | |
| 93 | + return "" | |
| 94 | + } | |
| 95 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, | |
| 96 | + base+"/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest", nil) | |
| 97 | + if err != nil { | |
| 98 | + return "" | |
| 99 | + } | |
| 100 | + client := &http.Client{ | |
| 101 | + Timeout: updateCheckTimeout, | |
| 102 | + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { | |
| 103 | + return http.ErrUseLastResponse | |
| 104 | + }, | |
| 105 | + } | |
| 106 | + resp, err := client.Do(req) | |
| 107 | + if err != nil { | |
| 108 | + return "" | |
| 109 | + } | |
| 110 | + defer func() { _ = resp.Body.Close() }() | |
| 111 | + if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently { | |
| 112 | + return "" | |
| 113 | + } | |
| 114 | + return tagFromReleaseLocation(resp.Header.Get("Location")) | |
| 115 | +} | |
| 116 | + | |
| 117 | +// tagFromReleaseLocation extracts the release tag from a /releases/latest | |
| 118 | +// Location value: "/rickub/cli/releases/v0.2.0" (relative, what the host | |
| 119 | +// writes) or "https://host/rickub/cli/releases/v0.1.0-rc.9" (absolute). The tag | |
| 120 | +// is the whole tail after "/releases/" — tags may contain slashes, and the | |
| 121 | +// redirect builds the path from the tag verbatim. Anything else returns "". | |
| 122 | +func tagFromReleaseLocation(loc string) string { | |
| 123 | + if loc == "" { | |
| 124 | + return "" | |
| 125 | + } | |
| 126 | + u, err := url.Parse(loc) | |
| 127 | + if err != nil { | |
| 128 | + return "" | |
| 129 | + } | |
| 130 | + parts := strings.Split(strings.Trim(u.Path, "/"), "/") | |
| 131 | + const min = 4 // owner, repo, "releases", tag... | |
| 132 | + if len(parts) < min || parts[2] != "releases" { | |
| 133 | + return "" | |
| 134 | + } | |
| 135 | + tag := strings.Join(parts[3:], "/") | |
| 136 | + if tag == "" || !looksLikeTag(tag) { | |
| 137 | + return "" | |
| 138 | + } | |
| 139 | + decoded, err := url.PathUnescape(tag) | |
| 140 | + if err != nil { | |
| 141 | + return "" | |
| 142 | + } | |
| 143 | + return decoded | |
| 144 | +} | |
| 145 | + | |
| 146 | +// looksLikeTag guards the tail-is-the-tag reading against a Location pointing | |
| 147 | +// at a different page under /releases/ (the list page, "latest" itself, an | |
| 148 | +// asset download). A release tag names a version; it may carry slashes, but its | |
| 149 | +// first segment is never one of the reserved page names. | |
| 150 | +func looksLikeTag(tag string) bool { | |
| 151 | + switch strings.Split(tag, "/")[0] { | |
| 152 | + case "new", "latest", "download", "edit", "release-assets": | |
| 153 | + return false | |
| 154 | + } | |
| 155 | + return true | |
| 156 | +} | |
| 157 | + | |
| 158 | +// ---- version comparison ------------------------------------------------------- | |
| 159 | +// | |
| 160 | +// Release tags are v-prefixed semver ("v0.2.0", "v0.1.0-rc.11"); the stamped | |
| 161 | +// Version is bare ("0.2.0", "0.1.0-rc.11"). Comparison follows semver ordering: | |
| 162 | +// numeric major.minor.patch (missing components are 0), then prerelease — where | |
| 163 | +// a version WITHOUT a prerelease is newer than the same version WITH one, and | |
| 164 | +// prerelease identifiers compare numerically when both are numeric, otherwise | |
| 165 | +// byte-wise (semver spec rule 12, the parts rc.N needs). | |
| 166 | + | |
| 167 | +// newerThan reports whether latest is a strictly newer version than current. | |
| 168 | +// Anything unparsable is NOT newer: an exotic tag never produces a notice. | |
| 169 | +func newerThan(latest, current string) bool { | |
| 170 | + return compareVersions(latest, current) > 0 | |
| 171 | +} | |
| 172 | + | |
| 173 | +// compareVersions returns -1, 0 or 1 comparing two v-prefixed-or-bare semver | |
| 174 | +// strings. Two unparsable, or an unparsable against a parsable, compare equal | |
| 175 | +// (0) — the caller's contract is "strictly newer means warn", so unknowns never | |
| 176 | +// warn. | |
| 177 | +func compareVersions(a, b string) int { | |
| 178 | + ac, ap, aok := splitVersion(a) | |
| 179 | + bc, bp, bok := splitVersion(b) | |
| 180 | + if !aok || !bok { | |
| 181 | + return 0 | |
| 182 | + } | |
| 183 | + for i := range 3 { | |
| 184 | + if ac[i] != bc[i] { | |
| 185 | + if ac[i] < bc[i] { | |
| 186 | + return -1 | |
| 187 | + } | |
| 188 | + return 1 | |
| 189 | + } | |
| 190 | + } | |
| 191 | + // Same numbers: no prerelease wins over any prerelease. | |
| 192 | + if ap == "" && bp == "" { | |
| 193 | + return 0 | |
| 194 | + } | |
| 195 | + if ap == "" { | |
| 196 | + return 1 | |
| 197 | + } | |
| 198 | + if bp == "" { | |
| 199 | + return -1 | |
| 200 | + } | |
| 201 | + return comparePrerelease(ap, bp) | |
| 202 | +} | |
| 203 | + | |
| 204 | +// splitVersion parses "v1.2.3-rc.4" into ([3]int{1,2,3}, "rc.4"); ok is false | |
| 205 | +// for a version it does not understand. | |
| 206 | +func splitVersion(v string) (core [3]int, pre string, ok bool) { | |
| 207 | + v = strings.TrimPrefix(strings.TrimSpace(v), "v") | |
| 208 | + coreStr, pre, _ := strings.Cut(v, "-") | |
| 209 | + parts := strings.Split(coreStr, ".") | |
| 210 | + if len(parts) == 0 || len(parts) > 3 { | |
| 211 | + return [3]int{}, "", false | |
| 212 | + } | |
| 213 | + for i, p := range parts { | |
| 214 | + n, err := strconv.Atoi(p) | |
| 215 | + if err != nil || n < 0 || (len(p) > 1 && p[0] == '0') { | |
| 216 | + return [3]int{}, "", false // leading zeros / non-numeric: not semver | |
| 217 | + } | |
| 218 | + core[i] = n | |
| 219 | + } | |
| 220 | + return core, pre, true | |
| 221 | +} | |
| 222 | + | |
| 223 | +// comparePrerelease orders two dot-separated prerelease strings per semver: each | |
| 224 | +// identifier numerically when both are all-digits, else byte-wise; a shorter | |
| 225 | +// list is LOWER when every shared identifier is equal. | |
| 226 | +func comparePrerelease(a, b string) int { | |
| 227 | + as, bs := strings.Split(a, "."), strings.Split(b, ".") | |
| 228 | + for i := 0; i < len(as) && i < len(bs); i++ { | |
| 229 | + an, aerr := strconv.Atoi(as[i]) | |
| 230 | + bn, berr := strconv.Atoi(bs[i]) | |
| 231 | + if aerr == nil && berr == nil { | |
| 232 | + if an != bn { | |
| 233 | + if an < bn { | |
| 234 | + return -1 | |
| 235 | + } | |
| 236 | + return 1 | |
| 237 | + } | |
| 238 | + continue | |
| 239 | + } | |
| 240 | + if as[i] != bs[i] { | |
| 241 | + if as[i] < bs[i] { | |
| 242 | + return -1 | |
| 243 | + } | |
| 244 | + return 1 | |
| 245 | + } | |
| 246 | + } | |
| 247 | + switch { | |
| 248 | + case len(as) < len(bs): | |
| 249 | + return -1 | |
| 250 | + case len(as) > len(bs): | |
| 251 | + return 1 | |
| 252 | + } | |
| 253 | + return 0 | |
| 254 | +} | |
| 255 | + | |
| 256 | +// ---- the once-a-day stamp ----------------------------------------------------- | |
| 257 | + | |
| 258 | +// updateStampPath names the stamp file beside the config file, one per host: | |
| 259 | +// <configdir>/rickub/update-check/<normalized host>. The host is normalized and | |
| 260 | +// path-safe (slashes and colons folded to underscores) so two hosts never share | |
| 261 | +// a stamp. | |
| 262 | +func updateStampPath(host string) (string, error) { | |
| 263 | + cfgPath, err := config.Path() | |
| 264 | + if err != nil { | |
| 265 | + return "", err | |
| 266 | + } | |
| 267 | + key := config.NormalizeHost(host) | |
| 268 | + if key == "" { | |
| 269 | + key = "default" | |
| 270 | + } | |
| 271 | + key = strings.NewReplacer("/", "_", ":", "_").Replace(key) | |
| 272 | + return filepath.Join(filepath.Dir(cfgPath), "update-check", key), nil | |
| 273 | +} | |
| 274 | + | |
| 275 | +// stampIsFresh reports whether the stamp at path was written within the check | |
| 276 | +// interval. A missing or unreadable stamp is stale (check again); a future | |
| 277 | +// stamp (clock moved back) is treated as fresh so it cannot pinball the probe. | |
| 278 | +func stampIsFresh(path string) bool { | |
| 279 | + // #nosec G304 -- path is the CLI's own stamp under the config directory | |
| 280 | + // (config.Path + a sanitized host key), the same class as the config read | |
| 281 | + // in internal/config; it is never assembled from request or command input. | |
| 282 | + data, err := os.ReadFile(path) | |
| 283 | + if err != nil { | |
| 284 | + return false | |
| 285 | + } | |
| 286 | + stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) | |
| 287 | + if err != nil { | |
| 288 | + return false | |
| 289 | + } | |
| 290 | + return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now()) | |
| 291 | +} | |
| 292 | + | |
| 293 | +// writeUpdateStamp records a successful attempt, best-effort: a stamp that | |
| 294 | +// cannot be written simply means the check may repeat — never a user-visible | |
| 295 | +// error. | |
| 296 | +func writeUpdateStamp(path string) { | |
| 297 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 298 | + return | |
| 299 | + } | |
| 300 | + _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600) | |
| 301 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,301 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "net/http" | ||
| 7 | + "net/url" | ||
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 10 | + "strconv" | ||
| 11 | + "strings" | ||
| 12 | + "time" | ||
| 13 | + | ||
| 14 | + "rickub.com/rickub/cli/internal/config" | ||
| 15 | + | ||
| 16 | + "github.com/spf13/cobra" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// --- CLI staleness detection (rickub/rickub issue 305) ------------------------- | ||
| 20 | +// | ||
| 21 | +// A stale CLI does not fail loudly — it describes an OLD version of the product | ||
| 22 | +// as if it were current, and everything downstream inherits that: issues filed | ||
| 23 | +// against defects already fixed (#294, #279), agents concluding a shipped | ||
| 24 | +// feature is missing, hours re-diagnosing what was diagnosed. It is also | ||
| 25 | +// self-concealing: "does my CLI support X?" answers truthfully about the BINARY, | ||
| 26 | +// which is the wrong question, and nothing prompts you to ask which build you | ||
| 27 | +// are holding. | ||
| 28 | +// | ||
| 29 | +// The honest, minimal signal: the host the CLI already talks to names the newest | ||
| 30 | +// rickub/cli release — GET /rickub/cli/releases/latest is a documented 302 whose | ||
| 31 | +// Location carries the tag (the same redirect the install script resolves | ||
| 32 | +// "latest" with; no token, no HTML scraping). When that release is strictly | ||
| 33 | +// NEWER than the running binary, print a one-line notice naming both versions. | ||
| 34 | +// | ||
| 35 | +// The check is throttled to once per day per host (a stamp beside the config | ||
| 36 | +// file), always best-effort — any failure is silent and never blocks the real | ||
| 37 | +// command — and skipped entirely for dev builds (Version "dev"), which carry no | ||
| 38 | +// release identity to compare. | ||
| 39 | + | ||
| 40 | +const ( | ||
| 41 | + // updateCheckOwner/Repo: the CLI's own distribution repository, served by | ||
| 42 | + // every host that mirrors it. A host without it 404s and the check stays | ||
| 43 | + // quiet — nothing to compare, nothing to say. | ||
| 44 | + updateCheckOwner = "rickub" | ||
| 45 | + updateCheckRepo = "cli" | ||
| 46 | + // updateCheckTimeout bounds the probe; it runs BEFORE the real command, so | ||
| 47 | + // a slow host must not tax every invocation (once a day, this is the ceiling). | ||
| 48 | + updateCheckTimeout = 2 * time.Second | ||
| 49 | + // updateCheckInterval is how often the probe repeats per host. | ||
| 50 | + updateCheckInterval = 24 * time.Hour | ||
| 51 | +) | ||
| 52 | + | ||
| 53 | +// maybeWarnAboutStaleCLI is hooked into the root command's PersistentPreRun: | ||
| 54 | +// every command (version included) gets the notice at most once a day. It never | ||
| 55 | +// returns an error and never writes to stdout — the notice goes to stderr, so | ||
| 56 | +// --json output stays parseable and pipes stay clean. | ||
| 57 | +func maybeWarnAboutStaleCLI(cmd *cobra.Command) { | ||
| 58 | + if Version == "dev" { | ||
| 59 | + return // a build from source has no release identity to compare | ||
| 60 | + } | ||
| 61 | + cfg, err := loadConfig() | ||
| 62 | + if err != nil { | ||
| 63 | + return | ||
| 64 | + } | ||
| 65 | + host := config.ResolveHost(flagHost, cfg) | ||
| 66 | + stampPath, err := updateStampPath(host) | ||
| 67 | + if err != nil { | ||
| 68 | + return | ||
| 69 | + } | ||
| 70 | + if stampIsFresh(stampPath) { | ||
| 71 | + return | ||
| 72 | + } | ||
| 73 | + // An attempted check (whatever the outcome) suppresses the next for a day: | ||
| 74 | + // a host that cannot answer today should not be probed on every command. | ||
| 75 | + writeUpdateStamp(stampPath) | ||
| 76 | + tag := resolveLatestCLITag(cmd.Context(), host) | ||
| 77 | + if !newerThan(tag, Version) { | ||
| 78 | + return | ||
| 79 | + } | ||
| 80 | + base := strings.TrimSuffix(config.NormalizeHost(host), "/") | ||
| 81 | + fmt.Fprintf(cmd.ErrOrStderr(), | ||
| 82 | + "note: this rickub CLI is %s but the newest release on %s is %s — some bugs you see may already be fixed; upgrade: %s/%s/%s/releases/%s\n", | ||
| 83 | + Version, base, tag, base, updateCheckOwner, updateCheckRepo, tag) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// resolveLatestCLITag asks host for the newest rickub/cli release tag via the | ||
| 87 | +// documented /releases/latest redirect, returning "" on ANY failure (no | ||
| 88 | +// redirect, no releases, unreachable host, unparsable Location). Redirects are | ||
| 89 | +// not followed: the Location header is the answer. | ||
| 90 | +func resolveLatestCLITag(ctx context.Context, host string) string { | ||
| 91 | + base := strings.TrimSuffix(config.NormalizeHost(host), "/") | ||
| 92 | + if base == "" { | ||
| 93 | + return "" | ||
| 94 | + } | ||
| 95 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, | ||
| 96 | + base+"/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest", nil) | ||
| 97 | + if err != nil { | ||
| 98 | + return "" | ||
| 99 | + } | ||
| 100 | + client := &http.Client{ | ||
| 101 | + Timeout: updateCheckTimeout, | ||
| 102 | + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { | ||
| 103 | + return http.ErrUseLastResponse | ||
| 104 | + }, | ||
| 105 | + } | ||
| 106 | + resp, err := client.Do(req) | ||
| 107 | + if err != nil { | ||
| 108 | + return "" | ||
| 109 | + } | ||
| 110 | + defer func() { _ = resp.Body.Close() }() | ||
| 111 | + if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently { | ||
| 112 | + return "" | ||
| 113 | + } | ||
| 114 | + return tagFromReleaseLocation(resp.Header.Get("Location")) | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +// tagFromReleaseLocation extracts the release tag from a /releases/latest | ||
| 118 | +// Location value: "/rickub/cli/releases/v0.2.0" (relative, what the host | ||
| 119 | +// writes) or "https://host/rickub/cli/releases/v0.1.0-rc.9" (absolute). The tag | ||
| 120 | +// is the whole tail after "/releases/" — tags may contain slashes, and the | ||
| 121 | +// redirect builds the path from the tag verbatim. Anything else returns "". | ||
| 122 | +func tagFromReleaseLocation(loc string) string { | ||
| 123 | + if loc == "" { | ||
| 124 | + return "" | ||
| 125 | + } | ||
| 126 | + u, err := url.Parse(loc) | ||
| 127 | + if err != nil { | ||
| 128 | + return "" | ||
| 129 | + } | ||
| 130 | + parts := strings.Split(strings.Trim(u.Path, "/"), "/") | ||
| 131 | + const min = 4 // owner, repo, "releases", tag... | ||
| 132 | + if len(parts) < min || parts[2] != "releases" { | ||
| 133 | + return "" | ||
| 134 | + } | ||
| 135 | + tag := strings.Join(parts[3:], "/") | ||
| 136 | + if tag == "" || !looksLikeTag(tag) { | ||
| 137 | + return "" | ||
| 138 | + } | ||
| 139 | + decoded, err := url.PathUnescape(tag) | ||
| 140 | + if err != nil { | ||
| 141 | + return "" | ||
| 142 | + } | ||
| 143 | + return decoded | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// looksLikeTag guards the tail-is-the-tag reading against a Location pointing | ||
| 147 | +// at a different page under /releases/ (the list page, "latest" itself, an | ||
| 148 | +// asset download). A release tag names a version; it may carry slashes, but its | ||
| 149 | +// first segment is never one of the reserved page names. | ||
| 150 | +func looksLikeTag(tag string) bool { | ||
| 151 | + switch strings.Split(tag, "/")[0] { | ||
| 152 | + case "new", "latest", "download", "edit", "release-assets": | ||
| 153 | + return false | ||
| 154 | + } | ||
| 155 | + return true | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +// ---- version comparison ------------------------------------------------------- | ||
| 159 | +// | ||
| 160 | +// Release tags are v-prefixed semver ("v0.2.0", "v0.1.0-rc.11"); the stamped | ||
| 161 | +// Version is bare ("0.2.0", "0.1.0-rc.11"). Comparison follows semver ordering: | ||
| 162 | +// numeric major.minor.patch (missing components are 0), then prerelease — where | ||
| 163 | +// a version WITHOUT a prerelease is newer than the same version WITH one, and | ||
| 164 | +// prerelease identifiers compare numerically when both are numeric, otherwise | ||
| 165 | +// byte-wise (semver spec rule 12, the parts rc.N needs). | ||
| 166 | + | ||
| 167 | +// newerThan reports whether latest is a strictly newer version than current. | ||
| 168 | +// Anything unparsable is NOT newer: an exotic tag never produces a notice. | ||
| 169 | +func newerThan(latest, current string) bool { | ||
| 170 | + return compareVersions(latest, current) > 0 | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +// compareVersions returns -1, 0 or 1 comparing two v-prefixed-or-bare semver | ||
| 174 | +// strings. Two unparsable, or an unparsable against a parsable, compare equal | ||
| 175 | +// (0) — the caller's contract is "strictly newer means warn", so unknowns never | ||
| 176 | +// warn. | ||
| 177 | +func compareVersions(a, b string) int { | ||
| 178 | + ac, ap, aok := splitVersion(a) | ||
| 179 | + bc, bp, bok := splitVersion(b) | ||
| 180 | + if !aok || !bok { | ||
| 181 | + return 0 | ||
| 182 | + } | ||
| 183 | + for i := range 3 { | ||
| 184 | + if ac[i] != bc[i] { | ||
| 185 | + if ac[i] < bc[i] { | ||
| 186 | + return -1 | ||
| 187 | + } | ||
| 188 | + return 1 | ||
| 189 | + } | ||
| 190 | + } | ||
| 191 | + // Same numbers: no prerelease wins over any prerelease. | ||
| 192 | + if ap == "" && bp == "" { | ||
| 193 | + return 0 | ||
| 194 | + } | ||
| 195 | + if ap == "" { | ||
| 196 | + return 1 | ||
| 197 | + } | ||
| 198 | + if bp == "" { | ||
| 199 | + return -1 | ||
| 200 | + } | ||
| 201 | + return comparePrerelease(ap, bp) | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// splitVersion parses "v1.2.3-rc.4" into ([3]int{1,2,3}, "rc.4"); ok is false | ||
| 205 | +// for a version it does not understand. | ||
| 206 | +func splitVersion(v string) (core [3]int, pre string, ok bool) { | ||
| 207 | + v = strings.TrimPrefix(strings.TrimSpace(v), "v") | ||
| 208 | + coreStr, pre, _ := strings.Cut(v, "-") | ||
| 209 | + parts := strings.Split(coreStr, ".") | ||
| 210 | + if len(parts) == 0 || len(parts) > 3 { | ||
| 211 | + return [3]int{}, "", false | ||
| 212 | + } | ||
| 213 | + for i, p := range parts { | ||
| 214 | + n, err := strconv.Atoi(p) | ||
| 215 | + if err != nil || n < 0 || (len(p) > 1 && p[0] == '0') { | ||
| 216 | + return [3]int{}, "", false // leading zeros / non-numeric: not semver | ||
| 217 | + } | ||
| 218 | + core[i] = n | ||
| 219 | + } | ||
| 220 | + return core, pre, true | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +// comparePrerelease orders two dot-separated prerelease strings per semver: each | ||
| 224 | +// identifier numerically when both are all-digits, else byte-wise; a shorter | ||
| 225 | +// list is LOWER when every shared identifier is equal. | ||
| 226 | +func comparePrerelease(a, b string) int { | ||
| 227 | + as, bs := strings.Split(a, "."), strings.Split(b, ".") | ||
| 228 | + for i := 0; i < len(as) && i < len(bs); i++ { | ||
| 229 | + an, aerr := strconv.Atoi(as[i]) | ||
| 230 | + bn, berr := strconv.Atoi(bs[i]) | ||
| 231 | + if aerr == nil && berr == nil { | ||
| 232 | + if an != bn { | ||
| 233 | + if an < bn { | ||
| 234 | + return -1 | ||
| 235 | + } | ||
| 236 | + return 1 | ||
| 237 | + } | ||
| 238 | + continue | ||
| 239 | + } | ||
| 240 | + if as[i] != bs[i] { | ||
| 241 | + if as[i] < bs[i] { | ||
| 242 | + return -1 | ||
| 243 | + } | ||
| 244 | + return 1 | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + switch { | ||
| 248 | + case len(as) < len(bs): | ||
| 249 | + return -1 | ||
| 250 | + case len(as) > len(bs): | ||
| 251 | + return 1 | ||
| 252 | + } | ||
| 253 | + return 0 | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// ---- the once-a-day stamp ----------------------------------------------------- | ||
| 257 | + | ||
| 258 | +// updateStampPath names the stamp file beside the config file, one per host: | ||
| 259 | +// <configdir>/rickub/update-check/<normalized host>. The host is normalized and | ||
| 260 | +// path-safe (slashes and colons folded to underscores) so two hosts never share | ||
| 261 | +// a stamp. | ||
| 262 | +func updateStampPath(host string) (string, error) { | ||
| 263 | + cfgPath, err := config.Path() | ||
| 264 | + if err != nil { | ||
| 265 | + return "", err | ||
| 266 | + } | ||
| 267 | + key := config.NormalizeHost(host) | ||
| 268 | + if key == "" { | ||
| 269 | + key = "default" | ||
| 270 | + } | ||
| 271 | + key = strings.NewReplacer("/", "_", ":", "_").Replace(key) | ||
| 272 | + return filepath.Join(filepath.Dir(cfgPath), "update-check", key), nil | ||
| 273 | +} | ||
| 274 | + | ||
| 275 | +// stampIsFresh reports whether the stamp at path was written within the check | ||
| 276 | +// interval. A missing or unreadable stamp is stale (check again); a future | ||
| 277 | +// stamp (clock moved back) is treated as fresh so it cannot pinball the probe. | ||
| 278 | +func stampIsFresh(path string) bool { | ||
| 279 | + // #nosec G304 -- path is the CLI's own stamp under the config directory | ||
| 280 | + // (config.Path + a sanitized host key), the same class as the config read | ||
| 281 | + // in internal/config; it is never assembled from request or command input. | ||
| 282 | + data, err := os.ReadFile(path) | ||
| 283 | + if err != nil { | ||
| 284 | + return false | ||
| 285 | + } | ||
| 286 | + stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) | ||
| 287 | + if err != nil { | ||
| 288 | + return false | ||
| 289 | + } | ||
| 290 | + return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now()) | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +// writeUpdateStamp records a successful attempt, best-effort: a stamp that | ||
| 294 | +// cannot be written simply means the check may repeat — never a user-visible | ||
| 295 | +// error. | ||
| 296 | +func writeUpdateStamp(path string) { | ||
| 297 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 298 | + return | ||
| 299 | + } | ||
| 300 | + _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600) | ||
| 301 | +} | ||
added
cmd/update_check_test.go +372 -0 | new file mode 100644 | ||
| @@ -0,0 +1,372 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "net/http/httptest" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "rickub.com/rickub/cli/internal/config" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// Tests for the CLI staleness notice (rickub/rickub#305). | |
| 16 | +// | |
| 17 | +// The notice is the one-line signal that a binary older than the newest release | |
| 18 | +// on its host keeps describing an old product as current — the failure mode that | |
| 19 | +// manufactured the false bug reports #294 and #279. Each behavior test below | |
| 20 | +// fails on the pre-change code (no notice, no probe) and on the two plausible | |
| 21 | +// regressions: the comparator flipping (nagging on fresh binaries, silence on | |
| 22 | +// stale ones) and the probe losing its throttle (a request on every command). | |
| 23 | + | |
| 24 | +// stampAge rewinds the stamp for host by d, so a test can simulate "checked | |
| 25 | +// yesterday" without sleeping. It fails the test when no stamp exists — a probe | |
| 26 | +// that never wrote its stamp is itself a bug (it would hammer the host). | |
| 27 | +func stampAge(t *testing.T, host string, d time.Duration) { | |
| 28 | + t.Helper() | |
| 29 | + path, err := updateStampPath(host) | |
| 30 | + if err != nil { | |
| 31 | + t.Fatalf("stamp path: %v", err) | |
| 32 | + } | |
| 33 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-d).Format(time.RFC3339)), 0o600); err != nil { | |
| 34 | + t.Fatalf("rewind stamp: %v", err) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +// updateServer stands up a host whose /rickub/cli/releases/latest redirects to | |
| 39 | +// tag, counting probes. It answers nothing else — a probe that wanders off its | |
| 40 | +// endpoint shows up as a failed assertion or an empty tag, never as a pass. | |
| 41 | +func updateServer(t *testing.T, tag string, probes *int) *httptest.Server { | |
| 42 | + t.Helper() | |
| 43 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 44 | + if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" { | |
| 45 | + if probes != nil { | |
| 46 | + *probes++ | |
| 47 | + } | |
| 48 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/"+tag, http.StatusFound) | |
| 49 | + return | |
| 50 | + } | |
| 51 | + http.NotFound(w, r) | |
| 52 | + })) | |
| 53 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 54 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 55 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 56 | + return srv | |
| 57 | +} | |
| 58 | + | |
| 59 | +// withVersion pins cmd.Version for one test and restores it after. | |
| 60 | +func withVersion(t *testing.T, v string) { | |
| 61 | + t.Helper() | |
| 62 | + old := Version | |
| 63 | + Version = v | |
| 64 | + t.Cleanup(func() { Version = old }) | |
| 65 | +} | |
| 66 | + | |
| 67 | +// TestStaleCLINoticeNamesBothVersions is the acceptance core of rickub/rickub#305: | |
| 68 | +// a CLI older than the newest release on its host prints a visible one-line | |
| 69 | +// warning naming BOTH versions, on stderr (stdout stays machine-parseable). | |
| 70 | +func TestStaleCLINoticeNamesBothVersions(t *testing.T) { | |
| 71 | + withVersion(t, "0.1.0") | |
| 72 | + srv := updateServer(t, "v0.2.0", nil) | |
| 73 | + defer srv.Close() | |
| 74 | + | |
| 75 | + out, errOut, err := execute(t, "version") | |
| 76 | + if err != nil { | |
| 77 | + t.Fatalf("execute: %v", err) | |
| 78 | + } | |
| 79 | + if !strings.Contains(errOut, "0.1.0") || !strings.Contains(errOut, "v0.2.0") { | |
| 80 | + t.Fatalf("notice must name both versions (running 0.1.0, available v0.2.0); stderr = %q", errOut) | |
| 81 | + } | |
| 82 | + if !strings.Contains(errOut, "upgrade") { | |
| 83 | + t.Fatalf("notice must say how to move on; stderr = %q", errOut) | |
| 84 | + } | |
| 85 | + if !strings.Contains(out, "rickub 0.1.0") { | |
| 86 | + t.Fatalf("stdout must keep the plain version line, got %q", out) | |
| 87 | + } | |
| 88 | + if strings.Contains(out, "note:") { | |
| 89 | + t.Fatalf("notice leaked into stdout: %q", out) | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +// TestFreshCLIIsQuiet: a binary at (or past) the newest release prints nothing — | |
| 94 | +// the notice is a staleness signal, not a check-in. | |
| 95 | +func TestFreshCLIIsQuiet(t *testing.T) { | |
| 96 | + withVersion(t, "0.2.0") | |
| 97 | + srv := updateServer(t, "v0.2.0", nil) | |
| 98 | + defer srv.Close() | |
| 99 | + | |
| 100 | + _, errOut, err := execute(t, "version") | |
| 101 | + if err != nil { | |
| 102 | + t.Fatalf("execute: %v", err) | |
| 103 | + } | |
| 104 | + if strings.Contains(errOut, "note:") { | |
| 105 | + t.Fatalf("fresh binary was nagged: %q", errOut) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +// TestRCOfCurrentVersionIsNotNewer: the host's "latest" includes prereleases | |
| 110 | +// (most-recently-created), and v0.2.0-rc.7 is NOT newer than a running 0.2.0 — | |
| 111 | +// semver orders the prerelease below its release. Nagging every stable user | |
| 112 | +// about each rc would teach people to ignore the notice. | |
| 113 | +func TestRCOfCurrentVersionIsNotNewer(t *testing.T) { | |
| 114 | + withVersion(t, "0.2.0") | |
| 115 | + srv := updateServer(t, "v0.2.0-rc.7", nil) | |
| 116 | + defer srv.Close() | |
| 117 | + | |
| 118 | + _, errOut, err := execute(t, "version") | |
| 119 | + if err != nil { | |
| 120 | + t.Fatalf("execute: %v", err) | |
| 121 | + } | |
| 122 | + if strings.Contains(errOut, "note:") { | |
| 123 | + t.Fatalf("rc-of-running-version produced a notice: %q", errOut) | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +// TestProbeIsThrottledPerHost: two commands in one day issue ONE probe; a day | |
| 128 | +// later the probe repeats. The stamp is what keeps the check off every command — | |
| 129 | +// without it the CLI adds a network round-trip to every invocation. | |
| 130 | +func TestProbeIsThrottledPerHost(t *testing.T) { | |
| 131 | + withVersion(t, "0.1.0") | |
| 132 | + var probes int | |
| 133 | + srv := updateServer(t, "v0.2.0", &probes) | |
| 134 | + defer srv.Close() | |
| 135 | + | |
| 136 | + if _, _, err := execute(t, "version"); err != nil { | |
| 137 | + t.Fatalf("execute: %v", err) | |
| 138 | + } | |
| 139 | + if _, _, err := execute(t, "version"); err != nil { | |
| 140 | + t.Fatalf("execute: %v", err) | |
| 141 | + } | |
| 142 | + if probes != 1 { | |
| 143 | + t.Fatalf("probes = %d after two same-day commands, want 1", probes) | |
| 144 | + } | |
| 145 | + stampAge(t, srv.URL, 25*time.Hour) // yesterday's check has aged out | |
| 146 | + if _, _, err := execute(t, "version"); err != nil { | |
| 147 | + t.Fatalf("execute: %v", err) | |
| 148 | + } | |
| 149 | + if probes != 2 { | |
| 150 | + t.Fatalf("probes = %d after the stamp aged out, want 2", probes) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +// TestDevBuildSkipsTheProbe: Version "dev" (a build from source, every `go | |
| 155 | +// test`/`go run`) never probes — it has no release identity, and the check must | |
| 156 | +// not add requests to test runs and local development. | |
| 157 | +func TestDevBuildSkipsTheProbe(t *testing.T) { | |
| 158 | + withVersion(t, "dev") | |
| 159 | + var probes int | |
| 160 | + srv := updateServer(t, "v9.9.9", &probes) | |
| 161 | + defer srv.Close() | |
| 162 | + | |
| 163 | + if _, _, err := execute(t, "version"); err != nil { | |
| 164 | + t.Fatalf("execute: %v", err) | |
| 165 | + } | |
| 166 | + if probes != 0 { | |
| 167 | + t.Fatalf("dev build probed the host %d times, want 0", probes) | |
| 168 | + } | |
| 169 | +} | |
| 170 | + | |
| 171 | +// TestProbeFailuresAreSilent: a host without the CLI repo (404), a non-redirect | |
| 172 | +// answer, a Location that is not a release tag, and an unreachable host each | |
| 173 | +// leave the command succeeding with no notice — best-effort means a failed check | |
| 174 | +// never becomes the user's problem. | |
| 175 | +func TestProbeFailuresAreSilent(t *testing.T) { | |
| 176 | + withVersion(t, "0.1.0") | |
| 177 | + | |
| 178 | + cases := []struct { | |
| 179 | + name string | |
| 180 | + handler http.HandlerFunc | |
| 181 | + }{ | |
| 182 | + {"no releases on host", func(w http.ResponseWriter, r *http.Request) { | |
| 183 | + http.NotFound(w, r) | |
| 184 | + }}, | |
| 185 | + {"not a redirect", func(w http.ResponseWriter, _ *http.Request) { | |
| 186 | + _, _ = w.Write([]byte(`{"ok":true}`)) | |
| 187 | + }}, | |
| 188 | + {"location is the releases list page", func(w http.ResponseWriter, r *http.Request) { | |
| 189 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases", http.StatusFound) | |
| 190 | + }}, | |
| 191 | + {"location names a non-version tag", func(w http.ResponseWriter, r *http.Request) { | |
| 192 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/runner-abc123", http.StatusFound) | |
| 193 | + }}, | |
| 194 | + } | |
| 195 | + for _, tc := range cases { | |
| 196 | + t.Run(tc.name, func(t *testing.T) { | |
| 197 | + srv := httptest.NewServer(tc.handler) | |
| 198 | + defer srv.Close() | |
| 199 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 200 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 201 | + | |
| 202 | + _, errOut, err := execute(t, "version") | |
| 203 | + if err != nil { | |
| 204 | + t.Fatalf("execute: %v", err) | |
| 205 | + } | |
| 206 | + if strings.Contains(errOut, "note:") { | |
| 207 | + t.Fatalf("failed probe produced a notice: %q", errOut) | |
| 208 | + } | |
| 209 | + }) | |
| 210 | + } | |
| 211 | + | |
| 212 | + // An unreachable host: point at a port that is closed. | |
| 213 | + t.Run("unreachable host", func(t *testing.T) { | |
| 214 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 215 | + t.Setenv("RICKUB_HOST", "http://127.0.0.1:1") | |
| 216 | + _, errOut, err := execute(t, "version") | |
| 217 | + if err != nil { | |
| 218 | + t.Fatalf("execute: %v", err) | |
| 219 | + } | |
| 220 | + if strings.Contains(errOut, "note:") { | |
| 221 | + t.Fatalf("unreachable host produced a notice: %q", errOut) | |
| 222 | + } | |
| 223 | + }) | |
| 224 | +} | |
| 225 | + | |
| 226 | +// TestStampIsPerHost: a stamp for one host must not silence the check against | |
| 227 | +// another — switching --host (or RICKUB_HOST) re-checks the host you actually | |
| 228 | +// asked about. | |
| 229 | +func TestStampIsPerHost(t *testing.T) { | |
| 230 | + withVersion(t, "0.1.0") | |
| 231 | + var probesA, probesB int | |
| 232 | + srvA := updateServer(t, "v0.2.0", &probesA) | |
| 233 | + defer srvA.Close() | |
| 234 | + if _, _, err := execute(t, "version"); err != nil { | |
| 235 | + t.Fatalf("execute: %v", err) | |
| 236 | + } | |
| 237 | + if probesA != 1 { | |
| 238 | + t.Fatalf("probesA = %d, want 1", probesA) | |
| 239 | + } | |
| 240 | + | |
| 241 | + srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 242 | + if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" { | |
| 243 | + probesB++ | |
| 244 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/v0.3.0", http.StatusFound) | |
| 245 | + return | |
| 246 | + } | |
| 247 | + http.NotFound(w, r) | |
| 248 | + })) | |
| 249 | + defer srvB.Close() | |
| 250 | + | |
| 251 | + _, errOut, err := execute(t, "--host", srvB.URL, "version") | |
| 252 | + if err != nil { | |
| 253 | + t.Fatalf("execute: %v", err) | |
| 254 | + } | |
| 255 | + if probesB != 1 { | |
| 256 | + t.Fatalf("probesB = %d, want 1 (host B must not inherit host A's stamp)", probesB) | |
| 257 | + } | |
| 258 | + if !strings.Contains(errOut, "v0.3.0") { | |
| 259 | + t.Fatalf("notice must reflect host B's release; stderr = %q", errOut) | |
| 260 | + } | |
| 261 | +} | |
| 262 | + | |
| 263 | +// TestCompareVersions pins the ordering rules the notice depends on. | |
| 264 | +func TestCompareVersions(t *testing.T) { | |
| 265 | + cases := []struct { | |
| 266 | + a, b string | |
| 267 | + want int | |
| 268 | + }{ | |
| 269 | + {"v0.2.0", "0.1.0", 1}, | |
| 270 | + {"0.1.0", "v0.2.0", -1}, | |
| 271 | + {"v0.2.0", "v0.2.0", 0}, | |
| 272 | + {"0.2", "0.1.9", 1}, // missing components are zero | |
| 273 | + {"0.2.0", "0.2.0-rc.1", 1}, // release > its prerelease | |
| 274 | + {"0.2.0-rc.11", "0.2.0-rc.9", 1}, // numeric identifiers, not lexical | |
| 275 | + {"0.2.0-rc.2", "0.2.0-rc.2", 0}, // | |
| 276 | + {"0.2.0-rc.1", "0.2.0-beta", 1}, // rc > beta byte-wise | |
| 277 | + {"0.2.0-rc.1", "0.2.0-rc.1.1", -1}, // | |
| 278 | + {"runner-abc123", "0.1.0", 0}, // unparsable never compares | |
| 279 | + {"0.1.0", "runner-abc123", 0}, // | |
| 280 | + {"", "0.1.0", 0}, // | |
| 281 | + } | |
| 282 | + for _, tc := range cases { | |
| 283 | + if got := compareVersions(tc.a, tc.b); got != tc.want { | |
| 284 | + t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) | |
| 285 | + } | |
| 286 | + } | |
| 287 | +} | |
| 288 | + | |
| 289 | +// TestTagFromReleaseLocation pins the Location shapes the host writes and the | |
| 290 | +// ones it must not be confused by. | |
| 291 | +func TestTagFromReleaseLocation(t *testing.T) { | |
| 292 | + cases := []struct { | |
| 293 | + loc string | |
| 294 | + want string | |
| 295 | + }{ | |
| 296 | + {"/rickub/cli/releases/v0.2.0", "v0.2.0"}, | |
| 297 | + {"https://host.example/rickub/cli/releases/v0.1.0-rc.9", "v0.1.0-rc.9"}, | |
| 298 | + {"/rickub/cli/releases/latest", ""}, // redirect to itself | |
| 299 | + {"/rickub/cli/releases", ""}, // the list page | |
| 300 | + {"/rickub/cli/releases/download/v0.2.0/rickub_linux_amd64.tar.gz", ""}, | |
| 301 | + {"/somewhere/else entirely", ""}, | |
| 302 | + {"", ""}, | |
| 303 | + } | |
| 304 | + for _, tc := range cases { | |
| 305 | + if got := tagFromReleaseLocation(tc.loc); got != tc.want { | |
| 306 | + t.Errorf("tagFromReleaseLocation(%q) = %q, want %q", tc.loc, got, tc.want) | |
| 307 | + } | |
| 308 | + } | |
| 309 | +} | |
| 310 | + | |
| 311 | +// TestUpdateStampPathIsPerHostAndSane: the stamp lands in its own directory | |
| 312 | +// beside the config file, with a path-safe host key — two hosts, two stamps, no | |
| 313 | +// clobbering, and never inside the config file itself. | |
| 314 | +func TestUpdateStampPathIsPerHostAndSane(t *testing.T) { | |
| 315 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 316 | + a, err := updateStampPath("https://rickub.com") | |
| 317 | + if err != nil { | |
| 318 | + t.Fatalf("stamp path: %v", err) | |
| 319 | + } | |
| 320 | + b, err := updateStampPath("https://dev.rickub.com/") | |
| 321 | + if err != nil { | |
| 322 | + t.Fatalf("stamp path: %v", err) | |
| 323 | + } | |
| 324 | + if a == b { | |
| 325 | + t.Fatalf("two hosts share a stamp: %q", a) | |
| 326 | + } | |
| 327 | + cfgPath, err := config.Path() | |
| 328 | + if err != nil { | |
| 329 | + t.Fatalf("config path: %v", err) | |
| 330 | + } | |
| 331 | + if a == cfgPath || filepath.Dir(a) == cfgPath { | |
| 332 | + t.Fatalf("stamp %q must not be (or sit inside) the config file %q", a, cfgPath) | |
| 333 | + } | |
| 334 | + if base := filepath.Base(a); !strings.HasSuffix(base, "rickub.com") { | |
| 335 | + t.Errorf("stamp base %q loses the host identity", base) | |
| 336 | + } | |
| 337 | + if dir := filepath.Dir(a); !strings.HasSuffix(filepath.Base(dir), "update-check") { | |
| 338 | + t.Errorf("stamp directory %q is not the dedicated update-check dir", dir) | |
| 339 | + } | |
| 340 | +} | |
| 341 | + | |
| 342 | +// TestStampFreshnessWindow pins the window: fresh inside the interval, stale | |
| 343 | +// after it, and a stamp in the future (clock jumped back) reads fresh so the | |
| 344 | +// probe cannot rapid-fire. | |
| 345 | +func TestStampFreshnessWindow(t *testing.T) { | |
| 346 | + path := filepath.Join(t.TempDir(), "stamp") | |
| 347 | + writeUpdateStamp(path) | |
| 348 | + if !stampIsFresh(path) { | |
| 349 | + t.Fatal("a just-written stamp must be fresh") | |
| 350 | + } | |
| 351 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-updateCheckInterval-time.Hour).Format(time.RFC3339)), 0o600); err != nil { | |
| 352 | + t.Fatalf("age stamp: %v", err) | |
| 353 | + } | |
| 354 | + if stampIsFresh(path) { | |
| 355 | + t.Fatal("a stamp older than the interval must be stale") | |
| 356 | + } | |
| 357 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(time.Hour).Format(time.RFC3339)), 0o600); err != nil { | |
| 358 | + t.Fatalf("future stamp: %v", err) | |
| 359 | + } | |
| 360 | + if !stampIsFresh(path) { | |
| 361 | + t.Fatal("a future stamp (clock moved back) must read fresh, not pinball the probe") | |
| 362 | + } | |
| 363 | + if stampIsFresh(filepath.Join(filepath.Dir(path), "absent")) { | |
| 364 | + t.Fatal("an absent stamp must read stale") | |
| 365 | + } | |
| 366 | + if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil { | |
| 367 | + t.Fatalf("garbage stamp: %v", err) | |
| 368 | + } | |
| 369 | + if stampIsFresh(path) { | |
| 370 | + t.Fatal("an unparsable stamp must read stale") | |
| 371 | + } | |
| 372 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,372 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + "net/http/httptest" | ||
| 6 | + "os" | ||
| 7 | + "path/filepath" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "rickub.com/rickub/cli/internal/config" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// Tests for the CLI staleness notice (rickub/rickub#305). | ||
| 16 | +// | ||
| 17 | +// The notice is the one-line signal that a binary older than the newest release | ||
| 18 | +// on its host keeps describing an old product as current — the failure mode that | ||
| 19 | +// manufactured the false bug reports #294 and #279. Each behavior test below | ||
| 20 | +// fails on the pre-change code (no notice, no probe) and on the two plausible | ||
| 21 | +// regressions: the comparator flipping (nagging on fresh binaries, silence on | ||
| 22 | +// stale ones) and the probe losing its throttle (a request on every command). | ||
| 23 | + | ||
| 24 | +// stampAge rewinds the stamp for host by d, so a test can simulate "checked | ||
| 25 | +// yesterday" without sleeping. It fails the test when no stamp exists — a probe | ||
| 26 | +// that never wrote its stamp is itself a bug (it would hammer the host). | ||
| 27 | +func stampAge(t *testing.T, host string, d time.Duration) { | ||
| 28 | + t.Helper() | ||
| 29 | + path, err := updateStampPath(host) | ||
| 30 | + if err != nil { | ||
| 31 | + t.Fatalf("stamp path: %v", err) | ||
| 32 | + } | ||
| 33 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-d).Format(time.RFC3339)), 0o600); err != nil { | ||
| 34 | + t.Fatalf("rewind stamp: %v", err) | ||
| 35 | + } | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// updateServer stands up a host whose /rickub/cli/releases/latest redirects to | ||
| 39 | +// tag, counting probes. It answers nothing else — a probe that wanders off its | ||
| 40 | +// endpoint shows up as a failed assertion or an empty tag, never as a pass. | ||
| 41 | +func updateServer(t *testing.T, tag string, probes *int) *httptest.Server { | ||
| 42 | + t.Helper() | ||
| 43 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 44 | + if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" { | ||
| 45 | + if probes != nil { | ||
| 46 | + *probes++ | ||
| 47 | + } | ||
| 48 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/"+tag, http.StatusFound) | ||
| 49 | + return | ||
| 50 | + } | ||
| 51 | + http.NotFound(w, r) | ||
| 52 | + })) | ||
| 53 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 54 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 55 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 56 | + return srv | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// withVersion pins cmd.Version for one test and restores it after. | ||
| 60 | +func withVersion(t *testing.T, v string) { | ||
| 61 | + t.Helper() | ||
| 62 | + old := Version | ||
| 63 | + Version = v | ||
| 64 | + t.Cleanup(func() { Version = old }) | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +// TestStaleCLINoticeNamesBothVersions is the acceptance core of rickub/rickub#305: | ||
| 68 | +// a CLI older than the newest release on its host prints a visible one-line | ||
| 69 | +// warning naming BOTH versions, on stderr (stdout stays machine-parseable). | ||
| 70 | +func TestStaleCLINoticeNamesBothVersions(t *testing.T) { | ||
| 71 | + withVersion(t, "0.1.0") | ||
| 72 | + srv := updateServer(t, "v0.2.0", nil) | ||
| 73 | + defer srv.Close() | ||
| 74 | + | ||
| 75 | + out, errOut, err := execute(t, "version") | ||
| 76 | + if err != nil { | ||
| 77 | + t.Fatalf("execute: %v", err) | ||
| 78 | + } | ||
| 79 | + if !strings.Contains(errOut, "0.1.0") || !strings.Contains(errOut, "v0.2.0") { | ||
| 80 | + t.Fatalf("notice must name both versions (running 0.1.0, available v0.2.0); stderr = %q", errOut) | ||
| 81 | + } | ||
| 82 | + if !strings.Contains(errOut, "upgrade") { | ||
| 83 | + t.Fatalf("notice must say how to move on; stderr = %q", errOut) | ||
| 84 | + } | ||
| 85 | + if !strings.Contains(out, "rickub 0.1.0") { | ||
| 86 | + t.Fatalf("stdout must keep the plain version line, got %q", out) | ||
| 87 | + } | ||
| 88 | + if strings.Contains(out, "note:") { | ||
| 89 | + t.Fatalf("notice leaked into stdout: %q", out) | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// TestFreshCLIIsQuiet: a binary at (or past) the newest release prints nothing — | ||
| 94 | +// the notice is a staleness signal, not a check-in. | ||
| 95 | +func TestFreshCLIIsQuiet(t *testing.T) { | ||
| 96 | + withVersion(t, "0.2.0") | ||
| 97 | + srv := updateServer(t, "v0.2.0", nil) | ||
| 98 | + defer srv.Close() | ||
| 99 | + | ||
| 100 | + _, errOut, err := execute(t, "version") | ||
| 101 | + if err != nil { | ||
| 102 | + t.Fatalf("execute: %v", err) | ||
| 103 | + } | ||
| 104 | + if strings.Contains(errOut, "note:") { | ||
| 105 | + t.Fatalf("fresh binary was nagged: %q", errOut) | ||
| 106 | + } | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// TestRCOfCurrentVersionIsNotNewer: the host's "latest" includes prereleases | ||
| 110 | +// (most-recently-created), and v0.2.0-rc.7 is NOT newer than a running 0.2.0 — | ||
| 111 | +// semver orders the prerelease below its release. Nagging every stable user | ||
| 112 | +// about each rc would teach people to ignore the notice. | ||
| 113 | +func TestRCOfCurrentVersionIsNotNewer(t *testing.T) { | ||
| 114 | + withVersion(t, "0.2.0") | ||
| 115 | + srv := updateServer(t, "v0.2.0-rc.7", nil) | ||
| 116 | + defer srv.Close() | ||
| 117 | + | ||
| 118 | + _, errOut, err := execute(t, "version") | ||
| 119 | + if err != nil { | ||
| 120 | + t.Fatalf("execute: %v", err) | ||
| 121 | + } | ||
| 122 | + if strings.Contains(errOut, "note:") { | ||
| 123 | + t.Fatalf("rc-of-running-version produced a notice: %q", errOut) | ||
| 124 | + } | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// TestProbeIsThrottledPerHost: two commands in one day issue ONE probe; a day | ||
| 128 | +// later the probe repeats. The stamp is what keeps the check off every command — | ||
| 129 | +// without it the CLI adds a network round-trip to every invocation. | ||
| 130 | +func TestProbeIsThrottledPerHost(t *testing.T) { | ||
| 131 | + withVersion(t, "0.1.0") | ||
| 132 | + var probes int | ||
| 133 | + srv := updateServer(t, "v0.2.0", &probes) | ||
| 134 | + defer srv.Close() | ||
| 135 | + | ||
| 136 | + if _, _, err := execute(t, "version"); err != nil { | ||
| 137 | + t.Fatalf("execute: %v", err) | ||
| 138 | + } | ||
| 139 | + if _, _, err := execute(t, "version"); err != nil { | ||
| 140 | + t.Fatalf("execute: %v", err) | ||
| 141 | + } | ||
| 142 | + if probes != 1 { | ||
| 143 | + t.Fatalf("probes = %d after two same-day commands, want 1", probes) | ||
| 144 | + } | ||
| 145 | + stampAge(t, srv.URL, 25*time.Hour) // yesterday's check has aged out | ||
| 146 | + if _, _, err := execute(t, "version"); err != nil { | ||
| 147 | + t.Fatalf("execute: %v", err) | ||
| 148 | + } | ||
| 149 | + if probes != 2 { | ||
| 150 | + t.Fatalf("probes = %d after the stamp aged out, want 2", probes) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// TestDevBuildSkipsTheProbe: Version "dev" (a build from source, every `go | ||
| 155 | +// test`/`go run`) never probes — it has no release identity, and the check must | ||
| 156 | +// not add requests to test runs and local development. | ||
| 157 | +func TestDevBuildSkipsTheProbe(t *testing.T) { | ||
| 158 | + withVersion(t, "dev") | ||
| 159 | + var probes int | ||
| 160 | + srv := updateServer(t, "v9.9.9", &probes) | ||
| 161 | + defer srv.Close() | ||
| 162 | + | ||
| 163 | + if _, _, err := execute(t, "version"); err != nil { | ||
| 164 | + t.Fatalf("execute: %v", err) | ||
| 165 | + } | ||
| 166 | + if probes != 0 { | ||
| 167 | + t.Fatalf("dev build probed the host %d times, want 0", probes) | ||
| 168 | + } | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +// TestProbeFailuresAreSilent: a host without the CLI repo (404), a non-redirect | ||
| 172 | +// answer, a Location that is not a release tag, and an unreachable host each | ||
| 173 | +// leave the command succeeding with no notice — best-effort means a failed check | ||
| 174 | +// never becomes the user's problem. | ||
| 175 | +func TestProbeFailuresAreSilent(t *testing.T) { | ||
| 176 | + withVersion(t, "0.1.0") | ||
| 177 | + | ||
| 178 | + cases := []struct { | ||
| 179 | + name string | ||
| 180 | + handler http.HandlerFunc | ||
| 181 | + }{ | ||
| 182 | + {"no releases on host", func(w http.ResponseWriter, r *http.Request) { | ||
| 183 | + http.NotFound(w, r) | ||
| 184 | + }}, | ||
| 185 | + {"not a redirect", func(w http.ResponseWriter, _ *http.Request) { | ||
| 186 | + _, _ = w.Write([]byte(`{"ok":true}`)) | ||
| 187 | + }}, | ||
| 188 | + {"location is the releases list page", func(w http.ResponseWriter, r *http.Request) { | ||
| 189 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases", http.StatusFound) | ||
| 190 | + }}, | ||
| 191 | + {"location names a non-version tag", func(w http.ResponseWriter, r *http.Request) { | ||
| 192 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/runner-abc123", http.StatusFound) | ||
| 193 | + }}, | ||
| 194 | + } | ||
| 195 | + for _, tc := range cases { | ||
| 196 | + t.Run(tc.name, func(t *testing.T) { | ||
| 197 | + srv := httptest.NewServer(tc.handler) | ||
| 198 | + defer srv.Close() | ||
| 199 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 200 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 201 | + | ||
| 202 | + _, errOut, err := execute(t, "version") | ||
| 203 | + if err != nil { | ||
| 204 | + t.Fatalf("execute: %v", err) | ||
| 205 | + } | ||
| 206 | + if strings.Contains(errOut, "note:") { | ||
| 207 | + t.Fatalf("failed probe produced a notice: %q", errOut) | ||
| 208 | + } | ||
| 209 | + }) | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + // An unreachable host: point at a port that is closed. | ||
| 213 | + t.Run("unreachable host", func(t *testing.T) { | ||
| 214 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 215 | + t.Setenv("RICKUB_HOST", "http://127.0.0.1:1") | ||
| 216 | + _, errOut, err := execute(t, "version") | ||
| 217 | + if err != nil { | ||
| 218 | + t.Fatalf("execute: %v", err) | ||
| 219 | + } | ||
| 220 | + if strings.Contains(errOut, "note:") { | ||
| 221 | + t.Fatalf("unreachable host produced a notice: %q", errOut) | ||
| 222 | + } | ||
| 223 | + }) | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +// TestStampIsPerHost: a stamp for one host must not silence the check against | ||
| 227 | +// another — switching --host (or RICKUB_HOST) re-checks the host you actually | ||
| 228 | +// asked about. | ||
| 229 | +func TestStampIsPerHost(t *testing.T) { | ||
| 230 | + withVersion(t, "0.1.0") | ||
| 231 | + var probesA, probesB int | ||
| 232 | + srvA := updateServer(t, "v0.2.0", &probesA) | ||
| 233 | + defer srvA.Close() | ||
| 234 | + if _, _, err := execute(t, "version"); err != nil { | ||
| 235 | + t.Fatalf("execute: %v", err) | ||
| 236 | + } | ||
| 237 | + if probesA != 1 { | ||
| 238 | + t.Fatalf("probesA = %d, want 1", probesA) | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 242 | + if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" { | ||
| 243 | + probesB++ | ||
| 244 | + http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/v0.3.0", http.StatusFound) | ||
| 245 | + return | ||
| 246 | + } | ||
| 247 | + http.NotFound(w, r) | ||
| 248 | + })) | ||
| 249 | + defer srvB.Close() | ||
| 250 | + | ||
| 251 | + _, errOut, err := execute(t, "--host", srvB.URL, "version") | ||
| 252 | + if err != nil { | ||
| 253 | + t.Fatalf("execute: %v", err) | ||
| 254 | + } | ||
| 255 | + if probesB != 1 { | ||
| 256 | + t.Fatalf("probesB = %d, want 1 (host B must not inherit host A's stamp)", probesB) | ||
| 257 | + } | ||
| 258 | + if !strings.Contains(errOut, "v0.3.0") { | ||
| 259 | + t.Fatalf("notice must reflect host B's release; stderr = %q", errOut) | ||
| 260 | + } | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +// TestCompareVersions pins the ordering rules the notice depends on. | ||
| 264 | +func TestCompareVersions(t *testing.T) { | ||
| 265 | + cases := []struct { | ||
| 266 | + a, b string | ||
| 267 | + want int | ||
| 268 | + }{ | ||
| 269 | + {"v0.2.0", "0.1.0", 1}, | ||
| 270 | + {"0.1.0", "v0.2.0", -1}, | ||
| 271 | + {"v0.2.0", "v0.2.0", 0}, | ||
| 272 | + {"0.2", "0.1.9", 1}, // missing components are zero | ||
| 273 | + {"0.2.0", "0.2.0-rc.1", 1}, // release > its prerelease | ||
| 274 | + {"0.2.0-rc.11", "0.2.0-rc.9", 1}, // numeric identifiers, not lexical | ||
| 275 | + {"0.2.0-rc.2", "0.2.0-rc.2", 0}, // | ||
| 276 | + {"0.2.0-rc.1", "0.2.0-beta", 1}, // rc > beta byte-wise | ||
| 277 | + {"0.2.0-rc.1", "0.2.0-rc.1.1", -1}, // | ||
| 278 | + {"runner-abc123", "0.1.0", 0}, // unparsable never compares | ||
| 279 | + {"0.1.0", "runner-abc123", 0}, // | ||
| 280 | + {"", "0.1.0", 0}, // | ||
| 281 | + } | ||
| 282 | + for _, tc := range cases { | ||
| 283 | + if got := compareVersions(tc.a, tc.b); got != tc.want { | ||
| 284 | + t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | +} | ||
| 288 | + | ||
| 289 | +// TestTagFromReleaseLocation pins the Location shapes the host writes and the | ||
| 290 | +// ones it must not be confused by. | ||
| 291 | +func TestTagFromReleaseLocation(t *testing.T) { | ||
| 292 | + cases := []struct { | ||
| 293 | + loc string | ||
| 294 | + want string | ||
| 295 | + }{ | ||
| 296 | + {"/rickub/cli/releases/v0.2.0", "v0.2.0"}, | ||
| 297 | + {"https://host.example/rickub/cli/releases/v0.1.0-rc.9", "v0.1.0-rc.9"}, | ||
| 298 | + {"/rickub/cli/releases/latest", ""}, // redirect to itself | ||
| 299 | + {"/rickub/cli/releases", ""}, // the list page | ||
| 300 | + {"/rickub/cli/releases/download/v0.2.0/rickub_linux_amd64.tar.gz", ""}, | ||
| 301 | + {"/somewhere/else entirely", ""}, | ||
| 302 | + {"", ""}, | ||
| 303 | + } | ||
| 304 | + for _, tc := range cases { | ||
| 305 | + if got := tagFromReleaseLocation(tc.loc); got != tc.want { | ||
| 306 | + t.Errorf("tagFromReleaseLocation(%q) = %q, want %q", tc.loc, got, tc.want) | ||
| 307 | + } | ||
| 308 | + } | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +// TestUpdateStampPathIsPerHostAndSane: the stamp lands in its own directory | ||
| 312 | +// beside the config file, with a path-safe host key — two hosts, two stamps, no | ||
| 313 | +// clobbering, and never inside the config file itself. | ||
| 314 | +func TestUpdateStampPathIsPerHostAndSane(t *testing.T) { | ||
| 315 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 316 | + a, err := updateStampPath("https://rickub.com") | ||
| 317 | + if err != nil { | ||
| 318 | + t.Fatalf("stamp path: %v", err) | ||
| 319 | + } | ||
| 320 | + b, err := updateStampPath("https://dev.rickub.com/") | ||
| 321 | + if err != nil { | ||
| 322 | + t.Fatalf("stamp path: %v", err) | ||
| 323 | + } | ||
| 324 | + if a == b { | ||
| 325 | + t.Fatalf("two hosts share a stamp: %q", a) | ||
| 326 | + } | ||
| 327 | + cfgPath, err := config.Path() | ||
| 328 | + if err != nil { | ||
| 329 | + t.Fatalf("config path: %v", err) | ||
| 330 | + } | ||
| 331 | + if a == cfgPath || filepath.Dir(a) == cfgPath { | ||
| 332 | + t.Fatalf("stamp %q must not be (or sit inside) the config file %q", a, cfgPath) | ||
| 333 | + } | ||
| 334 | + if base := filepath.Base(a); !strings.HasSuffix(base, "rickub.com") { | ||
| 335 | + t.Errorf("stamp base %q loses the host identity", base) | ||
| 336 | + } | ||
| 337 | + if dir := filepath.Dir(a); !strings.HasSuffix(filepath.Base(dir), "update-check") { | ||
| 338 | + t.Errorf("stamp directory %q is not the dedicated update-check dir", dir) | ||
| 339 | + } | ||
| 340 | +} | ||
| 341 | + | ||
| 342 | +// TestStampFreshnessWindow pins the window: fresh inside the interval, stale | ||
| 343 | +// after it, and a stamp in the future (clock jumped back) reads fresh so the | ||
| 344 | +// probe cannot rapid-fire. | ||
| 345 | +func TestStampFreshnessWindow(t *testing.T) { | ||
| 346 | + path := filepath.Join(t.TempDir(), "stamp") | ||
| 347 | + writeUpdateStamp(path) | ||
| 348 | + if !stampIsFresh(path) { | ||
| 349 | + t.Fatal("a just-written stamp must be fresh") | ||
| 350 | + } | ||
| 351 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-updateCheckInterval-time.Hour).Format(time.RFC3339)), 0o600); err != nil { | ||
| 352 | + t.Fatalf("age stamp: %v", err) | ||
| 353 | + } | ||
| 354 | + if stampIsFresh(path) { | ||
| 355 | + t.Fatal("a stamp older than the interval must be stale") | ||
| 356 | + } | ||
| 357 | + if err := os.WriteFile(path, []byte(time.Now().UTC().Add(time.Hour).Format(time.RFC3339)), 0o600); err != nil { | ||
| 358 | + t.Fatalf("future stamp: %v", err) | ||
| 359 | + } | ||
| 360 | + if !stampIsFresh(path) { | ||
| 361 | + t.Fatal("a future stamp (clock moved back) must read fresh, not pinball the probe") | ||
| 362 | + } | ||
| 363 | + if stampIsFresh(filepath.Join(filepath.Dir(path), "absent")) { | ||
| 364 | + t.Fatal("an absent stamp must read stale") | ||
| 365 | + } | ||
| 366 | + if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil { | ||
| 367 | + t.Fatalf("garbage stamp: %v", err) | ||
| 368 | + } | ||
| 369 | + if stampIsFresh(path) { | ||
| 370 | + t.Fatal("an unparsable stamp must read stale") | ||
| 371 | + } | ||
| 372 | +} | ||