| 1 | package provision |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/maintenanceguard" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | func testProjection(t *testing.T, generation int64) MaintenanceProjection { |
| 17 | t.Helper() |
| 18 | b, e := maintenanceguard.EncodeProjection(maintenanceguard.Projection{Version: 1, Generation: generation, GuardDigest: strings.Repeat("a", 64)}) |
| 19 | if e != nil { |
| 20 | t.Fatal(e) |
| 21 | } |
| 22 | return b |
| 23 | } |
| 24 | func TestMaintenanceProjectionParsing(t *testing.T) { |
| 25 | valid := string(testProjection(t, 1)) |
| 26 | for _, value := range []string{"null", "{}", "[]", `"private"`, strings.Replace(valid, `"version":1`, `"version":2`, 1), strings.Replace(valid, `"generation":1`, `"generation":1.0`, 1), strings.Replace(valid, `"generation":1`, `"generation":0`, 1), strings.Replace(valid, `"version":1`, `"version":1,"version":1`, 1), strings.Replace(valid, `"version":1`, `"Version":1`, 1), strings.Replace(valid, `"version":1`, `"version":1,"extra":true`, 1)} { |
| 27 | t.Run(value, func(t *testing.T) { |
| 28 | raw := []byte(`{"maintenance":` + value + `}`) |
| 29 | r, e := DecodeRecord(raw) |
| 30 | if e != ErrMaintenanceProjection { |
| 31 | t.Fatal("accepted invalid") |
| 32 | } |
| 33 | if _, e = json.Marshal(r); e == nil { |
| 34 | t.Fatal("invalid disappeared") |
| 35 | } |
| 36 | var direct Record |
| 37 | if json.Unmarshal(raw, &direct) == nil { |
| 38 | t.Fatal("field accepted invalid") |
| 39 | } |
| 40 | if _, e = json.Marshal(direct); e == nil { |
| 41 | t.Fatal("ignored field error disappeared") |
| 42 | } |
| 43 | }) |
| 44 | } |
| 45 | for _, raw := range []string{`{"maintenance":` + valid + `,"maintenance":` + valid + `}`, `{"Maintenance":` + valid + `}`, `{"maintenance":` + valid + `,"MAINTENANCE":null}`, `null`, `{} {}`} { |
| 46 | if _, e := DecodeRecord([]byte(raw)); e == nil { |
| 47 | t.Fatal("accepted outer ambiguity") |
| 48 | } |
| 49 | } |
| 50 | r, e := DecodeRecord([]byte(`{"maintenance":` + valid + `,"future_record_field":true}`)) |
| 51 | if e != nil { |
| 52 | t.Fatal(e) |
| 53 | } |
| 54 | if _, e = json.Marshal(r); e != nil { |
| 55 | t.Fatal(e) |
| 56 | } |
| 57 | legacy := Record{Host: "legacy"} |
| 58 | b, e := json.Marshal(legacy) |
| 59 | if e != nil || bytes.Contains(b, []byte("maintenance")) { |
| 60 | t.Fatal("legacy changed", e) |
| 61 | } |
| 62 | } |
| 63 | func TestMaintenanceDoesNotPromoteJSONMethods(t *testing.T) { |
| 64 | type view struct { |
| 65 | Record |
| 66 | HiddenGate string `json:"gate,omitempty"` |
| 67 | Extra string `json:"extra"` |
| 68 | } |
| 69 | in := view{Record: Record{Gate: "private hash", Maintenance: testProjection(t, 1)}, Extra: "joined"} |
| 70 | b, e := json.Marshal(in) |
| 71 | if e != nil || bytes.Contains(b, []byte("private hash")) { |
| 72 | t.Fatal("gate leaked", e) |
| 73 | } |
| 74 | var out view |
| 75 | if json.Unmarshal(b, &out) != nil || out.Extra != "joined" || len(out.Maintenance) == 0 { |
| 76 | t.Fatal("embedded fields swallowed") |
| 77 | } |
| 78 | } |
| 79 | func TestOrdinaryWritersPreserveProjection(t *testing.T) { |
| 80 | s, sqid := setup(t) |
| 81 | rec := acmeRecord(sqid) |
| 82 | rec.Maintenance = testProjection(t, 1) |
| 83 | if CreateRecord(ctx, s, rec) != ErrMaintenanceProjection { |
| 84 | t.Fatal("ordinary create installed projection") |
| 85 | } |
| 86 | key := RecordKey(rec.Account, rec.App, rec.Host) |
| 87 | raw, e := json.Marshal(rec) |
| 88 | if e != nil { |
| 89 | t.Fatal(e) |
| 90 | } |
| 91 | if _, e = s.Put(ctx, key, raw, ""); e != nil { |
| 92 | t.Fatal(e) |
| 93 | } |
| 94 | for _, mut := range []func(*Record){func(r *Record) { r.Maintenance = nil }, func(r *Record) { r.Maintenance = testProjection(t, 2) }, func(r *Record) { r.CreatedAt = r.CreatedAt.Add(time.Second) }, func(r *Record) { r.Account = "different" }, func(r *Record) { r.App = "different" }, func(r *Record) { r.Host = "different.example" }} { |
| 95 | before, etag, e := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host) |
| 96 | if e != nil { |
| 97 | t.Fatal(e) |
| 98 | } |
| 99 | mut(before) |
| 100 | if UpdateRecord(ctx, s, *before, etag) == nil { |
| 101 | t.Fatal("whole writer changed protected fields") |
| 102 | } |
| 103 | if _, e = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { mut(r); return nil }); e == nil { |
| 104 | t.Fatal("callback changed protected fields") |
| 105 | } |
| 106 | } |
| 107 | current, etag, e := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host) |
| 108 | if e != nil { |
| 109 | t.Fatal(e) |
| 110 | } |
| 111 | current.Maintenance = MaintenanceProjection(strings.Replace(string(current.Maintenance), ":", ": ", -1)) |
| 112 | current.CreatedAt = current.CreatedAt.In(time.FixedZone("same instant", 3600)) |
| 113 | current.Channel = "new" |
| 114 | if e = UpdateRecord(ctx, s, *current, etag); e != nil { |
| 115 | t.Fatal("semantic equality refused", e) |
| 116 | } |
| 117 | if e = SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "uk"); e != nil { |
| 118 | t.Fatal(e) |
| 119 | } |
| 120 | got, e := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| 121 | if e != nil || !protectedFieldsEqual(rec, *got) || got.Region != "uk" { |
| 122 | t.Fatal("projection lost", e) |
| 123 | } |
| 124 | absent := acmeRecord(sqid) |
| 125 | absent.Host = "other.go.tito.io" |
| 126 | if e = CreateRecord(ctx, s, absent); e != nil { |
| 127 | t.Fatal(e) |
| 128 | } |
| 129 | if _, e = SetRecordMove(ctx, s, absent.Account, absent.App, absent.Host, func(r *Record) error { r.Maintenance = testProjection(t, 1); return nil }); e != ErrMaintenanceProjection { |
| 130 | t.Fatal("installed into absent record", e) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | type projectionRaceStore struct { |
| 135 | release.Store |
| 136 | beforePut func() |
| 137 | } |
| 138 | |
| 139 | func (s *projectionRaceStore) Put(c context.Context, k string, b []byte, tag string) (string, error) { |
| 140 | if s.beforePut != nil { |
| 141 | f := s.beforePut |
| 142 | s.beforePut = nil |
| 143 | f() |
| 144 | } |
| 145 | return s.Store.Put(c, k, b, tag) |
| 146 | } |
| 147 | func TestProjectionCASRaces(t *testing.T) { |
| 148 | for _, retry := range []bool{false, true} { |
| 149 | t.Run(map[bool]string{false: "whole", true: "retry"}[retry], func(t *testing.T) { |
| 150 | base, sqid := setup(t) |
| 151 | rec := acmeRecord(sqid) |
| 152 | if e := CreateRecord(ctx, base, rec); e != nil { |
| 153 | t.Fatal(e) |
| 154 | } |
| 155 | old, tag, e := GetRecordETag(ctx, base, rec.Account, rec.App, rec.Host) |
| 156 | if e != nil { |
| 157 | t.Fatal(e) |
| 158 | } |
| 159 | wrapped := &projectionRaceStore{Store: base, beforePut: func() { |
| 160 | rec.Maintenance = testProjection(t, 1) |
| 161 | b, e := json.Marshal(rec) |
| 162 | if e != nil { |
| 163 | t.Fatal(e) |
| 164 | } |
| 165 | putReplace(t, base, RecordKey(rec.Account, rec.App, rec.Host), b) |
| 166 | }} |
| 167 | if retry { |
| 168 | e = SetRecordRegion(ctx, wrapped, rec.Account, rec.App, rec.Host, "uk") |
| 169 | if e != nil { |
| 170 | t.Fatal(e) |
| 171 | } |
| 172 | } else { |
| 173 | old.Channel = "new" |
| 174 | e = UpdateRecord(ctx, wrapped, *old, tag) |
| 175 | if !errors.Is(e, release.ErrCASConflict) { |
| 176 | t.Fatal("stale write won", e) |
| 177 | } |
| 178 | } |
| 179 | got, e := GetRecord(ctx, base, rec.Account, rec.App, rec.Host) |
| 180 | if e != nil || len(got.Maintenance) == 0 { |
| 181 | t.Fatal("concurrent projection stripped", e) |
| 182 | } |
| 183 | if !retry && got.Channel != rec.Channel { |
| 184 | t.Fatal("failed write applied") |
| 185 | } |
| 186 | }) |
| 187 | } |
| 188 | } |
| 189 | func TestMalformedProjectionReconcileAndLists(t *testing.T) { |
| 190 | s, sqid := setup(t) |
| 191 | rec := acmeRecord(sqid) |
| 192 | if e := CreateRecord(ctx, s, rec); e != nil { |
| 193 | t.Fatal(e) |
| 194 | } |
| 195 | reg := regWith(t) |
| 196 | cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| 197 | if e := Reconcile(ctx, s, reg, cfg); e != nil { |
| 198 | t.Fatal(e) |
| 199 | } |
| 200 | for _, host := range []string{rec.Host, "new.go.tito.io"} { |
| 201 | rec.Host = host |
| 202 | if host == "new.go.tito.io" { |
| 203 | if e := CreateRecord(ctx, s, rec); e != nil { |
| 204 | t.Fatal(e) |
| 205 | } |
| 206 | } |
| 207 | b, e := json.Marshal(rec) |
| 208 | if e != nil { |
| 209 | t.Fatal(e) |
| 210 | } |
| 211 | b = append(b[:len(b)-1], []byte(`,"maintenance":null}`)...) |
| 212 | putReplace(t, s, RecordKey(sqid, "tito", host), b) |
| 213 | if _, _, e = GetRecordETag(ctx, s, sqid, "tito", host); e == nil { |
| 214 | t.Fatal("get accepted invalid") |
| 215 | } |
| 216 | if e = SetRecordRegion(ctx, s, sqid, "tito", host, "uk"); e == nil { |
| 217 | t.Fatal("writer accepted invalid") |
| 218 | } |
| 219 | } |
| 220 | if e := Reconcile(ctx, s, reg, cfg); e != nil { |
| 221 | t.Fatal(e) |
| 222 | } |
| 223 | mustGet(t, reg, "acme.go.tito.io") |
| 224 | if r, e := reg.Get("new.go.tito.io"); e != nil || r != nil { |
| 225 | t.Fatal("malformed created route") |
| 226 | } |
| 227 | if records, _, e := ListRecords(ctx, s, sqid, "tito", 10); e != nil || len(records) != 0 { |
| 228 | t.Fatal("list semantics changed") |
| 229 | } |
| 230 | if records, e := ListAllRecords(ctx, s); e != nil || len(records) != 0 { |
| 231 | t.Fatal("all-list semantics changed") |
| 232 | } |
| 233 | } |
| 234 | |