| 1 | package domains |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | func TestAttachClaimCreates(t *testing.T) { |
| 15 | s := release.NewDirStore(t.TempDir()) |
| 16 | ctx := context.Background() |
| 17 | created, changed, err := AttachClaim(ctx, s, Claim{ |
| 18 | Host: "tickets.example.com", Account: "bab", App: "kass", |
| 19 | Route: "canary/live", CreatedAt: "2026-08-12T00:00:00Z", CreatedBy: "paul@tito.io", |
| 20 | }) |
| 21 | if err != nil || !created || !changed { |
| 22 | t.Fatalf("AttachClaim: created=%v changed=%v err=%v, want true true nil", created, changed, err) |
| 23 | } |
| 24 | got, err := GetClaim(ctx, s, "tickets.example.com") |
| 25 | if err != nil { |
| 26 | t.Fatalf("GetClaim: %v", err) |
| 27 | } |
| 28 | if got.Schema != 1 || got.Host != "tickets.example.com" || got.Account != "bab" || |
| 29 | got.App != "kass" || got.Route != "canary/live" || got.CreatedAt != "2026-08-12T00:00:00Z" || |
| 30 | got.CreatedBy != "paul@tito.io" { |
| 31 | t.Fatalf("GetClaim round-trip mismatch: %+v", got) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func TestAttachClaimIdempotent(t *testing.T) { |
| 36 | s := release.NewDirStore(t.TempDir()) |
| 37 | ctx := context.Background() |
| 38 | claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| 39 | if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | before, err := GetClaim(ctx, s, claim.Host) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | // changed=false is what makes this a NO-OP rather than a write: the door |
| 47 | // audits and bumps control/serial off that answer, and a re-attach on |
| 48 | // every router reload pass is the steady state, not the exception. |
| 49 | created, changed, err := AttachClaim(ctx, s, claim) |
| 50 | if err != nil || created || changed { |
| 51 | t.Fatalf("AttachClaim re-attach: created=%v changed=%v err=%v, want false false nil", created, changed, err) |
| 52 | } |
| 53 | after, err := GetClaim(ctx, s, claim.Host) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if !reflect.DeepEqual(before, after) { |
| 58 | t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestAttachClaimConvergesRoute(t *testing.T) { |
| 63 | s := release.NewDirStore(t.TempDir()) |
| 64 | ctx := context.Background() |
| 65 | claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| 66 | CreatedAt: "2026-08-12T00:00:00Z", Wildcard: true, Catchall: true} |
| 67 | if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | // A converge repoints Route and nothing else: the caller's claim here |
| 71 | // deliberately carries no CreatedAt and no Wildcard, and both stored |
| 72 | // values must survive — a route repoint must never strip another |
| 73 | // branch's TLS opt-in or restamp the claim's age. created=false but |
| 74 | // changed=TRUE: a converge IS a store write, so the door must audit it |
| 75 | // and announce it to the fleet. |
| 76 | created, changed, err := AttachClaim(ctx, s, Claim{Host: claim.Host, Account: "bab", App: "kass", Route: "canary/next"}) |
| 77 | if err != nil || created || !changed { |
| 78 | t.Fatalf("AttachClaim route change: created=%v changed=%v err=%v, want false true nil", created, changed, err) |
| 79 | } |
| 80 | got, err := GetClaim(ctx, s, claim.Host) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if got.Route != "canary/next" { |
| 85 | t.Fatalf("Route = %q, want canary/next", got.Route) |
| 86 | } |
| 87 | if !got.Wildcard || !got.Catchall || got.CreatedAt != "2026-08-12T00:00:00Z" { |
| 88 | t.Fatalf("converge must preserve Wildcard+Catchall+CreatedAt, got wildcard=%v catchall=%v created_at=%q", |
| 89 | got.Wildcard, got.Catchall, got.CreatedAt) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestAttachClaimOtherOwnerRefused(t *testing.T) { |
| 94 | s := release.NewDirStore(t.TempDir()) |
| 95 | ctx := context.Background() |
| 96 | claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| 97 | if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | before, err := GetClaim(ctx, s, claim.Host) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | |
| 105 | other := claim |
| 106 | other.Account = "bad" |
| 107 | if _, _, err := AttachClaim(ctx, s, other); !errors.Is(err, ErrHostUnavailable) { |
| 108 | t.Fatalf("other account: err = %v, want ErrHostUnavailable", err) |
| 109 | } |
| 110 | |
| 111 | otherApp := claim |
| 112 | otherApp.App = "console" |
| 113 | if _, _, err := AttachClaim(ctx, s, otherApp); !errors.Is(err, ErrHostUnavailable) { |
| 114 | t.Fatalf("other app: err = %v, want ErrHostUnavailable", err) |
| 115 | } |
| 116 | |
| 117 | after, err := GetClaim(ctx, s, claim.Host) |
| 118 | if err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if !reflect.DeepEqual(before, after) { |
| 122 | t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestAttachClaimNewerSchemaRefused(t *testing.T) { |
| 127 | s := release.NewDirStore(t.TempDir()) |
| 128 | ctx := context.Background() |
| 129 | body := []byte(`{"schema":2,"host":"tickets.example.com","account":"bab","app":"kass"}`) |
| 130 | if _, err := s.Put(ctx, ClaimKey("tickets.example.com"), body, ""); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | _, _, err := AttachClaim(ctx, s, Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}) |
| 134 | if err == nil || !strings.Contains(err.Error(), "schema") { |
| 135 | t.Fatalf("AttachClaim over newer schema: err = %v, want error mentioning schema", err) |
| 136 | } |
| 137 | got, _, gerr := s.Get(ctx, ClaimKey("tickets.example.com")) |
| 138 | if gerr != nil { |
| 139 | t.Fatal(gerr) |
| 140 | } |
| 141 | if string(got) != string(body) { |
| 142 | t.Fatalf("object changed on newer-schema refusal: got %s", got) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestDetachClaimRemoves(t *testing.T) { |
| 147 | s := release.NewDirStore(t.TempDir()) |
| 148 | ctx := context.Background() |
| 149 | claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| 150 | if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | removed, err := DetachClaim(ctx, s, claim.Host, claim.Account, claim.App) |
| 154 | if err != nil || !removed { |
| 155 | t.Fatalf("DetachClaim: removed=%v err=%v, want true nil", removed, err) |
| 156 | } |
| 157 | if _, err := GetClaim(ctx, s, claim.Host); !errors.Is(err, os.ErrNotExist) { |
| 158 | t.Fatalf("GetClaim after detach: err = %v, want os.ErrNotExist", err) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | func TestDetachClaimUnknownQuiet(t *testing.T) { |
| 163 | s := release.NewDirStore(t.TempDir()) |
| 164 | ctx := context.Background() |
| 165 | removed, err := DetachClaim(ctx, s, "nope.example.com", "bab", "kass") |
| 166 | if err != nil || removed { |
| 167 | t.Fatalf("DetachClaim unknown: removed=%v err=%v, want false nil", removed, err) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestDetachClaimOtherOwnerQuiet(t *testing.T) { |
| 172 | s := release.NewDirStore(t.TempDir()) |
| 173 | ctx := context.Background() |
| 174 | claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| 175 | if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | removed, err := DetachClaim(ctx, s, claim.Host, "bad", "console") |
| 179 | if err != nil || removed { |
| 180 | t.Fatalf("DetachClaim other owner: removed=%v err=%v, want false nil", removed, err) |
| 181 | } |
| 182 | if _, err := GetClaim(ctx, s, claim.Host); err != nil { |
| 183 | t.Fatalf("GetClaim after other-owner detach: %v, want claim still present", err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestListClaimsSkipsGarbage(t *testing.T) { |
| 188 | s := release.NewDirStore(t.TempDir()) |
| 189 | ctx := context.Background() |
| 190 | if _, _, err := AttachClaim(ctx, s, Claim{Host: "a.example.com", Account: "bab", App: "kass"}); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if _, _, err := AttachClaim(ctx, s, Claim{Host: "b.example.com", Account: "bad", App: "console"}); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | if _, err := s.Put(ctx, ClaimKey("garbage.example.com"), []byte("not json"), ""); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | claims, err := ListClaims(ctx, s) |
| 200 | if err != nil { |
| 201 | t.Fatalf("ListClaims: %v", err) |
| 202 | } |
| 203 | if len(claims) != 2 { |
| 204 | t.Fatalf("ListClaims returned %d claims, want 2: %+v", len(claims), claims) |
| 205 | } |
| 206 | hosts := map[string]bool{} |
| 207 | for _, c := range claims { |
| 208 | hosts[c.Host] = true |
| 209 | } |
| 210 | if !hosts["a.example.com"] || !hosts["b.example.com"] { |
| 211 | t.Fatalf("ListClaims missing expected hosts: %+v", claims) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestNormalizeAliasHost(t *testing.T) { |
| 216 | cases := []struct { |
| 217 | raw string |
| 218 | want string |
| 219 | ok bool |
| 220 | }{ |
| 221 | {"Tickets.Example.COM.", "tickets.example.com", true}, |
| 222 | {"localhost", "", false}, |
| 223 | {"a..b", "", false}, |
| 224 | {"", "", false}, |
| 225 | {strings.Repeat("a", 254), "", false}, |
| 226 | } |
| 227 | for _, c := range cases { |
| 228 | got, ok := NormalizeAliasHost(c.raw) |
| 229 | if ok != c.ok { |
| 230 | t.Errorf("NormalizeAliasHost(%q) ok = %v, want %v", c.raw, ok, c.ok) |
| 231 | continue |
| 232 | } |
| 233 | if ok && got != c.want { |
| 234 | t.Errorf("NormalizeAliasHost(%q) = %q, want %q", c.raw, got, c.want) |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestSetClaimCapabilities(t *testing.T) { |
| 240 | ctx := context.Background() |
| 241 | s := release.NewDirStore(t.TempDir()) |
| 242 | if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | |
| 246 | // Set both capabilities. |
| 247 | changed, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true) |
| 248 | if err != nil || !changed { |
| 249 | t.Fatalf("set: changed=%v err=%v", changed, err) |
| 250 | } |
| 251 | got, err := GetClaim(ctx, s, "woodstar.app") |
| 252 | if err != nil || !got.Wildcard || !got.Catchall { |
| 253 | t.Fatalf("stored claim: %+v err=%v", got, err) |
| 254 | } |
| 255 | // Route, CreatedAt survive the capability write. |
| 256 | if got.Route != "woodstar-relay.bab.oncarlos.com" || got.CreatedAt != "2026-08-13T00:00:00Z" { |
| 257 | t.Fatalf("capability write must preserve Route+CreatedAt: %+v", got) |
| 258 | } |
| 259 | |
| 260 | // Idempotent: same values = no write. |
| 261 | changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true) |
| 262 | if err != nil || changed { |
| 263 | t.Fatalf("no-op set: changed=%v err=%v", changed, err) |
| 264 | } |
| 265 | |
| 266 | // Turning both off works and reports changed. |
| 267 | changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, false) |
| 268 | if err != nil || !changed { |
| 269 | t.Fatalf("unset: changed=%v err=%v", changed, err) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestSetClaimCapabilitiesRefusals(t *testing.T) { |
| 274 | ctx := context.Background() |
| 275 | s := release.NewDirStore(t.TempDir()) |
| 276 | if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | |
| 280 | // Catchall without wildcard: refused, never stored. |
| 281 | if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, true); !errors.Is(err, ErrCatchallNeedsWildcard) { |
| 282 | t.Fatalf("want ErrCatchallNeedsWildcard, got %v", err) |
| 283 | } |
| 284 | if got, _ := GetClaim(ctx, s, "woodstar.app"); got.Catchall { |
| 285 | t.Fatal("refused catchall must not be stored") |
| 286 | } |
| 287 | |
| 288 | // Wrong owner: neutral refusal, same error the attach door uses. |
| 289 | if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bad", "other-app", true, false); !errors.Is(err, ErrHostUnavailable) { |
| 290 | t.Fatalf("want ErrHostUnavailable, got %v", err) |
| 291 | } |
| 292 | |
| 293 | // No claim at all: neutral, not a bare not-found (no existence oracle). |
| 294 | if _, err := SetClaimCapabilities(ctx, s, "unclaimed.example", "bab", "woodstar-relay", true, false); !errors.Is(err, ErrHostUnavailable) { |
| 295 | t.Fatalf("want ErrHostUnavailable for missing claim, got %v", err) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestHostDenied(t *testing.T) { |
| 300 | deny := []string{"oncarlos.com"} |
| 301 | cases := []struct { |
| 302 | host string |
| 303 | want bool |
| 304 | }{ |
| 305 | {"x.oncarlos.com", true}, |
| 306 | {"oncarlos.com", true}, |
| 307 | {"xoncarlos.com", false}, |
| 308 | } |
| 309 | for _, c := range cases { |
| 310 | if got := HostDenied(c.host, deny); got != c.want { |
| 311 | t.Errorf("HostDenied(%q, %v) = %v, want %v", c.host, deny, got, c.want) |
| 312 | } |
| 313 | } |
| 314 | if HostDenied("anything.example.com", []string{"", "oncarlos.com"}) { |
| 315 | t.Fatal("empty deny entry must never match") |
| 316 | } |
| 317 | } |
| 318 | |