| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net" |
| 8 | "net/http" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "syscall" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/caps" |
| 17 | ) |
| 18 | |
| 19 | func TestStagePointLiveRoundTrip(t *testing.T) { |
| 20 | opt := t.TempDir() |
| 21 | if err := StageArtifact(opt, "bab", "v1abc", "myapp", []byte("the binary")); err != nil { |
| 22 | t.Fatalf("StageArtifact: %v", err) |
| 23 | } |
| 24 | p := VersionPath(opt, "bab", "v1abc", "myapp") |
| 25 | st, err := os.Stat(p) |
| 26 | if err != nil { |
| 27 | t.Fatalf("staged file: %v", err) |
| 28 | } |
| 29 | if st.Mode().Perm() != 0o755 { |
| 30 | t.Fatalf("staged mode = %v, want 0755 (+x survives umask)", st.Mode().Perm()) |
| 31 | } |
| 32 | // An unmanaged host reads as "" with no error. |
| 33 | if v, err := LiveVersion(opt, "h.example.com"); err != nil || v != "" { |
| 34 | t.Fatalf("LiveVersion before pointing = %q, %v", v, err) |
| 35 | } |
| 36 | if err := PointLive(opt, "h.example.com", "bab", "v1abc", "myapp"); err != nil { |
| 37 | t.Fatalf("PointLive: %v", err) |
| 38 | } |
| 39 | v, err := LiveVersion(opt, "h.example.com") |
| 40 | if err != nil || v != "v1abc" { |
| 41 | t.Fatalf("LiveVersion = %q, %v; want v1abc", v, err) |
| 42 | } |
| 43 | // Repointing at an unstaged version is refused (no dangling links). |
| 44 | if err := PointLive(opt, "h.example.com", "bab", "vGhost", "myapp"); err == nil { |
| 45 | t.Fatalf("PointLive accepted an unstaged version") |
| 46 | } |
| 47 | // The refusal left the link untouched. |
| 48 | if v, _ := LiveVersion(opt, "h.example.com"); v != "v1abc" { |
| 49 | t.Fatalf("live link moved to %q after refused repoint", v) |
| 50 | } |
| 51 | // A second staged version swaps atomically. |
| 52 | if err := StageArtifact(opt, "bab", "v2def", "myapp", []byte("newer binary")); err != nil { |
| 53 | t.Fatalf("StageArtifact v2: %v", err) |
| 54 | } |
| 55 | if err := PointLive(opt, "h.example.com", "bab", "v2def", "myapp"); err != nil { |
| 56 | t.Fatalf("PointLive v2: %v", err) |
| 57 | } |
| 58 | if v, _ := LiveVersion(opt, "h.example.com"); v != "v2def" { |
| 59 | t.Fatalf("LiveVersion = %q, want v2def", v) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestValidVersion(t *testing.T) { |
| 64 | for v, want := range map[string]bool{ |
| 65 | "abc1234": true, "1.2.3": true, "a_b-c.d": true, |
| 66 | "": false, ".": false, "..": false, "a/b": false, "a b": false, |
| 67 | } { |
| 68 | if got := ValidVersion(v); got != want { |
| 69 | t.Errorf("ValidVersion(%q) = %v, want %v", v, got, want) |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestOptDirEnvOverride(t *testing.T) { |
| 75 | t.Setenv("CARLOS_OPT_DIR", "/x/y") |
| 76 | if d := OptDir(); d != "/x/y" { |
| 77 | t.Fatalf("OptDir = %q, want /x/y", d) |
| 78 | } |
| 79 | t.Setenv("CARLOS_OPT_DIR", "") |
| 80 | if d := OptDir(); d != "/opt/carlos" { |
| 81 | t.Fatalf("OptDir = %q, want /opt/carlos", d) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestProbeAndWaitHealthyVersion(t *testing.T) { |
| 86 | sock := filepath.Join(t.TempDir(), "h.sock") |
| 87 | ln, err := net.Listen("unix", sock) |
| 88 | if err != nil { |
| 89 | if errors.Is(err, os.ErrPermission) || strings.Contains(err.Error(), "operation not permitted") { |
| 90 | t.Skipf("unix sockets unavailable in this sandbox: %v", err) |
| 91 | } |
| 92 | t.Fatalf("listen: %v", err) |
| 93 | } |
| 94 | mux := http.NewServeMux() |
| 95 | mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { |
| 96 | w.Write([]byte(`{"build":"v1abc"}`)) |
| 97 | }) |
| 98 | srv := &http.Server{Handler: mux} |
| 99 | go srv.Serve(ln) |
| 100 | defer srv.Close() |
| 101 | |
| 102 | build, ok := ProbeVersion(sock) |
| 103 | if !ok || build != "v1abc" { |
| 104 | t.Fatalf("ProbeVersion = %q, %v; want v1abc, true", build, ok) |
| 105 | } |
| 106 | if err := WaitHealthyVersion(sock, "v1abc", 3*time.Second); err != nil { |
| 107 | t.Fatalf("WaitHealthyVersion: %v", err) |
| 108 | } |
| 109 | // A wrong expected build times out with the mismatch surfaced. |
| 110 | if err := WaitHealthyVersion(sock, "vOther", 400*time.Millisecond); err == nil { |
| 111 | t.Fatalf("WaitHealthyVersion accepted the wrong build") |
| 112 | } |
| 113 | // A dead socket probes false. |
| 114 | if _, ok := ProbeVersion(filepath.Join(t.TempDir(), "dead.sock")); ok { |
| 115 | t.Fatalf("ProbeVersion answered on a dead socket") |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestStagedManifestPathIsASiblingForBothAccountShapes pins the exact path |
| 120 | // StageManifest/ReadStagedManifest agree on: the ops account keeps the |
| 121 | // pre-accounts shape byte for byte (<opt>/versions/<ver>/<app>...), and every |
| 122 | // other account gets a level in between — the same split VersionPath already |
| 123 | // makes, so the manifest is always a SIBLING of the binary, never a new |
| 124 | // directory shape. |
| 125 | func TestStagedManifestPathIsASiblingForBothAccountShapes(t *testing.T) { |
| 126 | opt := t.TempDir() |
| 127 | if got, want := StagedManifestPath(opt, OpsAccount, "v1", "myapp"), |
| 128 | filepath.Join(opt, "versions", "v1", "myapp.manifest.json"); got != want { |
| 129 | t.Fatalf("ops path = %q, want %q", got, want) |
| 130 | } |
| 131 | if got, want := StagedManifestPath(opt, "acme", "v1", "myapp"), |
| 132 | filepath.Join(opt, "versions", "v1", "acme", "myapp.manifest.json"); got != want { |
| 133 | t.Fatalf("non-ops path = %q, want %q", got, want) |
| 134 | } |
| 135 | // It is always exactly VersionPath + ".manifest.json" — a sibling, not a |
| 136 | // new directory. |
| 137 | if got, want := StagedManifestPath(opt, "acme", "v1", "myapp"), VersionPath(opt, "acme", "v1", "myapp")+".manifest.json"; got != want { |
| 138 | t.Fatalf("sibling path = %q, want %q", got, want) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // TestReadStagedManifestIsNilNilWhenAbsent pins the three-state contract: |
| 143 | // absence is (nil, nil) — "unknown", not "none" — so a caller can tell a box |
| 144 | // staged before this feature existed apart from a manifest that legitimately |
| 145 | // declares no capabilities. |
| 146 | func TestReadStagedManifestIsNilNilWhenAbsent(t *testing.T) { |
| 147 | opt := t.TempDir() |
| 148 | m, err := ReadStagedManifest(opt, OpsAccount, "v1", "myapp") |
| 149 | if err != nil || m != nil { |
| 150 | t.Fatalf("ReadStagedManifest on an absent sibling = %+v, %v; want nil, nil", m, err) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // TestStageManifestRoundTripsCapabilities exercises the plain write/read |
| 155 | // path a fresh ensureStaged download takes, including that a re-stage |
| 156 | // OVERWRITES rather than appending (the reviewer's named concern). |
| 157 | func TestStageManifestRoundTripsCapabilities(t *testing.T) { |
| 158 | opt := t.TempDir() |
| 159 | // StageManifest is always called once the version directory exists (via |
| 160 | // StageArtifact, or because the binary is already staged in the |
| 161 | // back-fill path) — it is a sibling writer, not a directory creator. |
| 162 | if err := StageArtifact(opt, OpsAccount, "v1abc", "keymail", []byte("binary")); err != nil { |
| 163 | t.Fatalf("StageArtifact: %v", err) |
| 164 | } |
| 165 | m := Manifest{ |
| 166 | Schema: 1, App: "keymail", Version: "v1abc", Kind: "binary", |
| 167 | Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 12}}, |
| 168 | Capabilities: &caps.Set{V: 1, Command: "relay", Daemon: true, RuntimeDir: "keymail"}, |
| 169 | } |
| 170 | raw, err := json.MarshalIndent(&m, "", " ") |
| 171 | if err != nil { |
| 172 | t.Fatalf("marshal: %v", err) |
| 173 | } |
| 174 | if err := StageManifest(opt, OpsAccount, "v1abc", "keymail", raw); err != nil { |
| 175 | t.Fatalf("StageManifest: %v", err) |
| 176 | } |
| 177 | got, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail") |
| 178 | if err != nil { |
| 179 | t.Fatalf("ReadStagedManifest: %v", err) |
| 180 | } |
| 181 | if got == nil || got.App != "keymail" || got.Version != "v1abc" { |
| 182 | t.Fatalf("got = %+v, want App/Version to round-trip", got) |
| 183 | } |
| 184 | if got.Capabilities == nil || got.Capabilities.Command != "relay" || !got.Capabilities.Daemon || got.Capabilities.RuntimeDir != "keymail" { |
| 185 | t.Fatalf("Capabilities = %+v, did not round-trip", got.Capabilities) |
| 186 | } |
| 187 | |
| 188 | // Re-staging the SAME version overwrites, it does not append: a stale |
| 189 | // half of an old sibling left dangling after the rename would otherwise |
| 190 | // corrupt the JSON. |
| 191 | m2 := m |
| 192 | m2.Capabilities = &caps.Set{V: 1, Daemon: true} |
| 193 | raw2, err := json.MarshalIndent(&m2, "", " ") |
| 194 | if err != nil { |
| 195 | t.Fatalf("marshal: %v", err) |
| 196 | } |
| 197 | if err := StageManifest(opt, OpsAccount, "v1abc", "keymail", raw2); err != nil { |
| 198 | t.Fatalf("re-stage: %v", err) |
| 199 | } |
| 200 | got2, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail") |
| 201 | if err != nil { |
| 202 | t.Fatalf("ReadStagedManifest after re-stage: %v", err) |
| 203 | } |
| 204 | if got2.Capabilities.Command != "" || !got2.Capabilities.Daemon { |
| 205 | t.Fatalf("re-stage did not overwrite: %+v", got2.Capabilities) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // TestStageManifestCreatesTheAccountDir pins issue #58: a version staged by |
| 210 | // a pre-capability binary has NO <acct>/ subdirectory at all (the bare |
| 211 | // no-account layout StageArtifact(optDir, "", ...) produces — exactly what |
| 212 | // the old binary wrote). Back-filling a sibling for that version under a |
| 213 | // real account must not assume the directory already exists; StageManifest |
| 214 | // has to create it itself, the way StageArtifact already does for the |
| 215 | // binary, or the write fails ENOENT on every affected box, forever (seen |
| 216 | // live, hourly, on both production boxes). |
| 217 | func TestStageManifestCreatesTheAccountDir(t *testing.T) { |
| 218 | // Tighten the umask so the account dir StageManifest creates would come |
| 219 | // out non-traversable (0700) if the Chmod fix didn't run — otherwise the |
| 220 | // perm assertion below would pass even with the umask-masking bug back. |
| 221 | old := syscall.Umask(0o077) |
| 222 | defer syscall.Umask(old) |
| 223 | |
| 224 | opt := t.TempDir() |
| 225 | // Pre-capability layout: versions/<ver>/<app>, no account level at all. |
| 226 | if err := StageArtifact(opt, "", "v1abc", "myapp", []byte("binary")); err != nil { |
| 227 | t.Fatalf("StageArtifact: %v", err) |
| 228 | } |
| 229 | if err := StageManifest(opt, "bad", "v1abc", "myapp", []byte("{}")); err != nil { |
| 230 | t.Fatalf("StageManifest: %v", err) |
| 231 | } |
| 232 | p := StagedManifestPath(opt, "bad", "v1abc", "myapp") |
| 233 | st, err := os.Stat(p) |
| 234 | if err != nil { |
| 235 | t.Fatalf("staged manifest: %v", err) |
| 236 | } |
| 237 | if st.Mode().Perm() != 0o644 { |
| 238 | t.Fatalf("staged manifest mode = %v, want 0644", st.Mode().Perm()) |
| 239 | } |
| 240 | dirSt, err := os.Stat(filepath.Dir(p)) |
| 241 | if err != nil { |
| 242 | t.Fatalf("staged account dir: %v", err) |
| 243 | } |
| 244 | if dirSt.Mode().Perm() != 0o755 { |
| 245 | t.Fatalf("staged account dir mode = %v, want 0755", dirSt.Mode().Perm()) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // TestReadStagedManifestRunsTheValidationGate pins the Task-2-review |
| 250 | // backstop: a stale sibling written by a NEWER binary — one that understood |
| 251 | // a "v" this binary does not — must refuse cleanly here. This particular |
| 252 | // "v" is caught by caps.Set's own DECODER (UnmarshalJSON's v-range check) |
| 253 | // during ReadStagedManifest's json.Unmarshal, before validateManifest is |
| 254 | // even reached — see TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes |
| 255 | // below for the sibling that reaches validateManifest's caps.ValidCapabilities |
| 256 | // call specifically. Written directly to disk (bypassing StageManifest's |
| 257 | // own marshal), exactly as a newer binary's bytes would arrive. |
| 258 | func TestReadStagedManifestRunsTheValidationGate(t *testing.T) { |
| 259 | opt := t.TempDir() |
| 260 | raw := []byte(`{ |
| 261 | "schema": 1, |
| 262 | "app": "keymail", |
| 263 | "version": "v1abc", |
| 264 | "kind": "binary", |
| 265 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }, |
| 266 | "capabilities": { "v": 2, "daemon": true } |
| 267 | }`) |
| 268 | if err := os.MkdirAll(filepath.Dir(StagedManifestPath(opt, OpsAccount, "v1abc", "keymail")), 0o755); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if err := os.WriteFile(StagedManifestPath(opt, OpsAccount, "v1abc", "keymail"), raw, 0o644); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| 275 | t.Fatal("a sibling declaring a v this binary does not speak was accepted") |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes |
| 280 | // is the mutation-killing companion to the test above (2026-08-03 review, |
| 281 | // killing test 1): runtime_dir "systemd" decodes CLEANLY — RuntimeDir is a |
| 282 | // plain string field with no charset or reserved-word check in caps.Set's |
| 283 | // decoder — and is refused only by ValidCapabilities' reserved-name check, |
| 284 | // which ReadStagedManifest reaches exclusively through its validateManifest |
| 285 | // call. A mutant that deletes that call (while leaving json.Unmarshal in |
| 286 | // place) would let this exact sibling through; this test is the one that |
| 287 | // catches it. |
| 288 | func TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes(t *testing.T) { |
| 289 | opt := t.TempDir() |
| 290 | raw := []byte(`{ |
| 291 | "schema": 1, |
| 292 | "app": "keymail", |
| 293 | "version": "v1abc", |
| 294 | "kind": "binary", |
| 295 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }, |
| 296 | "capabilities": { "v": 1, "runtime_dir": "systemd" } |
| 297 | }`) |
| 298 | p := StagedManifestPath(opt, OpsAccount, "v1abc", "keymail") |
| 299 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | if err := os.WriteFile(p, raw, 0o644); err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| 306 | t.Fatal("a sibling with a reserved runtime_dir (refused only by ValidCapabilities, via validateManifest) was accepted") |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | // TestReadStagedManifestRefusesAnIdentityMismatch pins I3 (2026-08-03 |
| 311 | // review): the sibling's OWN App/Version fields must match the path it was |
| 312 | // read from, in both directions. Without this, a sibling at keymail's path |
| 313 | // whose body claims a different app or version is trusted against the |
| 314 | // PATH's identity — so a sibling written for (or by) the wrong app would |
| 315 | // have its capabilities validated and materialized under keymail's name. |
| 316 | func TestReadStagedManifestRefusesAnIdentityMismatch(t *testing.T) { |
| 317 | opt := t.TempDir() |
| 318 | write := func(bodyApp, bodyVer string) { |
| 319 | t.Helper() |
| 320 | raw := []byte(`{ |
| 321 | "schema": 1, |
| 322 | "app": "` + bodyApp + `", |
| 323 | "version": "` + bodyVer + `", |
| 324 | "kind": "binary", |
| 325 | "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } } |
| 326 | }`) |
| 327 | p := StagedManifestPath(opt, OpsAccount, "v1abc", "keymail") |
| 328 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 329 | t.Fatal(err) |
| 330 | } |
| 331 | if err := os.WriteFile(p, raw, 0o644); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | // Wrong app: a sibling at keymail's path whose body claims "console". |
| 337 | write("console", "v1abc") |
| 338 | if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| 339 | t.Fatal("a sibling naming a different app than its path was accepted") |
| 340 | } |
| 341 | |
| 342 | // Wrong version: same app, a different version in the body. |
| 343 | write("keymail", "vOTHER") |
| 344 | if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| 345 | t.Fatal("a sibling naming a different version than its path was accepted") |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | // modStamp writes key and pins its mtime — candidate ranking reads nothing |
| 350 | // else. |
| 351 | func modStamp(t *testing.T, s *DirStore, dir, key string, mod time.Time) { |
| 352 | t.Helper() |
| 353 | if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { |
| 357 | t.Fatal(err) |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // TestVersionCandidatesRanked: with a mod-aware store the candidates are the |
| 362 | // newest max versions by each manifest object's own stamp — the mod order |
| 363 | // here deliberately DISAGREES with version-name order, so a selection that |
| 364 | // quietly ranked by name would fail — and a version whose ship never wrote a |
| 365 | // manifest ranks by its newest artifact, so an aborted ship surfaces (as the |
| 366 | // caller's error row) rather than vanishing. |
| 367 | func TestVersionCandidatesRanked(t *testing.T) { |
| 368 | dir := t.TempDir() |
| 369 | s := NewDirStore(dir) |
| 370 | base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| 371 | for v, mod := range map[string]time.Time{ |
| 372 | "aa": base.Add(3 * time.Hour), // newest ship, first lexically too |
| 373 | "zz": base.Add(2 * time.Hour), // second-newest despite sorting last by name |
| 374 | "cc": base.Add(1 * time.Hour), |
| 375 | "bb": base, // oldest — the one the cap must cut |
| 376 | } { |
| 377 | modStamp(t, s, dir, ManifestKey("bab", "kass", v), mod) |
| 378 | // An artifact NEWER than its own manifest must not outrank the |
| 379 | // manifest's stamp. |
| 380 | modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod.Add(30*time.Minute)) |
| 381 | } |
| 382 | // bb's ARTIFACT ends up newer than cc's MANIFEST: a ranking that took |
| 383 | // the max over all of a version's objects instead of letting the |
| 384 | // manifest's own stamp win would rank bb (100m) over cc (60m) and evict |
| 385 | // cc from the want below — this line is what pins the rule. |
| 386 | bbArtifact := filepath.Join(dir, filepath.FromSlash(ArtifactKey("bab", "kass", "bb", "linux-arm64"))) |
| 387 | if err := os.Chtimes(bbArtifact, base.Add(100*time.Minute), base.Add(100*time.Minute)); err != nil { |
| 388 | t.Fatal(err) |
| 389 | } |
| 390 | // A ship that died before manifest.json: artifact only, newest of all. |
| 391 | modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), base.Add(4*time.Hour)) |
| 392 | |
| 393 | got, ranked, err := VersionCandidates(context.Background(), s, "bab", "kass", 4) |
| 394 | if err != nil { |
| 395 | t.Fatal(err) |
| 396 | } |
| 397 | if !ranked { |
| 398 | t.Fatal("DirStore is a ModLister — candidates must come back ranked") |
| 399 | } |
| 400 | want := []string{"dead1", "aa", "zz", "cc"} |
| 401 | if len(got) != len(want) { |
| 402 | t.Fatalf("candidates = %v, want %v", got, want) |
| 403 | } |
| 404 | for i := range want { |
| 405 | if got[i] != want[i] { |
| 406 | t.Fatalf("candidates = %v, want %v", got, want) |
| 407 | } |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // plainStore hides ListMod behind the bare interface, the shape every test |
| 412 | // fake has. |
| 413 | type plainStore struct{ Store } |
| 414 | |
| 415 | // TestVersionCandidatesFallback: a store without ListMod answers every |
| 416 | // version, unranked and uncapped, in listing order — the caller's |
| 417 | // fetch-everything path, byte-identical to the pre-ModLister behavior. |
| 418 | func TestVersionCandidatesFallback(t *testing.T) { |
| 419 | dir := t.TempDir() |
| 420 | s := NewDirStore(dir) |
| 421 | base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| 422 | for i, v := range []string{"v1", "v2", "v3"} { |
| 423 | modStamp(t, s, dir, ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour)) |
| 424 | } |
| 425 | got, ranked, err := VersionCandidates(context.Background(), plainStore{s}, "bab", "kass", 2) |
| 426 | if err != nil { |
| 427 | t.Fatal(err) |
| 428 | } |
| 429 | if ranked { |
| 430 | t.Fatal("a bare Store cannot rank") |
| 431 | } |
| 432 | want := []string{"v1", "v2", "v3"} // listing order, all of them — no cap |
| 433 | if len(got) != len(want) { |
| 434 | t.Fatalf("candidates = %v, want %v", got, want) |
| 435 | } |
| 436 | for i := range want { |
| 437 | if got[i] != want[i] { |
| 438 | t.Fatalf("candidates = %v, want %v", got, want) |
| 439 | } |
| 440 | } |
| 441 | } |
| 442 | |