| new file mode 100644 |
| index 000000000..c07a79467 |
| --- /dev/null |
| +++ b/internal/domains/aliases_test.go |
| @@ -0,0 +1,219 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// aliasRegWith opens a fresh registry and seeds it with routes, the same |
| +// pattern reconcile_test.go's regWith uses (that helper lives in the |
| +// domains_test package, unreachable from here). |
| +func aliasRegWith(t *testing.T, routes ...registry.Route) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return reg |
| +} |
| + |
| +// putClaim writes c's JSON straight to its ClaimKey, bypassing AttachClaim's |
| +// CAS dance — the reconcile pass reads whatever is in the bucket, not just |
| +// what AttachClaim would have produced. |
| +func putClaim(t *testing.T, s release.Store, c Claim) { |
| + t.Helper() |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), ClaimKey(c.Host), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func aliasNow() func() time.Time { |
| + return func() time.Time { return time.Unix(1755000000, 0) } |
| +} |
| + |
| +func TestReconcileAliasesMaterializes(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 1 { |
| + t.Fatalf("got %d aliases, want 1: %+v", len(aliases), aliases) |
| + } |
| + a := aliases[0] |
| + if a.Alias != "tickets.example.com" || a.Host != "canary/live" || |
| + a.Account != "bab" || a.App != "kass" || a.CreatedAt != 1755000000 { |
| + t.Fatalf("alias row mismatch: %+v", a) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesOwnershipGate(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + // Claim names the right route but the wrong app. |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRoutesWin(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, |
| + registry.Route{Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready"}, |
| + registry.Route{Host: "tickets.example.com", App: "other", Account: "bab", Kind: "instance", Status: "ready"}, |
| + ) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (a real route owns that host): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesMissingRouteSkips(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) // no routes at all — this box doesn't have the route |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRemovesOrphans(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: "orphan.example.com", Host: "canary/live", Account: "bab", App: "kass", |
| + CreatedAt: 1000, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No claim backs orphan.example.com. |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (orphan should have been removed): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRouteless(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + // A first-route claim (#82's case): no Route set yet. |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (routeless claim must be ignored): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(first) != 1 { |
| + t.Fatalf("got %d aliases after first pass, want 1: %+v", len(first), first) |
| + } |
| + firstCreatedAt := first[0].CreatedAt |
| + |
| + // Second pass, later clock: CreatedAt must be preserved, not bumped. |
| + later := func() time.Time { return time.Unix(1755000000+3600, 0) } |
| + if err := ReconcileAliases(context.Background(), s, reg, later); err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(second) != 1 { |
| + t.Fatalf("got %d aliases after second pass, want 1: %+v", len(second), second) |
| + } |
| + if second[0].CreatedAt != firstCreatedAt { |
| + t.Fatalf("CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt) |
| + } |
| + if second[0].Alias != first[0].Alias || second[0].Host != first[0].Host || |
| + second[0].Account != first[0].Account || second[0].App != first[0].App { |
| + t.Fatalf("alias row changed on re-run: first=%+v second=%+v", first[0], second[0]) |
| + } |
| +} |