package registry import ( "errors" "path/filepath" "reflect" "testing" ) func TestPlan14TimerMaterializesIntendedIdentity(t *testing.T) { for _, transport := range []string{"local", "child"} { t.Run(transport, func(t *testing.T) { var r *Registry if transport == "child" { r, _ = remoteTestRegistry(t) } else { var err error r, err = Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer r.Close() } host := "shop.example" routeOwner := ClaimOwnership{"inc", 3, "owner", "route"} if err := r.Upsert(Route{Host: host, ClaimOwnership: routeOwner}); err != nil { t.Fatal(err) } for _, kind := range []string{"schedule", "once", "landing"} { timerKind := kind if kind == "landing" { timerKind = "schedule" } old := Timer{ClaimOwnership: ClaimOwnership{"inc", 3, "owner", "first-" + kind}, Host: host, Name: kind, Kind: timerKind, Spec: "every 1h", Path: "/tick", NextAt: 123, DueAt: 120, CreatedAt: 90, LastAt: 80, LastMs: 7, LastStatus: "ok", Attempts: 2, Registration: "first-" + kind} installed, err := r.MaterializeTimerRegistration(old, "") if err != nil || installed == nil || *installed != old { t.Fatalf("intended identity changed: %+v %v", installed, err) } if got, err := r.MaterializeTimerRegistration(old, ""); err != nil || got == nil || *got != old { t.Fatalf("lost-reply replay: %+v %v", got, err) } changed := old changed.Path = "/collision" if _, err := r.MaterializeTimerRegistration(changed, old.Registration); !errors.Is(err, ErrTimerChanged) { t.Fatal("identity reused for changed timer", err) } for _, column := range []string{"incarnation", "stamp", "owner"} { wrong := old switch column { case "incarnation": wrong.ClaimIncarnation = "other" case "stamp": wrong.ClaimStamp++ case "owner": wrong.ClaimOwner = "other" } if err := r.DeleteTimerOwnedRegistration(wrong); !errors.Is(err, ErrTimerChanged) { t.Fatal("copied registration removed another owner", err) } } next := old next.Registration = "next-" + kind next.ClaimRowID = next.Registration next.Path = "/new" if _, err := r.MaterializeTimerRegistration(next, "wrong"); !errors.Is(err, ErrTimerChanged) { t.Fatal("stale update accepted", err) } if _, err := r.MaterializeTimerRegistration(next, old.Registration); err != nil { t.Fatal(err) } if err := r.DeleteTimerOwnedRegistration(old); !errors.Is(err, ErrTimerChanged) { t.Fatal("successor removed", err) } got, err := r.GetTimer(host, kind) if err != nil || !reflect.DeepEqual(got, &next) { t.Fatalf("successor changed: %+v %v", got, err) } if err := r.DeleteTimerOwnedRegistration(next); err != nil { t.Fatal(err) } } }) } } func TestPlan14TimerMaterializationRefusesOwnershipChanges(t *testing.T) { for _, attack := range []string{"missing owner", "other incarnation", "other stamp", "other owner", "row differs", "empty registration", "absent route", "replaced route"} { t.Run(attack, func(t *testing.T) { r, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer r.Close() owner := ClaimOwnership{"inc", 3, "owner", "route"} if attack != "absent route" { if err := r.Upsert(Route{Host: "shop.example", ClaimOwnership: owner}); err != nil { t.Fatal(err) } } timer := Timer{Host: "shop.example", Name: "once", Kind: "once", Path: "/tick", Registration: "intended", ClaimOwnership: ClaimOwnership{"inc", 3, "owner", "intended"}} switch attack { case "missing owner": timer.ClaimOwnership = ClaimOwnership{} case "other incarnation": timer.ClaimIncarnation = "other" case "other stamp": timer.ClaimStamp++ case "other owner": timer.ClaimOwner = "other" case "row differs": timer.ClaimRowID = "other" case "empty registration": timer.Registration = "" case "replaced route": if _, err := r.sql.Exec(`UPDATE routes SET claim_stamp=claim_stamp+1 WHERE host=?`, timer.Host); err != nil { t.Fatal(err) } } if _, err := r.MaterializeTimerRegistration(timer, ""); err == nil { t.Fatal("unintended ownership materialized") } if rows, err := r.TimersFor(timer.Host); err != nil || len(rows) != 0 { t.Fatal("refusal wrote rows", rows, err) } }) } }