| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | ) |
| 16 | |
| 17 | // D3-held (controller ruling): a held stage applies D3 exactly as adoption |
| 18 | // does (ProofGate.admit before staging, ProofGate.bind before publishing) |
| 19 | // and fails closed. Where adoption would refuse, the held stage returns |
| 20 | // ErrHeldNotProven before publishing and live/<host> is untouched; with no |
| 21 | // policy, or a policy that does not list the app, nothing changes. |
| 22 | |
| 23 | // heldProofEnv is heldFixture with the held host already live on "v0" (so |
| 24 | // "untouched" means something), a policy path, a bake directory, a prover |
| 25 | // key and a gate. |
| 26 | type heldProofEnv struct { |
| 27 | ctx context.Context |
| 28 | store Store |
| 29 | opt, bake string |
| 30 | policy string |
| 31 | gate *ProofGate |
| 32 | proverPub string |
| 33 | prover ed25519.PrivateKey |
| 34 | pinnedSHA string |
| 35 | } |
| 36 | |
| 37 | func newHeldProofEnv(t *testing.T) *heldProofEnv { |
| 38 | t.Helper() |
| 39 | ctx, s, opt := heldFixture(t) |
| 40 | if err := StageArtifact(opt, "bab", "v0", "console", []byte("earlier build")); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := PointLive(opt, heldTestHost, "bab", "v0", "console"); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | prover, _, pubB64 := proverKey(t) |
| 47 | e := &heldProofEnv{ctx: ctx, store: s, opt: opt, bake: t.TempDir(), |
| 48 | policy: filepath.Join(t.TempDir(), "proof-policy.json"), proverPub: pubB64, prover: prover, |
| 49 | pinnedSHA: etagOf([]byte("pinned build"))} |
| 50 | e.gate = NewProofGate(e.policy, e.bake) |
| 51 | return e |
| 52 | } |
| 53 | |
| 54 | func (e *heldProofEnv) writePolicy(t *testing.T, body string) { |
| 55 | t.Helper() |
| 56 | if err := os.WriteFile(e.policy, []byte(body), 0o644); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func (e *heldProofEnv) require(t *testing.T, app string) { |
| 62 | e.writePolicy(t, fmt.Sprintf(`{"schema":1,"require":[{"app":%q,"prover":"dev","key":%q}]}`, app, e.proverPub)) |
| 63 | } |
| 64 | |
| 65 | func (e *heldProofEnv) prove(t *testing.T, sha string) { |
| 66 | t.Helper() |
| 67 | p, err := SignProof(e.prover, Proof{App: "console", Platform: adoptPlatform, SHA256: sha, Version: "v1", Prover: "dev", |
| 68 | ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC), ProvenAt: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | b, err := json.MarshalIndent(p, "", " ") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", sha, "dev"), b, ""); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // staleCopyDown is stageStaleCopyKept for v1: a copy of other bytes staged |
| 82 | // while v1's canonical manifest cannot be read, so only D3's bind can catch |
| 83 | // it. A held stage reaches bind on such a copy only on pointer trust (want |
| 84 | // "", a build promoted before records, settings spec §4.1): under v1's |
| 85 | // promotion record the stage binds the record's digest and refuses the copy |
| 86 | // before bind (TestHeldStageRecordRefusesAStaleCopyItCannotCheck). So v1's |
| 87 | // record is removed first, and the digestless canary/x pointer is the |
| 88 | // stage's evidence. |
| 89 | func (e *heldProofEnv) staleCopyDown(t *testing.T, stale []byte) Store { |
| 90 | t.Helper() |
| 91 | if err := e.store.Delete(e.ctx, PromotionRecordKey("bab", "console", "v1")); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | return stageStaleCopyKept(t, e.ctx, e.store, e.opt, "bab", "console", "v1", stale) |
| 95 | } |
| 96 | |
| 97 | func (e *heldProofEnv) live() string { v, _ := LiveVersion(e.opt, heldTestHost); return v } |
| 98 | |
| 99 | // materialize runs MaterializeHeld with a guard that records whether it |
| 100 | // was asked to publish, and returns the error, whether the guard ran, and |
| 101 | // what was printed. |
| 102 | func (e *heldProofEnv) materialize(t *testing.T, s Store) (error, bool, string) { |
| 103 | t.Helper() |
| 104 | var ( |
| 105 | err error |
| 106 | guarded bool |
| 107 | out bytes.Buffer |
| 108 | ) |
| 109 | withCapturedStdout(t, &out, func() { |
| 110 | _, err = MaterializeHeld(e.ctx, s, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, func(publish func() error) error { |
| 111 | guarded = true |
| 112 | return publish() |
| 113 | }) |
| 114 | }) |
| 115 | return err, guarded, out.String() |
| 116 | } |
| 117 | |
| 118 | func (e *heldProofEnv) refusedUntouched(t *testing.T, err error, guarded bool) { |
| 119 | t.Helper() |
| 120 | if !errors.Is(err, ErrHeldNotProven) { |
| 121 | t.Fatalf("MaterializeHeld: %v, want ErrHeldNotProven", err) |
| 122 | } |
| 123 | if guarded { |
| 124 | t.Fatal("the held stage asked its authority to publish an unproven release") |
| 125 | } |
| 126 | if e.live() != "v0" { |
| 127 | t.Fatalf("live = %q, want v0 untouched", e.live()) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestHeldStageProofCoveredUnprovenIsRefused(t *testing.T) { |
| 132 | e := newHeldProofEnv(t) |
| 133 | e.require(t, "console") |
| 134 | err, guarded, out := e.materialize(t, e.store) |
| 135 | e.refusedUntouched(t, err, guarded) |
| 136 | if !strings.Contains(out, "is not proven on dev") { |
| 137 | t.Fatalf("printed %q, want adoption's not-proven line", out) |
| 138 | } |
| 139 | // admit runs before staging: an unproven build is never downloaded. |
| 140 | if _, err := os.Stat(VersionPath(e.opt, "bab", "v1", "console")); !errors.Is(err, os.ErrNotExist) { |
| 141 | t.Fatalf("v1 was staged for an unproven held stage: %v", err) |
| 142 | } |
| 143 | // A missing proof is not a refusal record, as in adoption. |
| 144 | for _, base := range []string{e.bake, HeldRefusalBase(e.bake)} { |
| 145 | if r, _ := ReadRefusal(base, heldTestHost); r != nil { |
| 146 | t.Fatalf("a missing proof wrote a refusal: %+v", r) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestHeldStageProofBadProofIsRefused(t *testing.T) { |
| 152 | e := newHeldProofEnv(t) |
| 153 | e.require(t, "console") |
| 154 | other, _, _ := proverKey(t) |
| 155 | p, err := SignProof(other, Proof{App: "console", Platform: adoptPlatform, SHA256: e.pinnedSHA, Version: "v1", Prover: "dev", |
| 156 | ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC), ProvenAt: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | b, _ := json.Marshal(p) |
| 161 | if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", e.pinnedSHA, "dev"), b, ""); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | err, guarded, _ := e.materialize(t, e.store) |
| 165 | e.refusedUntouched(t, err, guarded) |
| 166 | } |
| 167 | |
| 168 | func TestHeldStageProofCoveredAdmittedPublishes(t *testing.T) { |
| 169 | e := newHeldProofEnv(t) |
| 170 | e.require(t, "console") |
| 171 | e.prove(t, e.pinnedSHA) |
| 172 | if err, guarded, _ := e.materialize(t, e.store); err != nil || !guarded { |
| 173 | t.Fatalf("MaterializeHeld: %v (guarded %v)", err, guarded) |
| 174 | } |
| 175 | if e.live() != "v1" { |
| 176 | t.Fatalf("live = %q, want the proven v1", e.live()) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestHeldStageProofHealthyHistoryPublishes(t *testing.T) { |
| 181 | e := newHeldProofEnv(t) |
| 182 | e.require(t, "console") |
| 183 | if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.pinnedSHA, Version: "v1", How: "settle", At: time.Now()}); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | cs := &proofReadCountingStore{Store: e.store} |
| 187 | if err, _, _ := e.materialize(t, cs); err != nil { |
| 188 | t.Fatalf("MaterializeHeld: %v", err) |
| 189 | } |
| 190 | if e.live() != "v1" || cs.proofGets() != 0 { |
| 191 | t.Fatalf("live = %q, %d proof GETs; want v1 from the healthy history and no proof read", e.live(), cs.proofGets()) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestHeldStageProofUncoveredIsUnchanged(t *testing.T) { |
| 196 | for name, setup := range map[string]func(t *testing.T, e *heldProofEnv){ |
| 197 | "no policy file": func(*testing.T, *heldProofEnv) {}, |
| 198 | "policy lists another app": func(t *testing.T, e *heldProofEnv) { e.require(t, "other") }, |
| 199 | } { |
| 200 | t.Run(name, func(t *testing.T) { |
| 201 | e := newHeldProofEnv(t) |
| 202 | setup(t, e) |
| 203 | cs := &proofReadCountingStore{Store: e.store} |
| 204 | if err, guarded, _ := e.materialize(t, cs); err != nil || !guarded { |
| 205 | t.Fatalf("MaterializeHeld: %v (guarded %v)", err, guarded) |
| 206 | } |
| 207 | if e.live() != "v1" { |
| 208 | t.Fatalf("live = %q, want v1", e.live()) |
| 209 | } |
| 210 | if n := cs.proofGets(); n != 0 { |
| 211 | t.Fatalf("%d proof GETs for an app no policy covers, want 0", n) |
| 212 | } |
| 213 | }) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // A policy that does not load is closed for every held route, named unit |
| 218 | // or not: the held path cannot vouch for the route's unit binding. |
| 219 | func TestHeldStageProofBrokenPolicyIsRefused(t *testing.T) { |
| 220 | e := newHeldProofEnv(t) |
| 221 | e.writePolicy(t, `{"schema":1,"require":[`) |
| 222 | err, guarded, _ := e.materialize(t, e.store) |
| 223 | e.refusedUntouched(t, err, guarded) |
| 224 | } |
| 225 | |
| 226 | // bind: the manifest's sha256 is proven, but the staged file holds other |
| 227 | // bytes (a kept stale copy: see stageStaleCopyKept). Refused before |
| 228 | // publishing, with adoption's not-proven record. |
| 229 | func TestHeldStageProofBindingRefusesOtherStagedBytes(t *testing.T) { |
| 230 | e := newHeldProofEnv(t) |
| 231 | e.require(t, "console") |
| 232 | e.prove(t, e.pinnedSHA) |
| 233 | stale := []byte("not the shipped bytes") |
| 234 | down := e.staleCopyDown(t, stale) |
| 235 | // bind runs inside the publication (fix round 1), so the guard is asked |
| 236 | // and the refusal comes from within it. |
| 237 | err, _, out := e.materialize(t, down) |
| 238 | if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| 239 | t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| 240 | } |
| 241 | if !strings.Contains(out, "not pointed live") { |
| 242 | t.Fatalf("printed %q, want the binding refusal", out) |
| 243 | } |
| 244 | r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost) |
| 245 | if r == nil || r.Kind != RefusalNotProven || !strings.Contains(r.Reason, etagOf(stale)) || r.RestoredVersion != "v0" { |
| 246 | t.Fatalf("refusal = %+v, want a not-proven record naming the staged hash, still on v0", r) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // PublishHeld called on its own (after StageHeld) runs the same check. |
| 251 | func TestHeldStageProofPublishHeldChecksToo(t *testing.T) { |
| 252 | e := newHeldProofEnv(t) |
| 253 | if _, err := StageHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1"); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | e.require(t, "console") |
| 257 | var out bytes.Buffer |
| 258 | var err error |
| 259 | withCapturedStdout(t, &out, func() { |
| 260 | err = PublishHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, openGuard) |
| 261 | }) |
| 262 | if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| 263 | t.Fatalf("PublishHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // A held stage with no gate cannot publish: D3 is not optional here. |
| 268 | func TestHeldStageProofNeedsAGate(t *testing.T) { |
| 269 | e := newHeldProofEnv(t) |
| 270 | if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", nil, openGuard); err == nil { |
| 271 | t.Fatal("MaterializeHeld published with no proof gate") |
| 272 | } |
| 273 | if err := PublishHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", nil, openGuard); err == nil { |
| 274 | t.Fatal("PublishHeld published with no proof gate") |
| 275 | } |
| 276 | if e.live() != "v0" { |
| 277 | t.Fatalf("live = %q, want v0", e.live()) |
| 278 | } |
| 279 | if _, err := os.Stat(VersionPath(e.opt, "bab", "v1", "console")); !errors.Is(err, os.ErrNotExist) { |
| 280 | t.Fatalf("v1 was staged with no gate: %v", err) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // The root relay: an unproven request is removed, never published and |
| 285 | // never acknowledged; a proven one goes live. |
| 286 | func TestServeHeldStageRequestsAppliesTheProofGate(t *testing.T) { |
| 287 | e := newHeldProofEnv(t) |
| 288 | e.require(t, "console") |
| 289 | dd := t.TempDir() |
| 290 | path := writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| 291 | var out bytes.Buffer |
| 292 | withCapturedStdout(t, &out, func() { |
| 293 | if err := ServeHeldStageRequests(e.ctx, e.store, heldPub, nr(t), e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| 294 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 295 | } |
| 296 | }) |
| 297 | gone(t, path) |
| 298 | gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| 299 | if e.live() != "v0" { |
| 300 | t.Fatalf("live = %q, want v0 untouched", e.live()) |
| 301 | } |
| 302 | if !strings.Contains(out.String(), "refused") { |
| 303 | t.Fatalf("printed %q, want the refusal", out.String()) |
| 304 | } |
| 305 | |
| 306 | e.prove(t, e.pinnedSHA) |
| 307 | e.gate = NewProofGate(e.policy, e.bake) // a fresh root pass: no cached miss |
| 308 | writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| 309 | withCapturedStdout(t, &out, func() { |
| 310 | if err := ServeHeldStageRequests(e.ctx, e.store, heldPub, nr(t), e.opt, dd, e.gate, passAuth(nil)); err != nil { |
| 311 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 312 | } |
| 313 | }) |
| 314 | if e.live() != "v1" { |
| 315 | t.Fatalf("live = %q, want the proven v1", e.live()) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | // materializeWith is materialize with the caller's guard. |
| 320 | func (e *heldProofEnv) materializeWith(t *testing.T, guard func(publish func() error) error) (error, string) { |
| 321 | t.Helper() |
| 322 | var ( |
| 323 | err error |
| 324 | out bytes.Buffer |
| 325 | ) |
| 326 | withCapturedStdout(t, &out, func() { |
| 327 | _, err = MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, guard) |
| 328 | }) |
| 329 | return err, out.String() |
| 330 | } |
| 331 | |
| 332 | // Fix round 1, finding 1: the authority's re-check (bucket reads, the |
| 333 | // registry freeze) runs between staging and publication, and the staged |
| 334 | // bytes can be replaced meanwhile. bind runs inside the publication, after |
| 335 | // the re-check, so bytes replaced while the guard ran are refused. |
| 336 | func TestHeldStageProofBindsInsideThePublication(t *testing.T) { |
| 337 | e := newHeldProofEnv(t) |
| 338 | e.require(t, "console") |
| 339 | e.prove(t, e.pinnedSHA) |
| 340 | stale := []byte("replaced while the guard re-checked") |
| 341 | err, _ := e.materializeWith(t, func(publish func() error) error { |
| 342 | if err := StageArtifact(e.opt, "bab", "v1", "console", stale); err != nil { |
| 343 | t.Fatal(err) |
| 344 | } |
| 345 | return publish() |
| 346 | }) |
| 347 | if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| 348 | t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // Fix round 1, finding 1: bytes replaced between bind and the link swap |
| 353 | // are caught by a re-hash at the swap, and the link goes back to what it |
| 354 | // was (or away, when there was nothing). |
| 355 | func TestHeldStageProofRehashesAtTheSwap(t *testing.T) { |
| 356 | for name, hadLive := range map[string]bool{"was on v0": true, "had no link": false} { |
| 357 | t.Run(name, func(t *testing.T) { |
| 358 | e := newHeldProofEnv(t) |
| 359 | if !hadLive { |
| 360 | if err := os.Remove(LiveLink(e.opt, heldTestHost)); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | } |
| 364 | e.require(t, "console") |
| 365 | e.prove(t, e.pinnedSHA) |
| 366 | prev := heldBeforeSwap |
| 367 | t.Cleanup(func() { heldBeforeSwap = prev }) |
| 368 | swapped := false |
| 369 | heldBeforeSwap = func() { |
| 370 | swapped = true |
| 371 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced after bind")); err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | } |
| 375 | err, guarded, _ := e.materialize(t, e.store) |
| 376 | if !swapped || !guarded { |
| 377 | t.Fatalf("the seam ran %v, the guard ran %v", swapped, guarded) |
| 378 | } |
| 379 | want := "v0" |
| 380 | if !hadLive { |
| 381 | want = "" |
| 382 | } |
| 383 | if !errors.Is(err, ErrHeldNotProven) || e.live() != want { |
| 384 | t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and %q", err, e.live(), want) |
| 385 | } |
| 386 | }) |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | // Fix round 1, finding 2: a held binding refusal carries its move's op. |
| 391 | func TestHeldStageProofRefusalCarriesTheMove(t *testing.T) { |
| 392 | e := newHeldProofEnv(t) |
| 393 | e.require(t, "console") |
| 394 | e.prove(t, e.pinnedSHA) |
| 395 | down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| 396 | e.materialize(t, down) |
| 397 | r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost) |
| 398 | if r == nil || r.MoveOp != heldTestOp || r.Kind != RefusalNotProven { |
| 399 | t.Fatalf("refusal = %+v, want a not-proven record naming op %s", r, heldTestOp) |
| 400 | } |
| 401 | // Final review finding 3: in the held namespace, never adoption's. |
| 402 | if r, _ := ReadRefusal(e.bake, heldTestHost); r != nil { |
| 403 | t.Fatalf("the held refusal reached adoption's records: %+v", r) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | // Fix round 1, finding 2: an adoption refusal already standing for the host |
| 408 | // is never overwritten by a held one. Since final review finding 3 the held |
| 409 | // one is recorded beside it, in its own namespace. |
| 410 | func TestHeldStageProofLeavesAnAdoptionRefusal(t *testing.T) { |
| 411 | e := newHeldProofEnv(t) |
| 412 | e.require(t, "console") |
| 413 | e.prove(t, e.pinnedSHA) |
| 414 | adoption := Refusal{Host: heldTestHost, App: "console", Version: "v0", SHA256: "aa", Reason: "did not come up", |
| 415 | PointerPromotedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), RefusedAt: time.Date(2026, 10, 1, 0, 1, 0, 0, time.UTC)} |
| 416 | if err := WriteRefusal(e.bake, adoption); err != nil { |
| 417 | t.Fatal(err) |
| 418 | } |
| 419 | before, _ := os.ReadFile(filepath.Join(RefusalDir(e.bake), heldTestHost+".json")) |
| 420 | down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| 421 | err, _, _ := e.materialize(t, down) |
| 422 | if !errors.Is(err, ErrHeldNotProven) { |
| 423 | t.Fatalf("MaterializeHeld: %v, want ErrHeldNotProven", err) |
| 424 | } |
| 425 | after, _ := os.ReadFile(filepath.Join(RefusalDir(e.bake), heldTestHost+".json")) |
| 426 | if !bytes.Equal(before, after) { |
| 427 | t.Fatalf("the adoption refusal changed:\n%s\nwant\n%s", after, before) |
| 428 | } |
| 429 | if r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost); r == nil || r.MoveOp != heldTestOp { |
| 430 | t.Fatalf("the held refusal = %+v, want it recorded beside the adoption one", r) |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | // Fix round 1, finding 2: a later held stage of the same move that |
| 435 | // succeeds retires that move's refusal. |
| 436 | func TestHeldStageProofSuccessRetiresTheMovesRefusal(t *testing.T) { |
| 437 | e := newHeldProofEnv(t) |
| 438 | e.require(t, "console") |
| 439 | e.prove(t, e.pinnedSHA) |
| 440 | down := e.staleCopyDown(t, []byte("not the shipped bytes")) |
| 441 | e.materialize(t, down) |
| 442 | if r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost); r == nil { |
| 443 | t.Fatal("no refusal to retire") |
| 444 | } |
| 445 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("pinned build")); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if err, _, _ := e.materialize(t, e.store); err != nil || e.live() != "v1" { |
| 449 | t.Fatalf("retry: %v, live %q", err, e.live()) |
| 450 | } |
| 451 | if r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost); r != nil { |
| 452 | t.Fatalf("the move's refusal outlived its successful retry: %+v", r) |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | // RetireHeldRefusals removes a held refusal whose move is over and nothing |
| 457 | // else: not a live move's, never an adoption refusal. |
| 458 | func TestRetireHeldRefusals(t *testing.T) { |
| 459 | bake := t.TempDir() |
| 460 | at := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| 461 | for _, r := range []Refusal{ |
| 462 | {Host: "live.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven}, |
| 463 | {Host: "over.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven}, |
| 464 | } { |
| 465 | if err := heldRefusalWriter(bake, "mv_"+strings.TrimSuffix(r.Host, ".example.com"))(r); err != nil { |
| 466 | t.Fatal(err) |
| 467 | } |
| 468 | } |
| 469 | if err := WriteRefusal(bake, Refusal{Host: "adopted.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven}); err != nil { |
| 470 | t.Fatal(err) |
| 471 | } |
| 472 | retired, err := RetireHeldRefusals(bake, func(r Refusal) bool { return r.MoveOp == "mv_live" }) |
| 473 | if err != nil || len(retired) != 1 || retired[0] != "over.example.com" { |
| 474 | t.Fatalf("retired %v, %v; want over.example.com", retired, err) |
| 475 | } |
| 476 | left, _ := ListHeldRefusals(bake) |
| 477 | if len(left) != 1 || left[0].Host != "live.example.com" { |
| 478 | t.Fatalf("held left %+v", left) |
| 479 | } |
| 480 | if adopted, _ := ListRefusals(bake); len(adopted) != 1 || adopted[0].Host != "adopted.example.com" { |
| 481 | t.Fatalf("adoption's records %+v", adopted) |
| 482 | } |
| 483 | // With no refusal directory it reads nothing and fails nothing. |
| 484 | if retired, err := RetireHeldRefusals(t.TempDir(), func(Refusal) bool { return false }); err != nil || len(retired) != 0 { |
| 485 | t.Fatalf("empty: %v, %v", retired, err) |
| 486 | } |
| 487 | } |
| 488 | |