package domains import ( "bytes" "context" "log" "os" "sort" "strconv" "strings" "testing" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // pathsByPrefix collapses ListPaths into a prefix -> target map for one host. func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string { t.Helper() rows, err := reg.ListPaths() if err != nil { t.Fatal(err) } out := map[string]string{} for _, p := range rows { if p.Host == host { out[p.Prefix] = p.Target } } return out } // TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes // route_paths rows. func TestReconcileDerivesPathRows(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") addRoute(t, reg, "go.tito.io", "tito", "classic") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "go.tito.io"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" { t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got) } } // TestReconcileRemovesUndesiredPathRows is the rollback path and the // pass-ownership property: a prefix dropped from the published map disappears // from the table on the next pass, with no delete verb anywhere. func TestReconcileRemovesUndesiredPathRows(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") addRoute(t, reg, "go.tito.io", "tito", "classic") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io"}); err != nil { t.Fatal(err) } if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } got := pathsByPrefix(t, reg, "ti.to") if _, still := got["bigcorp"]; still { t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got) } if got["paulca"] != "go.tito.io" { t.Fatalf("route_paths = %v, want paulca still routed", got) } } // TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization // rule in this feature: a prefix must never point another tenant's traffic at // your backend. func TestReconcileRefusesCrossTenantTarget(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") addRoute(t, reg, "victim.example", "someone-else", "their-app") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "victim.example"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got) } } // TestReconcileSkipsUnknownTarget: a target that is not a local route yields // no row, so the prefix falls through to the host's own route. That is the // correct failure direction for a migration — a missing Go route serves // classic rather than 404ing. func TestReconcileSkipsUnknownTarget(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "not-deployed-here.example"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got) } } // TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to // the hostname's own route. If that route lives on another box, this box // derives nothing — the same per-box rule the alias half already follows. func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "go.tito.io", "tito", "classic") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "go.tito.io"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got) } } // TestReconcileCapsPathRows: a hand-written claim above the cap is truncated // deterministically (sorted key order), never silently and never at random. func TestReconcileCapsPathRows(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") addRoute(t, reg, "go.tito.io", "tito", "classic") big := make(map[string]string, MaxPathsPerClaim+10) for i := 0; i < MaxPathsPerClaim+10; i++ { big["acct"+strconv.Itoa(i)] = "go.tito.io" } putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: big, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } got := pathsByPrefix(t, reg, "ti.to") if len(got) != MaxPathsPerClaim { t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim) } // WHICH prefixes survive, not just how many. The count alone passes with // the sort deleted, and the sort is the safety-critical half: every box // folds the same claim independently, so a truncation that varied with // Go's map iteration order would let the same URL resolve to different // backends depending on which edge answered. want := make([]string, 0, len(big)) for p := range big { want = append(want, p) } sort.Strings(want) want = want[:MaxPathsPerClaim] for _, p := range want { if _, ok := got[p]; !ok { t.Fatalf("prefix %q was dropped; the surviving set must be the first %d in SORTED order, identically on every box", p, MaxPathsPerClaim) } } } // TestReconcileSkipsUnreadyTarget: a target route that exists on this box but // is not ready yet contributes no row (design spec §4, "a target that is not // a local READY route contributes no row"). The edge filters unready routes // out of its map anyway, so traffic already fell through safely — what this // gate buys is that `carlos routes` and the edge agree about which prefixes // are actually serving, and that surface is the one built for diagnosing a // flip. func TestReconcileSkipsUnreadyTarget(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") if err := reg.Upsert(registry.Route{ Host: "go.tito.io", App: "classic", Account: "tito", Kind: "instance", Status: "provisioning", }); err != nil { t.Fatal(err) } putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{"paulca": "go.tito.io"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { t.Fatalf("route_paths = %v, want no row for a target that is not ready", got) } } // TestReconcileLogsUnresolvedTargetsOncePerHost is the critical-silence fix. // A target homed on ANOTHER BOX is the likeliest real-world refusal in this // feature — `ti.to` and `go.tito.io` are two very different apps — and it // used to be the ONLY refusal in the path block that said nothing at all, so // a migration could be published, accepted, and never route, forever, with // no signal. It must log; and because this pass runs every ~2s against a map // that may hold 10k entries, it must log ONE line per host per pass. func TestReconcileLogsUnresolvedTargetsOncePerHost(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "ti.to", "tito", "classic") putClaim(t, s, Claim{ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", Paths: map[string]string{ "paulca": "elsewhere.example", "bigcorp": "elsewhere.example", "acme": "also-elsewhere.example", }, }) var buf bytes.Buffer old := log.Writer() log.SetOutput(&buf) defer log.SetOutput(old) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } log.SetOutput(old) out := buf.String() if n := strings.Count(out, "name no local ready route"); n != 1 { t.Fatalf("logged the unresolved-target line %d times, want exactly one per host per pass:\n%s", n, out) } for _, want := range []string{"ti.to", "3 prefix(es)", "elsewhere.example", "homed on another box"} { if !strings.Contains(out, want) { t.Fatalf("unresolved-target log is missing %q:\n%s", want, out) } } } // --- the reserved sidecar suffix, at path routing's own door ---------------- // // The alias half of this pass has refused reserved-suffix hosts since the // sidecar-members branch was written. Path-based routing landed on main // afterwards, minting rows into a THIRD table from the same claims, through a // branch the alias guard never sees. These two tests are that omission // closed, and they are deliberately the pair — the two ends differ in what // stops them, and one of them nothing else stops at all. // The host end: a claim whose own hostname is under the reserved suffix must // mint no route_paths row, for the alias guard's reason exactly. A path row // keyed on a sidecar's name puts that name in the edge's routing map, and // edge.go's Kind check never sees it — the row it reads is a path row, not a // Kind=="sidecar" route. func TestReconcilePathsRefusesAReservedSuffixHost(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "bot.eleven.bdm.sidecar.internal", "bdm", "eleven") addRoute(t, reg, "go.example.com", "bdm", "eleven") putClaim(t, s, Claim{ Host: "bot.eleven.bdm.sidecar.internal", Account: "bdm", App: "eleven", Route: "bot.eleven.bdm.sidecar.internal", CreatedAt: "2026-09-05T00:00:00Z", Paths: map[string]string{"api": "go.example.com"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "bot.eleven.bdm.sidecar.internal"); len(got) != 0 { t.Fatalf("route_paths = %v, want none — a reserved-suffix host must mint no path row", got) } } // The TARGET end, which is the sharper one: a prefix pointing AT a sidecar. // Nothing else in this block refuses it. The cross-tenant check passes, // because a sidecar belongs to the claim's own account — that is the normal // case, not an edge one — and the readiness check passes, because a live // sidecar row is "ready". So the target field is the one place in this pass // where an account could route real traffic into its own undialable member, // and only a check on the name itself closes it. func TestReconcilePathsRefusesASidecarAsATarget(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := aliasRegWith(t) addRoute(t, reg, "eleven.example.com", "bdm", "eleven") // The sidecar's own row, exactly as the reconcile pass mints it: same // account, same app, ready. Every other gate in this block waves it past. if err := reg.Upsert(registry.Route{ Host: "bot.eleven.bdm.sidecar.internal", App: "eleven", Account: "bdm", Kind: "sidecar", Status: "ready", }); err != nil { t.Fatal(err) } var logged bytes.Buffer log.SetOutput(&logged) // os.Stderr, not nil: the log package is process-global, and this file's // other tests read it. Restoring nil leaves every later test in the // package writing into a discarded logger — which is how this landed as // a failure in a test that has nothing to do with sidecars. t.Cleanup(func() { log.SetOutput(os.Stderr) }) putClaim(t, s, Claim{ Host: "eleven.example.com", Account: "bdm", App: "eleven", Route: "eleven.example.com", CreatedAt: "2026-09-05T00:00:00Z", Paths: map[string]string{"bot": "bot.eleven.bdm.sidecar.internal"}, }) if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { t.Fatal(err) } if got := pathsByPrefix(t, reg, "eleven.example.com"); len(got) != 0 { t.Fatalf("route_paths = %v, want none — nothing routes to a sidecar", got) } if !strings.Contains(logged.String(), "sidecar") { t.Fatalf("the refusal must name its reason, got: %s", logged.String()) } }