| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "reflect" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | type testLossOwner struct { |
| 18 | err error |
| 19 | mutate bool |
| 20 | } |
| 21 | |
| 22 | func (o testLossOwner) ValidateDeclareLost(c Claim, in LostDeclaration) error { |
| 23 | if o.mutate { |
| 24 | c.Holder.Stamp++ |
| 25 | in.Consequences.RecoveryIntent[2] = 'X' |
| 26 | } |
| 27 | return o.err |
| 28 | } |
| 29 | |
| 30 | func TestDeclareLostExactScopeAndReadmitRevision(t *testing.T) { |
| 31 | ctx := context.Background() |
| 32 | s := release.NewDirStore(t.TempDir()) |
| 33 | obs := servingClaim(t, ctx, s) |
| 34 | obs, err := Update(ctx, s, obs, func(c *Claim) error { |
| 35 | c.Op = Op{Kind: KindMoving, ID: "op-move", Phase: "precommit", Exec: "box-a", Participants: []string{"box-a", "box-b"}} |
| 36 | c.Obligations = []Obligation{ |
| 37 | {ID: "local", Op: "discover", Kind: "park-local", Box: "box-a", Retirement: "ret-local", Owner: "box:box-a"}, |
| 38 | {ID: "remote", Op: "op-move", Kind: "park-replica", Region: "ie", Retirement: "ret-remote", Owner: "box:box-a"}, |
| 39 | } |
| 40 | c.Touched.Complete, c.Touched.DiscoveryOp = false, "discover" |
| 41 | return nil |
| 42 | }) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | stamp, session := obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| 47 | in := LostDeclaration{ |
| 48 | LossID: "loss_1", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag, |
| 49 | HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: stamp, HolderSession: session, |
| 50 | OpID: "op-move", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a", |
| 51 | QuarantineRevision: 7, FencedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), |
| 52 | Consequences: LossConsequences{ |
| 53 | RevokeExecution: true, RemoveParticipation: true, |
| 54 | DischargeLocal: []ObligationRef{{ID: "local", Op: "discover", Retirement: "ret-local"}}, |
| 55 | ReassignRegional: []ObligationRef{{ID: "remote", Op: "op-move", Retirement: "ret-remote"}}, |
| 56 | DestroyedStorage: []string{"discover/local/ret-local"}, |
| 57 | RecoveryIntent: []byte(`{"kind":"lost-target","destination":"owner-decides"}`), |
| 58 | }, |
| 59 | } |
| 60 | pub, priv, err := ed25519.GenerateKey(nil) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | record := validLossRecord(t) |
| 65 | record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation |
| 66 | record.HolderStamp, record.HolderSession = stamp, session |
| 67 | record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag |
| 68 | record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase |
| 69 | record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt |
| 70 | record.ConfirmedConsequences = in.Consequences |
| 71 | record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...) |
| 72 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | got, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{mutate: true}) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if got.Claim.Op.Exec != "none" || len(got.Claim.Op.Participants) != 1 || got.Claim.Op.Participants[0] != "box-b" { |
| 80 | t.Fatalf("authority = exec %q participants %v", got.Claim.Op.Exec, got.Claim.Op.Participants) |
| 81 | } |
| 82 | if got.Claim.Holder.Stamp != stamp || got.Claim.Holder.Session != session { |
| 83 | t.Fatalf("installation authority changed: stamp/session %d/%d", got.Claim.Holder.Stamp, got.Claim.Holder.Session) |
| 84 | } |
| 85 | if len(got.Claim.Obligations) != 1 || got.Claim.Obligations[0].ID != "remote" || got.Claim.Obligations[0].Owner != "region:ie" || |
| 86 | got.Claim.Obligations[0].Retirement != "ret-remote" || got.Claim.Obligations[0].Op != "op-move" { |
| 87 | t.Fatalf("obligations = %+v", got.Claim.Obligations) |
| 88 | } |
| 89 | if got.Claim.Touched.DiscoveryOp != "" || !got.Claim.Touched.Complete { |
| 90 | t.Fatalf("discovery completion stranded: %+v", got.Claim.Touched) |
| 91 | } |
| 92 | if got.Claim.LostRevisions["box-a"] != 7 || !stringPresent(got.Claim.Lost, "box-a") || !lossRefPresent(got.Claim, "loss_1") { |
| 93 | t.Fatalf("loss state = lost %v revisions %v refs %v", got.Claim.Lost, got.Claim.LostRevisions, got.Claim.LossRefs) |
| 94 | } |
| 95 | if got.Claim.Op.Journal["loss.loss_1"] != `{"destination":"owner-decides","kind":"lost-target"}` { |
| 96 | t.Fatalf("recovery intent = %q", got.Claim.Op.Journal["loss.loss_1"]) |
| 97 | } |
| 98 | |
| 99 | if retry, err := DeclareLost(ctx, s, pub, got, in.LossID, testLossOwner{}); err != nil || retry.ETag != got.ETag { |
| 100 | t.Fatalf("lost-response retry = %+v, %v", retry, err) |
| 101 | } |
| 102 | if _, err := Update(ctx, s, got, Readmit("box-a", 6)); !errors.Is(err, ErrWrongState) { |
| 103 | t.Fatalf("old readmit revision = %v", err) |
| 104 | } |
| 105 | readmitted, err := Update(ctx, s, got, Readmit("box-a", 7)) |
| 106 | if err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if stringPresent(readmitted.Claim.Lost, "box-a") || readmitted.Claim.LostRevisions["box-a"] != 0 || !lossRefPresent(readmitted.Claim, "loss_1") { |
| 110 | t.Fatalf("readmission state = lost %v revisions %v refs %v", readmitted.Claim.Lost, readmitted.Claim.LostRevisions, readmitted.Claim.LossRefs) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestDeclareLostFailsClosedWithoutOperationOwnerOrExactDiskProof(t *testing.T) { |
| 115 | ctx := context.Background() |
| 116 | s := release.NewDirStore(t.TempDir()) |
| 117 | obs := servingClaim(t, ctx, s) |
| 118 | obs, err := Update(ctx, s, obs, func(c *Claim) error { |
| 119 | c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}} |
| 120 | c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}} |
| 121 | return nil |
| 122 | }) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | in := LostDeclaration{LossID: "loss", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag, |
| 127 | HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: obs.Claim.Holder.Stamp, HolderSession: obs.Claim.Holder.Session, |
| 128 | OpID: "delete", OpKind: KindDeleting, OpPhase: "parking", Box: "box-a", Enrollment: "e", QuarantineRevision: 1, FencedAt: time.Now(), |
| 129 | Consequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, RecoveryIntent: []byte(`{"kind":"local-only"}`)}} |
| 130 | pub, priv, err := ed25519.GenerateKey(nil) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | record := validLossRecord(t) |
| 135 | record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation |
| 136 | record.HolderStamp, record.HolderSession = in.HolderStamp, in.HolderSession |
| 137 | record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag |
| 138 | record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase |
| 139 | record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt |
| 140 | record.ConfirmedConsequences = in.Consequences |
| 141 | record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...) |
| 142 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, nil); !errors.Is(err, ErrLossOwnerRequired) { |
| 146 | t.Fatalf("nil owner = %v", err) |
| 147 | } |
| 148 | if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{}); !errors.Is(err, ErrWrongState) { |
| 149 | t.Fatalf("missing disk assertion = %v", err) |
| 150 | } |
| 151 | denied := errors.New("move record already committed") |
| 152 | if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{err: denied}); !errors.Is(err, denied) { |
| 153 | t.Fatalf("owner denial = %v", err) |
| 154 | } |
| 155 | fresh, err := Read(ctx, s, testHost) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if fresh.Claim.Gen != obs.Claim.Gen || len(fresh.Claim.Lost) != 0 || len(fresh.Claim.LossRefs) != 0 { |
| 160 | t.Fatalf("refusal mutated claim: %+v", fresh.Claim) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func lossForObservation(t *testing.T, obs Observed, id string, revision uint64, box string, effects LossConsequences) LossRecord { |
| 165 | t.Helper() |
| 166 | r := validLossRecord(t) |
| 167 | r.ID, r.Host = id, obs.Host |
| 168 | r.HolderIncarnation, r.HolderStamp, r.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| 169 | r.ExpectedClaimGen, r.ExpectedClaimETag = obs.Claim.Gen, obs.ETag |
| 170 | r.OpID, r.OpKind, r.OpPhase = obs.Claim.Op.ID, obs.Claim.Op.Kind, obs.Claim.Op.Phase |
| 171 | r.Box, r.QuarantineRevision = box, revision |
| 172 | r.ConfirmedConsequences = effects |
| 173 | r.WriteSources = nil |
| 174 | r.OperatorAttestation.DestroyedStorage = append([]string(nil), effects.DestroyedStorage...) |
| 175 | return r |
| 176 | } |
| 177 | |
| 178 | func TestDeclareLostPreservesLargeRecoveryIntentIntegers(t *testing.T) { |
| 179 | ctx := t.Context() |
| 180 | s := release.NewDirStore(t.TempDir()) |
| 181 | pub, priv, err := ed25519.GenerateKey(nil) |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | obs := servingClaim(t, ctx, s) |
| 186 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 187 | c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}} |
| 188 | return nil |
| 189 | }) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | intent := `{"kind":"recover","nested":{"txid":9007199254740993},"positions":[18446744073709551615,-9007199254740993]}` |
| 194 | record := lossForObservation(t, obs, "large_integers", 1, "box-a", LossConsequences{ |
| 195 | RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(intent), |
| 196 | }) |
| 197 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if _, err := DeclareLost(ctx, s, pub, obs, record.ID, testLossOwner{}); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | fresh, err := Read(ctx, s, obs.Host) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if got := fresh.Claim.Op.Journal["loss."+record.ID]; got != intent { |
| 208 | t.Fatalf("persisted recovery intent = %s, want %s", got, intent) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestDeclareLostNewerRevisionAppliesSecondConsequences(t *testing.T) { |
| 213 | ctx := t.Context() |
| 214 | s := release.NewDirStore(t.TempDir()) |
| 215 | pub, priv, err := ed25519.GenerateKey(nil) |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | obs := servingClaim(t, ctx, s) |
| 220 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 221 | c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}} |
| 222 | c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}} |
| 223 | return nil |
| 224 | }) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | first := lossForObservation(t, obs, "stopped", 9, "box-a", LossConsequences{ |
| 229 | RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: []byte(`{"kind":"wait-for-storage"}`), |
| 230 | }) |
| 231 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), first); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | stopped, err := DeclareLost(ctx, s, pub, obs, first.ID, testLossOwner{}) |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | if len(stopped.Claim.Obligations) != 1 { |
| 239 | t.Fatal("compute stop discharged local storage") |
| 240 | } |
| 241 | stopped, err = Update(ctx, s, stopped, func(c *Claim) error { c.Op.ID = "delete-second"; c.Op.Journal = nil; return nil }) |
| 242 | if err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | second := lossForObservation(t, stopped, "destroyed", 10, "box-a", LossConsequences{ |
| 246 | DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, |
| 247 | DestroyedStorage: []string{"delete/local/ret"}, RecoveryIntent: []byte(`{"kind":"destroyed-storage"}`), |
| 248 | }) |
| 249 | // A new revision does not excuse stale claim generation or ETag bindings. |
| 250 | stale := second |
| 251 | stale.ID, stale.ExpectedClaimGen, stale.ExpectedClaimETag = "stale", obs.Claim.Gen, obs.ETag |
| 252 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), stale); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if _, err := DeclareLost(ctx, s, pub, stopped, stale.ID, testLossOwner{}); !errors.Is(err, ErrConflict) { |
| 256 | t.Fatalf("stale second binding = %v", err) |
| 257 | } |
| 258 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), second); err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | destroyed, err := DeclareLost(ctx, s, pub, stopped, second.ID, testLossOwner{}) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if len(destroyed.Claim.Obligations) != 0 || destroyed.Claim.LostRevisions["box-a"] != 10 || |
| 266 | !reflect.DeepEqual(destroyed.Claim.LossRefs, []string{"stopped", "destroyed"}) { |
| 267 | t.Fatalf("second declaration not applied: %+v", destroyed.Claim) |
| 268 | } |
| 269 | for _, rev := range []uint64{8, 9, 10} { |
| 270 | r := lossForObservation(t, destroyed, fmt.Sprintf("older_%d", rev), rev, "box-a", LossConsequences{RecoveryIntent: []byte(`{}`)}) |
| 271 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | if _, err := DeclareLost(ctx, s, pub, destroyed, r.ID, testLossOwner{}); !errors.Is(err, ErrWrongState) { |
| 275 | t.Fatalf("revision %d accepted: %v", rev, err) |
| 276 | } |
| 277 | } |
| 278 | if _, err := Update(ctx, s, destroyed, Readmit("box-a", 9)); !errors.Is(err, ErrWrongState) { |
| 279 | t.Fatalf("old readmission cleared newer quarantine: %v", err) |
| 280 | } |
| 281 | for _, id := range []string{first.ID, second.ID} { |
| 282 | retry, err := DeclareLost(ctx, s, pub, destroyed, id, testLossOwner{}) |
| 283 | if err != nil || retry.ETag != destroyed.ETag { |
| 284 | t.Fatalf("replay %s = %v", id, err) |
| 285 | } |
| 286 | } |
| 287 | unchanged, err := ReadLossRecord(ctx, s, pub, first.ID) |
| 288 | if err != nil || !reflect.DeepEqual(unchanged.Record, first) { |
| 289 | t.Fatal("first declaration was rewritten", err) |
| 290 | } |
| 291 | fresh, err := Read(ctx, s, obs.Host) |
| 292 | if err != nil || fresh.ETag != destroyed.ETag { |
| 293 | t.Fatal("refusal or replay mutated claim", err) |
| 294 | } |
| 295 | readmitted, err := Update(ctx, s, fresh, Readmit("box-a", 10)) |
| 296 | if err != nil || len(readmitted.Claim.Lost) != 0 || len(readmitted.Claim.LossRefs) != 2 { |
| 297 | t.Fatal("matching readmission", err) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestServingDestroyedLocalOnlyPreservesAuthorityAndOtherChores(t *testing.T) { |
| 302 | for _, otherChores := range []bool{false, true} { |
| 303 | t.Run(fmt.Sprint(otherChores), func(t *testing.T) { |
| 304 | ctx := t.Context() |
| 305 | s := release.NewDirStore(t.TempDir()) |
| 306 | pub, priv, err := ed25519.GenerateKey(nil) |
| 307 | if err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | obs, err := Read(ctx, s, testHost) |
| 311 | if err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | var regions []string |
| 315 | boxes := []string{"box-a"} |
| 316 | if otherChores { |
| 317 | regions = []string{"uk"} |
| 318 | boxes = append(boxes, "other-box") |
| 319 | } |
| 320 | obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", regions, boxes, "discovery", counter())) |
| 321 | if err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | if obs.Claim.Op.Kind != KindServing || obs.Claim.Op.ID != "" { |
| 325 | t.Fatal("fixture is not an adopted serving claim") |
| 326 | } |
| 327 | var ref ObligationRef |
| 328 | for _, o := range obs.Claim.Obligations { |
| 329 | if o.Kind == "park-local" && o.Box == "box-a" { |
| 330 | ref = ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| 331 | } |
| 332 | } |
| 333 | effects := LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)} |
| 334 | r := lossForObservation(t, obs, "local_only", 11, "box-a", effects) |
| 335 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | got, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{mutate: true}) |
| 339 | if err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | want, err := cloneClaim(obs.Claim) |
| 343 | if err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | remaining := make([]Obligation, 0, len(want.Obligations)-1) |
| 347 | for _, o := range want.Obligations { |
| 348 | if o.ID != ref.ID { |
| 349 | remaining = append(remaining, o) |
| 350 | } |
| 351 | } |
| 352 | want.Obligations = remaining |
| 353 | if !otherChores { |
| 354 | want.Touched.Complete, want.Touched.DiscoveryOp = true, "" |
| 355 | } |
| 356 | want.Gen = got.Claim.Gen |
| 357 | want.Lost, want.LostRevisions, want.LossRefs = []string{"box-a"}, map[string]uint64{"box-a": 11}, []string{r.ID} |
| 358 | // Compare serialized claims so nil/empty omitted arrays are equivalent. |
| 359 | wantBytes, _ := json.Marshal(want) |
| 360 | gotBytes, _ := json.Marshal(got.Claim) |
| 361 | if !bytes.Equal(wantBytes, gotBytes) { |
| 362 | t.Fatalf("local-only changed unrelated state:\nwant %s\ngot %s", wantBytes, gotBytes) |
| 363 | } |
| 364 | if retry, err := DeclareLost(ctx, s, pub, got, r.ID, testLossOwner{}); err != nil || retry.ETag != got.ETag { |
| 365 | t.Fatal("local-only replay", err) |
| 366 | } |
| 367 | }) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | func TestServingDestroyedLocalOnlyRefusesWideningAndStaleBindings(t *testing.T) { |
| 372 | ctx := t.Context() |
| 373 | s := release.NewDirStore(t.TempDir()) |
| 374 | pub, priv, err := ed25519.GenerateKey(nil) |
| 375 | if err != nil { |
| 376 | t.Fatal(err) |
| 377 | } |
| 378 | obs, err := Read(ctx, s, testHost) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", nil, []string{"box-a"}, "discovery", counter())) |
| 383 | if err != nil { |
| 384 | t.Fatal(err) |
| 385 | } |
| 386 | o := obs.Claim.Obligations[0] |
| 387 | ref := ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| 388 | base := lossForObservation(t, obs, "local", 1, "box-a", LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)}) |
| 389 | for name, mutate := range map[string]func(*LossRecord){ |
| 390 | "execution": func(r *LossRecord) { r.ConfirmedConsequences.RevokeExecution = true }, |
| 391 | "participation": func(r *LossRecord) { r.ConfirmedConsequences.RemoveParticipation = true }, |
| 392 | "regional": func(r *LossRecord) { r.ConfirmedConsequences.ReassignRegional = []ObligationRef{ref} }, |
| 393 | "writes": func(r *LossRecord) { r.ConfirmedConsequences.WritesPotentiallyLost = true }, |
| 394 | "timers": func(r *LossRecord) { r.ConfirmedConsequences.TimersPotentiallyLost = true }, |
| 395 | "recovery": func(r *LossRecord) { |
| 396 | r.ConfirmedConsequences.RecoveryIntent = []byte(`{"kind":"destroyed-local-only","destination":"other"}`) |
| 397 | }, |
| 398 | "empty": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal = nil }, |
| 399 | "missing-proof": func(r *LossRecord) { |
| 400 | r.ConfirmedConsequences.DestroyedStorage = nil |
| 401 | r.OperatorAttestation.DestroyedStorage = nil |
| 402 | }, |
| 403 | "op": func(r *LossRecord) { r.OpID = "discovery" }, |
| 404 | "phase": func(r *LossRecord) { r.OpPhase = "recover" }, |
| 405 | "non-serving-empty-op": func(r *LossRecord) { r.OpKind = KindDeleting }, |
| 406 | } { |
| 407 | t.Run(name, func(t *testing.T) { |
| 408 | r := base |
| 409 | r.ID = name |
| 410 | mutate(&r) |
| 411 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); !errors.Is(err, ErrLossRecord) { |
| 412 | t.Fatalf("widened serving declaration = %v", err) |
| 413 | } |
| 414 | }) |
| 415 | } |
| 416 | for name, mutate := range map[string]func(*LossRecord){ |
| 417 | "claim-gen": func(r *LossRecord) { r.ExpectedClaimGen++ }, |
| 418 | "claim-etag": func(r *LossRecord) { r.ExpectedClaimETag = "stale" }, |
| 419 | "stamp": func(r *LossRecord) { r.HolderStamp++ }, |
| 420 | "session": func(r *LossRecord) { r.HolderSession++ }, |
| 421 | "box": func(r *LossRecord) { r.Box = "other-box" }, |
| 422 | "original-op": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Op = "other-op" }, |
| 423 | "retirement": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Retirement = "other-retirement" }, |
| 424 | } { |
| 425 | t.Run(name, func(t *testing.T) { |
| 426 | r := base |
| 427 | r.ID = name |
| 428 | r.ConfirmedConsequences.DischargeLocal = append([]ObligationRef(nil), base.ConfirmedConsequences.DischargeLocal...) |
| 429 | mutate(&r) |
| 430 | ref := r.ConfirmedConsequences.DischargeLocal[0] |
| 431 | r.ConfirmedConsequences.DestroyedStorage = []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement} |
| 432 | r.OperatorAttestation.DestroyedStorage = r.ConfirmedConsequences.DestroyedStorage |
| 433 | if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| 434 | t.Fatal(err) |
| 435 | } |
| 436 | if _, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{}); err == nil { |
| 437 | t.Fatal("stale or wrong scope was accepted") |
| 438 | } |
| 439 | }) |
| 440 | } |
| 441 | fresh, err := Read(ctx, s, obs.Host) |
| 442 | if err != nil || fresh.ETag != obs.ETag { |
| 443 | t.Fatal("refusal changed serving claim", err) |
| 444 | } |
| 445 | } |
| 446 | |