| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | const testHost = "shop.bab.oncarlos.com" |
| 16 | |
| 17 | func TestKeyRefusesBadHosts(t *testing.T) { |
| 18 | if k, err := Key(testHost); err != nil || k != "control/hosts/"+testHost+".json" { |
| 19 | t.Fatalf("Key = %q, %v", k, err) |
| 20 | } |
| 21 | for _, bad := range []string{"", "../x", "a/b", "UPPER.example.com"} { |
| 22 | if _, err := Key(bad); err == nil { |
| 23 | t.Errorf("Key(%q) accepted", bad) |
| 24 | } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | func TestIncarnationStableAndLegacyDistinct(t *testing.T) { |
| 29 | at := time.Date(2026, 10, 4, 1, 2, 3, 4, time.UTC) |
| 30 | a1, err := Incarnation("bab", "jam", testHost, at) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | a2, _ := Incarnation("bab", "jam", testHost, at) |
| 35 | if a1 != a2 || len(a1) != 64 { |
| 36 | t.Fatalf("not stable 64-hex: %q %q", a1, a2) |
| 37 | } |
| 38 | l1, err := Incarnation("bab", "jam", testHost, time.Time{}) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | l2, _ := Incarnation("bab", "other", testHost, time.Time{}) |
| 43 | if l1 == a1 || l1 == l2 || len(l1) != 64 { |
| 44 | t.Fatalf("legacy identity not distinct: %q %q %q", l1, l2, a1) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestValidate(t *testing.T) { |
| 49 | created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| 50 | inc, _ := Incarnation("bab", "jam", testHost, created) |
| 51 | ok := &Claim{V: 2, Host: testHost, Gen: 1, |
| 52 | Holder: &Holder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json", Account: "bab", App: "jam", |
| 53 | Incarnation: inc, CreatedAt: created, Location: "instances/" + testHost + "/" + inc}, |
| 54 | Op: Op{Kind: KindServing}} |
| 55 | if err := ok.Validate(); err != nil { |
| 56 | t.Fatalf("valid claim: %v", err) |
| 57 | } |
| 58 | bad := map[string]func(c *Claim){ |
| 59 | "holderless serving": func(c *Claim) { c.Holder = nil }, |
| 60 | "unknown kind": func(c *Claim) { c.Op = Op{Kind: "teleporting", ID: "x"} }, |
| 61 | "foreign location": func(c *Claim) { c.Holder.Location = "instances/other.example.com" }, |
| 62 | "short incarnation": func(c *Claim) { c.Holder.Incarnation = "abc" }, |
| 63 | "wrong incarnation": func(c *Claim) { c.Holder.Incarnation = strings.Repeat("a", 64) }, |
| 64 | "foreign key": func(c *Claim) { c.Holder.Key = "apps/zzz/jam/instances/" + testHost + ".json" }, |
| 65 | "free with chores": func(c *Claim) { |
| 66 | c.Holder, c.Op = nil, Op{} |
| 67 | c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b"}} |
| 68 | }, |
| 69 | "unknown chore kind": func(c *Claim) { |
| 70 | c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "teleport", Region: "ie", Retirement: "r", Owner: "region:ie"}} |
| 71 | }, |
| 72 | "locationless chore": func(c *Claim) { |
| 73 | c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-replica", Retirement: "r", Owner: "region:"}} |
| 74 | }, |
| 75 | "owner mismatch": func(c *Claim) { |
| 76 | c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:other"}} |
| 77 | }, |
| 78 | "duplicate chore id": func(c *Claim) { |
| 79 | ob := Obligation{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:b"} |
| 80 | c.Obligations = []Obligation{ob, ob} |
| 81 | }, |
| 82 | "old schema": func(c *Claim) { c.V = 1 }, |
| 83 | } |
| 84 | for name, mut := range bad { |
| 85 | raw, _ := json.Marshal(ok) |
| 86 | var c Claim |
| 87 | _ = json.Unmarshal(raw, &c) |
| 88 | mut(&c) |
| 89 | if err := c.Validate(); err == nil { |
| 90 | t.Errorf("%s validated", name) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestParkGenerationValidation(t *testing.T) { |
| 96 | for _, name := range []string{"valid", "no region", "no generation", "box", "wrong owner", "no owner", "park-replica", "park-local"} { |
| 97 | t.Run(name, func(t *testing.T) { |
| 98 | ctx := context.Background() |
| 99 | s := release.NewDirStore(t.TempDir()) |
| 100 | before := servingClaim(t, ctx, s) |
| 101 | ob := Obligation{ID: "o1", Op: "m1", Kind: "park-generation", Region: "ie", Generation: "dead_gen", Retirement: "r1", Owner: "region:ie"} |
| 102 | switch name { |
| 103 | case "no region": |
| 104 | ob.Region, ob.Owner = "", "region:" |
| 105 | case "no generation": |
| 106 | ob.Generation = "" |
| 107 | case "box": |
| 108 | ob.Box = "boxS" |
| 109 | case "wrong owner": |
| 110 | ob.Owner = "region:uk" |
| 111 | case "no owner": |
| 112 | ob.Owner = "" |
| 113 | case "park-replica": |
| 114 | ob.Kind = name |
| 115 | case "park-local": |
| 116 | ob.Kind, ob.Region, ob.Box, ob.Owner = name, "", "boxS", "box:boxS" |
| 117 | } |
| 118 | next, err := Update(ctx, s, before, func(c *Claim) error { c.Obligations = []Obligation{ob}; return nil }) |
| 119 | if name != "valid" { |
| 120 | if !errors.Is(err, ErrMalformed) { |
| 121 | t.Fatalf("invalid obligation = %v, want ErrMalformed", err) |
| 122 | } |
| 123 | assertAdmissionStored(t, ctx, s, before) |
| 124 | return |
| 125 | } |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | stored := assertAdmissionStored(t, ctx, s, next) |
| 130 | if !reflect.DeepEqual(stored.Claim.Obligations, []Obligation{ob}) { |
| 131 | t.Fatalf("stored obligations = %+v", stored.Claim.Obligations) |
| 132 | } |
| 133 | raw, err := json.Marshal(ob) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if !strings.Contains(string(raw), `"generation":"dead_gen"`) { |
| 138 | t.Fatalf("generation wire form = %s", raw) |
| 139 | } |
| 140 | ob.Generation = "" |
| 141 | raw, err = json.Marshal(ob) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if strings.Contains(string(raw), `"generation"`) { |
| 146 | t.Fatalf("empty generation not omitted: %s", raw) |
| 147 | } |
| 148 | }) |
| 149 | } |
| 150 | } |
| 151 | |