feat: warn when the CLI is older than the newest release on its hostUnverified
Implements the staleness signal of rickub/rickub issue 305: a stale binary describes an old product as current, which is how two false bug reports were filed in one night against defects main had already fixed. Every command (version included) starts with a throttled best-effort probe: GET /rickub/cli/releases/latest on the effective host, the documented 302 whose Location carries the newest release tag (the same redirect install scripts resolve latest with - no token, no HTML scraping). When that release is strictly newer than the running version, a one-line notice names both versions and the upgrade URL, on stderr so JSON output and pipes stay clean. Once per day per host, via a stamp beside the config file. Dev builds (Version dev) never probe. Any failure - no releases, unreachable host, exotic tag - is silent; an unparsable version never warns. Semver ordering keeps an rc of the running version from nagging stable users. Bite-proven by removing the PersistentPreRunE hook (notice and throttle tests fail) and end-to-end with real binaries: a 0.1.0 build against a host whose latest is v0.2.0 prints the notice on stderr while stdout keeps the plain version line; a 0.2.0 build stays silent.
b4bb81e parent: 507eaf7 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 +298 -0 | new file mode 100644 | ||
| @@ -0,0 +1,298 @@ | ||
| 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 | + data, err := os.ReadFile(path) | |
| 280 | + if err != nil { | |
| 281 | + return false | |
| 282 | + } | |
| 283 | + stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) | |
| 284 | + if err != nil { | |
| 285 | + return false | |
| 286 | + } | |
| 287 | + return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now()) | |
| 288 | +} | |
| 289 | + | |
| 290 | +// writeUpdateStamp records a successful attempt, best-effort: a stamp that | |
| 291 | +// cannot be written simply means the check may repeat — never a user-visible | |
| 292 | +// error. | |
| 293 | +func writeUpdateStamp(path string) { | |
| 294 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 295 | + return | |
| 296 | + } | |
| 297 | + _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600) | |
| 298 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,298 @@ | |||
| 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 | + data, err := os.ReadFile(path) | ||
| 280 | + if err != nil { | ||
| 281 | + return false | ||
| 282 | + } | ||
| 283 | + stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) | ||
| 284 | + if err != nil { | ||
| 285 | + return false | ||
| 286 | + } | ||
| 287 | + return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now()) | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +// writeUpdateStamp records a successful attempt, best-effort: a stamp that | ||
| 291 | +// cannot be written simply means the check may repeat — never a user-visible | ||
| 292 | +// error. | ||
| 293 | +func writeUpdateStamp(path string) { | ||
| 294 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 295 | + return | ||
| 296 | + } | ||
| 297 | + _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600) | ||
| 298 | +} | ||
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 | +} | ||