| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // pruneFixture seeds n versions v01..vNN with ascending hour-apart stamps |
| 14 | // ending 30 days before now, so age protection (72h) never applies unless a |
| 15 | // test asks for it. Each manifest is a real, parseable one (not just a |
| 16 | // stamp): TestPruneEligibleProtectsPointerVersionAndHistory promotes fixture |
| 17 | // versions, and Promote's GetManifest call parses the body — a version's |
| 18 | // SHA256 is a fixed placeholder, never checked against the artifact bytes |
| 19 | // (validateManifest only checks the hex shape). Returns the signing pair for |
| 20 | // pointer/production writes. |
| 21 | func pruneFixture(t *testing.T, n int) (dir string, s *DirStore, now time.Time) { |
| 22 | t.Helper() |
| 23 | dir = t.TempDir() |
| 24 | s = NewDirStore(dir) |
| 25 | now = time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) |
| 26 | base := now.Add(-30 * 24 * time.Hour) |
| 27 | ctx := context.Background() |
| 28 | for i := 1; i <= n; i++ { |
| 29 | v := version(i) |
| 30 | mod := base.Add(time.Duration(i) * time.Hour) |
| 31 | m := Manifest{ |
| 32 | Schema: 1, App: "kass", Version: v, Kind: "binary", |
| 33 | Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 1}}, |
| 34 | } |
| 35 | raw, err := json.Marshal(&m) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | manifestKey := ManifestKey("bab", "kass", v) |
| 40 | if _, err := s.Put(ctx, manifestKey, raw, ""); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(manifestKey)), mod, mod); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod) |
| 47 | } |
| 48 | return dir, s, now |
| 49 | } |
| 50 | |
| 51 | func version(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) } |
| 52 | |
| 53 | // restamp updates an already-written key's mod time in place. modStamp's Put |
| 54 | // is create-only (it exists to seed a fresh key + stamp in one call), so |
| 55 | // re-stamping a key pruneFixture already wrote needs the bare Chtimes half |
| 56 | // of that pattern on its own. |
| 57 | func restamp(t *testing.T, dir, key string, mod time.Time) { |
| 58 | t.Helper() |
| 59 | if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestPruneEligibleKeepsNewestNAndReturnsOldestFirst(t *testing.T) { |
| 65 | _, s, now := pruneFixture(t, 25) |
| 66 | seedB64, pubB64, err := GenerateKey() |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | _ = seedB64 |
| 71 | pub, err := ParsePublicKey(pubB64, "test") |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | // 25 versions, keep 20 → the 5 oldest are eligible, oldest first. |
| 80 | want := []string{"v01", "v02", "v03", "v04", "v05"} |
| 81 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 82 | t.Fatalf("eligible = %v, want %v", got, want) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestPruneEligibleProtectsTheBakeCeiling(t *testing.T) { |
| 87 | dir, s, now := pruneFixture(t, 25) |
| 88 | // Restamp the OLDEST version to 1h ago: inside the 72h ceiling, so even |
| 89 | // though it ranks outside keep-20 it must not be eligible. |
| 90 | restamp(t, dir, ManifestKey("bab", "kass", "v01"), now.Add(-time.Hour)) |
| 91 | _, pubB64, _ := GenerateKey() |
| 92 | pub, _ := ParsePublicKey(pubB64, "test") |
| 93 | got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | for _, v := range got { |
| 98 | if v == "v01" { |
| 99 | t.Fatal("a version inside the 72h bake ceiling was eligible") |
| 100 | } |
| 101 | } |
| 102 | // v01 now ranks NEWEST, so keep-20 shifts: v02..v06 become the eligible five. |
| 103 | want := []string{"v02", "v03", "v04", "v05", "v06"} |
| 104 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 105 | t.Fatalf("eligible = %v, want %v", got, want) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestPruneEligibleProtectsPointerVersionAndHistory(t *testing.T) { |
| 110 | _, s, now := pruneFixture(t, 25) |
| 111 | seedB64, pubB64, _ := GenerateKey() |
| 112 | priv, _ := ParseSigningKey(seedB64, "test") |
| 113 | pub, _ := ParsePublicKey(pubB64, "test") |
| 114 | ctx := context.Background() |
| 115 | // Promote v01 then v02 onto edge: pointer names v02, History carries v01. |
| 116 | if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v01"); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v02"); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | want := []string{"v03", "v04", "v05"} |
| 127 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 128 | t.Fatalf("eligible = %v, want %v (pointer + history protected)", got, want) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestPruneEligibleProtectsCanaryBranchPointerAndHistory(t *testing.T) { |
| 133 | _, s, now := pruneFixture(t, 25) |
| 134 | seedB64, pubB64, _ := GenerateKey() |
| 135 | priv, _ := ParseSigningKey(seedB64, "test") |
| 136 | pub, _ := ParsePublicKey(pubB64, "test") |
| 137 | ctx := context.Background() |
| 138 | // Promote v01 then v02 onto the nested channel name canary/x: pointer |
| 139 | // names v02, History carries v01. Pins the TrimPrefix+CutSuffix parsing |
| 140 | // in PruneEligible against a channel name that itself contains a slash — |
| 141 | // production fleets run on canary/* channels today. |
| 142 | if err := Promote(ctx, s, priv, "bab", "kass", "canary/x", "v01"); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if err := Promote(ctx, s, priv, "bab", "kass", "canary/x", "v02"); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | for _, v := range got { |
| 153 | if v == "v01" || v == "v02" { |
| 154 | t.Fatalf("eligible = %v, want v01 and v02 protected (canary/x pointer + history)", got) |
| 155 | } |
| 156 | } |
| 157 | want := []string{"v03", "v04", "v05"} |
| 158 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 159 | t.Fatalf("eligible = %v, want %v (canary/x pointer + history protected)", got, want) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestPruneEligibleProtectsTagReleases(t *testing.T) { |
| 164 | _, s, now := pruneFixture(t, 25) |
| 165 | _, pubB64, _ := GenerateKey() |
| 166 | pub, _ := ParsePublicKey(pubB64, "test") |
| 167 | ctx := context.Background() |
| 168 | // A tag whose Release names v03 — Tag.Release is the protected field, |
| 169 | // Tag.Version is just the semver name. |
| 170 | raw := []byte(`{"schema":1,"version":"1.2.0","release":"v03","tagged_at":"2026-08-01T00:00:00Z"}`) |
| 171 | if _, err := s.Put(ctx, TagKey("bab", "kass", "1.2.0"), raw, ""); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | want := []string{"v01", "v02", "v04", "v05"} |
| 179 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 180 | t.Fatalf("eligible = %v, want %v (tagged release protected)", got, want) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestPruneEligibleProtectsCallerExtras(t *testing.T) { |
| 185 | _, s, now := pruneFixture(t, 25) |
| 186 | _, pubB64, _ := GenerateKey() |
| 187 | pub, _ := ParsePublicKey(pubB64, "test") |
| 188 | got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, |
| 189 | map[string]bool{"v02": true, "v04": true}) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | want := []string{"v01", "v03", "v05"} |
| 194 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 195 | t.Fatalf("eligible = %v, want %v (box pins protected)", got, want) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestPruneEligibleAbortsOnUnverifiablePointer(t *testing.T) { |
| 200 | _, s, now := pruneFixture(t, 25) |
| 201 | _, pubB64, _ := GenerateKey() |
| 202 | pub, _ := ParsePublicKey(pubB64, "test") |
| 203 | ctx := context.Background() |
| 204 | // Garbage where a signed pointer should be: the whole app's prune must |
| 205 | // abort — never "prune what we could verify". |
| 206 | if _, err := s.Put(ctx, ChannelKey("bab", "kass", "edge"), []byte(`{"not":"signed"}`), ""); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if _, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil); err == nil { |
| 210 | t.Fatal("an unverifiable pointer did not abort the prune") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestPruneEligibleFailClosedGates(t *testing.T) { |
| 215 | _, s, now := pruneFixture(t, 5) |
| 216 | _, pubB64, _ := GenerateKey() |
| 217 | pub, _ := ParsePublicKey(pubB64, "test") |
| 218 | ctx := context.Background() |
| 219 | if _, err := PruneEligible(ctx, s, nil, "bab", "kass", 20, now, nil); err == nil { |
| 220 | t.Fatal("nil pubkey accepted") |
| 221 | } |
| 222 | if _, err := PruneEligible(ctx, s, pub, "bab", "kass", KeepFloor-1, now, nil); err == nil { |
| 223 | t.Fatal("keep below floor accepted") |
| 224 | } |
| 225 | if _, err := PruneEligible(ctx, plainStore{s}, pub, "bab", "kass", 20, now, nil); err == nil { |
| 226 | t.Fatal("a store that cannot rank by age was accepted") |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | func TestPruneEligibleManifestlessAgedShipIsEligible(t *testing.T) { |
| 231 | dir, s, now := pruneFixture(t, 25) |
| 232 | // An aborted ship (artifact only, no manifest), OLDER than every shipped |
| 233 | // version (the fixture's stamps start 30 days back): junk, outside |
| 234 | // keep-N, eligible. (Stamped newer it would occupy a keep slot instead — |
| 235 | // aborted ships surface in listings by design, PR #134.) |
| 236 | modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), now.Add(-31*24*time.Hour)) |
| 237 | _, pubB64, _ := GenerateKey() |
| 238 | pub, _ := ParsePublicKey(pubB64, "test") |
| 239 | got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil) |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | found := false |
| 244 | for _, v := range got { |
| 245 | found = found || v == "dead1" |
| 246 | } |
| 247 | if !found { |
| 248 | t.Fatalf("eligible = %v, want dead1 included (aged aborted ship)", got) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // deleteRecorder pins the manifest-first rule: the order of Deletes is the |
| 253 | // difference between a half-deleted version that reads as an aborted ship |
| 254 | // and one that reads as a complete version with missing artifacts. |
| 255 | type deleteRecorder struct { |
| 256 | Store |
| 257 | order []string |
| 258 | } |
| 259 | |
| 260 | func (d *deleteRecorder) Delete(ctx context.Context, key string) error { |
| 261 | d.order = append(d.order, key) |
| 262 | return d.Store.Delete(ctx, key) |
| 263 | } |
| 264 | |
| 265 | func TestDeleteVersionManifestFirstThenIdempotent(t *testing.T) { |
| 266 | dir, s, _ := pruneFixture(t, 1) |
| 267 | _ = dir |
| 268 | rec := &deleteRecorder{Store: s} |
| 269 | ctx := context.Background() |
| 270 | if err := DeleteVersion(ctx, rec, "bab", "kass", "v01"); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | if len(rec.order) < 2 || rec.order[0] != ManifestKey("bab", "kass", "v01") { |
| 274 | t.Fatalf("delete order = %v, want manifest first", rec.order) |
| 275 | } |
| 276 | keys, err := s.List(ctx, ReleasesPrefix("bab", "kass")+"v01/") |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | if len(keys) != 0 { |
| 281 | t.Fatalf("v01 keys after delete = %v, want none", keys) |
| 282 | } |
| 283 | // Deleting again is a no-op, not an error. |
| 284 | if err := DeleteVersion(ctx, s, "bab", "kass", "v01"); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | } |
| 288 | |