package release import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) // pruneFixture seeds n versions v01..vNN with ascending hour-apart stamps // ending 30 days before now, so age protection (72h) never applies unless a // test asks for it. Each manifest is a real, parseable one (not just a // stamp): TestPruneEligibleProtectsPointerVersionAndHistory promotes fixture // versions, and Promote's GetManifest call parses the body — a version's // SHA256 is a fixed placeholder, never checked against the artifact bytes // (validateManifest only checks the hex shape). Returns the signing pair for // pointer/production writes. func pruneFixture(t *testing.T, n int) (dir string, s *DirStore, now time.Time) { t.Helper() dir = t.TempDir() s = NewDirStore(dir) now = time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) base := now.Add(-30 * 24 * time.Hour) ctx := context.Background() for i := 1; i <= n; i++ { v := version(i) mod := base.Add(time.Duration(i) * time.Hour) m := Manifest{ Schema: 1, App: "kass", Version: v, Kind: "binary", Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 1}}, } raw, err := json.Marshal(&m) if err != nil { t.Fatal(err) } manifestKey := ManifestKey("bab", "kass", v) if _, err := s.Put(ctx, manifestKey, raw, ""); err != nil { t.Fatal(err) } if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(manifestKey)), mod, mod); err != nil { t.Fatal(err) } modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod) } return dir, s, now } func version(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) } // restamp updates an already-written key's mod time in place. modStamp's Put // is create-only (it exists to seed a fresh key + stamp in one call), so // re-stamping a key pruneFixture already wrote needs the bare Chtimes half // of that pattern on its own. func restamp(t *testing.T, dir, key string, mod time.Time) { t.Helper() if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { t.Fatal(err) } } func TestPruneEligibleKeepsNewestNAndReturnsOldestFirst(t *testing.T) { _, s, now := pruneFixture(t, 25) seedB64, pubB64, err := GenerateKey() if err != nil { t.Fatal(err) } _ = seedB64 pub, err := ParsePublicKey(pubB64, "test") if err != nil { t.Fatal(err) } got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } // 25 versions, keep 20 → the 5 oldest are eligible, oldest first. want := []string{"v01", "v02", "v03", "v04", "v05"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v", got, want) } } func TestPruneEligibleProtectsTheBakeCeiling(t *testing.T) { dir, s, now := pruneFixture(t, 25) // Restamp the OLDEST version to 1h ago: inside the 72h ceiling, so even // though it ranks outside keep-20 it must not be eligible. restamp(t, dir, ManifestKey("bab", "kass", "v01"), now.Add(-time.Hour)) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } for _, v := range got { if v == "v01" { t.Fatal("a version inside the 72h bake ceiling was eligible") } } // v01 now ranks NEWEST, so keep-20 shifts: v02..v06 become the eligible five. want := []string{"v02", "v03", "v04", "v05", "v06"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v", got, want) } } func TestPruneEligibleProtectsPointerVersionAndHistory(t *testing.T) { _, s, now := pruneFixture(t, 25) seedB64, pubB64, _ := GenerateKey() priv, _ := ParseSigningKey(seedB64, "test") pub, _ := ParsePublicKey(pubB64, "test") ctx := context.Background() // Promote v01 then v02 onto edge: pointer names v02, History carries v01. if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v01"); err != nil { t.Fatal(err) } if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v02"); err != nil { t.Fatal(err) } got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } want := []string{"v03", "v04", "v05"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v (pointer + history protected)", got, want) } } func TestPruneEligibleProtectsCanaryBranchPointerAndHistory(t *testing.T) { _, s, now := pruneFixture(t, 25) seedB64, pubB64, _ := GenerateKey() priv, _ := ParseSigningKey(seedB64, "test") pub, _ := ParsePublicKey(pubB64, "test") ctx := context.Background() // Promote v01 then v02 onto the nested channel name canary/x: pointer // names v02, History carries v01. Pins the TrimPrefix+CutSuffix parsing // in PruneEligible against a channel name that itself contains a slash — // production fleets run on canary/* channels today. if err := Promote(ctx, s, priv, "bab", "kass", "canary/x", "v01"); err != nil { t.Fatal(err) } if err := Promote(ctx, s, priv, "bab", "kass", "canary/x", "v02"); err != nil { t.Fatal(err) } got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } for _, v := range got { if v == "v01" || v == "v02" { t.Fatalf("eligible = %v, want v01 and v02 protected (canary/x pointer + history)", got) } } want := []string{"v03", "v04", "v05"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v (canary/x pointer + history protected)", got, want) } } func TestPruneEligibleProtectsTagReleases(t *testing.T) { _, s, now := pruneFixture(t, 25) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") ctx := context.Background() // A tag whose Release names v03 — Tag.Release is the protected field, // Tag.Version is just the semver name. raw := []byte(`{"schema":1,"version":"1.2.0","release":"v03","tagged_at":"2026-08-01T00:00:00Z"}`) if _, err := s.Put(ctx, TagKey("bab", "kass", "1.2.0"), raw, ""); err != nil { t.Fatal(err) } got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } want := []string{"v01", "v02", "v04", "v05"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v (tagged release protected)", got, want) } } func TestPruneEligibleProtectsCallerExtras(t *testing.T) { _, s, now := pruneFixture(t, 25) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, map[string]bool{"v02": true, "v04": true}) if err != nil { t.Fatal(err) } want := []string{"v01", "v03", "v05"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("eligible = %v, want %v (box pins protected)", got, want) } } func TestPruneEligibleAbortsOnUnverifiablePointer(t *testing.T) { _, s, now := pruneFixture(t, 25) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") ctx := context.Background() // Garbage where a signed pointer should be: the whole app's prune must // abort — never "prune what we could verify". if _, err := s.Put(ctx, ChannelKey("bab", "kass", "edge"), []byte(`{"not":"signed"}`), ""); err != nil { t.Fatal(err) } if _, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil); err == nil { t.Fatal("an unverifiable pointer did not abort the prune") } } func TestPruneEligibleFailClosedGates(t *testing.T) { _, s, now := pruneFixture(t, 5) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") ctx := context.Background() if _, err := PruneEligible(ctx, s, nil, "bab", "kass", 20, now, nil); err == nil { t.Fatal("nil pubkey accepted") } if _, err := PruneEligible(ctx, s, pub, "bab", "kass", KeepFloor-1, now, nil); err == nil { t.Fatal("keep below floor accepted") } if _, err := PruneEligible(ctx, plainStore{s}, pub, "bab", "kass", 20, now, nil); err == nil { t.Fatal("a store that cannot rank by age was accepted") } } func TestPruneEligibleManifestlessAgedShipIsEligible(t *testing.T) { dir, s, now := pruneFixture(t, 25) // An aborted ship (artifact only, no manifest), OLDER than every shipped // version (the fixture's stamps start 30 days back): junk, outside // keep-N, eligible. (Stamped newer it would occupy a keep slot instead — // aborted ships surface in listings by design, PR #134.) modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), now.Add(-31*24*time.Hour)) _, pubB64, _ := GenerateKey() pub, _ := ParsePublicKey(pubB64, "test") got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) if err != nil { t.Fatal(err) } found := false for _, v := range got { found = found || v == "dead1" } if !found { t.Fatalf("eligible = %v, want dead1 included (aged aborted ship)", got) } } // deleteRecorder pins the manifest-first rule: the order of Deletes is the // difference between a half-deleted version that reads as an aborted ship // and one that reads as a complete version with missing artifacts. type deleteRecorder struct { Store order []string } func (d *deleteRecorder) Delete(ctx context.Context, key string) error { d.order = append(d.order, key) return d.Store.Delete(ctx, key) } func TestDeleteVersionManifestFirstThenIdempotent(t *testing.T) { dir, s, _ := pruneFixture(t, 1) _ = dir rec := &deleteRecorder{Store: s} ctx := context.Background() if err := DeleteVersion(ctx, rec, "bab", "kass", "v01"); err != nil { t.Fatal(err) } if len(rec.order) < 2 || rec.order[0] != ManifestKey("bab", "kass", "v01") { t.Fatalf("delete order = %v, want manifest first", rec.order) } keys, err := s.List(ctx, ReleasesPrefix("bab", "kass")+"v01/") if err != nil { t.Fatal(err) } if len(keys) != 0 { t.Fatalf("v01 keys after delete = %v, want none", keys) } // Deleting again is a no-op, not an error. if err := DeleteVersion(ctx, s, "bab", "kass", "v01"); err != nil { t.Fatal(err) } }