| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/provision" |
| 10 | "github.com/superfly/ltx" |
| 11 | ) |
| 12 | |
| 13 | const ( |
| 14 | rlHost = "a.example.com" |
| 15 | rlRB = "rb_abcdefghijklmnopqrstuvwxyz" |
| 16 | ) |
| 17 | |
| 18 | // The return lane is a staging lane (§7.3's rules apply to it, round-2 |
| 19 | // finding 6): a staging prefix named by the rollback id, never the move's. |
| 20 | func TestReturnLaneIsTheRollbacksStagingPrefix(t *testing.T) { |
| 21 | if got := ReturnLane(rlHost, rlRB); got != StagingPrefix(rlHost, rlRB) || got != "moves/"+rlHost+"/"+rlRB+"/" { |
| 22 | t.Fatalf("ReturnLane = %q", got) |
| 23 | } |
| 24 | if ScopeLocation(rlHost, rlRB)+"/" != ReturnLane(rlHost, rlRB) { |
| 25 | t.Fatal("the lane's park scope is not the lane") |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func returnFiles() ([]SealedObject, []SealedObject) { |
| 30 | lane := ReturnLane(rlHost, rlRB) |
| 31 | return []SealedObject{{Key: lane + "files/files-ab.sealed", VersionID: "v2", Size: 100}, {Key: lane + "files/files-ab.json", VersionID: "v3", Size: 50}}, |
| 32 | []SealedObject{{Key: lane + "timers/timers.json", VersionID: "v4", Size: 40}} |
| 33 | } |
| 34 | |
| 35 | // The return seal states the two facts a dirty return makes true (§7.8 |
| 36 | // returning): it is built from the stopped target's state (FromDB) and the |
| 37 | // lane covers that state exactly (SrcCovered). Its bound is the staging |
| 38 | // lease's, and it validates and round-trips like any seal. |
| 39 | func TestReturnSealOfTheStoppedSnapshot(t *testing.T) { |
| 40 | at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) |
| 41 | until := at.Add(40 * time.Minute) |
| 42 | snap := SealedObject{Key: ReturnLane(rlHost, rlRB) + "replica/0009/" + ltx.FormatFilename(1, 9), VersionID: "v1", Size: ltx.HeaderSize + 4096} |
| 43 | files, timers := returnFiles() |
| 44 | s := ReturnSeal(9, []SealedObject{snap}, files, timers, nil, at, until) |
| 45 | if s.V != SealVersion || s.N != 9 || !s.FromDB || !s.SrcCovered || !s.SealedAt.Equal(at) || !s.Bound().Equal(until) { |
| 46 | t.Fatalf("seal %+v", s) |
| 47 | } |
| 48 | if err := s.Validate(); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if !s.ReachesN() || !s.HasFiles() { |
| 52 | t.Fatalf("the snapshot does not reach N or the archive is missing: %+v", s) |
| 53 | } |
| 54 | got, err := DecodeSeal(s.Encode()) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if got.Encode() != s.Encode() || len(got.Replica) != 1 || got.Replica[0] != snap { |
| 59 | t.Fatalf("round trip: %+v", got) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // The "as given" return (J22): the staged seal's many level files, each in |
| 64 | // the lane at the version the copy answered. |
| 65 | func TestReturnSealAsGivenTakesManyReplicaObjects(t *testing.T) { |
| 66 | at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) |
| 67 | lane := ReturnLane(rlHost, rlRB) |
| 68 | replica := []SealedObject{ |
| 69 | {Key: lane + "replica/0000/" + ltx.FormatFilename(3, 3), VersionID: "v6", Size: ltx.HeaderSize}, |
| 70 | {Key: lane + "replica/0009/" + ltx.FormatFilename(1, 2), VersionID: "v5", Size: ltx.HeaderSize + 10}, |
| 71 | } |
| 72 | files, timers := returnFiles() |
| 73 | s := ReturnSeal(3, replica, files, timers, nil, at, at.Add(time.Hour)) |
| 74 | if err := s.Validate(); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if len(s.Replica) != 2 || !s.ReachesN() { |
| 78 | t.Fatalf("seal %+v", s) |
| 79 | } |
| 80 | // The caller's slices are not reordered under it. |
| 81 | if replica[0].VersionID != "v6" { |
| 82 | t.Fatalf("ReturnSeal sorted the caller's slice: %+v", replica) |
| 83 | } |
| 84 | // A bound before the seal pins nothing: Validate refuses it. |
| 85 | if err := ReturnSeal(3, replica, files, timers, nil, at, at.Add(-time.Second)).Validate(); !errors.Is(err, ErrBadSeal) { |
| 86 | t.Fatalf("a bound before the seal: %v", err) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func baselineForTest() provision.MoveSource { |
| 91 | return provision.MoveSource{Replication: "standard", NextDue: 1234, FinalTXID: 9, DBSHA256: "sha256:db", |
| 92 | Files: &provision.MoveFilesDigest{Plain: "sha256:plain", Sealed: "sha256:sealed", Bytes: 5, Entries: 2}, |
| 93 | TimersSHA256: "sha256:timers", Evidence: "moves/bab/app/" + rlHost + "/mv_x/stopped-target.json"} |
| 94 | } |
| 95 | |
| 96 | // The return's baseline (what the dirty return restores to, Task 3's |
| 97 | // installSource.Baseline) is journalled beside the return seal and read back |
| 98 | // strictly. |
| 99 | func TestReturnBaselineRoundTrip(t *testing.T) { |
| 100 | b := baselineForTest() |
| 101 | raw, err := EncodeReturnBaseline(b) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | got, err := DecodeReturnBaseline(raw) |
| 106 | if err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if got.Files == nil || *got.Files != *b.Files || got.FinalTXID != 9 || got.TimersSHA256 != b.TimersSHA256 || got.Evidence != b.Evidence || |
| 110 | got.Replication != "standard" || got.NextDue != 1234 || got.DBSHA256 != "sha256:db" { |
| 111 | t.Fatalf("round trip: %+v", got) |
| 112 | } |
| 113 | for name, edit := range map[string]func(*provision.MoveSource){ |
| 114 | "no final txid": func(b *provision.MoveSource) { b.FinalTXID = 0 }, |
| 115 | "no files": func(b *provision.MoveSource) { b.Files = nil }, |
| 116 | "no plain digest": func(b *provision.MoveSource) { b.Files.Plain = "" }, |
| 117 | "no sealed digest": func(b *provision.MoveSource) { b.Files.Sealed = "" }, |
| 118 | "no timers": func(b *provision.MoveSource) { b.TimersSHA256 = "" }, |
| 119 | "no evidence": func(b *provision.MoveSource) { b.Evidence = "" }, |
| 120 | } { |
| 121 | bad := baselineForTest() |
| 122 | files := *bad.Files |
| 123 | bad.Files = &files |
| 124 | edit(&bad) |
| 125 | if _, err := EncodeReturnBaseline(bad); err == nil { |
| 126 | t.Errorf("%s: encoded", name) |
| 127 | } |
| 128 | } |
| 129 | for name, raw := range map[string]string{ |
| 130 | "empty": "", |
| 131 | "not json": "{", |
| 132 | "unknown field": strings.Replace(raw, `{`, `{"extra":1,`, 1), |
| 133 | "trailing data": raw + "{}", |
| 134 | "incomplete": `{"final_txid":9}`, |
| 135 | } { |
| 136 | if _, err := DecodeReturnBaseline(raw); err == nil { |
| 137 | t.Errorf("%s: decoded", name) |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |