package console import ( "context" "crypto/ed25519" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // deletableStore adds the retirement primitive the console needs (Delete) on // top of a DirStore — the shape release.Store is expected to grow. Console // tests exercise canary retirement through it today. type deletableStore struct { release.Store root string // putHook, when set, runs before each Put reaches the store: a test's // concurrent writer. putHook func(ctx context.Context, key string) // afterPut, when set, runs after each Put the store accepted: a // test's writer that lands just after another's write. afterPut func(ctx context.Context, key string) } func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if h := d.putHook; h != nil { h(ctx, key) } etag, err := d.Store.Put(ctx, key, body, ifMatch) if h := d.afterPut; h != nil && err == nil { h(ctx, key) } return etag, err } // DeleteIfMatch passes the conditional delete through to the wrapped store, // as every production store offers it (S3Store, DirStore). Embedding the // interface alone would hide it, and a caller that must never fall back to // an unconditional delete (domains.DetachClaim on an instance catch-all) // would then refuse every time. func (d *deletableStore) DeleteIfMatch(ctx context.Context, key, etag string) error { return release.DeleteIfMatch(ctx, d.Store, key, etag) } func (d *deletableStore) Delete(_ context.Context, key string) error { if key == "" || strings.HasPrefix(key, "/") { return fmt.Errorf("deletablestore: invalid key %q", key) } for _, seg := range strings.Split(key, "/") { if seg == "" || seg == "." || seg == ".." { return fmt.Errorf("deletablestore: invalid key %q", key) } } // Idempotent, like every real Store's Delete (release.Store's contract, // store.go: "deleting a missing key is not an error"). A double that is // stricter than the interface it stands in for makes callers look // broken for relying on the contract — the instance delete verb, whose // whole promise is that a second delete still answers 200, found this // the hard way. if err := os.Remove(filepath.Join(d.root, filepath.FromSlash(key))); err != nil && !os.IsNotExist(err) { return err } return nil } // ListMod forwards to the wrapped store's ModLister. Both production stores // (DirStore, S3Store) implement it; deletableStore's embedded field is // declared as the release.Store INTERFACE, so Go's promotion rules only // carry that interface's own methods onto deletableStore — ListMod, being // outside it, is otherwise silently dropped even though the concrete value // underneath answers it. release.PruneEligible fails closed without a // ModLister, and the console door this fixture exercises (issue #136) is a // real consumer of it, so the double must forward rather than lie about // what a production store can do. func (d *deletableStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) { ml, ok := d.Store.(release.ModLister) if !ok { return nil, fmt.Errorf("deletablestore: wrapped store has no ListMod") } return ml.ListMod(ctx, prefix) } // ListSizes forwards to the wrapped store's SizeLister for the same reason // ListMod does above: the economics storage sweep (console.storagePass) is // a real consumer, and a double that silently drops what the concrete store // underneath can answer makes the sweep look unable to size a real bucket. func (d *deletableStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) { sl, ok := d.Store.(release.SizeLister) if !ok { return nil, fmt.Errorf("deletablestore: wrapped store has no ListSizes") } return sl.ListSizes(ctx, prefix) } // buildFixtures makes a little platform: a registry with routes for apps // kass (ready instance on stable) and otherapp, and a deployment bucket with // kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go. func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25519.PrivateKey, pub ed25519.PublicKey) { t.Helper() dir := t.TempDir() regPath = filepath.Join(dir, "registry.db") reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", Version: "v1", App: "kass", Channel: "stable", Addr: "127.0.0.1:9001"}, {Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", Version: "v1", App: "kass", Hibernate: true}, {Host: "otherapp.carlosapp.dev", Kind: "instance", Status: "ready", Version: "z9", App: "otherapp", Channel: "stable"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } bucketDir := filepath.Join(dir, "bucket") store = &deletableStore{Store: release.NewDirStore(bucketDir), root: bucketDir} seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err = release.ParseSigningKey(seed, "test") if err != nil { t.Fatal(err) } pub, err = release.ParsePublicKey(pubB64, "test") if err != nil { t.Fatal(err) } ctx := context.Background() builtAt := map[string]string{ "v1": "2026-07-01T10:00:00Z", "v2": "2026-07-02T10:00:00Z", } for _, v := range []string{"v1", "v2"} { m := release.Manifest{ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: builtAt[v], Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, } if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { t.Fatal(err) } } if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "stable", "v1"); err != nil { t.Fatal(err) } if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "canary/x", "v2"); err != nil { t.Fatal(err) } return regPath, store, priv, pub } func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { t.Helper() for _, c := range chans { if c.Name == name { return c } } t.Fatalf("channel %s not in %+v", name, chans) return ChannelView{} } func TestAssemble(t *testing.T) { regPath, store, priv, pub := buildFixtures(t) // Bake estimates only render for an app the flag marks as production // (2026-08-04 console bake controls spec §7) — seed it so the // BakedUntil assertions below still exercise the "still baking" path // rather than the new "nothing bakes without the flag" zeroing. if err := release.SetProduction(context.Background(), store, priv, release.OpsAccount, "kass", true, "test"); err != nil { t.Fatal(err) } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) if err != nil { t.Fatal(err) } if len(views) != 1 || views[0].App != "kass" { t.Fatalf("views = %+v, want exactly kass", views) } v := views[0] if len(v.Routes) != 2 { t.Fatalf("kass routes = %+v, want 2", v.Routes) } // List orders by host: docs.kass... before kass... inst := v.Routes[1] if inst.Host != "kass.carlosapp.dev" || inst.Kind != "instance" || inst.Status != "ready" || inst.Version != "v1" || inst.Channel != "stable" || inst.Hibernate { t.Fatalf("instance route = %+v", inst) } if st := v.Routes[0]; !st.Hibernate || st.Channel != "stable" { t.Fatalf("static route = %+v (empty registry channel must display as stable)", st) } stable := findChannel(t, v.Channels, "stable") if stable.Version != "v1" || stable.Err != "" { t.Fatalf("stable = %+v", stable) } // Promoted moments ago with a 72h default hold: still baking, and the // approximation is PromotedAt + hold. if stable.BakedUntil.IsZero() { t.Fatal("freshly promoted stable should be baking") } if want := stable.PromotedAt.Add(release.DefaultHolds["stable"]); !stable.BakedUntil.Equal(want) { t.Fatalf("BakedUntil = %v, want %v", stable.BakedUntil, want) } canary := findChannel(t, v.Channels, "canary/x") if canary.Version != "v2" || canary.Err != "" { t.Fatalf("canary/x = %+v", canary) } if !canary.BakedUntil.IsZero() { t.Fatalf("canary hold is 0 — BakedUntil should be zero (live), got %v", canary.BakedUntil) } if canary.PromotedAt.IsZero() || time.Since(canary.PromotedAt) > time.Hour { t.Fatalf("PromotedAt = %v", canary.PromotedAt) } } func TestAssembleCanaries(t *testing.T) { regPath, store, _, pub := buildFixtures(t) // Attach a route that follows the fixture's canary channel. reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance", Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { t.Fatal(err) } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) if err != nil { t.Fatal(err) } canaries := views[0].Canaries if len(canaries) != 1 { t.Fatalf("canaries = %+v, want exactly canary/x", canaries) } c := canaries[0] if c.Name != "x" || c.Channel != "canary/x" || c.Version != "v2" || c.Err != "" { t.Fatalf("canary = %+v", c) } if len(c.Hosts) != 1 || c.Hosts[0] != "x.kass.carlosapp.dev" { t.Fatalf("canary hosts = %v, want the subscribed route", c.Hosts) } if c.PromotedAt.IsZero() { t.Fatal("canary has no PromotedAt") } } func TestAssembleFiltersToGivenApps(t *testing.T) { regPath, store, _, pub := buildFixtures(t) views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) if err != nil { t.Fatal(err) } for _, v := range views { if v.App == "otherapp" { t.Fatal("otherapp leaked into a kass-only view") } for _, rt := range v.Routes { if rt.Host == "otherapp.carlosapp.dev" { t.Fatal("otherapp route leaked") } } } } func TestAssembleEmptyAppStillListed(t *testing.T) { regPath, store, _, pub := buildFixtures(t) views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, DNSTargets{}) if err != nil { t.Fatal(err) } if len(views) != 2 || views[0].App != "ghost" { t.Fatalf("views = %+v, want ghost then kass", views) } if len(views[0].Routes) != 0 || len(views[0].Channels) != 0 { t.Fatalf("ghost should be empty: %+v", views[0]) } } func TestAssembleDegradesBadChannel(t *testing.T) { regPath, store, _, pub := buildFixtures(t) // A garbage pointer must become one row's error, never a failed page. if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "beta"), []byte("not a signed pointer"), ""); err != nil { t.Fatal(err) } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) if err != nil { t.Fatalf("Assemble must not fail on a bad channel: %v", err) } beta := findChannel(t, views[0].Channels, "beta") if beta.Err == "" { t.Fatalf("beta = %+v, want an Err", beta) } if stable := findChannel(t, views[0].Channels, "stable"); stable.Err != "" { t.Fatalf("healthy channel degraded too: %+v", stable) } } // TestAssembleCarriesProcessOwnership guards the copy in Assemble's // RouteView literal: Unit/Backing/Provisioned are what later tasks (2, 3, 6, // 9) use to tell "no readings because nothing samples this route's process" // apart from "not responding" — a silent drop here would make every // unit-backed route look unowned. func TestAssembleCarriesProcessOwnership(t *testing.T) { regPath, store, _, pub := buildFixtures(t) reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "unit-stamped.example.test", Kind: "instance", Status: "ready", App: "kass", Channel: "stable", Unit: "titogo@demo.service", Backing: registry.BackingUnit, Provisioned: true}); err != nil { t.Fatal(err) } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) if err != nil { t.Fatal(err) } var rt RouteView found := false for _, r := range views[0].Routes { if r.Host == "unit-stamped.example.test" { rt = r found = true } } if !found { t.Fatalf("route unit-stamped.example.test not in %+v", views[0].Routes) } if rt.Unit != "titogo@demo.service" || rt.Backing != registry.BackingUnit || !rt.Provisioned { t.Fatalf("RouteView lost process ownership: %+v", rt) } } // TestMismatchListsRoutes guards the route-granularity replacement for the // old app-wide first-writer-wins mismatch string: an app with one route // stamped to another account, among others that agree with the account // being viewed, must name that one route — not claim the whole app is // broken. func TestMismatchListsRoutes(t *testing.T) { regPath, store, _, pub := buildFixtures(t) reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "one.mismatchlist.test", Kind: "instance", Status: "ready", App: "mismatchlist", Channel: "stable", Account: "otheracct"}, {Host: "two.mismatchlist.test", Kind: "instance", Status: "ready", App: "mismatchlist", Channel: "stable"}, {Host: "three.mismatchlist.test", Kind: "instance", Status: "ready", App: "mismatchlist", Channel: "stable"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, DNSTargets{}) if err != nil { t.Fatal(err) } v := views[0] if len(v.Routes) != 2 { t.Fatalf("Routes = %+v, want only the viewed account's two routes", v.Routes) } if v.AllMismatched { t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)") } if v.AccountMismatch() || v.RouteAccount != "" { t.Fatalf("Assemble leaked cross-account diagnostics: %+v", v) } } // TestMismatchAllRoutes guards the AllMismatched claim: only when every // non-platform route disagrees is "this app is not deploying" a sentence // the page can back. func TestMismatchAllRoutes(t *testing.T) { regPath, store, _, pub := buildFixtures(t) reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "one.mismatchall.test", Kind: "instance", Status: "ready", App: "mismatchall", Channel: "stable", Account: "otheracct"}, {Host: "two.mismatchall.test", Kind: "instance", Status: "ready", App: "mismatchall", Channel: "stable", Account: "otheracct"}, {Host: "three.mismatchall.test", Kind: "instance", Status: "ready", App: "mismatchall", Channel: "stable", Account: "otheracct"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, DNSTargets{}) if err != nil { t.Fatal(err) } v := views[0] if len(v.Routes) != 0 || v.AccountMismatch() || v.AllMismatched { t.Fatalf("foreign routes leaked into view: %+v", v) } } // TestMismatchIgnoresPlatformRoutes guards the exclusion carried over from // the old routeAccount map: a platform-minted route stamped to another // account is reconciliation lag, not a stuck deploy, and must contribute // nothing to Mismatched/AllMismatched. func TestMismatchIgnoresPlatformRoutes(t *testing.T) { regPath, store, _, pub := buildFixtures(t) reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "real.mismatchplatform.test", Kind: "instance", Status: "ready", App: "mismatchplatform", Channel: "stable"}, {Host: "alias.mismatchplatform.test", Kind: "instance", Status: "ready", App: "mismatchplatform", Channel: "stable", Account: "otheracct", Platform: true}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, DNSTargets{}) if err != nil { t.Fatal(err) } v := views[0] if len(v.Mismatched) != 0 { t.Fatalf("Mismatched = %+v, want none (only route is platform-minted)", v.Mismatched) } if v.AllMismatched { t.Fatal("AllMismatched = true, want false") } if v.AccountMismatch() { t.Fatal("AccountMismatch() = true, want false") } } func TestVersionsNewestFirst(t *testing.T) { _, store, _, _ := buildFixtures(t) vs, err := Versions(store, release.OpsAccount, "kass") if err != nil { t.Fatal(err) } if len(vs) != 2 { t.Fatalf("versions = %+v, want 2", vs) } if vs[0].Version != "v2" || vs[1].Version != "v1" { t.Fatalf("order = %s, %s — want v2 (newer built_at) first", vs[0].Version, vs[1].Version) } if vs[0].Kind != "binary" || vs[0].BuiltAt != "2026-07-02T10:00:00Z" || vs[0].Err != "" { t.Fatalf("v2 = %+v", vs[0]) } } func TestVersionsDegradesBadManifest(t *testing.T) { _, store, _, _ := buildFixtures(t) if _, err := store.Put(context.Background(), release.ManifestKey(release.OpsAccount, "kass", "v3"), []byte("junk"), ""); err != nil { t.Fatal(err) } vs, err := Versions(store, release.OpsAccount, "kass") if err != nil { t.Fatalf("Versions must not fail on one bad manifest: %v", err) } var v3 *VersionView for i := range vs { if vs[i].Version == "v3" { v3 = &vs[i] } } if v3 == nil || v3.Err == "" { t.Fatalf("versions = %+v, want a degraded v3 row", vs) } } // countingModStore is the production store shape in miniature — a bare // DirStore, ListMod and all (struct embedding, so the method survives where // deletableStore's interface embedding would drop it) — counting manifest // reads. type countingModStore struct { *release.DirStore gets int } func (c *countingModStore) Get(ctx context.Context, key string) ([]byte, string, error) { c.gets++ return c.DirStore.Get(ctx, key) } // shipAt ships a real release (manifest and all — GetManifest's validation // gate must accept these rows) and pins every object's mod time so the // ranking key is under the test's control. func shipAt(t *testing.T, store release.Store, dir, v string, mod time.Time) { t.Helper() ctx := context.Background() m := release.Manifest{ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339), Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, } if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { t.Fatal(err) } for _, key := range []string{ release.ManifestKey(release.OpsAccount, "kass", v), release.ArtifactKey(release.OpsAccount, "kass", v, "kass"), } { p := filepath.Join(dir, filepath.FromSlash(key)) if _, err := os.Stat(p); err != nil { continue // artifact filename differs per Ship's layout; manifest is the one that must rank } if err := os.Chtimes(p, mod, mod); err != nil { t.Fatal(err) } } } // TestVersionsBoundsManifestReads: when the store can rank keys by recency // (ModLister — both real stores), Versions reads only the manifests it will // render, and picks them by manifest MOD TIME, not by name — version names // here are numbered AGAINST recency (v01 shipped last), so a selection that // quietly ranked by name would return the wrong rows. Fetch-then-sort made // the app page O(every version ever shipped) in sequential bucket GETs — // tito's titogo reached 318 versions and ~8s per page render before this // was bounded. func TestVersionsBoundsManifestReads(t *testing.T) { dir := t.TempDir() store := &countingModStore{DirStore: release.NewDirStore(dir)} base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) const shipped = versionsCap + 5 for i := 1; i <= shipped; i++ { // v01 gets the NEWEST stamp, v25 the oldest. shipAt(t, store, dir, fmt.Sprintf("v%02d", i), base.Add(time.Duration(shipped-i)*time.Minute)) } store.gets = 0 // Ship reads during setup don't count vs, err := Versions(store, release.OpsAccount, "kass") if err != nil { t.Fatal(err) } if len(vs) != versionsCap { t.Fatalf("len = %d, want versionsCap %d", len(vs), versionsCap) } // The newest versionsCap ships, newest first: v01..v20. v21..v25 (the // oldest ships, despite their bigger numbers) are the ones cut. for i := 0; i < versionsCap; i++ { want := fmt.Sprintf("v%02d", i+1) if vs[i].Version != want { t.Fatalf("vs[%d] = %s, want %s (rank by mod time, not name)", i, vs[i].Version, want) } } // Real rendered rows, not error rows: the fixture manifests must survive // GetManifest's validation. if vs[0].Kind != "binary" || vs[0].BuiltAt == "" || vs[0].Err != "" { t.Fatalf("vs[0] = %+v, want a rendered release row", vs[0]) } if store.gets != versionsCap { t.Fatalf("read %d manifests for %d shipped versions, want %d — rank by mod time BEFORE reading", store.gets, shipped, versionsCap) } } // TestVersionsRankedPathDegrades: the ranked path keeps the fallback path's // degradation contract — a junk manifest and a ship that died before its // manifest each cost one row (Err set, sorted last by their unparseable // built_at), never the listing. func TestVersionsRankedPathDegrades(t *testing.T) { dir := t.TempDir() store := &countingModStore{DirStore: release.NewDirStore(dir)} ctx := context.Background() base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) shipAt(t, store, dir, "good1", base) shipAt(t, store, dir, "good2", base.Add(time.Hour)) if _, err := store.Put(ctx, release.ManifestKey(release.OpsAccount, "kass", "junk1"), []byte("junk"), ""); err != nil { t.Fatal(err) } if _, err := store.Put(ctx, release.ArtifactKey(release.OpsAccount, "kass", "dead1", "kass"), []byte("bin"), ""); err != nil { t.Fatal(err) } vs, err := Versions(store, release.OpsAccount, "kass") if err != nil { t.Fatalf("Versions must not fail on bad rows: %v", err) } if len(vs) != 4 { t.Fatalf("versions = %+v, want all 4 rows", vs) } // Healthy rows first (newest built_at first), degraded rows last. if vs[0].Version != "good2" || vs[1].Version != "good1" { t.Fatalf("healthy rows = %s, %s; want good2, good1", vs[0].Version, vs[1].Version) } for _, i := range []int{2, 3} { if vs[i].Err == "" { t.Fatalf("vs[%d] = %+v, want a degraded row", i, vs[i]) } } } func TestVersionsEmptyApp(t *testing.T) { _, store, _, _ := buildFixtures(t) vs, err := Versions(store, release.OpsAccount, "ghost") if err != nil || len(vs) != 0 { t.Fatalf("Versions(ghost) = %+v %v, want empty nil", vs, err) } }