carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package domains
2
3import (
4 "bytes"
5 "context"
6 "log"
7 "os"
8 "sort"
9 "strconv"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// pathsByPrefix collapses ListPaths into a prefix -> target map for one host.
18func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string {
19 t.Helper()
20 rows, err := reg.ListPaths()
21 if err != nil {
22 t.Fatal(err)
23 }
24 out := map[string]string{}
25 for _, p := range rows {
26 if p.Host == host {
27 out[p.Prefix] = p.Target
28 }
29 }
30 return out
31}
32
33// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes
34// route_paths rows.
35func TestReconcileDerivesPathRows(t *testing.T) {
36 ctx := context.Background()
37 s := release.NewDirStore(t.TempDir())
38 reg := aliasRegWith(t)
39 addRoute(t, reg, "ti.to", "tito", "classic")
40 addRoute(t, reg, "go.tito.io", "tito", "classic")
41 putClaim(t, s, Claim{
42 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
43 CreatedAt: "2026-09-03T00:00:00Z",
44 Paths: map[string]string{"paulca": "go.tito.io"},
45 })
46
47 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
48 t.Fatal(err)
49 }
50 if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" {
51 t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got)
52 }
53}
54
55// TestReconcileRemovesUndesiredPathRows is the rollback path and the
56// pass-ownership property: a prefix dropped from the published map disappears
57// from the table on the next pass, with no delete verb anywhere.
58func TestReconcileRemovesUndesiredPathRows(t *testing.T) {
59 ctx := context.Background()
60 s := release.NewDirStore(t.TempDir())
61 reg := aliasRegWith(t)
62 addRoute(t, reg, "ti.to", "tito", "classic")
63 addRoute(t, reg, "go.tito.io", "tito", "classic")
64 putClaim(t, s, Claim{
65 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
66 CreatedAt: "2026-09-03T00:00:00Z",
67 Paths: map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"},
68 })
69 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
70 t.Fatal(err)
71 }
72 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
73 map[string]string{"paulca": "go.tito.io"}); err != nil {
74 t.Fatal(err)
75 }
76 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
77 t.Fatal(err)
78 }
79 got := pathsByPrefix(t, reg, "ti.to")
80 if _, still := got["bigcorp"]; still {
81 t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got)
82 }
83 if got["paulca"] != "go.tito.io" {
84 t.Fatalf("route_paths = %v, want paulca still routed", got)
85 }
86}
87
88// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization
89// rule in this feature: a prefix must never point another tenant's traffic at
90// your backend.
91func TestReconcileRefusesCrossTenantTarget(t *testing.T) {
92 ctx := context.Background()
93 s := release.NewDirStore(t.TempDir())
94 reg := aliasRegWith(t)
95 addRoute(t, reg, "ti.to", "tito", "classic")
96 addRoute(t, reg, "victim.example", "someone-else", "their-app")
97 putClaim(t, s, Claim{
98 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
99 CreatedAt: "2026-09-03T00:00:00Z",
100 Paths: map[string]string{"paulca": "victim.example"},
101 })
102
103 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
104 t.Fatal(err)
105 }
106 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
107 t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got)
108 }
109}
110
111// TestReconcileSkipsUnknownTarget: a target that is not a local route yields
112// no row, so the prefix falls through to the host's own route. That is the
113// correct failure direction for a migration — a missing Go route serves
114// classic rather than 404ing.
115func TestReconcileSkipsUnknownTarget(t *testing.T) {
116 ctx := context.Background()
117 s := release.NewDirStore(t.TempDir())
118 reg := aliasRegWith(t)
119 addRoute(t, reg, "ti.to", "tito", "classic")
120 putClaim(t, s, Claim{
121 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
122 CreatedAt: "2026-09-03T00:00:00Z",
123 Paths: map[string]string{"paulca": "not-deployed-here.example"},
124 })
125
126 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
127 t.Fatal(err)
128 }
129 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
130 t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got)
131 }
132}
133
134// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to
135// the hostname's own route. If that route lives on another box, this box
136// derives nothing — the same per-box rule the alias half already follows.
137func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) {
138 ctx := context.Background()
139 s := release.NewDirStore(t.TempDir())
140 reg := aliasRegWith(t)
141 addRoute(t, reg, "go.tito.io", "tito", "classic")
142 putClaim(t, s, Claim{
143 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
144 CreatedAt: "2026-09-03T00:00:00Z",
145 Paths: map[string]string{"paulca": "go.tito.io"},
146 })
147
148 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
149 t.Fatal(err)
150 }
151 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
152 t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got)
153 }
154}
155
156// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated
157// deterministically (sorted key order), never silently and never at random.
158func TestReconcileCapsPathRows(t *testing.T) {
159 ctx := context.Background()
160 s := release.NewDirStore(t.TempDir())
161 reg := aliasRegWith(t)
162 addRoute(t, reg, "ti.to", "tito", "classic")
163 addRoute(t, reg, "go.tito.io", "tito", "classic")
164 big := make(map[string]string, MaxPathsPerClaim+10)
165 for i := 0; i < MaxPathsPerClaim+10; i++ {
166 big["acct"+strconv.Itoa(i)] = "go.tito.io"
167 }
168 putClaim(t, s, Claim{
169 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
170 CreatedAt: "2026-09-03T00:00:00Z",
171 Paths: big,
172 })
173
174 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
175 t.Fatal(err)
176 }
177 got := pathsByPrefix(t, reg, "ti.to")
178 if len(got) != MaxPathsPerClaim {
179 t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
180 }
181 // WHICH prefixes survive, not just how many. The count alone passes with
182 // the sort deleted, and the sort is the safety-critical half: every box
183 // folds the same claim independently, so a truncation that varied with
184 // Go's map iteration order would let the same URL resolve to different
185 // backends depending on which edge answered.
186 want := make([]string, 0, len(big))
187 for p := range big {
188 want = append(want, p)
189 }
190 sort.Strings(want)
191 want = want[:MaxPathsPerClaim]
192 for _, p := range want {
193 if _, ok := got[p]; !ok {
194 t.Fatalf("prefix %q was dropped; the surviving set must be the first %d in SORTED order, identically on every box", p, MaxPathsPerClaim)
195 }
196 }
197}
198
199// TestReconcileSkipsUnreadyTarget: a target route that exists on this box but
200// is not ready yet contributes no row (design spec §4, "a target that is not
201// a local READY route contributes no row"). The edge filters unready routes
202// out of its map anyway, so traffic already fell through safely — what this
203// gate buys is that `carlos routes` and the edge agree about which prefixes
204// are actually serving, and that surface is the one built for diagnosing a
205// flip.
206func TestReconcileSkipsUnreadyTarget(t *testing.T) {
207 ctx := context.Background()
208 s := release.NewDirStore(t.TempDir())
209 reg := aliasRegWith(t)
210 addRoute(t, reg, "ti.to", "tito", "classic")
211 if err := reg.Upsert(registry.Route{
212 Host: "go.tito.io", App: "classic", Account: "tito", Kind: "instance", Status: "provisioning",
213 }); err != nil {
214 t.Fatal(err)
215 }
216 putClaim(t, s, Claim{
217 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
218 CreatedAt: "2026-09-03T00:00:00Z",
219 Paths: map[string]string{"paulca": "go.tito.io"},
220 })
221
222 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
223 t.Fatal(err)
224 }
225 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
226 t.Fatalf("route_paths = %v, want no row for a target that is not ready", got)
227 }
228}
229
230// TestReconcileLogsUnresolvedTargetsOncePerHost is the critical-silence fix.
231// A target homed on ANOTHER BOX is the likeliest real-world refusal in this
232// feature — `ti.to` and `go.tito.io` are two very different apps — and it
233// used to be the ONLY refusal in the path block that said nothing at all, so
234// a migration could be published, accepted, and never route, forever, with
235// no signal. It must log; and because this pass runs every ~2s against a map
236// that may hold 10k entries, it must log ONE line per host per pass.
237func TestReconcileLogsUnresolvedTargetsOncePerHost(t *testing.T) {
238 ctx := context.Background()
239 s := release.NewDirStore(t.TempDir())
240 reg := aliasRegWith(t)
241 addRoute(t, reg, "ti.to", "tito", "classic")
242 putClaim(t, s, Claim{
243 Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
244 CreatedAt: "2026-09-03T00:00:00Z",
245 Paths: map[string]string{
246 "paulca": "elsewhere.example",
247 "bigcorp": "elsewhere.example",
248 "acme": "also-elsewhere.example",
249 },
250 })
251
252 var buf bytes.Buffer
253 old := log.Writer()
254 log.SetOutput(&buf)
255 defer log.SetOutput(old)
256 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
257 t.Fatal(err)
258 }
259 log.SetOutput(old)
260
261 out := buf.String()
262 if n := strings.Count(out, "name no local ready route"); n != 1 {
263 t.Fatalf("logged the unresolved-target line %d times, want exactly one per host per pass:\n%s", n, out)
264 }
265 for _, want := range []string{"ti.to", "3 prefix(es)", "elsewhere.example", "homed on another box"} {
266 if !strings.Contains(out, want) {
267 t.Fatalf("unresolved-target log is missing %q:\n%s", want, out)
268 }
269 }
270}
271
272// --- the reserved sidecar suffix, at path routing's own door ----------------
273//
274// The alias half of this pass has refused reserved-suffix hosts since the
275// sidecar-members branch was written. Path-based routing landed on main
276// afterwards, minting rows into a THIRD table from the same claims, through a
277// branch the alias guard never sees. These two tests are that omission
278// closed, and they are deliberately the pair — the two ends differ in what
279// stops them, and one of them nothing else stops at all.
280
281// The host end: a claim whose own hostname is under the reserved suffix must
282// mint no route_paths row, for the alias guard's reason exactly. A path row
283// keyed on a sidecar's name puts that name in the edge's routing map, and
284// edge.go's Kind check never sees it — the row it reads is a path row, not a
285// Kind=="sidecar" route.
286func TestReconcilePathsRefusesAReservedSuffixHost(t *testing.T) {
287 ctx := context.Background()
288 s := release.NewDirStore(t.TempDir())
289 reg := aliasRegWith(t)
290 addRoute(t, reg, "bot.eleven.bdm.sidecar.internal", "bdm", "eleven")
291 addRoute(t, reg, "go.example.com", "bdm", "eleven")
292 putClaim(t, s, Claim{
293 Host: "bot.eleven.bdm.sidecar.internal", Account: "bdm", App: "eleven",
294 Route: "bot.eleven.bdm.sidecar.internal",
295 CreatedAt: "2026-09-05T00:00:00Z",
296 Paths: map[string]string{"api": "go.example.com"},
297 })
298 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
299 t.Fatal(err)
300 }
301 if got := pathsByPrefix(t, reg, "bot.eleven.bdm.sidecar.internal"); len(got) != 0 {
302 t.Fatalf("route_paths = %v, want none — a reserved-suffix host must mint no path row", got)
303 }
304}
305
306// The TARGET end, which is the sharper one: a prefix pointing AT a sidecar.
307// Nothing else in this block refuses it. The cross-tenant check passes,
308// because a sidecar belongs to the claim's own account — that is the normal
309// case, not an edge one — and the readiness check passes, because a live
310// sidecar row is "ready". So the target field is the one place in this pass
311// where an account could route real traffic into its own undialable member,
312// and only a check on the name itself closes it.
313func TestReconcilePathsRefusesASidecarAsATarget(t *testing.T) {
314 ctx := context.Background()
315 s := release.NewDirStore(t.TempDir())
316 reg := aliasRegWith(t)
317 addRoute(t, reg, "eleven.example.com", "bdm", "eleven")
318 // The sidecar's own row, exactly as the reconcile pass mints it: same
319 // account, same app, ready. Every other gate in this block waves it past.
320 if err := reg.Upsert(registry.Route{
321 Host: "bot.eleven.bdm.sidecar.internal", App: "eleven", Account: "bdm",
322 Kind: "sidecar", Status: "ready",
323 }); err != nil {
324 t.Fatal(err)
325 }
326 var logged bytes.Buffer
327 log.SetOutput(&logged)
328 // os.Stderr, not nil: the log package is process-global, and this file's
329 // other tests read it. Restoring nil leaves every later test in the
330 // package writing into a discarded logger — which is how this landed as
331 // a failure in a test that has nothing to do with sidecars.
332 t.Cleanup(func() { log.SetOutput(os.Stderr) })
333
334 putClaim(t, s, Claim{
335 Host: "eleven.example.com", Account: "bdm", App: "eleven",
336 Route: "eleven.example.com", CreatedAt: "2026-09-05T00:00:00Z",
337 Paths: map[string]string{"bot": "bot.eleven.bdm.sidecar.internal"},
338 })
339 if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
340 t.Fatal(err)
341 }
342 if got := pathsByPrefix(t, reg, "eleven.example.com"); len(got) != 0 {
343 t.Fatalf("route_paths = %v, want none — nothing routes to a sidecar", got)
344 }
345 if !strings.Contains(logged.String(), "sidecar") {
346 t.Fatalf("the refusal must name its reason, got: %s", logged.String())
347 }
348}
349