eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "database/sql"
5 "flag"
6 "fmt"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "reflect"
12 "strings"
13 "syscall"
14 "testing"
15 "time"
16)
17
18func TestFlagsAfterPositional(t *testing.T) {
19 fs := flag.NewFlagSet("t", flag.ContinueOnError)
20 socket := fs.String("socket", "", "")
21 all := fs.Bool("all", false, "")
22 // host BEFORE the flags — the natural order that the stdlib parser drops.
23 if err := fs.Parse(reorderFlagsFirst(fs, []string{"chat.example.com", "--socket", "/x.sock", "--all"})); err != nil {
24 t.Fatalf("parse: %v", err)
25 }
26 if *socket != "/x.sock" {
27 t.Errorf("socket = %q, want /x.sock", *socket)
28 }
29 if !*all {
30 t.Error("all = false, want true")
31 }
32 if fs.Arg(0) != "chat.example.com" {
33 t.Errorf("Arg(0) = %q, want chat.example.com", fs.Arg(0))
34 }
35}
36
37func TestReorderDoesNotPullFlagAsValue(t *testing.T) {
38 fs := flag.NewFlagSet("t", flag.ContinueOnError)
39 fs.String("socket", "", "")
40 fs.String("router-unit", "", "")
41 // --socket is given no value (next token is another flag); reorder must not
42 // swallow --router-unit as --socket's value.
43 out := reorderFlagsFirst(fs, []string{"host", "--socket", "--router-unit", "foo"})
44 if !strings.Contains(strings.Join(out, " "), "--router-unit foo") {
45 t.Errorf("reorder paired a flag as a value: %v", out)
46 }
47}
48
49func TestValidHost(t *testing.T) {
50 ok := []string{"chat.example.com", "rtr-test.example.com", "a.b", "x1.y2.example.com"}
51 bad := []string{"", "nodots", "-bad.example.com", "bad-.example.com", "has space.com", "Chat.Example.com", "a/b.com", "a..b"}
52 for _, h := range ok {
53 if !validHost(h) {
54 t.Errorf("validHost(%q) = false, want true", h)
55 }
56 }
57 for _, h := range bad {
58 if validHost(h) {
59 t.Errorf("validHost(%q) = true, want false", h)
60 }
61 }
62}
63
64func TestCheckSocketLen(t *testing.T) {
65 if err := checkSocketLen("/run/lchat/chat.example.com.sock"); err != nil {
66 t.Errorf("short path: unexpected error %v", err)
67 }
68 long := "/run/lchat/" + strings.Repeat("a", 120) + ".sock"
69 if err := checkSocketLen(long); err == nil {
70 t.Error("long path: expected error, got nil")
71 }
72}
73
74func TestEnvFileRoundTrip(t *testing.T) {
75 path := filepath.Join(t.TempDir(), "chat.example.com.env")
76 in := map[string]string{"LCHAT_ADMIN_TOKEN": "tok-123", "LCHAT_HOME_ORIGIN": "https://home.example.com"}
77 if err := writeEnvFile(path, in); err != nil {
78 t.Fatalf("write: %v", err)
79 }
80 fi, err := os.Stat(path)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if fi.Mode().Perm() != 0o600 {
85 t.Errorf("perms = %v, want -rw-------", fi.Mode().Perm())
86 }
87 out, err := readEnvFile(path)
88 if err != nil {
89 t.Fatalf("read: %v", err)
90 }
91 for k, v := range in {
92 if out[k] != v {
93 t.Errorf("readEnvFile[%q] = %q, want %q", k, out[k], v)
94 }
95 }
96}
97
98func TestRegistryGet(t *testing.T) {
99 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
100 if err != nil {
101 t.Fatal(err)
102 }
103 if got, err := reg.get("missing.example.com"); err != nil || got != nil {
104 t.Fatalf("get(missing) = %v, %v; want nil, nil", got, err)
105 }
106 if err := reg.upsert(Route{Host: "chat.example.com", Kind: "instance", Socket: "/run/lchat/chat.example.com.sock", Status: "ready"}); err != nil {
107 t.Fatal(err)
108 }
109 got, err := reg.get("chat.example.com")
110 if err != nil || got == nil {
111 t.Fatalf("get(existing) = %v, %v", got, err)
112 }
113 if got.Socket != "/run/lchat/chat.example.com.sock" || got.Status != "ready" || got.Kind != "instance" {
114 t.Errorf("get returned %+v", *got)
115 }
116
117 // delete stops routing the host.
118 if err := reg.delete("chat.example.com"); err != nil {
119 t.Fatalf("delete: %v", err)
120 }
121 if got, err := reg.get("chat.example.com"); err != nil || got != nil {
122 t.Fatalf("after delete, get = %v, %v; want nil, nil", got, err)
123 }
124}
125
126// The register/materialize seam must be pure refactor: running the two named
127// steps by hand ends in exactly the state `lchat add-instance` produces.
128func TestRegisterThenMaterializeMatchesAddInstance(t *testing.T) {
129 reg := controlTestEnv(t)
130
131 composed, err := addInstance(reg, addInstanceOpts{Host: "composed.example.com"})
132 if err != nil {
133 t.Fatalf("addInstance: %v", err)
134 }
135
136 route, token, err := registerInstance(reg, addInstanceOpts{Host: "split.example.com"})
137 if err != nil {
138 t.Fatalf("register: %v", err)
139 }
140 // After register alone the space exists but nothing runs: a "creating" row,
141 // no env file (the token's only persistent home is written by materialize).
142 if rt, err := reg.get("split.example.com"); err != nil || rt == nil || rt.Status != "creating" {
143 t.Fatalf("after register, route = %+v, err %v; want status creating", rt, err)
144 }
145 if _, err := os.Stat(filepath.Join(instanceDataDir, "split.example.com.env")); !os.IsNotExist(err) {
146 t.Error("register wrote the env file — that's materialize's job")
147 }
148 split, err := materializeInstance(reg, route, token, addInstanceOpts{Host: "split.example.com"})
149 if err != nil {
150 t.Fatalf("materialize: %v", err)
151 }
152
153 // Both paths yield a ready route with the same host-independent shape…
154 for _, host := range []string{"composed.example.com", "split.example.com"} {
155 res := composed
156 if host == "split.example.com" {
157 res = split
158 }
159 rt, err := reg.get(host)
160 if err != nil || rt == nil {
161 t.Fatalf("get(%s): %+v, %v", host, rt, err)
162 }
163 if rt.Status != "ready" || rt.Kind != "instance" || rt.Version == "" {
164 t.Errorf("%s: route = %+v, want ready instance with a version", host, *rt)
165 }
166 // …an env file holding the admin token the result reports…
167 env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
168 if err != nil {
169 t.Fatalf("read env %s: %v", host, err)
170 }
171 if env["LCHAT_ADMIN_TOKEN"] != res.AdminToken {
172 t.Errorf("%s: env token != result token", host)
173 }
174 // …and a setup link minted against the started instance.
175 if !strings.HasPrefix(res.SetupLink, "https://"+host+"/join/setup#") {
176 t.Errorf("%s: setup link = %q", host, res.SetupLink)
177 }
178 }
179
180 // Field-for-field, the two routes differ only in what derives from the host
181 // (paths, sockets) and the create timestamp.
182 a, _ := reg.get("composed.example.com")
183 b, _ := reg.get("split.example.com")
184 for _, rt := range []*Route{a, b} {
185 rt.Host, rt.Socket, rt.DBPath, rt.CreatedAt = "", "", "", 0
186 }
187 if !reflect.DeepEqual(*a, *b) {
188 t.Errorf("routes diverge:\n composed %+v\n split %+v", *a, *b)
189 }
190}
191
192// register() carries the geo/co-op placement seams onto the row, and
193// materialize preserves them through its ready upsert. Nothing else may key on
194// them yet — this test pins only the plumbing.
195// `lchat add-instance --home-region <r>` records the region on the new route
196// (multi-edge Phase 1): the flag flows through addInstanceOpts.HomeRegion into
197// the registry row the mesh later places by, with surrounding whitespace
198// trimmed. Exercises the real CLI entry point (runAddInstance) over the shared
199// controlTestEnv fakes.
200func TestAddInstanceHomeRegionFlag(t *testing.T) {
201 _ = controlTestEnv(t) // fakes: systemd (binds the socket), self-stage, dirs, public-ready
202 regPath := filepath.Join(t.TempDir(), "registry.db")
203
204 runAddInstance([]string{"region.example.com", "--home-region", " ie ", "--registry", regPath})
205
206 reg, err := openRegistry(regPath)
207 if err != nil {
208 t.Fatal(err)
209 }
210 rt, err := reg.get("region.example.com")
211 if err != nil || rt == nil {
212 t.Fatalf("route = %+v, err %v", rt, err)
213 }
214 if rt.HomeRegion != "ie" {
215 t.Errorf("home_region = %q, want ie (flag value, trimmed)", rt.HomeRegion)
216 }
217}
218
219func TestRegisterAcceptsRegionProvider(t *testing.T) {
220 reg := controlTestEnv(t)
221
222 opts := addInstanceOpts{Host: "geo.example.com", HomeRegion: "eu-central-1", Provider: "hetzner"}
223 route, token, err := registerInstance(reg, opts)
224 if err != nil {
225 t.Fatalf("register: %v", err)
226 }
227 if rt, _ := reg.get("geo.example.com"); rt == nil || rt.HomeRegion != "eu-central-1" || rt.Provider != "hetzner" {
228 t.Fatalf("after register, route = %+v, want home_region/provider recorded", rt)
229 }
230 if _, err := materializeInstance(reg, route, token, opts); err != nil {
231 t.Fatalf("materialize: %v", err)
232 }
233 rt, _ := reg.get("geo.example.com")
234 if rt == nil || rt.Status != "ready" || rt.HomeRegion != "eu-central-1" || rt.Provider != "hetzner" {
235 t.Errorf("after materialize, route = %+v, want placement preserved on the ready row", rt)
236 }
237}
238
239// A registry created before the geo/co-op columns existed must gain them on
240// open, with existing rows reading as the implicit ""/"" placement — live boxes
241// carry such DBs, so the migration has to be safe over real data.
242func TestRegistryMigratesGeoProviderColumns(t *testing.T) {
243 path := filepath.Join(t.TempDir(), "reg.db")
244
245 // Build the pre-seam schema by hand and seed a row, as a live box would have.
246 old, err := sql.Open("sqlite", path)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if _, err := old.Exec(`
251CREATE TABLE routes (
252 host TEXT PRIMARY KEY,
253 kind TEXT NOT NULL,
254 socket TEXT NOT NULL,
255 status TEXT NOT NULL DEFAULT 'ready',
256 last_error TEXT NOT NULL DEFAULT '',
257 db_path TEXT NOT NULL DEFAULT '',
258 version TEXT NOT NULL DEFAULT '',
259 prev_version TEXT NOT NULL DEFAULT '',
260 app_store_url TEXT NOT NULL DEFAULT '',
261 home_origin TEXT NOT NULL DEFAULT '',
262 push_relay_url TEXT NOT NULL DEFAULT '',
263 push_relay_token TEXT NOT NULL DEFAULT '',
264 created_at INTEGER NOT NULL
265);
266INSERT INTO routes (host, kind, socket, created_at) VALUES ('old.example.com', 'instance', '/run/lchat/old.sock', 1700000000);
267`); err != nil {
268 t.Fatal(err)
269 }
270 if err := old.Close(); err != nil {
271 t.Fatal(err)
272 }
273
274 reg, err := openRegistry(path)
275 if err != nil {
276 t.Fatalf("open over pre-seam registry: %v", err)
277 }
278 rt, err := reg.get("old.example.com")
279 if err != nil || rt == nil {
280 t.Fatalf("get pre-existing row: %+v, %v", rt, err)
281 }
282 if rt.HomeRegion != "" || rt.Provider != "" {
283 t.Errorf("pre-existing row: home_region=%q provider=%q, want the implicit \"\"/\"\"", rt.HomeRegion, rt.Provider)
284 }
285 // The pre-seam schema predates next_due too — the same open must add it.
286 if rt.NextDue != 0 {
287 t.Errorf("pre-existing row: next_due=%d, want 0", rt.NextDue)
288 }
289
290 // New values round-trip through the migrated table.
291 rt.HomeRegion, rt.Provider = "us-east-1", "aws"
292 if err := reg.upsert(*rt); err != nil {
293 t.Fatalf("upsert with placement: %v", err)
294 }
295 got, err := reg.get("old.example.com")
296 if err != nil || got == nil || got.HomeRegion != "us-east-1" || got.Provider != "aws" {
297 t.Errorf("round-trip = %+v, %v", got, err)
298 }
299
300 // migrate is idempotent — reopening must not trip on the added columns.
301 if _, err := openRegistry(path); err != nil {
302 t.Errorf("reopen after migration: %v", err)
303 }
304}
305
306// The due-time index's registry half (activator.go's actDueStore): setNextDue
307// is a narrow write, dueInstances returns exactly the ready instances whose
308// time has arrived, and a route rewrite (deploy's read-modify-write upsert)
309// must not clobber a sleeping host's due time.
310func TestRegistryDueTimeIndex(t *testing.T) {
311 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
312 if err != nil {
313 t.Fatal(err)
314 }
315 seed := []Route{
316 {Host: "sleepy.example.com", Kind: "instance", Socket: "/run/a.sock", Hibernate: true},
317 {Host: "quiet.example.com", Kind: "instance", Socket: "/run/b.sock", Hibernate: true},
318 {Host: "svc.example.com", Kind: "service", Socket: "/run/c.sock"},
319 {Host: "broken.example.com", Kind: "instance", Socket: "/run/d.sock", Status: "failed", Hibernate: true},
320 {Host: "manual.example.com", Kind: "instance", Socket: "/run/e.sock"}, // systemd-managed (hibernate off)
321 }
322 for _, rt := range seed {
323 if err := reg.upsert(rt); err != nil {
324 t.Fatalf("upsert %s: %v", rt.Host, err)
325 }
326 }
327 const now = int64(1_700_000_000)
328 for host, due := range map[string]int64{
329 "sleepy.example.com": now - 5, // due
330 "svc.example.com": now - 5, // due but a service — never woken
331 "broken.example.com": now - 5, // due but not ready — never woken
332 "manual.example.com": now - 5, // due but NOT hibernating — always on, never due-woken
333 // quiet: next_due stays 0 — nothing scheduled, never due-woken
334 } {
335 if err := reg.setNextDue(host, due); err != nil {
336 t.Fatalf("setNextDue %s: %v", host, err)
337 }
338 }
339
340 due, err := reg.dueInstances(now)
341 if err != nil {
342 t.Fatalf("dueInstances: %v", err)
343 }
344 if len(due) != 1 || due[0].Host != "sleepy.example.com" {
345 t.Fatalf("dueInstances = %+v, want just sleepy.example.com", due)
346 }
347 // Not yet due: a future timestamp stays out of the set.
348 if err := reg.setNextDue("sleepy.example.com", now+60); err != nil {
349 t.Fatal(err)
350 }
351 if due, _ := reg.dueInstances(now); len(due) != 0 {
352 t.Fatalf("future due time returned early: %+v", due)
353 }
354
355 // Read-modify-write (how deploy/rollback update a route) carries next_due.
356 rt, err := reg.get("sleepy.example.com")
357 if err != nil || rt == nil {
358 t.Fatalf("get: %+v, %v", rt, err)
359 }
360 rt.Version = "v2"
361 if err := reg.upsert(*rt); err != nil {
362 t.Fatalf("upsert rewrite: %v", err)
363 }
364 if got, _ := reg.get("sleepy.example.com"); got.NextDue != now+60 {
365 t.Fatalf("route rewrite clobbered next_due: %d, want %d", got.NextDue, now+60)
366 }
367
368 // The narrow setters stay orthogonal: the rewrite above carried the
369 // hibernate flag, and setHibernate must not touch next_due.
370 if got, _ := reg.get("sleepy.example.com"); !got.Hibernate {
371 t.Fatal("route rewrite dropped the hibernate flag")
372 }
373 if err := reg.setHibernate("sleepy.example.com", false); err != nil {
374 t.Fatalf("setHibernate: %v", err)
375 }
376 if got, _ := reg.get("sleepy.example.com"); got.Hibernate || got.NextDue != now+60 {
377 t.Fatalf("setHibernate clobbered the route: hibernate=%v next_due=%d", got.Hibernate, got.NextDue)
378 }
379
380 // An unknown host is a quiet no-op — it can't be woken anyway.
381 if err := reg.setNextDue("ghost.example.com", now); err != nil {
382 t.Fatalf("setNextDue unknown host: %v", err)
383 }
384}
385
386// A registry created before the hibernate column existed must gain it on open,
387// with existing rows reading as hibernate=off — the live fleet's registry is
388// exactly such a DB, so the migration must default every real instance to the
389// classic systemd shape (the whole point of the per-host opt-in).
390func TestRegistryMigratesHibernateColumn(t *testing.T) {
391 path := filepath.Join(t.TempDir(), "reg.db")
392
393 // The schema as of the due-time index — everything current EXCEPT hibernate.
394 old, err := sql.Open("sqlite", path)
395 if err != nil {
396 t.Fatal(err)
397 }
398 if _, err := old.Exec(`
399CREATE TABLE routes (
400 host TEXT PRIMARY KEY,
401 kind TEXT NOT NULL,
402 socket TEXT NOT NULL,
403 status TEXT NOT NULL DEFAULT 'ready',
404 last_error TEXT NOT NULL DEFAULT '',
405 db_path TEXT NOT NULL DEFAULT '',
406 version TEXT NOT NULL DEFAULT '',
407 prev_version TEXT NOT NULL DEFAULT '',
408 app_store_url TEXT NOT NULL DEFAULT '',
409 home_origin TEXT NOT NULL DEFAULT '',
410 push_relay_url TEXT NOT NULL DEFAULT '',
411 push_relay_token TEXT NOT NULL DEFAULT '',
412 home_region TEXT NOT NULL DEFAULT '',
413 provider TEXT NOT NULL DEFAULT '',
414 next_due INTEGER NOT NULL DEFAULT 0,
415 created_at INTEGER NOT NULL
416);
417INSERT INTO routes (host, kind, socket, created_at) VALUES ('old.example.com', 'instance', '/run/lchat/old.sock', 1700000000);
418`); err != nil {
419 t.Fatal(err)
420 }
421 if err := old.Close(); err != nil {
422 t.Fatal(err)
423 }
424
425 reg, err := openRegistry(path)
426 if err != nil {
427 t.Fatalf("open over pre-hibernate registry: %v", err)
428 }
429 rt, err := reg.get("old.example.com")
430 if err != nil || rt == nil {
431 t.Fatalf("get pre-existing row: %+v, %v", rt, err)
432 }
433 if rt.Hibernate {
434 t.Error("pre-existing row: hibernate=true, want the systemd-managed default (false)")
435 }
436
437 // The flag round-trips through both the narrow setter and a full upsert.
438 if err := reg.setHibernate("old.example.com", true); err != nil {
439 t.Fatalf("setHibernate: %v", err)
440 }
441 if got, _ := reg.get("old.example.com"); got == nil || !got.Hibernate {
442 t.Fatalf("after setHibernate, route = %+v, want hibernate on", got)
443 }
444 rt, _ = reg.get("old.example.com")
445 rt.Hibernate = false
446 if err := reg.upsert(*rt); err != nil {
447 t.Fatalf("upsert with hibernate: %v", err)
448 }
449 if got, _ := reg.get("old.example.com"); got == nil || got.Hibernate {
450 t.Fatalf("after upsert, route = %+v, want hibernate off", got)
451 }
452
453 // migrate is idempotent — reopening must not trip on the added column.
454 if _, err := openRegistry(path); err != nil {
455 t.Errorf("reopen after migration: %v", err)
456 }
457}
458
459// A registry created before the replication column existed must gain it on
460// open, with existing rows reading as "" — the stream class, i.e. exactly the
461// pre-ladder behavior, so a deploy over the live fleet's registry changes no
462// host's durability until an operator (or billing) says so.
463func TestRegistryMigratesReplicationColumn(t *testing.T) {
464 path := filepath.Join(t.TempDir(), "reg.db")
465
466 // The schema as of the hibernate flag — everything current EXCEPT replication.
467 old, err := sql.Open("sqlite", path)
468 if err != nil {
469 t.Fatal(err)
470 }
471 if _, err := old.Exec(`
472CREATE TABLE routes (
473 host TEXT PRIMARY KEY,
474 kind TEXT NOT NULL,
475 socket TEXT NOT NULL,
476 status TEXT NOT NULL DEFAULT 'ready',
477 last_error TEXT NOT NULL DEFAULT '',
478 db_path TEXT NOT NULL DEFAULT '',
479 version TEXT NOT NULL DEFAULT '',
480 prev_version TEXT NOT NULL DEFAULT '',
481 app_store_url TEXT NOT NULL DEFAULT '',
482 home_origin TEXT NOT NULL DEFAULT '',
483 push_relay_url TEXT NOT NULL DEFAULT '',
484 push_relay_token TEXT NOT NULL DEFAULT '',
485 home_region TEXT NOT NULL DEFAULT '',
486 provider TEXT NOT NULL DEFAULT '',
487 hibernate INTEGER NOT NULL DEFAULT 0,
488 next_due INTEGER NOT NULL DEFAULT 0,
489 created_at INTEGER NOT NULL
490);
491INSERT INTO routes (host, kind, socket, hibernate, created_at) VALUES ('old.example.com', 'instance', '/run/lchat/old.sock', 1, 1700000000);
492`); err != nil {
493 t.Fatal(err)
494 }
495 if err := old.Close(); err != nil {
496 t.Fatal(err)
497 }
498
499 reg, err := openRegistry(path)
500 if err != nil {
501 t.Fatalf("open over pre-replication registry: %v", err)
502 }
503 rt, err := reg.get("old.example.com")
504 if err != nil || rt == nil {
505 t.Fatalf("get pre-existing row: %+v, %v", rt, err)
506 }
507 if rt.Replication != "" {
508 t.Errorf("pre-existing row: replication=%q, want \"\" (the stream default)", rt.Replication)
509 }
510
511 // The class round-trips through the narrow setter, an upsert carries it,
512 // and setReplication stays orthogonal to the rest of the route.
513 if err := reg.setReplication("old.example.com", replicationDaily); err != nil {
514 t.Fatalf("setReplication: %v", err)
515 }
516 if got, _ := reg.get("old.example.com"); got == nil || got.Replication != replicationDaily || !got.Hibernate {
517 t.Fatalf("after setReplication, route = %+v, want daily + hibernate intact", got)
518 }
519 rt, _ = reg.get("old.example.com")
520 rt.Version = "v2"
521 if err := reg.upsert(*rt); err != nil {
522 t.Fatalf("upsert rewrite: %v", err)
523 }
524 if got, _ := reg.get("old.example.com"); got == nil || got.Replication != replicationDaily {
525 t.Fatalf("route rewrite dropped the class: %+v", got)
526 }
527
528 // Garbage never lands: no write path may store a class the wake path would
529 // silently read as stream.
530 if err := reg.setReplication("old.example.com", "hourly"); err == nil {
531 t.Error("setReplication accepted an unknown class")
532 }
533 if got, _ := reg.get("old.example.com"); got == nil || got.Replication != replicationDaily {
534 t.Fatalf("rejected set still changed the row: %+v", got)
535 }
536
537 // An unknown host is a quiet no-op, matching setHibernate/setNextDue.
538 if err := reg.setReplication("ghost.example.com", replicationBatched); err != nil {
539 t.Fatalf("setReplication unknown host: %v", err)
540 }
541
542 // migrate is idempotent — reopening must not trip on the added column.
543 if _, err := openRegistry(path); err != nil {
544 t.Errorf("reopen after migration: %v", err)
545 }
546}
547
548// The class→interval mapping is the ladder's contract: stream (and the unset
549// "" every pre-ladder registry row reads as) keeps litestream's default, and
550// the coarser classes stretch it — never the other way around.
551func TestReplicationSyncInterval(t *testing.T) {
552 for class, want := range map[string]time.Duration{
553 "": 0,
554 replicationStream: 0,
555 replicationBatched: 5 * time.Minute,
556 replicationDaily: 24 * time.Hour,
557 } {
558 if got := replicationSyncInterval(class); got != want {
559 t.Errorf("replicationSyncInterval(%q) = %v, want %v", class, got, want)
560 }
561 }
562 for _, ok := range []string{"", "stream", "batched", "daily"} {
563 if !validReplication(ok) {
564 t.Errorf("validReplication(%q) = false, want true", ok)
565 }
566 }
567 for _, bad := range []string{"hourly", "Stream", "off"} {
568 if validReplication(bad) {
569 t.Errorf("validReplication(%q) = true, want false", bad)
570 }
571 }
572}
573
574// `add-instance --hibernate` produces a registered, unmaterialized-but-
575// spawnable route: everything the activator's spawn needs at first wake (the
576// env file with the admin token, a staged binary behind the live symlink, a
577// ready+flagged route with the router reloaded) and nothing systemd (no unit
578// enabled, no add-time health check, no DB — the first wake boots fresh). The
579// public-readiness probe doubles as that first wake — simulated here by
580// binding the socket when it fires — and the setup-invite mint rides the
581// woken socket, so the space is claimable the moment add-instance returns.
582func TestAddInstanceHibernate(t *testing.T) {
583 reg := controlTestEnv(t)
584 const host = "hib.example.com"
585
586 // Replace the harness's systemd fake with a pure recorder: the hibernating
587 // path must never enable a unit, so nothing here binds the socket for it.
588 var calls []string
589 systemctl = func(args ...string) error {
590 calls = append(calls, strings.Join(args, " "))
591 return nil
592 }
593 // Play the router+activator: the readiness probe is the first request, and
594 // the wake it triggers binds the instance socket — answering the health
595 // check and the invite mint like a freshly spawned child would.
596 confirmPublicReady = func(h, _ string) {
597 calls = append(calls, "confirm "+h)
598 ln, err := net.Listen("unix", filepath.Join(instanceSocketDir, h+".sock"))
599 if err != nil {
600 t.Errorf("bind woken socket: %v", err)
601 return
602 }
603 mux := http.NewServeMux()
604 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
605 mux.HandleFunc("POST /api/admin/key-invites", func(w http.ResponseWriter, r *http.Request) {
606 writeJSON(w, 200, map[string]any{"ok": true})
607 })
608 srv := &http.Server{Handler: mux}
609 go srv.Serve(ln)
610 t.Cleanup(func() { srv.Close() })
611 }
612
613 res, err := addInstance(reg, addInstanceOpts{Host: host, Hibernate: true})
614 if err != nil {
615 t.Fatalf("addInstance --hibernate: %v", err)
616 }
617
618 // Registered: ready, flagged, under version management.
619 rt, err := reg.get(host)
620 if err != nil || rt == nil {
621 t.Fatalf("route = %+v, %v", rt, err)
622 }
623 if rt.Status != "ready" || !rt.Hibernate || rt.Version == "" {
624 t.Errorf("route = %+v, want a ready hibernating instance with a version", *rt)
625 }
626
627 // Spawnable: the env file holds the admin token (the spawn's env layering
628 // and every saved admin link depend on it)…
629 env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
630 if err != nil {
631 t.Fatalf("read env: %v", err)
632 }
633 if env["LCHAT_ADMIN_TOKEN"] != res.AdminToken {
634 t.Error("env token != result token")
635 }
636 // …and the live symlink resolves to a staged binary (the spawn execs it).
637 if ver, err := liveVersion(host); err != nil || ver == "" {
638 t.Errorf("live symlink not staged: ver=%q err=%v", ver, err)
639 }
640
641 // Unmaterialized: no unit, no DB; the router WAS reloaded (the route must
642 // serve before any request can wake it) and only then probed.
643 reloadIdx, confirmIdx := -1, -1
644 for i, c := range calls {
645 if strings.HasPrefix(c, "enable ") {
646 t.Errorf("a systemd unit was touched: %q", c)
647 }
648 if c == "reload lchat-router" {
649 reloadIdx = i
650 }
651 if c == "confirm "+host {
652 confirmIdx = i
653 }
654 }
655 if reloadIdx == -1 || confirmIdx == -1 || confirmIdx < reloadIdx {
656 t.Errorf("want router reload before the wake probe, got calls %v", calls)
657 }
658 if _, err := os.Stat(filepath.Join(instanceDataDir, host+".db")); !os.IsNotExist(err) {
659 t.Error("a DB was created — the first wake should boot fresh")
660 }
661
662 // Claimable: the setup link was minted through the woken socket.
663 if !strings.HasPrefix(res.SetupLink, "https://"+host+"/join/setup#") {
664 t.Errorf("setup link = %q (err %v)", res.SetupLink, res.SetupErr)
665 }
666}
667
668// The flip-on ordering (hibernateOn): flag first — it's inert until a reload —
669// then the unit stops BEFORE the router reloads (a wake must be able to bind
670// the socket), then the warm request closes the gap. A crash at any seam
671// leaves the host serveable.
672func TestHibernateOnOrdering(t *testing.T) {
673 reg := controlTestEnv(t)
674 const host = "flip-on.example.com"
675 if _, err := addInstance(reg, addInstanceOpts{Host: host}); err != nil {
676 t.Fatalf("addInstance: %v", err)
677 }
678
679 var calls []string
680 flagAtDisable, flagAtReload := false, false
681 systemctl = func(args ...string) error {
682 call := strings.Join(args, " ")
683 calls = append(calls, call)
684 rt, _ := reg.get(host)
685 switch call {
686 case "disable --now lchat@" + host:
687 flagAtDisable = rt.Hibernate
688 case "reload lchat-router":
689 flagAtReload = rt.Hibernate
690 }
691 return nil
692 }
693 confirmPublicReady = func(h, _ string) { calls = append(calls, "confirm "+h) }
694
695 rt, _ := reg.get(host)
696 if err := hibernateOn(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
697 t.Fatalf("hibernateOn: %v", err)
698 }
699
700 want := []string{"disable --now lchat@" + host, "reload lchat-router", "confirm " + host}
701 if !reflect.DeepEqual(calls, want) {
702 t.Errorf("flip-on sequence = %v, want %v", calls, want)
703 }
704 if !flagAtDisable || !flagAtReload {
705 t.Errorf("flag not set before unit stop/reload (disable=%v reload=%v) — a crash mid-flip would strand the ordering", flagAtDisable, flagAtReload)
706 }
707 if got, _ := reg.get(host); got == nil || !got.Hibernate {
708 t.Error("flag not set after flip-on")
709 }
710}
711
712// The flip-off ordering (hibernateOff): flag cleared, router reloaded (which
713// makes the activator release the host), and the unit started ONLY after the
714// activator's child has let the socket go — verified by a fake child that
715// holds the socket until the reload lands, releasing it a beat later like the
716// real (asynchronous) reconcile.
717func TestHibernateOffOrdering(t *testing.T) {
718 reg := controlTestEnv(t)
719 const host = "flip-off.example.com"
720 sock := filepath.Join(instanceSocketDir, host+".sock")
721 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: sock, Status: "ready", Hibernate: true}); err != nil {
722 t.Fatal(err)
723 }
724
725 // The fake activator child, awake and holding the socket.
726 child, err := net.Listen("unix", sock)
727 if err != nil {
728 t.Fatal(err)
729 }
730 childSrv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })}
731 go childSrv.Serve(child)
732 t.Cleanup(func() { childSrv.Close() })
733
734 var calls []string
735 systemctl = func(args ...string) error {
736 call := strings.Join(args, " ")
737 calls = append(calls, call)
738 switch {
739 case call == "reload lchat-router":
740 // The reconcile's release is asynchronous inside the router
741 // process — model that: the child lets go a beat after the reload.
742 go func() {
743 time.Sleep(300 * time.Millisecond)
744 childSrv.Close()
745 }()
746 case call == "enable --now lchat@"+host:
747 // The ordering crux: by unit-start time the child must be gone…
748 if c, err := net.DialTimeout("unix", sock, 100*time.Millisecond); err == nil {
749 c.Close()
750 t.Error("unit started while the activator's child still held the socket")
751 }
752 // …and the flag off, so systemd unambiguously owns the host.
753 if rt, _ := reg.get(host); rt.Hibernate {
754 t.Error("unit started with the hibernate flag still set")
755 }
756 // Bind like a started unit would (clearing the stale socket file
757 // first, as the instance's listen() does), so waitHealthy passes.
758 _ = os.Remove(sock)
759 ln, err := net.Listen("unix", sock)
760 if err != nil {
761 return err
762 }
763 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })}
764 go srv.Serve(ln)
765 t.Cleanup(func() { srv.Close() })
766 }
767 return nil
768 }
769
770 rt, _ := reg.get(host)
771 if err := hibernateOff(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
772 t.Fatalf("hibernateOff: %v", err)
773 }
774 want := []string{"reload lchat-router", "enable --now lchat@" + host}
775 if !reflect.DeepEqual(calls, want) {
776 t.Errorf("flip-off sequence = %v, want %v", calls, want)
777 }
778 if got, _ := reg.get(host); got == nil || got.Hibernate {
779 t.Error("flag still set after flip-off")
780 }
781}
782
783// A host with a bot sidecar staged (its -bot.env exists) hands the sidecar
784// over at the hibernate flips: --on disables the always-on unit BEFORE the
785// router reload (the activator co-spawns from then on; a systemd sidecar and
786// a co-spawned one would double every bot reply), and --off re-enables it
787// LAST — only after the instance unit is healthy, add-bot's "instance first"
788// bootstrap order. A host without one (the two ordering tests above) never
789// touches the bot unit.
790func TestHibernateFlipHandsSidecarUnitOver(t *testing.T) {
791 reg := controlTestEnv(t)
792 const host = "flip-bots.example.com"
793 sock := filepath.Join(instanceSocketDir, host+".sock")
794 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: sock, Status: "ready"}); err != nil {
795 t.Fatal(err)
796 }
797 // The instance env (hibernateOn chowns it) and the sidecar staging —
798 // exactly what add-instance + add-bot leave on disk.
799 if err := os.WriteFile(filepath.Join(instanceDataDir, host+".env"), []byte("LCHAT_ADMIN_TOKEN=t\n"), 0o600); err != nil {
800 t.Fatal(err)
801 }
802 if err := os.WriteFile(botEnvPath(host), []byte("LCHAT_BASE=https://"+host+"\n"), 0o600); err != nil {
803 t.Fatal(err)
804 }
805 if err := os.MkdirAll(botSessionDir(host), 0o750); err != nil {
806 t.Fatal(err)
807 }
808
809 var calls []string
810 systemctl = func(args ...string) error {
811 call := strings.Join(args, " ")
812 calls = append(calls, call)
813 if call == "enable --now lchat@"+host {
814 // Bind like a started unit so hibernateOff's health check passes.
815 ln, err := net.Listen("unix", sock)
816 if err != nil {
817 return err
818 }
819 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })}
820 go srv.Serve(ln)
821 t.Cleanup(func() { srv.Close() })
822 }
823 return nil
824 }
825
826 rt, _ := reg.get(host)
827 if err := hibernateOn(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
828 t.Fatalf("hibernateOn: %v", err)
829 }
830 wantOn := []string{"disable --now lchat@" + host, "disable --now lchat-bot@" + host, "reload lchat-router"}
831 if !reflect.DeepEqual(calls, wantOn) {
832 t.Errorf("flip-on sequence = %v, want %v", calls, wantOn)
833 }
834
835 calls = nil
836 rt, _ = reg.get(host)
837 if err := hibernateOff(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
838 t.Fatalf("hibernateOff: %v", err)
839 }
840 wantOff := []string{"reload lchat-router", "enable --now lchat@" + host, "enable --now lchat-bot@" + host}
841 if !reflect.DeepEqual(calls, wantOff) {
842 t.Errorf("flip-off sequence = %v, want %v", calls, wantOff)
843 }
844}
845
846// waitSocketQuiet is the flip-off's release observation: quiet means no
847// listener (dial failure), whether or not the socket file lingers.
848func TestWaitSocketQuiet(t *testing.T) {
849 sock := tmpSock(t)
850 // No listener: quiet immediately.
851 if err := waitSocketQuiet(sock, time.Second); err != nil {
852 t.Errorf("no listener: %v, want quiet", err)
853 }
854 // A held listener: not quiet — the bounded wait must report it.
855 ln, err := net.Listen("unix", sock)
856 if err != nil {
857 t.Fatal(err)
858 }
859 defer ln.Close()
860 if err := waitSocketQuiet(sock, 400*time.Millisecond); err == nil {
861 t.Error("live listener reported quiet — the flip-off would start a unit against a live child")
862 }
863}
864
865// matchOwner hands a root-written env file to the service user (the owner of
866// /data) so the router's activator can read it at wake time — the bug the
867// production canary's first wake hit. Unprivileged test: same-owner is a
868// clean no-op and the owners really do match afterwards.
869func TestMatchOwner(t *testing.T) {
870 dir := t.TempDir()
871 path := filepath.Join(dir, "x.env")
872 if err := os.WriteFile(path, []byte("A=b\n"), 0o600); err != nil {
873 t.Fatal(err)
874 }
875 if err := matchOwner(path, dir); err != nil {
876 t.Fatalf("matchOwner: %v", err)
877 }
878 ds, _ := os.Stat(dir)
879 fs, _ := os.Stat(path)
880 if ds.Sys().(*syscall.Stat_t).Uid != fs.Sys().(*syscall.Stat_t).Uid {
881 t.Fatalf("owner mismatch after matchOwner")
882 }
883}
884
885// The flip-window guard: while the box-wide litestream config still tracks a
886// host's DB, flipping it on must stop before touching the unit — two
887// replicators share one meta dir and corrupt it (hit live on the first real
888// flip). The flag must already be SET when the guard fires, because the
889// operator's fix (re-running the backup script, which excludes hibernate=1
890// hosts) depends on it — and the flag is inert until the reload the guard
891// prevents.
892func TestHibernateOnRefusesWhileBoxLitestreamTracks(t *testing.T) {
893 reg := controlTestEnv(t)
894 const host = "flip-guard.example.com"
895 if _, err := addInstance(reg, addInstanceOpts{Host: host}); err != nil {
896 t.Fatalf("addInstance: %v", err)
897 }
898
899 conf := filepath.Join(t.TempDir(), "litestream.yml")
900 body := "dbs:\n - path: " + filepath.Join(instanceDataDir, host+".db") + "\n"
901 if err := os.WriteFile(conf, []byte(body), 0o644); err != nil {
902 t.Fatal(err)
903 }
904 t.Setenv("LCHAT_LITESTREAM_CONFIG", conf)
905
906 var calls []string
907 systemctl = func(args ...string) error {
908 calls = append(calls, strings.Join(args, " "))
909 return nil
910 }
911 confirmPublicReady = func(string, string) { t.Fatal("must not warm while guarded") }
912
913 rt, _ := reg.get(host)
914 err := hibernateOn(reg, rt, "lchat@", "lchat-bot@", "lchat-router")
915 if err == nil || !strings.Contains(err.Error(), "fleet-backup.sh") {
916 t.Fatalf("want the re-run-fleet-backup refusal, got %v", err)
917 }
918 if len(calls) != 0 {
919 t.Fatalf("guarded flip touched systemd: %v", calls)
920 }
921 if got, _ := reg.get(host); !got.Hibernate {
922 t.Fatalf("flag must be set when the guard fires (the operator's fix depends on it)")
923 }
924
925 // The exact two-step flow the error prescribes: the config regen drops the
926 // host (the flag now reads true), and the re-run flip proceeds.
927 if err := os.WriteFile(conf, []byte("dbs: []\n"), 0o644); err != nil {
928 t.Fatal(err)
929 }
930 confirmPublicReady = func(string, string) {}
931 rt, _ = reg.get(host)
932 if err := hibernateOn(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
933 t.Fatalf("re-run flip after exclusion: %v", err)
934 }
935 if len(calls) == 0 || calls[0] != "disable --now lchat@"+host {
936 t.Fatalf("re-run flip sequence = %v", calls)
937 }
938}
939
940// The litestream guard's two-step leaves flag-set-but-unit-running behind; the
941// promised re-run must FINISH the handover, not early-exit on the flag (hit
942// live on the first bulk flip: "already hibernating" with the unit still up).
943func TestHibernateOnRerunFinishesHandover(t *testing.T) {
944 reg := controlTestEnv(t)
945 const host = "flip-rerun.example.com"
946 if _, err := addInstance(reg, addInstanceOpts{Host: host}); err != nil {
947 t.Fatal(err)
948 }
949 if err := reg.setHibernate(host, true); err != nil { // the guard's leftover state
950 t.Fatal(err)
951 }
952 oldQ := systemctlQuery
953 systemctlQuery = func(args ...string) error { return nil } // unit reads active
954 defer func() { systemctlQuery = oldQ }()
955 var calls []string
956 systemctl = func(args ...string) error { calls = append(calls, strings.Join(args, " ")); return nil }
957 confirmPublicReady = func(string, string) { calls = append(calls, "confirm") }
958
959 rt, _ := reg.get(host)
960 if err := hibernateOn(reg, rt, "lchat@", "lchat-bot@", "lchat-router"); err != nil {
961 t.Fatalf("re-run: %v", err)
962 }
963 if len(calls) == 0 || calls[0] != "disable --now lchat@"+host {
964 t.Fatalf("re-run must stop the unit first, got %v", calls)
965 }
966}
967
968func TestRegistryMigratesCanaryColumn(t *testing.T) {
969 path := filepath.Join(t.TempDir(), "reg.db")
970
971 // The schema as of the replication column — everything current EXCEPT canary.
972 old, err := sql.Open("sqlite", path)
973 if err != nil {
974 t.Fatal(err)
975 }
976 if _, err := old.Exec(`
977CREATE TABLE routes (
978 host TEXT PRIMARY KEY,
979 kind TEXT NOT NULL,
980 socket TEXT NOT NULL,
981 status TEXT NOT NULL DEFAULT 'ready',
982 last_error TEXT NOT NULL DEFAULT '',
983 db_path TEXT NOT NULL DEFAULT '',
984 version TEXT NOT NULL DEFAULT '',
985 prev_version TEXT NOT NULL DEFAULT '',
986 app_store_url TEXT NOT NULL DEFAULT '',
987 home_origin TEXT NOT NULL DEFAULT '',
988 push_relay_url TEXT NOT NULL DEFAULT '',
989 push_relay_token TEXT NOT NULL DEFAULT '',
990 home_region TEXT NOT NULL DEFAULT '',
991 provider TEXT NOT NULL DEFAULT '',
992 hibernate INTEGER NOT NULL DEFAULT 0,
993 replication TEXT NOT NULL DEFAULT '',
994 next_due INTEGER NOT NULL DEFAULT 0,
995 created_at INTEGER NOT NULL
996);
997INSERT INTO routes (host, kind, socket, hibernate, created_at) VALUES ('old.example.com', 'instance', '/run/lchat/old.sock', 1, 1700000000);
998`); err != nil {
999 t.Fatal(err)
1000 }
1001 if err := old.Close(); err != nil {
1002 t.Fatal(err)
1003 }
1004
1005 reg, err := openRegistry(path)
1006 if err != nil {
1007 t.Fatalf("open over pre-canary registry: %v", err)
1008 }
1009 // Existing rows read as canary=false — a live instance is never a throwaway
1010 // by default; the flag is only ever set deliberately.
1011 rt, err := reg.get("old.example.com")
1012 if err != nil || rt == nil {
1013 t.Fatalf("get pre-existing row: %+v, %v", rt, err)
1014 }
1015 if rt.Canary {
1016 t.Error("pre-existing row: canary=true, want the never-inferred default (false)")
1017 }
1018
1019 // The flag round-trips through the narrow setter, an upsert carries it, and
1020 // setCanary stays orthogonal to the rest of the route (hibernate intact).
1021 if err := reg.setCanary("old.example.com", true); err != nil {
1022 t.Fatalf("setCanary: %v", err)
1023 }
1024 if got, _ := reg.get("old.example.com"); got == nil || !got.Canary || !got.Hibernate {
1025 t.Fatalf("after setCanary, route = %+v, want canary + hibernate intact", got)
1026 }
1027 rt, _ = reg.get("old.example.com")
1028 rt.Version = "v2"
1029 if err := reg.upsert(*rt); err != nil {
1030 t.Fatalf("upsert rewrite: %v", err)
1031 }
1032 if got, _ := reg.get("old.example.com"); got == nil || !got.Canary {
1033 t.Fatalf("route rewrite dropped the canary flag: %+v", got)
1034 }
1035 // Clearing it round-trips too, and an unknown host is a quiet no-op.
1036 if err := reg.setCanary("old.example.com", false); err != nil {
1037 t.Fatalf("setCanary off: %v", err)
1038 }
1039 if got, _ := reg.get("old.example.com"); got == nil || got.Canary {
1040 t.Fatalf("setCanary off left it flagged: %+v", got)
1041 }
1042 if err := reg.setCanary("ghost.example.com", true); err != nil {
1043 t.Fatalf("setCanary unknown host should no-op: %v", err)
1044 }
1045}
1046
1047func TestCanResetGatesOnTheCanaryFlag(t *testing.T) {
1048 // The whole safety model: reset refuses anything not flagged a throwaway.
1049 if err := canReset(nil); err == nil {
1050 t.Error("canReset(nil) should refuse an unknown host")
1051 }
1052 if err := canReset(&Route{Host: "push.example.com", Kind: "service", Canary: true}); err == nil {
1053 t.Error("canReset should refuse a non-instance route even if flagged")
1054 }
1055 real := &Route{Host: "family.example.com", Kind: "instance", Canary: false}
1056 if err := canReset(real); err == nil {
1057 t.Fatal("canReset MUST refuse a real (unflagged) instance — this is the data-safety gate")
1058 }
1059 canary := &Route{Host: "test-x.11ch.at", Kind: "instance", Canary: true}
1060 if err := canReset(canary); err != nil {
1061 t.Fatalf("canReset should allow a flagged canary instance, got: %v", err)
1062 }
1063}
1064
1065func TestArchiveInstanceDBMovesDBKeepsEnv(t *testing.T) {
1066 dir := t.TempDir()
1067 oldData := instanceDataDir
1068 instanceDataDir = dir
1069 defer func() { instanceDataDir = oldData }()
1070
1071 host := "test-x.11ch.at"
1072 // Seed the DB set + a blob dir + the env/vapid that must SURVIVE.
1073 for _, f := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".env", host + ".vapid"} {
1074 if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
1075 t.Fatal(err)
1076 }
1077 }
1078 if err := os.MkdirAll(filepath.Join(dir, host+"-blobs"), 0o700); err != nil {
1079 t.Fatal(err)
1080 }
1081
1082 if err := archiveInstanceDB(host); err != nil {
1083 t.Fatalf("archiveInstanceDB: %v", err)
1084 }
1085
1086 // DB + blobs gone from /data; env + vapid kept in place.
1087 for _, gone := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + "-blobs"} {
1088 if _, err := os.Stat(filepath.Join(dir, gone)); !os.IsNotExist(err) {
1089 t.Errorf("%s should have been archived out of the data dir", gone)
1090 }
1091 }
1092 for _, kept := range []string{host + ".env", host + ".vapid"} {
1093 if _, err := os.Stat(filepath.Join(dir, kept)); err != nil {
1094 t.Errorf("%s must survive a reset (admin token + config): %v", kept, err)
1095 }
1096 }
1097 // And the DB is recoverable under /data/trash, not destroyed.
1098 trashes, _ := filepath.Glob(filepath.Join(dir, "trash", host+"-reset-*", host+".db"))
1099 if len(trashes) != 1 {
1100 t.Errorf("archived DB should be recoverable in /data/trash, found %d", len(trashes))
1101 }
1102}
1103
1104func TestSanitizeGroupName(t *testing.T) {
1105 long := strings.Repeat("é", 40) // 80 bytes — the cap must cut on a rune boundary
1106 cases := []struct{ in, want string }{
1107 {" The Campbells ", "The Campbells"},
1108 {"Line\nBREAK=1\ninjection", "LineBREAK=1injection"}, // env-line injection defused
1109 {"tabs\tand\rreturns", "tabsandreturns"},
1110 {long, strings.Repeat("é", 30)},
1111 {"\n\t ", ""},
1112 }
1113 for _, c := range cases {
1114 if got := sanitizeGroupName(c.in); got != c.want {
1115 t.Errorf("sanitizeGroupName(%q) = %q, want %q", c.in, got, c.want)
1116 }
1117 }
1118}
1119
1120// A seeded display name lands in the instance env (LCHAT_GROUP_NAME) already
1121// sanitized — the instance names itself from it at first boot.
1122func TestAddInstanceSeedsGroupName(t *testing.T) {
1123 reg := controlTestEnv(t)
1124 host := "named.example.com"
1125 if _, err := addInstance(reg, addInstanceOpts{Host: host, GroupName: " The Campbells\n "}); err != nil {
1126 t.Fatalf("addInstance: %v", err)
1127 }
1128 env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
1129 if err != nil {
1130 t.Fatalf("read env: %v", err)
1131 }
1132 if env["LCHAT_GROUP_NAME"] != "The Campbells" {
1133 t.Errorf("LCHAT_GROUP_NAME = %q, want %q", env["LCHAT_GROUP_NAME"], "The Campbells")
1134 }
1135
1136 // No name → no env line at all (the instance keeps its default behaviour).
1137 host2 := "unnamed.example.com"
1138 if _, err := addInstance(reg, addInstanceOpts{Host: host2}); err != nil {
1139 t.Fatalf("addInstance: %v", err)
1140 }
1141 env2, err := readEnvFile(filepath.Join(instanceDataDir, host2+".env"))
1142 if err != nil {
1143 t.Fatalf("read env: %v", err)
1144 }
1145 if _, present := env2["LCHAT_GROUP_NAME"]; present {
1146 t.Error("empty group name still wrote LCHAT_GROUP_NAME")
1147 }
1148}
1149
1150// applySeededName consumes LCHAT_GROUP_NAME exactly once: it names a fresh
1151// space and never overrides a name an admin has since chosen.
1152func TestApplySeededName(t *testing.T) {
1153 db := newTestDB(t)
1154 t.Setenv("LCHAT_GROUP_NAME", "Book Club")
1155 if err := applySeededName(db); err != nil {
1156 t.Fatal(err)
1157 }
1158 if got := db.getGroupName(); got != "Book Club" {
1159 t.Errorf("seeded name = %q, want Book Club", got)
1160 }
1161 // A later boot with a different env value must not clobber the set name.
1162 t.Setenv("LCHAT_GROUP_NAME", "Usurper")
1163 if err := applySeededName(db); err != nil {
1164 t.Fatal(err)
1165 }
1166 if got := db.getGroupName(); got != "Book Club" {
1167 t.Errorf("after re-seed, name = %q, want Book Club (seed-only)", got)
1168 }
1169}
1170
1171// The readiness probe's status vocabulary: an instance's own answers (2xx-4xx
1172// bar 404) mean ready; 404 is the router's "unknown host" (a route still
1173// replicating to its owner box) and must keep the probe waiting, like any 5xx.
1174func TestPublicReadyStatus(t *testing.T) {
1175 for code, want := range map[int]bool{200: true, 302: true, 401: true, 404: false, 500: false, 502: false} {
1176 if got := publicReadyStatus(code); got != want {
1177 t.Errorf("publicReadyStatus(%d) = %v, want %v", code, got, want)
1178 }
1179 }
1180}
1181
1182// A hibernating instance the mesh placed on a peer box never listens on the
1183// local socket — the setup link then mints through the public path (the same
1184// router hop members use, which follows the mesh to wherever the instance
1185// woke). Both paths failing surfaces both errors and no link.
1186func TestAddInstanceSetupLinkPublicFallback(t *testing.T) {
1187 reg := controlTestEnv(t)
1188 systemctl = func(args ...string) error { return nil }
1189 oldMint := mintPublicSetupInvite
1190 defer func() { mintPublicSetupInvite = oldMint }()
1191 confirmPublicReady = func(string, string) { /* woke on the peer — nothing local */ }
1192 var mintedHost, mintedToken string
1193 mintPublicSetupInvite = func(host, token string) (string, error) {
1194 mintedHost, mintedToken = host, token
1195 return "https://" + host + "/join/setup#PUB", nil
1196 }
1197
1198 res, err := addInstance(reg, addInstanceOpts{Host: "peerowned.example.com", Hibernate: true})
1199 if err != nil {
1200 t.Fatalf("addInstance: %v", err)
1201 }
1202 if res.SetupErr != nil || res.SetupLink != "https://peerowned.example.com/join/setup#PUB" {
1203 t.Fatalf("setup link = %q (err %v), want the public-path mint", res.SetupLink, res.SetupErr)
1204 }
1205 if mintedHost != "peerowned.example.com" || mintedToken != res.AdminToken {
1206 t.Errorf("public mint saw host %q, token match %v", mintedHost, mintedToken == res.AdminToken)
1207 }
1208
1209 mintPublicSetupInvite = func(string, string) (string, error) { return "", fmt.Errorf("no route") }
1210 res2, err := addInstance(reg, addInstanceOpts{Host: "dark.example.com", Hibernate: true})
1211 if err != nil {
1212 t.Fatalf("addInstance: %v", err)
1213 }
1214 if res2.SetupLink != "" || res2.SetupErr == nil {
1215 t.Fatalf("double failure: link %q, err %v — want no link + a surfaced error", res2.SetupLink, res2.SetupErr)
1216 }
1217 if !strings.Contains(res2.SetupErr.Error(), "public") {
1218 t.Errorf("error should name both paths: %v", res2.SetupErr)
1219 }
1220}
1221