carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/ed25519"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// deletableStore adds the retirement primitive the console needs (Delete) on
18// top of a DirStore — the shape release.Store is expected to grow. Console
19// tests exercise canary retirement through it today.
20type deletableStore struct {
21 release.Store
22 root string
23 // putHook, when set, runs before each Put reaches the store: a test's
24 // concurrent writer.
25 putHook func(ctx context.Context, key string)
26 // afterPut, when set, runs after each Put the store accepted: a
27 // test's writer that lands just after another's write.
28 afterPut func(ctx context.Context, key string)
29}
30
31func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
32 if h := d.putHook; h != nil {
33 h(ctx, key)
34 }
35 etag, err := d.Store.Put(ctx, key, body, ifMatch)
36 if h := d.afterPut; h != nil && err == nil {
37 h(ctx, key)
38 }
39 return etag, err
40}
41
42// DeleteIfMatch passes the conditional delete through to the wrapped store,
43// as every production store offers it (S3Store, DirStore). Embedding the
44// interface alone would hide it, and a caller that must never fall back to
45// an unconditional delete (domains.DetachClaim on an instance catch-all)
46// would then refuse every time.
47func (d *deletableStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
48 return release.DeleteIfMatch(ctx, d.Store, key, etag)
49}
50
51func (d *deletableStore) Delete(_ context.Context, key string) error {
52 if key == "" || strings.HasPrefix(key, "/") {
53 return fmt.Errorf("deletablestore: invalid key %q", key)
54 }
55 for _, seg := range strings.Split(key, "/") {
56 if seg == "" || seg == "." || seg == ".." {
57 return fmt.Errorf("deletablestore: invalid key %q", key)
58 }
59 }
60 // Idempotent, like every real Store's Delete (release.Store's contract,
61 // store.go: "deleting a missing key is not an error"). A double that is
62 // stricter than the interface it stands in for makes callers look
63 // broken for relying on the contract — the instance delete verb, whose
64 // whole promise is that a second delete still answers 200, found this
65 // the hard way.
66 if err := os.Remove(filepath.Join(d.root, filepath.FromSlash(key))); err != nil && !os.IsNotExist(err) {
67 return err
68 }
69 return nil
70}
71
72// ListMod forwards to the wrapped store's ModLister. Both production stores
73// (DirStore, S3Store) implement it; deletableStore's embedded field is
74// declared as the release.Store INTERFACE, so Go's promotion rules only
75// carry that interface's own methods onto deletableStore — ListMod, being
76// outside it, is otherwise silently dropped even though the concrete value
77// underneath answers it. release.PruneEligible fails closed without a
78// ModLister, and the console door this fixture exercises (issue #136) is a
79// real consumer of it, so the double must forward rather than lie about
80// what a production store can do.
81func (d *deletableStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
82 ml, ok := d.Store.(release.ModLister)
83 if !ok {
84 return nil, fmt.Errorf("deletablestore: wrapped store has no ListMod")
85 }
86 return ml.ListMod(ctx, prefix)
87}
88
89// ListSizes forwards to the wrapped store's SizeLister for the same reason
90// ListMod does above: the economics storage sweep (console.storagePass) is
91// a real consumer, and a double that silently drops what the concrete store
92// underneath can answer makes the sweep look unable to size a real bucket.
93func (d *deletableStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) {
94 sl, ok := d.Store.(release.SizeLister)
95 if !ok {
96 return nil, fmt.Errorf("deletablestore: wrapped store has no ListSizes")
97 }
98 return sl.ListSizes(ctx, prefix)
99}
100
101// buildFixtures makes a little platform: a registry with routes for apps
102// kass (ready instance on stable) and otherapp, and a deployment bucket with
103// kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go.
104func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25519.PrivateKey, pub ed25519.PublicKey) {
105 t.Helper()
106 dir := t.TempDir()
107 regPath = filepath.Join(dir, "registry.db")
108 reg, err := registry.Open(regPath)
109 if err != nil {
110 t.Fatal(err)
111 }
112 for _, rt := range []registry.Route{
113 {Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
114 Version: "v1", App: "kass", Channel: "stable", Addr: "127.0.0.1:9001"},
115 {Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
116 Version: "v1", App: "kass", Hibernate: true},
117 {Host: "otherapp.carlosapp.dev", Kind: "instance", Status: "ready",
118 Version: "z9", App: "otherapp", Channel: "stable"},
119 } {
120 if err := reg.Upsert(rt); err != nil {
121 t.Fatal(err)
122 }
123 }
124
125 bucketDir := filepath.Join(dir, "bucket")
126 store = &deletableStore{Store: release.NewDirStore(bucketDir), root: bucketDir}
127 seed, pubB64, err := release.GenerateKey()
128 if err != nil {
129 t.Fatal(err)
130 }
131 priv, err = release.ParseSigningKey(seed, "test")
132 if err != nil {
133 t.Fatal(err)
134 }
135 pub, err = release.ParsePublicKey(pubB64, "test")
136 if err != nil {
137 t.Fatal(err)
138 }
139 ctx := context.Background()
140 builtAt := map[string]string{
141 "v1": "2026-07-01T10:00:00Z",
142 "v2": "2026-07-02T10:00:00Z",
143 }
144 for _, v := range []string{"v1", "v2"} {
145 m := release.Manifest{
146 Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: builtAt[v],
147 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
148 }
149 if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
150 t.Fatal(err)
151 }
152 }
153 if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "stable", "v1"); err != nil {
154 t.Fatal(err)
155 }
156 if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "canary/x", "v2"); err != nil {
157 t.Fatal(err)
158 }
159 return regPath, store, priv, pub
160}
161
162func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView {
163 t.Helper()
164 for _, c := range chans {
165 if c.Name == name {
166 return c
167 }
168 }
169 t.Fatalf("channel %s not in %+v", name, chans)
170 return ChannelView{}
171}
172
173func TestAssemble(t *testing.T) {
174 regPath, store, priv, pub := buildFixtures(t)
175 // Bake estimates only render for an app the flag marks as production
176 // (2026-08-04 console bake controls spec §7) — seed it so the
177 // BakedUntil assertions below still exercise the "still baking" path
178 // rather than the new "nothing bakes without the flag" zeroing.
179 if err := release.SetProduction(context.Background(), store, priv, release.OpsAccount, "kass", true, "test"); err != nil {
180 t.Fatal(err)
181 }
182 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
183 if err != nil {
184 t.Fatal(err)
185 }
186 if len(views) != 1 || views[0].App != "kass" {
187 t.Fatalf("views = %+v, want exactly kass", views)
188 }
189 v := views[0]
190 if len(v.Routes) != 2 {
191 t.Fatalf("kass routes = %+v, want 2", v.Routes)
192 }
193 // List orders by host: docs.kass... before kass...
194 inst := v.Routes[1]
195 if inst.Host != "kass.carlosapp.dev" || inst.Kind != "instance" || inst.Status != "ready" ||
196 inst.Version != "v1" || inst.Channel != "stable" || inst.Hibernate {
197 t.Fatalf("instance route = %+v", inst)
198 }
199 if st := v.Routes[0]; !st.Hibernate || st.Channel != "stable" {
200 t.Fatalf("static route = %+v (empty registry channel must display as stable)", st)
201 }
202
203 stable := findChannel(t, v.Channels, "stable")
204 if stable.Version != "v1" || stable.Err != "" {
205 t.Fatalf("stable = %+v", stable)
206 }
207 // Promoted moments ago with a 72h default hold: still baking, and the
208 // approximation is PromotedAt + hold.
209 if stable.BakedUntil.IsZero() {
210 t.Fatal("freshly promoted stable should be baking")
211 }
212 if want := stable.PromotedAt.Add(release.DefaultHolds["stable"]); !stable.BakedUntil.Equal(want) {
213 t.Fatalf("BakedUntil = %v, want %v", stable.BakedUntil, want)
214 }
215 canary := findChannel(t, v.Channels, "canary/x")
216 if canary.Version != "v2" || canary.Err != "" {
217 t.Fatalf("canary/x = %+v", canary)
218 }
219 if !canary.BakedUntil.IsZero() {
220 t.Fatalf("canary hold is 0 — BakedUntil should be zero (live), got %v", canary.BakedUntil)
221 }
222 if canary.PromotedAt.IsZero() || time.Since(canary.PromotedAt) > time.Hour {
223 t.Fatalf("PromotedAt = %v", canary.PromotedAt)
224 }
225}
226
227func TestAssembleCanaries(t *testing.T) {
228 regPath, store, _, pub := buildFixtures(t)
229 // Attach a route that follows the fixture's canary channel.
230 reg, err := registry.Open(regPath)
231 if err != nil {
232 t.Fatal(err)
233 }
234 if err := reg.Upsert(registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance",
235 Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil {
236 t.Fatal(err)
237 }
238 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
239 if err != nil {
240 t.Fatal(err)
241 }
242 canaries := views[0].Canaries
243 if len(canaries) != 1 {
244 t.Fatalf("canaries = %+v, want exactly canary/x", canaries)
245 }
246 c := canaries[0]
247 if c.Name != "x" || c.Channel != "canary/x" || c.Version != "v2" || c.Err != "" {
248 t.Fatalf("canary = %+v", c)
249 }
250 if len(c.Hosts) != 1 || c.Hosts[0] != "x.kass.carlosapp.dev" {
251 t.Fatalf("canary hosts = %v, want the subscribed route", c.Hosts)
252 }
253 if c.PromotedAt.IsZero() {
254 t.Fatal("canary has no PromotedAt")
255 }
256}
257
258func TestAssembleFiltersToGivenApps(t *testing.T) {
259 regPath, store, _, pub := buildFixtures(t)
260 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
261 if err != nil {
262 t.Fatal(err)
263 }
264 for _, v := range views {
265 if v.App == "otherapp" {
266 t.Fatal("otherapp leaked into a kass-only view")
267 }
268 for _, rt := range v.Routes {
269 if rt.Host == "otherapp.carlosapp.dev" {
270 t.Fatal("otherapp route leaked")
271 }
272 }
273 }
274}
275
276func TestAssembleEmptyAppStillListed(t *testing.T) {
277 regPath, store, _, pub := buildFixtures(t)
278 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, DNSTargets{})
279 if err != nil {
280 t.Fatal(err)
281 }
282 if len(views) != 2 || views[0].App != "ghost" {
283 t.Fatalf("views = %+v, want ghost then kass", views)
284 }
285 if len(views[0].Routes) != 0 || len(views[0].Channels) != 0 {
286 t.Fatalf("ghost should be empty: %+v", views[0])
287 }
288}
289
290func TestAssembleDegradesBadChannel(t *testing.T) {
291 regPath, store, _, pub := buildFixtures(t)
292 // A garbage pointer must become one row's error, never a failed page.
293 if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "beta"), []byte("not a signed pointer"), ""); err != nil {
294 t.Fatal(err)
295 }
296 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
297 if err != nil {
298 t.Fatalf("Assemble must not fail on a bad channel: %v", err)
299 }
300 beta := findChannel(t, views[0].Channels, "beta")
301 if beta.Err == "" {
302 t.Fatalf("beta = %+v, want an Err", beta)
303 }
304 if stable := findChannel(t, views[0].Channels, "stable"); stable.Err != "" {
305 t.Fatalf("healthy channel degraded too: %+v", stable)
306 }
307}
308
309// TestAssembleCarriesProcessOwnership guards the copy in Assemble's
310// RouteView literal: Unit/Backing/Provisioned are what later tasks (2, 3, 6,
311// 9) use to tell "no readings because nothing samples this route's process"
312// apart from "not responding" — a silent drop here would make every
313// unit-backed route look unowned.
314func TestAssembleCarriesProcessOwnership(t *testing.T) {
315 regPath, store, _, pub := buildFixtures(t)
316 reg, err := registry.Open(regPath)
317 if err != nil {
318 t.Fatal(err)
319 }
320 if err := reg.Upsert(registry.Route{Host: "unit-stamped.example.test", Kind: "instance",
321 Status: "ready", App: "kass", Channel: "stable",
322 Unit: "titogo@demo.service", Backing: registry.BackingUnit, Provisioned: true}); err != nil {
323 t.Fatal(err)
324 }
325 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
326 if err != nil {
327 t.Fatal(err)
328 }
329 var rt RouteView
330 found := false
331 for _, r := range views[0].Routes {
332 if r.Host == "unit-stamped.example.test" {
333 rt = r
334 found = true
335 }
336 }
337 if !found {
338 t.Fatalf("route unit-stamped.example.test not in %+v", views[0].Routes)
339 }
340 if rt.Unit != "titogo@demo.service" || rt.Backing != registry.BackingUnit || !rt.Provisioned {
341 t.Fatalf("RouteView lost process ownership: %+v", rt)
342 }
343}
344
345// TestMismatchListsRoutes guards the route-granularity replacement for the
346// old app-wide first-writer-wins mismatch string: an app with one route
347// stamped to another account, among others that agree with the account
348// being viewed, must name that one route — not claim the whole app is
349// broken.
350func TestMismatchListsRoutes(t *testing.T) {
351 regPath, store, _, pub := buildFixtures(t)
352 reg, err := registry.Open(regPath)
353 if err != nil {
354 t.Fatal(err)
355 }
356 for _, rt := range []registry.Route{
357 {Host: "one.mismatchlist.test", Kind: "instance", Status: "ready",
358 App: "mismatchlist", Channel: "stable", Account: "otheracct"},
359 {Host: "two.mismatchlist.test", Kind: "instance", Status: "ready",
360 App: "mismatchlist", Channel: "stable"},
361 {Host: "three.mismatchlist.test", Kind: "instance", Status: "ready",
362 App: "mismatchlist", Channel: "stable"},
363 } {
364 if err := reg.Upsert(rt); err != nil {
365 t.Fatal(err)
366 }
367 }
368 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, DNSTargets{})
369 if err != nil {
370 t.Fatal(err)
371 }
372 v := views[0]
373 if len(v.Routes) != 2 {
374 t.Fatalf("Routes = %+v, want only the viewed account's two routes", v.Routes)
375 }
376 if v.AllMismatched {
377 t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)")
378 }
379 if v.AccountMismatch() || v.RouteAccount != "" {
380 t.Fatalf("Assemble leaked cross-account diagnostics: %+v", v)
381 }
382}
383
384// TestMismatchAllRoutes guards the AllMismatched claim: only when every
385// non-platform route disagrees is "this app is not deploying" a sentence
386// the page can back.
387func TestMismatchAllRoutes(t *testing.T) {
388 regPath, store, _, pub := buildFixtures(t)
389 reg, err := registry.Open(regPath)
390 if err != nil {
391 t.Fatal(err)
392 }
393 for _, rt := range []registry.Route{
394 {Host: "one.mismatchall.test", Kind: "instance", Status: "ready",
395 App: "mismatchall", Channel: "stable", Account: "otheracct"},
396 {Host: "two.mismatchall.test", Kind: "instance", Status: "ready",
397 App: "mismatchall", Channel: "stable", Account: "otheracct"},
398 {Host: "three.mismatchall.test", Kind: "instance", Status: "ready",
399 App: "mismatchall", Channel: "stable", Account: "otheracct"},
400 } {
401 if err := reg.Upsert(rt); err != nil {
402 t.Fatal(err)
403 }
404 }
405 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, DNSTargets{})
406 if err != nil {
407 t.Fatal(err)
408 }
409 v := views[0]
410 if len(v.Routes) != 0 || v.AccountMismatch() || v.AllMismatched {
411 t.Fatalf("foreign routes leaked into view: %+v", v)
412 }
413}
414
415// TestMismatchIgnoresPlatformRoutes guards the exclusion carried over from
416// the old routeAccount map: a platform-minted route stamped to another
417// account is reconciliation lag, not a stuck deploy, and must contribute
418// nothing to Mismatched/AllMismatched.
419func TestMismatchIgnoresPlatformRoutes(t *testing.T) {
420 regPath, store, _, pub := buildFixtures(t)
421 reg, err := registry.Open(regPath)
422 if err != nil {
423 t.Fatal(err)
424 }
425 for _, rt := range []registry.Route{
426 {Host: "real.mismatchplatform.test", Kind: "instance", Status: "ready",
427 App: "mismatchplatform", Channel: "stable"},
428 {Host: "alias.mismatchplatform.test", Kind: "instance", Status: "ready",
429 App: "mismatchplatform", Channel: "stable", Account: "otheracct", Platform: true},
430 } {
431 if err := reg.Upsert(rt); err != nil {
432 t.Fatal(err)
433 }
434 }
435 views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, DNSTargets{})
436 if err != nil {
437 t.Fatal(err)
438 }
439 v := views[0]
440 if len(v.Mismatched) != 0 {
441 t.Fatalf("Mismatched = %+v, want none (only route is platform-minted)", v.Mismatched)
442 }
443 if v.AllMismatched {
444 t.Fatal("AllMismatched = true, want false")
445 }
446 if v.AccountMismatch() {
447 t.Fatal("AccountMismatch() = true, want false")
448 }
449}
450
451func TestVersionsNewestFirst(t *testing.T) {
452 _, store, _, _ := buildFixtures(t)
453 vs, err := Versions(store, release.OpsAccount, "kass")
454 if err != nil {
455 t.Fatal(err)
456 }
457 if len(vs) != 2 {
458 t.Fatalf("versions = %+v, want 2", vs)
459 }
460 if vs[0].Version != "v2" || vs[1].Version != "v1" {
461 t.Fatalf("order = %s, %s — want v2 (newer built_at) first", vs[0].Version, vs[1].Version)
462 }
463 if vs[0].Kind != "binary" || vs[0].BuiltAt != "2026-07-02T10:00:00Z" || vs[0].Err != "" {
464 t.Fatalf("v2 = %+v", vs[0])
465 }
466}
467
468func TestVersionsDegradesBadManifest(t *testing.T) {
469 _, store, _, _ := buildFixtures(t)
470 if _, err := store.Put(context.Background(), release.ManifestKey(release.OpsAccount, "kass", "v3"), []byte("junk"), ""); err != nil {
471 t.Fatal(err)
472 }
473 vs, err := Versions(store, release.OpsAccount, "kass")
474 if err != nil {
475 t.Fatalf("Versions must not fail on one bad manifest: %v", err)
476 }
477 var v3 *VersionView
478 for i := range vs {
479 if vs[i].Version == "v3" {
480 v3 = &vs[i]
481 }
482 }
483 if v3 == nil || v3.Err == "" {
484 t.Fatalf("versions = %+v, want a degraded v3 row", vs)
485 }
486}
487
488// countingModStore is the production store shape in miniature — a bare
489// DirStore, ListMod and all (struct embedding, so the method survives where
490// deletableStore's interface embedding would drop it) — counting manifest
491// reads.
492type countingModStore struct {
493 *release.DirStore
494 gets int
495}
496
497func (c *countingModStore) Get(ctx context.Context, key string) ([]byte, string, error) {
498 c.gets++
499 return c.DirStore.Get(ctx, key)
500}
501
502// shipAt ships a real release (manifest and all — GetManifest's validation
503// gate must accept these rows) and pins every object's mod time so the
504// ranking key is under the test's control.
505func shipAt(t *testing.T, store release.Store, dir, v string, mod time.Time) {
506 t.Helper()
507 ctx := context.Background()
508 m := release.Manifest{
509 Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339),
510 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
511 }
512 if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
513 t.Fatal(err)
514 }
515 for _, key := range []string{
516 release.ManifestKey(release.OpsAccount, "kass", v),
517 release.ArtifactKey(release.OpsAccount, "kass", v, "kass"),
518 } {
519 p := filepath.Join(dir, filepath.FromSlash(key))
520 if _, err := os.Stat(p); err != nil {
521 continue // artifact filename differs per Ship's layout; manifest is the one that must rank
522 }
523 if err := os.Chtimes(p, mod, mod); err != nil {
524 t.Fatal(err)
525 }
526 }
527}
528
529// TestVersionsBoundsManifestReads: when the store can rank keys by recency
530// (ModLister — both real stores), Versions reads only the manifests it will
531// render, and picks them by manifest MOD TIME, not by name — version names
532// here are numbered AGAINST recency (v01 shipped last), so a selection that
533// quietly ranked by name would return the wrong rows. Fetch-then-sort made
534// the app page O(every version ever shipped) in sequential bucket GETs —
535// tito's titogo reached 318 versions and ~8s per page render before this
536// was bounded.
537func TestVersionsBoundsManifestReads(t *testing.T) {
538 dir := t.TempDir()
539 store := &countingModStore{DirStore: release.NewDirStore(dir)}
540 base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
541 const shipped = versionsCap + 5
542 for i := 1; i <= shipped; i++ {
543 // v01 gets the NEWEST stamp, v25 the oldest.
544 shipAt(t, store, dir, fmt.Sprintf("v%02d", i), base.Add(time.Duration(shipped-i)*time.Minute))
545 }
546 store.gets = 0 // Ship reads during setup don't count
547 vs, err := Versions(store, release.OpsAccount, "kass")
548 if err != nil {
549 t.Fatal(err)
550 }
551 if len(vs) != versionsCap {
552 t.Fatalf("len = %d, want versionsCap %d", len(vs), versionsCap)
553 }
554 // The newest versionsCap ships, newest first: v01..v20. v21..v25 (the
555 // oldest ships, despite their bigger numbers) are the ones cut.
556 for i := 0; i < versionsCap; i++ {
557 want := fmt.Sprintf("v%02d", i+1)
558 if vs[i].Version != want {
559 t.Fatalf("vs[%d] = %s, want %s (rank by mod time, not name)", i, vs[i].Version, want)
560 }
561 }
562 // Real rendered rows, not error rows: the fixture manifests must survive
563 // GetManifest's validation.
564 if vs[0].Kind != "binary" || vs[0].BuiltAt == "" || vs[0].Err != "" {
565 t.Fatalf("vs[0] = %+v, want a rendered release row", vs[0])
566 }
567 if store.gets != versionsCap {
568 t.Fatalf("read %d manifests for %d shipped versions, want %d — rank by mod time BEFORE reading", store.gets, shipped, versionsCap)
569 }
570}
571
572// TestVersionsRankedPathDegrades: the ranked path keeps the fallback path's
573// degradation contract — a junk manifest and a ship that died before its
574// manifest each cost one row (Err set, sorted last by their unparseable
575// built_at), never the listing.
576func TestVersionsRankedPathDegrades(t *testing.T) {
577 dir := t.TempDir()
578 store := &countingModStore{DirStore: release.NewDirStore(dir)}
579 ctx := context.Background()
580 base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
581 shipAt(t, store, dir, "good1", base)
582 shipAt(t, store, dir, "good2", base.Add(time.Hour))
583 if _, err := store.Put(ctx, release.ManifestKey(release.OpsAccount, "kass", "junk1"), []byte("junk"), ""); err != nil {
584 t.Fatal(err)
585 }
586 if _, err := store.Put(ctx, release.ArtifactKey(release.OpsAccount, "kass", "dead1", "kass"), []byte("bin"), ""); err != nil {
587 t.Fatal(err)
588 }
589 vs, err := Versions(store, release.OpsAccount, "kass")
590 if err != nil {
591 t.Fatalf("Versions must not fail on bad rows: %v", err)
592 }
593 if len(vs) != 4 {
594 t.Fatalf("versions = %+v, want all 4 rows", vs)
595 }
596 // Healthy rows first (newest built_at first), degraded rows last.
597 if vs[0].Version != "good2" || vs[1].Version != "good1" {
598 t.Fatalf("healthy rows = %s, %s; want good2, good1", vs[0].Version, vs[1].Version)
599 }
600 for _, i := range []int{2, 3} {
601 if vs[i].Err == "" {
602 t.Fatalf("vs[%d] = %+v, want a degraded row", i, vs[i])
603 }
604 }
605}
606
607func TestVersionsEmptyApp(t *testing.T) {
608 _, store, _, _ := buildFixtures(t)
609 vs, err := Versions(store, release.OpsAccount, "ghost")
610 if err != nil || len(vs) != 0 {
611 t.Fatalf("Versions(ghost) = %+v %v, want empty nil", vs, err)
612 }
613}
614