| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "github.com/carlosframework/platform/internal/release" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestPlan10MoveLocalDischargeCompletesDiscovery(t *testing.T) { |
| 13 | for _, otherChores := range []bool{false, true} { |
| 14 | t.Run(fmt.Sprint(otherChores), func(t *testing.T) { |
| 15 | ctx := t.Context() |
| 16 | s := release.NewDirStore(t.TempDir()) |
| 17 | pub, priv, err := ed25519.GenerateKey(nil) |
| 18 | if err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | obs, err := Read(ctx, s, testHost) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | var regions []string |
| 26 | boxes := []string{"box-a"} |
| 27 | if otherChores { |
| 28 | regions = []string{"uk"} |
| 29 | boxes = append(boxes, "other-box") |
| 30 | } |
| 31 | obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", regions, boxes, "discovery", counter())) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if obs.Claim.Op.Kind != KindServing || obs.Claim.Op.ID != "" { |
| 36 | t.Fatal("fixture is not an adopted serving claim") |
| 37 | } |
| 38 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 39 | c.Op = Op{ID: lostOp, Kind: KindLanding, Phase: PhaseCommitted, Exec: "serving-box", Participants: []string{"box-a", "serving-box"}, Journal: map[string]string{"keep": "unchanged"}} |
| 40 | return nil |
| 41 | }) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | var ref ObligationRef |
| 46 | for _, o := range obs.Claim.Obligations { |
| 47 | if o.Kind == "park-local" && o.Box == "box-a" { |
| 48 | ref = ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| 49 | } |
| 50 | } |
| 51 | effects := LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)} |
| 52 | r := lossForObservation(t, obs, "local_only", 11, "box-a", effects) |
| 53 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | got, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{mutate: true}) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | want, err := cloneClaim(obs.Claim) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | remaining := make([]Obligation, 0, len(want.Obligations)-1) |
| 65 | for _, o := range want.Obligations { |
| 66 | if o.ID != ref.ID { |
| 67 | remaining = append(remaining, o) |
| 68 | } |
| 69 | } |
| 70 | want.Obligations = remaining |
| 71 | if !otherChores { |
| 72 | want.Touched.Complete, want.Touched.DiscoveryOp = true, "" |
| 73 | } |
| 74 | want.Gen = got.Claim.Gen |
| 75 | want.Lost, want.LostRevisions, want.LossRefs = []string{"box-a"}, map[string]uint64{"box-a": 11}, []string{r.ID} |
| 76 | // Compare serialized claims so nil/empty omitted arrays are equivalent. |
| 77 | wantBytes, _ := json.Marshal(want) |
| 78 | gotBytes, _ := json.Marshal(got.Claim) |
| 79 | if !bytes.Equal(wantBytes, gotBytes) { |
| 80 | t.Fatalf("local-only changed unrelated state:\nwant %s\ngot %s", wantBytes, gotBytes) |
| 81 | } |
| 82 | if retry, err := DeclareLost(ctx, s, pub, got, r.ID, testLossOwner{}); err != nil || retry.ETag != got.ETag { |
| 83 | t.Fatal("local-only replay", err) |
| 84 | } |
| 85 | }) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestPlan10MoveLocalDischargeRefusesExecutingBoxAndWidening(t *testing.T) { |
| 90 | s := release.NewDirStore(t.TempDir()) |
| 91 | pub, priv, err := ed25519.GenerateKey(nil) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | o, err := Read(t.Context(), s, testHost) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | o, err = Update(t.Context(), s, o, Adopt(newHolder(), "box-a", nil, []string{"box-a"}, "discovery", counter())) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | o, err = Update(t.Context(), s, o, func(c *Claim) error { |
| 104 | c.Op = Op{ID: lostOp, Kind: KindLanding, Phase: PhaseCommitted, Exec: "box-a"} |
| 105 | return nil |
| 106 | }) |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | chore := o.Claim.Obligations[0] |
| 111 | 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"}`)} |
| 112 | r := lossForObservation(t, o, "local_exec", 11, "box-a", e) |
| 113 | if _, err := CreateLossRecord(t.Context(), s, release.RootSigner(priv), r); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if _, err := DeclareLost(t.Context(), s, pub, o, r.ID, testLossOwner{}); err == nil { |
| 117 | t.Fatal("local-only discharged executing box") |
| 118 | } |
| 119 | r.ID = "widened" |
| 120 | r.ConfirmedConsequences.RevokeExecution = true |
| 121 | if err := r.Validate(); err == nil { |
| 122 | t.Fatal("local-only intent accepted widened effects") |
| 123 | } |
| 124 | } |
| 125 | |