carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package registry
2
3import (
4 "database/sql"
5 "errors"
6 "path/filepath"
7 "regexp"
8 "strings"
9 "sync"
10 "testing"
11)
12
13// Distinct, realistic-shape (64 lowercase hex) token hashes for tests —
14// AddFleetBox/SetFleetBoxToken now reject anything else.
15var (
16 tokenHashA = strings.Repeat("a", 64)
17 tokenHashB = strings.Repeat("b", 64)
18 tokenHashC = strings.Repeat("c", 64)
19)
20
21// TestFleetRoundTrips pins UpsertFleet/GetFleet/ListFleets against the exact
22// shape later tasks depend on (task-1-brief.md's Fleet struct).
23func TestFleetRoundTrips(t *testing.T) {
24 reg := openTestRegistry(t)
25 want := Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data", CreatedAt: 1700000000}
26 if err := reg.UpsertFleet(want); err != nil {
27 t.Fatal(err)
28 }
29 got, err := reg.GetFleet("acme")
30 if err != nil {
31 t.Fatal(err)
32 }
33 if got == nil {
34 t.Fatal("GetFleet returned nil for an upserted fleet")
35 }
36 if *got != want {
37 t.Fatalf("round trip mismatch:\n got %+v\n want %+v", *got, want)
38 }
39 list, err := reg.ListFleets()
40 if err != nil {
41 t.Fatal(err)
42 }
43 if len(list) != 1 || list[0] != want {
44 t.Fatalf("ListFleets = %+v, want [%+v]", list, want)
45 }
46}
47
48func TestGetFleetMissingIsNilNil(t *testing.T) {
49 reg := openTestRegistry(t)
50 got, err := reg.GetFleet("nope")
51 if err != nil || got != nil {
52 t.Fatalf("GetFleet(missing) = %v, %v; want nil, nil", got, err)
53 }
54}
55
56// TestCreateFleetInsertOnly: the ordinary sequential case — first create
57// wins, a second with the same name is refused without touching the
58// existing row.
59func TestCreateFleetInsertOnly(t *testing.T) {
60 reg := openTestRegistry(t)
61 created, err := reg.CreateFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"})
62 if err != nil || !created {
63 t.Fatalf("first CreateFleet: created=%v err=%v, want true, nil", created, err)
64 }
65 created, err = reg.CreateFleet(Fleet{Name: "acme", Account: "intruder", Bucket: "stolen"})
66 if err != nil || created {
67 t.Fatalf("second CreateFleet (same name): created=%v err=%v, want false, nil", created, err)
68 }
69 got, err := reg.GetFleet("acme")
70 if err != nil || got == nil || got.Account != "amadan" || got.Bucket != "acme-data" {
71 t.Fatalf("GetFleet after a refused create = %+v, %v, want the FIRST caller's row untouched", got, err)
72 }
73}
74
75// TestCreateFleetRaceInsertOnly is the property the console's
76// handleAPIFleetNew now depends on entirely (no more read-then-write): two
77// callers racing the SAME never-before-seen name — from two DIFFERENT
78// accounts — must never both win, and the row that survives must belong to
79// whichever one did. This is what makes UpsertFleet's ON CONFLICT DO UPDATE
80// (which CreateFleet deliberately does not use) unreachable from a genuine
81// race here.
82func TestCreateFleetRaceInsertOnly(t *testing.T) {
83 reg := openTestRegistry(t)
84 var wg sync.WaitGroup
85 created := make([]bool, 2)
86 errs := make([]error, 2)
87 accounts := []string{"aaaa", "bbbb"}
88 wg.Add(2)
89 for i := 0; i < 2; i++ {
90 go func(i int) {
91 defer wg.Done()
92 created[i], errs[i] = reg.CreateFleet(Fleet{Name: "acme", Account: accounts[i], Bucket: accounts[i] + "-data"})
93 }(i)
94 }
95 wg.Wait()
96
97 for i, err := range errs {
98 if err != nil {
99 t.Fatalf("CreateFleet[%d]: %v", i, err)
100 }
101 }
102 if created[0] == created[1] {
103 t.Fatalf("created = %v, %v — want exactly one true", created[0], created[1])
104 }
105 winner := accounts[0]
106 if created[1] {
107 winner = accounts[1]
108 }
109 got, err := reg.GetFleet("acme")
110 if err != nil || got == nil {
111 t.Fatalf("GetFleet after the race: %+v, %v", got, err)
112 }
113 if got.Account != winner {
114 t.Fatalf("surviving row account = %q, want the winner's %q", got.Account, winner)
115 }
116}
117
118// TestFleetBoxRoundTrips pins AddFleetBox/ListFleetBoxes against the exact
119// FleetBox shape later tasks depend on.
120func TestFleetBoxRoundTrips(t *testing.T) {
121 reg := openTestRegistry(t)
122 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
123 t.Fatal(err)
124 }
125 want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active", AddedAt: 1700000001}
126 if err := reg.AddFleetBox(want); err != nil {
127 t.Fatal(err)
128 }
129 boxes, err := reg.ListFleetBoxes("acme")
130 if err != nil {
131 t.Fatal(err)
132 }
133 if len(boxes) != 1 || boxes[0] != want {
134 t.Fatalf("ListFleetBoxes = %+v, want [%+v]", boxes, want)
135 }
136}
137
138// TestAddFleetBoxRejectsUnknownFleet: no FK, so this is the only rail
139// against an orphan box — one authenticating a channel for a fleet with no
140// account/bucket record at all.
141func TestAddFleetBoxRejectsUnknownFleet(t *testing.T) {
142 reg := openTestRegistry(t)
143 if err := reg.AddFleetBox(FleetBox{Fleet: "ghost", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err == nil {
144 t.Fatal("AddFleetBox against a never-upserted fleet: got nil error, want one")
145 }
146}
147
148// TestAddFleetBoxRejectsDuplicateActive is the brief's core invariant: two
149// boxes cannot hold the same (fleet, label) channel identity while both are
150// active.
151func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) {
152 reg := openTestRegistry(t)
153 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
154 t.Fatal(err)
155 }
156 box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}
157 if err := reg.AddFleetBox(box); err != nil {
158 t.Fatal(err)
159 }
160 err := reg.AddFleetBox(box)
161 if err == nil {
162 t.Fatal("AddFleetBox on a duplicate active (fleet,label): got nil error, want one")
163 }
164 // Distinguishable from any other failure via errors.Is, not string
165 // matching — the property console/fleets.go's handler now relies on to
166 // tell an ordinary conflict apart from a genuine infrastructure error.
167 if !errors.Is(err, ErrFleetBoxActive) {
168 t.Fatalf("AddFleetBox on a duplicate active: err = %v, want it to wrap ErrFleetBoxActive", err)
169 }
170}
171
172// TestAddFleetBoxReactivatesADetachedLabel: the brief only forbids a
173// duplicate ACTIVE box, implying a detached label can be re-added — the path
174// a re-provisioned box takes.
175func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) {
176 reg := openTestRegistry(t)
177 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
178 t.Fatal(err)
179 }
180 box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}
181 if err := reg.AddFleetBox(box); err != nil {
182 t.Fatal(err)
183 }
184 if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
185 t.Fatal(err)
186 }
187 reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashB, Status: "active"}
188 if err := reg.AddFleetBox(reAdd); err != nil {
189 t.Fatalf("AddFleetBox over a detached label: %v", err)
190 }
191 boxes, err := reg.ListFleetBoxes("acme")
192 if err != nil {
193 t.Fatal(err)
194 }
195 if len(boxes) != 1 || boxes[0].TokenHash != tokenHashB || boxes[0].Status != "active" {
196 t.Fatalf("re-add over a detached label = %+v, want reactivated with the new token", boxes)
197 }
198}
199
200// TestFleetChannelAuthOmitsDetachedBoxes pins the exact contract later tasks
201// rely on: channel name "<fleet>/<label>" -> TokenHash, ACTIVE boxes only.
202func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) {
203 reg := openTestRegistry(t)
204 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
205 t.Fatal(err)
206 }
207 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
208 t.Fatal(err)
209 }
210 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: tokenHashB, Status: "active"}); err != nil {
211 t.Fatal(err)
212 }
213 if err := reg.SetFleetBoxStatus("acme", "pi-2", "detached"); err != nil {
214 t.Fatal(err)
215 }
216 auth, err := reg.FleetChannelAuth()
217 if err != nil {
218 t.Fatal(err)
219 }
220 if _, ok := auth["acme/pi-2"]; ok {
221 t.Fatalf("FleetChannelAuth included a detached box: %+v", auth)
222 }
223 if got, ok := auth["acme/pi-1"]; !ok || got != tokenHashA {
224 t.Fatalf("FleetChannelAuth missing/wrong entry for the active box: %+v", auth)
225 }
226}
227
228func TestSetFleetBoxTokenMissingRowErrors(t *testing.T) {
229 reg := openTestRegistry(t)
230 if err := reg.SetFleetBoxToken("acme", "nope", tokenHashA); err == nil {
231 t.Fatal("SetFleetBoxToken on a missing row: got nil error, want one")
232 }
233}
234
235func TestSetFleetBoxStatusMissingRowErrors(t *testing.T) {
236 reg := openTestRegistry(t)
237 if err := reg.SetFleetBoxStatus("acme", "nope", "detached"); err == nil {
238 t.Fatal("SetFleetBoxStatus on a missing row: got nil error, want one")
239 }
240}
241
242// TestFleetWritersRejectInvalidNames: every writer validates fleet/label
243// names with ValidTunnelName before touching the DB.
244func TestFleetWritersRejectInvalidNames(t *testing.T) {
245 reg := openTestRegistry(t)
246 if err := reg.UpsertFleet(Fleet{Name: "UPPER"}); err == nil {
247 t.Fatal("UpsertFleet({Name:UPPER}): got nil error, want one")
248 }
249 if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1", TokenHash: tokenHashA}); err == nil {
250 t.Fatal("AddFleetBox with an invalid fleet name: got nil error, want one")
251 }
252 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER", TokenHash: tokenHashA}); err == nil {
253 t.Fatal("AddFleetBox with an invalid label: got nil error, want one")
254 }
255 if err := reg.SetFleetBoxToken("UPPER", "pi-1", tokenHashA); err == nil {
256 t.Fatal("SetFleetBoxToken with an invalid fleet name: got nil error, want one")
257 }
258 if err := reg.SetFleetBoxStatus("acme", "UPPER", "detached"); err == nil {
259 t.Fatal("SetFleetBoxStatus with an invalid label: got nil error, want one")
260 }
261}
262
263// TestFleetBoxWritersRejectInvalidStatus: AddFleetBox and SetFleetBoxStatus
264// both reject a status outside {"active","detached"} — a typo'd status
265// would otherwise make a live box invisible to AddFleetBox's uniqueness
266// guard, which keys off status = 'active'.
267func TestFleetBoxWritersRejectInvalidStatus(t *testing.T) {
268 reg := openTestRegistry(t)
269 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
270 t.Fatal(err)
271 }
272 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "enabled"}); err == nil {
273 t.Fatal("AddFleetBox with status \"enabled\": got nil error, want one")
274 }
275 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
276 t.Fatal(err)
277 }
278 if err := reg.SetFleetBoxStatus("acme", "pi-1", "enabled"); err == nil {
279 t.Fatal("SetFleetBoxStatus(\"enabled\"): got nil error, want one")
280 }
281}
282
283// TestFleetBoxWritersRejectInvalidTokenHash: AddFleetBox and
284// SetFleetBoxToken both require exactly 64 lowercase hex characters —
285// structurally enforcing "hashes only, never plaintext".
286func TestFleetBoxWritersRejectInvalidTokenHash(t *testing.T) {
287 reg := openTestRegistry(t)
288 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
289 t.Fatal(err)
290 }
291 for _, bad := range []string{"", "not-a-hash", strings.Repeat("a", 63), strings.Repeat("A", 64)} {
292 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: bad, Status: "active"}); err == nil {
293 t.Fatalf("AddFleetBox with token hash %q: got nil error, want one", bad)
294 }
295 }
296 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
297 t.Fatal(err)
298 }
299 if err := reg.SetFleetBoxToken("acme", "pi-1", "plaintext-token"); err == nil {
300 t.Fatal("SetFleetBoxToken with a plaintext-shaped value: got nil error, want one")
301 }
302}
303
304// TestFingerprintChangesOnFleetMutation: Fingerprint must fold the fleets
305// table into its aggregate — mutating a FLEET row (not a route) must still
306// change it, which is what lets the edge notice a fleet change later.
307func TestFingerprintChangesOnFleetMutation(t *testing.T) {
308 reg := openTestRegistry(t)
309 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
310 t.Fatal(err)
311 }
312 before, err := reg.Fingerprint()
313 if err != nil {
314 t.Fatal(err)
315 }
316 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan-longer-name", Bucket: "acme-data"}); err != nil {
317 t.Fatal(err)
318 }
319 after, err := reg.Fingerprint()
320 if err != nil {
321 t.Fatal(err)
322 }
323 if before == after {
324 t.Fatalf("fingerprint unchanged across a fleet mutation: %q", after)
325 }
326}
327
328// TestFingerprintChangesOnFleetBoxMutation: same guarantee for fleet_boxes —
329// a box status flip must change the fingerprint too (SetFleetBoxStatus is
330// exactly how a detach reaches the edge in later tasks).
331func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) {
332 reg := openTestRegistry(t)
333 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
334 t.Fatal(err)
335 }
336 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
337 t.Fatal(err)
338 }
339 before, err := reg.Fingerprint()
340 if err != nil {
341 t.Fatal(err)
342 }
343 if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
344 t.Fatal(err)
345 }
346 after, err := reg.Fingerprint()
347 if err != nil {
348 t.Fatal(err)
349 }
350 if before == after {
351 t.Fatalf("fingerprint unchanged across a fleet_boxes status flip: %q", after)
352 }
353}
354
355// TestFingerprintChangesOnTokenRotation is the Critical-1 regression pin: a
356// sha256 hex token_hash is ALWAYS 64 characters, so a rotation between two
357// same-length, different-content hashes changes no LENGTH, no COUNT, no
358// MAX(rowid) — only the rev column moves. Without rev folded into
359// Fingerprint, this rotation would be invisible to the edge's poll and a
360// revoked token would keep authenticating.
361func TestFingerprintChangesOnTokenRotation(t *testing.T) {
362 reg := openTestRegistry(t)
363 if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
364 t.Fatal(err)
365 }
366 if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
367 t.Fatal(err)
368 }
369 if len(tokenHashA) != len(tokenHashC) {
370 t.Fatalf("test fixture bug: rotation hashes must be equal length, got %d and %d", len(tokenHashA), len(tokenHashC))
371 }
372 before, err := reg.Fingerprint()
373 if err != nil {
374 t.Fatal(err)
375 }
376 if err := reg.SetFleetBoxToken("acme", "pi-1", tokenHashC); err != nil {
377 t.Fatal(err)
378 }
379 after, err := reg.Fingerprint()
380 if err != nil {
381 t.Fatal(err)
382 }
383 if before == after {
384 t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after)
385 }
386}
387
388// TestFingerprintChangesOnFleetBoxRestamp is the Task 10 regression pin —
389// the defect the two-box e2e (internal/edge/fleet_e2e_test.go) surfaced. A
390// failover restamps routes.fleet_box (ReconcileFleetBindings -> SetFleetBox)
391// between two labels of the SAME length, which is the common case for fleet
392// box labels ("box-a"/"box-b", "pi-1"/"pi-2"). Before the fleet-placement
393// projection was added to Fingerprint, that write changed nothing the
394// aggregate looked at — fleet_box is not in the routes weighted sum at all —
395// so the edge never reloaded, kept the dead box's label captured in its
396// fleet proxy, and dispatched straight back to it the moment it re-dialed.
397// Unlike an operator's route edit, a restamp is unattended: there is nobody
398// there to SIGHUP.
399func TestFingerprintChangesOnFleetBoxRestamp(t *testing.T) {
400 reg := openTestRegistry(t)
401 if err := reg.Upsert(Route{Host: "app.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-a"}); err != nil {
402 t.Fatal(err)
403 }
404 before, err := reg.Fingerprint()
405 if err != nil {
406 t.Fatal(err)
407 }
408 if err := reg.SetFleetBox("app.example", "box-b"); err != nil {
409 t.Fatal(err)
410 }
411 after, err := reg.Fingerprint()
412 if err != nil {
413 t.Fatal(err)
414 }
415 if before == after {
416 t.Fatalf("fingerprint unchanged across a same-length fleet_box restamp: %q — the edge would keep routing to the box that died", after)
417 }
418}
419
420// TestFingerprintChangesOnPlacement: the same projection covers the OTHER
421// column fleet dispatch reads — Region. Placing an existing route on a fleet
422// (`carlos apps -place`, a different PROCESS from the edge, so the
423// fingerprint poll is the only path in) must be visible too.
424func TestFingerprintChangesOnPlacement(t *testing.T) {
425 reg := openTestRegistry(t)
426 if err := reg.Upsert(Route{Host: "app.example", Kind: "instance"}); err != nil {
427 t.Fatal(err)
428 }
429 before, err := reg.Fingerprint()
430 if err != nil {
431 t.Fatal(err)
432 }
433 if err := reg.SetPlacement("app.example", PlacementTarget("pi")); err != nil {
434 t.Fatal(err)
435 }
436 mid, err := reg.Fingerprint()
437 if err != nil {
438 t.Fatal(err)
439 }
440 if before == mid {
441 t.Fatalf("fingerprint unchanged when a route was placed on a fleet: %q", mid)
442 }
443 // ...and clearing it again is equally visible (the row drops out of the
444 // projection rather than lingering as a stale entry).
445 if err := reg.SetPlacement("app.example", ""); err != nil {
446 t.Fatal(err)
447 }
448 after, err := reg.Fingerprint()
449 if err != nil {
450 t.Fatal(err)
451 }
452 if after == mid {
453 t.Fatalf("fingerprint unchanged when a route left fleet placement: %q", after)
454 }
455}
456
457// A registry with no fleet-placed routes at all — every deployment in the
458// fleet today — must produce a STABLE fingerprint across repeated polls: the
459// projection is a GROUP_CONCAT, and one that flapped (or that grew with
460// unrelated routes) would make the edge reload every 2s forever.
461func TestFingerprintStableWithoutFleetRoutes(t *testing.T) {
462 reg := openTestRegistry(t)
463 for _, h := range []string{"one.example", "two.example", "three.example"} {
464 if err := reg.Upsert(Route{Host: h, Kind: "service", Addr: "127.0.0.1:1", Region: "eu"}); err != nil {
465 t.Fatal(err)
466 }
467 }
468 first, err := reg.Fingerprint()
469 if err != nil {
470 t.Fatal(err)
471 }
472 for i := 0; i < 5; i++ {
473 again, err := reg.Fingerprint()
474 if err != nil {
475 t.Fatal(err)
476 }
477 if again != first {
478 t.Fatalf("fingerprint flapped with no writes at all: %q then %q", first, again)
479 }
480 }
481 // The boundedness claim itself, pinned. This assertion used to read
482 // "the placement projection is empty, hence a trailing ':'", because the
483 // fingerprint was a concatenation whose last component was a
484 // GROUP_CONCAT of fleet-placed rows — a WHERE clause that ever widened
485 // would have put hostnames' worth of text through a 2s poll. The merge
486 // with main replaced that concatenation with a sha256 over every
487 // reload-relevant column (registry.go's Fingerprint), which closes the
488 // same hole structurally rather than by scoping: the result is
489 // "<route count>:<64 hex>" no matter how many routes are fleet-placed,
490 // so no projection can grow and the poll's payload is fixed-size
491 // forever. That is what is pinned here now.
492 if !fingerprintShape.MatchString(first) {
493 t.Fatalf("fingerprint = %q, want the fixed-size %q shape — a poll payload that grows with the route table is the thing this test exists to catch", first, fingerprintShape)
494 }
495}
496
497// fingerprintShape is Fingerprint's whole contract as a string: a route
498// count, a colon, and a sha256 in hex. Nothing about it varies with how many
499// routes, fleets or fleet boxes the registry holds.
500var fingerprintShape = regexp.MustCompile(`^[0-9]+:[0-9a-f]{64}$`)
501
502// The other half of the stability question, and the one that actually
503// exercises the concat: with fleet-placed routes present the projection is
504// non-empty and built by GROUP_CONCAT over an ORDER BY subquery. If that
505// order were not stable across identical polls, the edge would see a
506// "changed" fingerprint every 2 seconds and reload forever — on every box
507// running a fleet, permanently. Mixed FleetBox values (one stamped, one
508// stamped differently, one placed but unstamped) so a reordering has
509// something to reorder.
510func TestFingerprintStableWithFleetRoutes(t *testing.T) {
511 reg := openTestRegistry(t)
512 for _, rt := range []Route{
513 {Host: "one.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-a"},
514 {Host: "two.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-b"},
515 {Host: "three.example", Kind: "instance", Region: PlacementTarget("eleven"), FleetBox: ""},
516 {Host: "four.example", Kind: "service", Addr: "127.0.0.1:1"}, // not fleet-placed
517 } {
518 if err := reg.Upsert(rt); err != nil {
519 t.Fatal(err)
520 }
521 }
522 first, err := reg.Fingerprint()
523 if err != nil {
524 t.Fatal(err)
525 }
526 if strings.HasSuffix(first, ":") {
527 t.Fatalf("fingerprint = %q, want a NON-empty final component — with three fleet-placed routes the projection this test exists to exercise is empty, so the stability loop below proves nothing", first)
528 }
529 for i := 0; i < 5; i++ {
530 again, err := reg.Fingerprint()
531 if err != nil {
532 t.Fatal(err)
533 }
534 if again != first {
535 t.Fatalf("fingerprint flapped across identical polls with fleet routes present: %q then %q — every edge running a fleet would reload every 2s forever", first, again)
536 }
537 }
538}
539
540// TestFleetPlacement pins the "fleet/<name>" grammar Route.Region carries
541// when a route is bound to a customer fleet: the prefix alone is not enough,
542// the name half must also pass ValidTunnelName, and a bare region code (a
543// FUTURE placement form this v1 does not parse) must answer false rather
544// than partially matching.
545func TestFleetPlacement(t *testing.T) {
546 cases := []struct {
547 region string
548 wantFleet string
549 wantOK bool
550 }{
551 {"fleet/pi", "pi", true},
552 {"", "", false},
553 {"eu", "", false},
554 {"fleet/UPPER", "", false},
555 {"fleet/", "", false},
556 {"fleet", "", false},
557 }
558 for _, c := range cases {
559 fleet, ok := FleetPlacement(c.region)
560 if fleet != c.wantFleet || ok != c.wantOK {
561 t.Errorf("FleetPlacement(%q) = (%q, %v), want (%q, %v)", c.region, fleet, ok, c.wantFleet, c.wantOK)
562 }
563 }
564}
565
566// TestPlacementTarget pins the inverse rendering FleetPlacement parses back.
567func TestPlacementTarget(t *testing.T) {
568 if got := PlacementTarget("pi"); got != "fleet/pi" {
569 t.Errorf("PlacementTarget(%q) = %q, want %q", "pi", got, "fleet/pi")
570 }
571 fleet, ok := FleetPlacement(PlacementTarget("pi-box"))
572 if !ok || fleet != "pi-box" {
573 t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box")
574 }
575}
576
577// mustCreateFleet is a tiny helper for tests that don't care about anything
578// beyond fixed test values for account/bucket.
579func mustCreateFleet(t *testing.T, r *Registry, fleet, account, bucket string) {
580 t.Helper()
581 if _, err := r.CreateFleet(Fleet{Name: fleet, Account: account, Bucket: bucket}); err != nil {
582 t.Fatalf("CreateFleet(%q): %v", fleet, err)
583 }
584}
585
586// mustAddBox is a tiny helper for tests that don't care about anything
587// beyond a fixed test token hash and an "active" status.
588func mustAddBox(t *testing.T, r *Registry, fleet, label string) {
589 t.Helper()
590 if err := r.AddFleetBox(FleetBox{Fleet: fleet, Label: label, TokenHash: tokenHashA, Status: "active"}); err != nil {
591 t.Fatalf("AddFleetBox(%q, %q): %v", fleet, label, err)
592 }
593}
594
595// TestFleetBoxTags pins ValidBoxTags's grammar and SetFleetBoxTags's
596// round-trip through ListFleetBoxes, including the invalid-grammar and
597// unknown-box refusals and clearing back to "".
598func TestFleetBoxTags(t *testing.T) {
599 r := openTestRegistry(t)
600 mustCreateFleet(t, r, "pi", "acct1", "bkt")
601 mustAddBox(t, r, "pi", "box-a")
602 // grammar
603 for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b",
604 "a,b,c,d,e,f,g,h,i"} {
605 if ValidBoxTags(bad) {
606 t.Errorf("ValidBoxTags(%q) = true, want false", bad)
607 }
608 }
609 for _, good := range []string{"", "office", "office,free", "a-1"} {
610 if !ValidBoxTags(good) {
611 t.Errorf("ValidBoxTags(%q) = false, want true", good)
612 }
613 }
614 if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil {
615 t.Fatal("invalid grammar accepted")
616 }
617 if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil {
618 t.Fatal("unknown box accepted")
619 }
620 if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil {
621 t.Fatal(err)
622 }
623 boxes, err := r.ListFleetBoxes("pi")
624 if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" {
625 t.Fatalf("tags round-trip: %+v err=%v", boxes, err)
626 }
627 // clearing
628 if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil {
629 t.Fatal(err)
630 }
631}
632
633// TestFleetBoxTagsFingerprintMoves mirrors
634// TestFingerprintChangesOnTokenRotation for tags: a tags write must move
635// Fingerprint the same way a token rotation does, or the edge would never
636// notice a tag change land.
637func TestFleetBoxTagsFingerprintMoves(t *testing.T) {
638 r := openTestRegistry(t)
639 mustCreateFleet(t, r, "pi", "acct1", "bkt")
640 mustAddBox(t, r, "pi", "box-a")
641 before, err := r.Fingerprint()
642 if err != nil {
643 t.Fatal(err)
644 }
645 if err := r.SetFleetBoxTags("pi", "box-a", "office"); err != nil {
646 t.Fatal(err)
647 }
648 after, err := r.Fingerprint()
649 if err != nil {
650 t.Fatal(err)
651 }
652 if before == after {
653 t.Fatalf("fingerprint unchanged across a fleet_boxes tags write: %q", after)
654 }
655}
656
657// TestFleetBoxesPreTagsRowsParse mirrors TestFleetBoxMigratesFromPreChangeDB
658// (registry_test.go): a fleet_boxes row written before the tags column
659// existed is simulated by creating the table without it and inserting a raw
660// row, then Open() (which runs migrateFleets) must still open the file and
661// ListFleetBoxes must read the row back with Tags == "".
662func TestFleetBoxesPreTagsRowsParse(t *testing.T) {
663 path := filepath.Join(t.TempDir(), "registry.db")
664 db, err := sql.Open("sqlite", path)
665 if err != nil {
666 t.Fatal(err)
667 }
668 if _, err := db.Exec(`
669CREATE TABLE fleet_boxes (
670 fleet TEXT NOT NULL,
671 label TEXT NOT NULL,
672 token_hash TEXT NOT NULL,
673 status TEXT NOT NULL,
674 added_at INTEGER NOT NULL,
675 rev INTEGER NOT NULL DEFAULT 0,
676 PRIMARY KEY (fleet, label)
677);`); err != nil {
678 t.Fatal(err)
679 }
680 if _, err := db.Exec(`INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at) VALUES (?, ?, ?, ?, ?)`,
681 "pi", "box-a", tokenHashA, "active", 1700000000); err != nil {
682 t.Fatal(err)
683 }
684 if err := db.Close(); err != nil {
685 t.Fatal(err)
686 }
687
688 r, err := Open(path)
689 if err != nil {
690 t.Fatalf("Open over a pre-tags DB: %v", err)
691 }
692 defer r.Close()
693 boxes, err := r.ListFleetBoxes("pi")
694 if err != nil {
695 t.Fatal(err)
696 }
697 if len(boxes) != 1 || boxes[0].Tags != "" {
698 t.Fatalf("pre-tags row should default tags=\"\": %+v", boxes)
699 }
700}
701