carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
4 "errors"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/carlosframework/platform/internal/provision"
10 "github.com/superfly/ltx"
11)
12
13const (
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.
20func 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
29func 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.
39func 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.
65func 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
90func 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.
99func 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