package hostclaim import ( "bytes" "crypto/ed25519" "encoding/json" "fmt" "github.com/carlosframework/platform/internal/release" "testing" ) func TestPlan10MoveLocalDischargeCompletesDiscovery(t *testing.T) { for _, otherChores := range []bool{false, true} { t.Run(fmt.Sprint(otherChores), func(t *testing.T) { ctx := t.Context() s := release.NewDirStore(t.TempDir()) pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } obs, err := Read(ctx, s, testHost) if err != nil { t.Fatal(err) } var regions []string boxes := []string{"box-a"} if otherChores { regions = []string{"uk"} boxes = append(boxes, "other-box") } obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", regions, boxes, "discovery", counter())) if err != nil { t.Fatal(err) } if obs.Claim.Op.Kind != KindServing || obs.Claim.Op.ID != "" { t.Fatal("fixture is not an adopted serving claim") } obs, err = Update(ctx, s, obs, func(c *Claim) error { c.Op = Op{ID: lostOp, Kind: KindLanding, Phase: PhaseCommitted, Exec: "serving-box", Participants: []string{"box-a", "serving-box"}, Journal: map[string]string{"keep": "unchanged"}} return nil }) if err != nil { t.Fatal(err) } var ref ObligationRef for _, o := range obs.Claim.Obligations { if o.Kind == "park-local" && o.Box == "box-a" { ref = ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} } } effects := LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)} r := lossForObservation(t, obs, "local_only", 11, "box-a", effects) if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { t.Fatal(err) } got, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{mutate: true}) if err != nil { t.Fatal(err) } want, err := cloneClaim(obs.Claim) if err != nil { t.Fatal(err) } remaining := make([]Obligation, 0, len(want.Obligations)-1) for _, o := range want.Obligations { if o.ID != ref.ID { remaining = append(remaining, o) } } want.Obligations = remaining if !otherChores { want.Touched.Complete, want.Touched.DiscoveryOp = true, "" } want.Gen = got.Claim.Gen want.Lost, want.LostRevisions, want.LossRefs = []string{"box-a"}, map[string]uint64{"box-a": 11}, []string{r.ID} // Compare serialized claims so nil/empty omitted arrays are equivalent. wantBytes, _ := json.Marshal(want) gotBytes, _ := json.Marshal(got.Claim) if !bytes.Equal(wantBytes, gotBytes) { t.Fatalf("local-only changed unrelated state:\nwant %s\ngot %s", wantBytes, gotBytes) } if retry, err := DeclareLost(ctx, s, pub, got, r.ID, testLossOwner{}); err != nil || retry.ETag != got.ETag { t.Fatal("local-only replay", err) } }) } } func TestPlan10MoveLocalDischargeRefusesExecutingBoxAndWidening(t *testing.T) { s := release.NewDirStore(t.TempDir()) pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } o, err := Read(t.Context(), s, testHost) if err != nil { t.Fatal(err) } o, err = Update(t.Context(), s, o, Adopt(newHolder(), "box-a", nil, []string{"box-a"}, "discovery", counter())) if err != nil { t.Fatal(err) } o, err = Update(t.Context(), s, o, func(c *Claim) error { c.Op = Op{ID: lostOp, Kind: KindLanding, Phase: PhaseCommitted, Exec: "box-a"} return nil }) if err != nil { t.Fatal(err) } chore := o.Claim.Obligations[0] e := LossConsequences{DischargeLocal: []ObligationRef{{ID: chore.ID, Op: chore.Op, Retirement: chore.Retirement}}, DestroyedStorage: []string{chore.Op + "/" + chore.ID + "/" + chore.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)} r := lossForObservation(t, o, "local_exec", 11, "box-a", e) if _, err := CreateLossRecord(t.Context(), s, release.RootSigner(priv), r); err != nil { t.Fatal(err) } if _, err := DeclareLost(t.Context(), s, pub, o, r.ID, testLossOwner{}); err == nil { t.Fatal("local-only discharged executing box") } r.ID = "widened" r.ConfirmedConsequences.RevokeExecution = true if err := r.Validate(); err == nil { t.Fatal("local-only intent accepted widened effects") } }