package provision import ( "bytes" "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/maintenanceguard" "github.com/carlosframework/platform/internal/release" ) func testProjection(t *testing.T, generation int64) MaintenanceProjection { t.Helper() b, e := maintenanceguard.EncodeProjection(maintenanceguard.Projection{Version: 1, Generation: generation, GuardDigest: strings.Repeat("a", 64)}) if e != nil { t.Fatal(e) } return b } func TestMaintenanceProjectionParsing(t *testing.T) { valid := string(testProjection(t, 1)) 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)} { t.Run(value, func(t *testing.T) { raw := []byte(`{"maintenance":` + value + `}`) r, e := DecodeRecord(raw) if e != ErrMaintenanceProjection { t.Fatal("accepted invalid") } if _, e = json.Marshal(r); e == nil { t.Fatal("invalid disappeared") } var direct Record if json.Unmarshal(raw, &direct) == nil { t.Fatal("field accepted invalid") } if _, e = json.Marshal(direct); e == nil { t.Fatal("ignored field error disappeared") } }) } for _, raw := range []string{`{"maintenance":` + valid + `,"maintenance":` + valid + `}`, `{"Maintenance":` + valid + `}`, `{"maintenance":` + valid + `,"MAINTENANCE":null}`, `null`, `{} {}`} { if _, e := DecodeRecord([]byte(raw)); e == nil { t.Fatal("accepted outer ambiguity") } } r, e := DecodeRecord([]byte(`{"maintenance":` + valid + `,"future_record_field":true}`)) if e != nil { t.Fatal(e) } if _, e = json.Marshal(r); e != nil { t.Fatal(e) } legacy := Record{Host: "legacy"} b, e := json.Marshal(legacy) if e != nil || bytes.Contains(b, []byte("maintenance")) { t.Fatal("legacy changed", e) } } func TestMaintenanceDoesNotPromoteJSONMethods(t *testing.T) { type view struct { Record HiddenGate string `json:"gate,omitempty"` Extra string `json:"extra"` } in := view{Record: Record{Gate: "private hash", Maintenance: testProjection(t, 1)}, Extra: "joined"} b, e := json.Marshal(in) if e != nil || bytes.Contains(b, []byte("private hash")) { t.Fatal("gate leaked", e) } var out view if json.Unmarshal(b, &out) != nil || out.Extra != "joined" || len(out.Maintenance) == 0 { t.Fatal("embedded fields swallowed") } } func TestOrdinaryWritersPreserveProjection(t *testing.T) { s, sqid := setup(t) rec := acmeRecord(sqid) rec.Maintenance = testProjection(t, 1) if CreateRecord(ctx, s, rec) != ErrMaintenanceProjection { t.Fatal("ordinary create installed projection") } key := RecordKey(rec.Account, rec.App, rec.Host) raw, e := json.Marshal(rec) if e != nil { t.Fatal(e) } if _, e = s.Put(ctx, key, raw, ""); e != nil { t.Fatal(e) } 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" }} { before, etag, e := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host) if e != nil { t.Fatal(e) } mut(before) if UpdateRecord(ctx, s, *before, etag) == nil { t.Fatal("whole writer changed protected fields") } if _, e = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { mut(r); return nil }); e == nil { t.Fatal("callback changed protected fields") } } current, etag, e := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host) if e != nil { t.Fatal(e) } current.Maintenance = MaintenanceProjection(strings.Replace(string(current.Maintenance), ":", ": ", -1)) current.CreatedAt = current.CreatedAt.In(time.FixedZone("same instant", 3600)) current.Channel = "new" if e = UpdateRecord(ctx, s, *current, etag); e != nil { t.Fatal("semantic equality refused", e) } if e = SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "uk"); e != nil { t.Fatal(e) } got, e := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) if e != nil || !protectedFieldsEqual(rec, *got) || got.Region != "uk" { t.Fatal("projection lost", e) } absent := acmeRecord(sqid) absent.Host = "other.go.tito.io" if e = CreateRecord(ctx, s, absent); e != nil { t.Fatal(e) } if _, e = SetRecordMove(ctx, s, absent.Account, absent.App, absent.Host, func(r *Record) error { r.Maintenance = testProjection(t, 1); return nil }); e != ErrMaintenanceProjection { t.Fatal("installed into absent record", e) } } type projectionRaceStore struct { release.Store beforePut func() } func (s *projectionRaceStore) Put(c context.Context, k string, b []byte, tag string) (string, error) { if s.beforePut != nil { f := s.beforePut s.beforePut = nil f() } return s.Store.Put(c, k, b, tag) } func TestProjectionCASRaces(t *testing.T) { for _, retry := range []bool{false, true} { t.Run(map[bool]string{false: "whole", true: "retry"}[retry], func(t *testing.T) { base, sqid := setup(t) rec := acmeRecord(sqid) if e := CreateRecord(ctx, base, rec); e != nil { t.Fatal(e) } old, tag, e := GetRecordETag(ctx, base, rec.Account, rec.App, rec.Host) if e != nil { t.Fatal(e) } wrapped := &projectionRaceStore{Store: base, beforePut: func() { rec.Maintenance = testProjection(t, 1) b, e := json.Marshal(rec) if e != nil { t.Fatal(e) } putReplace(t, base, RecordKey(rec.Account, rec.App, rec.Host), b) }} if retry { e = SetRecordRegion(ctx, wrapped, rec.Account, rec.App, rec.Host, "uk") if e != nil { t.Fatal(e) } } else { old.Channel = "new" e = UpdateRecord(ctx, wrapped, *old, tag) if !errors.Is(e, release.ErrCASConflict) { t.Fatal("stale write won", e) } } got, e := GetRecord(ctx, base, rec.Account, rec.App, rec.Host) if e != nil || len(got.Maintenance) == 0 { t.Fatal("concurrent projection stripped", e) } if !retry && got.Channel != rec.Channel { t.Fatal("failed write applied") } }) } } func TestMalformedProjectionReconcileAndLists(t *testing.T) { s, sqid := setup(t) rec := acmeRecord(sqid) if e := CreateRecord(ctx, s, rec); e != nil { t.Fatal(e) } reg := regWith(t) cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} if e := Reconcile(ctx, s, reg, cfg); e != nil { t.Fatal(e) } for _, host := range []string{rec.Host, "new.go.tito.io"} { rec.Host = host if host == "new.go.tito.io" { if e := CreateRecord(ctx, s, rec); e != nil { t.Fatal(e) } } b, e := json.Marshal(rec) if e != nil { t.Fatal(e) } b = append(b[:len(b)-1], []byte(`,"maintenance":null}`)...) putReplace(t, s, RecordKey(sqid, "tito", host), b) if _, _, e = GetRecordETag(ctx, s, sqid, "tito", host); e == nil { t.Fatal("get accepted invalid") } if e = SetRecordRegion(ctx, s, sqid, "tito", host, "uk"); e == nil { t.Fatal("writer accepted invalid") } } if e := Reconcile(ctx, s, reg, cfg); e != nil { t.Fatal(e) } mustGet(t, reg, "acme.go.tito.io") if r, e := reg.Get("new.go.tito.io"); e != nil || r != nil { t.Fatal("malformed created route") } if records, _, e := ListRecords(ctx, s, sqid, "tito", 10); e != nil || len(records) != 0 { t.Fatal("list semantics changed") } if records, e := ListAllRecords(ctx, s); e != nil || len(records) != 0 { t.Fatal("all-list semantics changed") } }