| new file mode 100644 |
| index 000000000..eed5778b0 |
| --- /dev/null |
| +++ b/internal/moves/returnlane_test.go |
| @@ -0,0 +1,140 @@ |
| +package moves |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +const ( |
| + rlHost = "a.example.com" |
| + rlRB = "rb_abcdefghijklmnopqrstuvwxyz" |
| +) |
| + |
| +// The return lane is a staging lane (§7.3's rules apply to it, round-2 |
| +// finding 6): a staging prefix named by the rollback id, never the move's. |
| +func TestReturnLaneIsTheRollbacksStagingPrefix(t *testing.T) { |
| + if got := ReturnLane(rlHost, rlRB); got != StagingPrefix(rlHost, rlRB) || got != "moves/"+rlHost+"/"+rlRB+"/" { |
| + t.Fatalf("ReturnLane = %q", got) |
| + } |
| + if ScopeLocation(rlHost, rlRB)+"/" != ReturnLane(rlHost, rlRB) { |
| + t.Fatal("the lane's park scope is not the lane") |
| + } |
| +} |
| + |
| +func returnFiles() ([]SealedObject, []SealedObject) { |
| + lane := ReturnLane(rlHost, rlRB) |
| + return []SealedObject{{Key: lane + "files/files-ab.sealed", VersionID: "v2", Size: 100}, {Key: lane + "files/files-ab.json", VersionID: "v3", Size: 50}}, |
| + []SealedObject{{Key: lane + "timers/timers.json", VersionID: "v4", Size: 40}} |
| +} |
| + |
| +// The return seal states the two facts a dirty return makes true (§7.8 |
| +// returning): it is built from the stopped target's state (FromDB) and the |
| +// lane covers that state exactly (SrcCovered). Its bound is the staging |
| +// lease's, and it validates and round-trips like any seal. |
| +func TestReturnSealOfTheStoppedSnapshot(t *testing.T) { |
| + at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) |
| + until := at.Add(40 * time.Minute) |
| + snap := SealedObject{Key: ReturnLane(rlHost, rlRB) + "replica/0009/" + ltx.FormatFilename(1, 9), VersionID: "v1", Size: ltx.HeaderSize + 4096} |
| + files, timers := returnFiles() |
| + s := ReturnSeal(9, []SealedObject{snap}, files, timers, at, until) |
| + if s.V != SealVersion || s.N != 9 || !s.FromDB || !s.SrcCovered || !s.SealedAt.Equal(at) || !s.Bound().Equal(until) { |
| + t.Fatalf("seal %+v", s) |
| + } |
| + if err := s.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !s.ReachesN() || !s.HasFiles() { |
| + t.Fatalf("the snapshot does not reach N or the archive is missing: %+v", s) |
| + } |
| + got, err := DecodeSeal(s.Encode()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Encode() != s.Encode() || len(got.Replica) != 1 || got.Replica[0] != snap { |
| + t.Fatalf("round trip: %+v", got) |
| + } |
| +} |
| + |
| +// The "as given" return (J22): the staged seal's many level files, each in |
| +// the lane at the version the copy answered. |
| +func TestReturnSealAsGivenTakesManyReplicaObjects(t *testing.T) { |
| + at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) |
| + lane := ReturnLane(rlHost, rlRB) |
| + replica := []SealedObject{ |
| + {Key: lane + "replica/0000/" + ltx.FormatFilename(3, 3), VersionID: "v6", Size: ltx.HeaderSize}, |
| + {Key: lane + "replica/0009/" + ltx.FormatFilename(1, 2), VersionID: "v5", Size: ltx.HeaderSize + 10}, |
| + } |
| + files, timers := returnFiles() |
| + s := ReturnSeal(3, replica, files, timers, at, at.Add(time.Hour)) |
| + if err := s.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(s.Replica) != 2 || !s.ReachesN() { |
| + t.Fatalf("seal %+v", s) |
| + } |
| + // The caller's slices are not reordered under it. |
| + if replica[0].VersionID != "v6" { |
| + t.Fatalf("ReturnSeal sorted the caller's slice: %+v", replica) |
| + } |
| + // A bound before the seal pins nothing: Validate refuses it. |
| + if err := ReturnSeal(3, replica, files, timers, at, at.Add(-time.Second)).Validate(); !errors.Is(err, ErrBadSeal) { |
| + t.Fatalf("a bound before the seal: %v", err) |
| + } |
| +} |
| + |
| +func baselineForTest() provision.MoveSource { |
| + return provision.MoveSource{Replication: "standard", NextDue: 1234, FinalTXID: 9, DBSHA256: "sha256:db", |
| + Files: &provision.MoveFilesDigest{Plain: "sha256:plain", Sealed: "sha256:sealed", Bytes: 5, Entries: 2}, |
| + TimersSHA256: "sha256:timers", Evidence: "moves/bab/app/" + rlHost + "/mv_x/stopped-target.json"} |
| +} |
| + |
| +// The return's baseline (what the dirty return restores to, Task 3's |
| +// installSource.Baseline) is journalled beside the return seal and read back |
| +// strictly. |
| +func TestReturnBaselineRoundTrip(t *testing.T) { |
| + b := baselineForTest() |
| + raw, err := EncodeReturnBaseline(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := DecodeReturnBaseline(raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Files == nil || *got.Files != *b.Files || got.FinalTXID != 9 || got.TimersSHA256 != b.TimersSHA256 || got.Evidence != b.Evidence || |
| + got.Replication != "standard" || got.NextDue != 1234 || got.DBSHA256 != "sha256:db" { |
| + t.Fatalf("round trip: %+v", got) |
| + } |
| + for name, edit := range map[string]func(*provision.MoveSource){ |
| + "no final txid": func(b *provision.MoveSource) { b.FinalTXID = 0 }, |
| + "no files": func(b *provision.MoveSource) { b.Files = nil }, |
| + "no plain digest": func(b *provision.MoveSource) { b.Files.Plain = "" }, |
| + "no sealed digest": func(b *provision.MoveSource) { b.Files.Sealed = "" }, |
| + "no timers": func(b *provision.MoveSource) { b.TimersSHA256 = "" }, |
| + "no evidence": func(b *provision.MoveSource) { b.Evidence = "" }, |
| + } { |
| + bad := baselineForTest() |
| + files := *bad.Files |
| + bad.Files = &files |
| + edit(&bad) |
| + if _, err := EncodeReturnBaseline(bad); err == nil { |
| + t.Errorf("%s: encoded", name) |
| + } |
| + } |
| + for name, raw := range map[string]string{ |
| + "empty": "", |
| + "not json": "{", |
| + "unknown field": strings.Replace(raw, `{`, `{"extra":1,`, 1), |
| + "trailing data": raw + "{}", |
| + "incomplete": `{"final_txid":9}`, |
| + } { |
| + if _, err := DecodeReturnBaseline(raw); err == nil { |
| + t.Errorf("%s: decoded", name) |
| + } |
| + } |
| +} |