| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/superfly/ltx" |
| 10 | ) |
| 11 | |
| 12 | func sealForTest() Seal { |
| 13 | at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| 14 | return Seal{V: 1, N: 7, |
| 15 | Replica: []SealedObject{ |
| 16 | {Key: "moves/h/mv/replica/0001/0000000000000001-0000000000000007.ltx", VersionID: "v2", Size: ltx.HeaderSize + 10}, |
| 17 | {Key: "moves/h/mv/replica/0000/0000000000000007-0000000000000007.ltx", VersionID: "v1", Size: ltx.HeaderSize}, |
| 18 | }, |
| 19 | Timers: []SealedObject{{Key: "moves/h/mv/timers/timers.json", VersionID: "v3", Size: 40}}, |
| 20 | Files: []SealedObject{{Key: "moves/h/mv/files/files-ab.sealed", VersionID: "v4", Size: 100}, {Key: "moves/h/mv/files/files-ab.json", VersionID: "v5", Size: 50}}, |
| 21 | FromDB: true, |
| 22 | SealedAt: at, LeaseUntil: at.Add(16 * time.Minute)} |
| 23 | } |
| 24 | |
| 25 | // The seal round-trips through the claim journal's string, in one canonical |
| 26 | // form: objects sorted by key, so two encodings of one seal are equal. |
| 27 | func TestSealEncodeDecodeRoundTrip(t *testing.T) { |
| 28 | s := sealForTest() |
| 29 | enc := s.Encode() |
| 30 | got, err := DecodeSeal(enc) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if got.N != 7 || len(got.Replica) != 2 || len(got.Files) != 2 || len(got.Timers) != 1 || !got.FromDB || got.SrcCovered { |
| 35 | t.Fatalf("decoded %+v", got) |
| 36 | } |
| 37 | if got.Replica[0].Key > got.Replica[1].Key { |
| 38 | t.Fatalf("replica not sorted: %+v", got.Replica) |
| 39 | } |
| 40 | if again := got.Encode(); again != enc { |
| 41 | t.Fatalf("not canonical:\n%s\n%s", enc, again) |
| 42 | } |
| 43 | if !got.Bound().Equal(s.LeaseUntil) { |
| 44 | t.Fatalf("Bound %v, want LeaseUntil %v", got.Bound(), s.LeaseUntil) |
| 45 | } |
| 46 | if !got.HasFiles() { |
| 47 | t.Fatal("HasFiles false on a seal that names the archive") |
| 48 | } |
| 49 | got.Files = nil |
| 50 | if got.HasFiles() { |
| 51 | t.Fatal("HasFiles true with no files") |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // A seal that pins nothing is refused, on encode-side validation and on |
| 56 | // decode: a missing or "null" version, an empty key, a key twice, a replica |
| 57 | // object below an LTX header, no position, no bound, another version. |
| 58 | func TestSealRefusesWhatPinsNothing(t *testing.T) { |
| 59 | for name, edit := range map[string]func(*Seal){ |
| 60 | "version 2": func(s *Seal) { s.V = 2 }, |
| 61 | "no position": func(s *Seal) { s.N = 0 }, |
| 62 | "no replica": func(s *Seal) { s.Replica = nil }, |
| 63 | "null version": func(s *Seal) { s.Replica[0].VersionID = "null" }, |
| 64 | "empty version": func(s *Seal) { s.Timers[0].VersionID = "" }, |
| 65 | "empty key": func(s *Seal) { s.Files[0].Key = "" }, |
| 66 | "duplicate key": func(s *Seal) { s.Files[1].Key = s.Files[0].Key }, |
| 67 | "short replica": func(s *Seal) { s.Replica[1].Size = ltx.HeaderSize - 1 }, |
| 68 | "negative size": func(s *Seal) { s.Files[0].Size = -1 }, |
| 69 | "no bound": func(s *Seal) { s.LeaseUntil = time.Time{} }, |
| 70 | "not sealed": func(s *Seal) { s.SealedAt = time.Time{} }, |
| 71 | "replica not an ltx": func(s *Seal) { s.Replica[0].Key = "moves/h/mv/replica/lock.json" }, |
| 72 | "bound before sealed": func(s *Seal) { s.LeaseUntil = s.SealedAt.Add(-time.Second) }, |
| 73 | } { |
| 74 | t.Run(name, func(t *testing.T) { |
| 75 | s := sealForTest() |
| 76 | edit(&s) |
| 77 | if err := s.Validate(); !errors.Is(err, ErrBadSeal) { |
| 78 | t.Fatalf("Validate = %v, want ErrBadSeal", err) |
| 79 | } |
| 80 | if _, err := DecodeSeal(s.Encode()); !errors.Is(err, ErrBadSeal) { |
| 81 | t.Fatalf("DecodeSeal = %v, want ErrBadSeal", err) |
| 82 | } |
| 83 | }) |
| 84 | } |
| 85 | for name, raw := range map[string]string{ |
| 86 | "empty": "", |
| 87 | "not json": "{", |
| 88 | "unknown field": strings.Replace(sealForTest().Encode(), `"n":7`, `"n":7,"extra":1`, 1), |
| 89 | "trailing": sealForTest().Encode() + "{}", |
| 90 | } { |
| 91 | t.Run(name, func(t *testing.T) { |
| 92 | if _, err := DecodeSeal(raw); !errors.Is(err, ErrBadSeal) { |
| 93 | t.Fatalf("DecodeSeal(%q) = %v, want ErrBadSeal", raw, err) |
| 94 | } |
| 95 | }) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // The replica reaches N: some sealed level file ends at N, and none ends |
| 100 | // after it. |
| 101 | func TestSealReachesN(t *testing.T) { |
| 102 | s := sealForTest() |
| 103 | if !s.ReachesN() { |
| 104 | t.Fatal("a seal whose newest file ends at N does not reach N") |
| 105 | } |
| 106 | s.N = 8 |
| 107 | if s.ReachesN() { |
| 108 | t.Fatal("a seal ending at 7 reaches N = 8") |
| 109 | } |
| 110 | s.N = 6 |
| 111 | if s.ReachesN() { |
| 112 | t.Fatal("a seal holding a file past N reaches N = 6") |
| 113 | } |
| 114 | } |
| 115 | |