| 1 | package main |
| 2 | |
| 3 | // Tests for meshreplicate.go: the trust boundary in toLocalRoute (only |
| 4 | // content fields survive a peer's payload; Socket/DBPath are always derived |
| 5 | // locally), the receiving handler's bearer auth, and the full round trip over |
| 6 | // a plain HTTP peer (meshReplicateRoute's TLS-SNI-pinned dial for https peers |
| 7 | // mirrors mesh.go's own newPeerProxy, already exercised by TestPeerProxyRewrite |
| 8 | // — this file proves the request/response/auth/upsert plumbing, not the TLS |
| 9 | // mechanics a second time). |
| 10 | |
| 11 | import ( |
| 12 | "bytes" |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "net/http" |
| 16 | "net/http/httptest" |
| 17 | "os" |
| 18 | "path/filepath" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | // toLocalRoute must never trust a peer's Socket/DBPath (box-local filesystem |
| 24 | // layout) — it derives both itself from Host, exactly like registerInstance |
| 25 | // does for a route created locally. |
| 26 | func TestMeshRoutePayloadTrustBoundary(t *testing.T) { |
| 27 | route := Route{ |
| 28 | Host: "space1.11ch.at", Kind: "instance", Socket: "/peer/only/space1.sock", |
| 29 | DBPath: "/peer/only/space1.db", Hibernate: true, Version: "v1", |
| 30 | AppStoreURL: "https://apps.example.com", HomeRegion: "eu", |
| 31 | Replication: replicationDaily, |
| 32 | } |
| 33 | payload := routeToPayload(route) |
| 34 | |
| 35 | // Round-trip through JSON, as it travels over the wire. |
| 36 | body, err := json.Marshal(payload) |
| 37 | if err != nil { |
| 38 | t.Fatalf("marshal: %v", err) |
| 39 | } |
| 40 | var decoded meshRoutePayload |
| 41 | if err := json.Unmarshal(body, &decoded); err != nil { |
| 42 | t.Fatalf("unmarshal: %v", err) |
| 43 | } |
| 44 | |
| 45 | local, err := decoded.toLocalRoute() |
| 46 | if err != nil { |
| 47 | t.Fatalf("toLocalRoute: %v", err) |
| 48 | } |
| 49 | if local.Socket == route.Socket || local.DBPath == route.DBPath { |
| 50 | t.Fatalf("peer's Socket/DBPath leaked through: got %q / %q", local.Socket, local.DBPath) |
| 51 | } |
| 52 | if want := filepath.Join(instanceSocketDir, "space1.11ch.at.sock"); local.Socket != want { |
| 53 | t.Fatalf("Socket = %q, want locally-derived %q", local.Socket, want) |
| 54 | } |
| 55 | if want := filepath.Join(instanceDataDir, "space1.11ch.at.db"); local.DBPath != want { |
| 56 | t.Fatalf("DBPath = %q, want locally-derived %q", local.DBPath, want) |
| 57 | } |
| 58 | if local.Host != route.Host || local.Version != route.Version || local.AppStoreURL != route.AppStoreURL || local.HomeRegion != route.HomeRegion || !local.Hibernate { |
| 59 | t.Fatalf("content fields did not survive: %+v", local) |
| 60 | } |
| 61 | // The replication class rides the mesh too — a space that fails over to a |
| 62 | // peer box must keep its durability rung, not silently revert to stream |
| 63 | // (safe for the data, wrong for the plan's economics). |
| 64 | if local.Replication != replicationDaily { |
| 65 | t.Fatalf("replication class did not survive: %q, want %q", local.Replication, replicationDaily) |
| 66 | } |
| 67 | if local.Status != "" || local.NextDue != 0 || local.CreatedAt != 0 { |
| 68 | t.Fatalf("receiver-local lifecycle fields should be zero, got %+v", local) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // An invalid host in the payload must be rejected, not silently coerced into |
| 73 | // a filename/socket path. |
| 74 | func TestMeshRoutePayloadRejectsBadHost(t *testing.T) { |
| 75 | if _, err := (meshRoutePayload{Host: "not a host!!"}).toLocalRoute(); err == nil { |
| 76 | t.Fatal("bad host should be rejected") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // meshRouteHandler: wrong or missing bearer is 401 and never touches the |
| 81 | // registry; a correctly authenticated request upserts the route and reloads |
| 82 | // the router so it's immediately servable. |
| 83 | func TestMeshRouteHandlerAuth(t *testing.T) { |
| 84 | reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| 85 | if err != nil { |
| 86 | t.Fatalf("open registry: %v", err) |
| 87 | } |
| 88 | rt, err := newRouter(reg, nil) |
| 89 | if err != nil { |
| 90 | t.Fatalf("new router: %v", err) |
| 91 | } |
| 92 | srv := httptest.NewServer(meshRouteHandler(reg, rt, "correct-token")) |
| 93 | defer srv.Close() |
| 94 | |
| 95 | route := Route{Host: "space2.11ch.at", Kind: "instance", Hibernate: true} |
| 96 | body, _ := json.Marshal(routeToPayload(route)) |
| 97 | |
| 98 | post := func(bearer string) int { |
| 99 | req, _ := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) |
| 100 | req.Header.Set("Content-Type", "application/json") |
| 101 | if bearer != "" { |
| 102 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 103 | } |
| 104 | resp, err := http.DefaultClient.Do(req) |
| 105 | if err != nil { |
| 106 | t.Fatalf("post: %v", err) |
| 107 | } |
| 108 | defer resp.Body.Close() |
| 109 | return resp.StatusCode |
| 110 | } |
| 111 | |
| 112 | if code := post(""); code != http.StatusUnauthorized { |
| 113 | t.Fatalf("no bearer: got %d, want 401", code) |
| 114 | } |
| 115 | if code := post("wrong-token"); code != http.StatusUnauthorized { |
| 116 | t.Fatalf("wrong bearer: got %d, want 401", code) |
| 117 | } |
| 118 | if rt.allowed(route.Host) { |
| 119 | t.Fatal("route must not exist after unauthorized attempts") |
| 120 | } |
| 121 | |
| 122 | if code := post("correct-token"); code != http.StatusNoContent { |
| 123 | t.Fatalf("correct bearer: got %d, want 204", code) |
| 124 | } |
| 125 | if !rt.allowed(route.Host) { |
| 126 | t.Fatal("route should be servable immediately after a successful replication (reload)") |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // The full round trip over a plain HTTP peer: meshReplicateRoute dials the |
| 131 | // peer's raw address and the handler on the other end upserts + reloads. |
| 132 | func TestMeshReplicateRouteOverHTTP(t *testing.T) { |
| 133 | reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| 134 | if err != nil { |
| 135 | t.Fatalf("open registry: %v", err) |
| 136 | } |
| 137 | rt, err := newRouter(reg, nil) |
| 138 | if err != nil { |
| 139 | t.Fatalf("new router: %v", err) |
| 140 | } |
| 141 | mux := http.NewServeMux() |
| 142 | mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) |
| 143 | srv := httptest.NewServer(mux) |
| 144 | defer srv.Close() |
| 145 | |
| 146 | route := testRoute("space3.11ch.at") |
| 147 | if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", routeToPayload(route)); err != nil { |
| 148 | t.Fatalf("meshReplicateRoute: %v", err) |
| 149 | } |
| 150 | if !rt.allowed(route.Host) { |
| 151 | t.Fatal("route was not replicated") |
| 152 | } |
| 153 | |
| 154 | if err := meshReplicateRoute(context.Background(), srv.URL, "wrong-secret", routeToPayload(route)); err == nil { |
| 155 | t.Fatal("wrong token should be rejected") |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // The instance env rides replication: the receiving box writes /data/<host>.env |
| 160 | // from the payload, so its first wake of the host boots with the SAME admin |
| 161 | // token the creating box minted — without it, the creator's setup link could |
| 162 | // never authenticate against a peer-woken instance. A payload without a token |
| 163 | // (an older peer) must never clobber an env that already exists. |
| 164 | func TestMeshReplicationCarriesInstanceEnv(t *testing.T) { |
| 165 | dir := t.TempDir() |
| 166 | oldData := instanceDataDir |
| 167 | instanceDataDir = dir |
| 168 | defer func() { instanceDataDir = oldData }() |
| 169 | |
| 170 | reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| 171 | if err != nil { |
| 172 | t.Fatalf("open registry: %v", err) |
| 173 | } |
| 174 | rt, err := newRouter(reg, nil) |
| 175 | if err != nil { |
| 176 | t.Fatalf("new router: %v", err) |
| 177 | } |
| 178 | mux := http.NewServeMux() |
| 179 | mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) |
| 180 | srv := httptest.NewServer(mux) |
| 181 | defer srv.Close() |
| 182 | |
| 183 | route := testRoute("space4.11ch.at") |
| 184 | route.PushRelayURL = "https://push.example.com" |
| 185 | env := map[string]string{ |
| 186 | "LCHAT_ADMIN_TOKEN": "tok-abc", |
| 187 | "LCHAT_ENTITLEMENT": "paid", |
| 188 | "LCHAT_GROUP_NAME": "The Campbells", |
| 189 | } |
| 190 | if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", payloadFromRouteEnv(route, env)); err != nil { |
| 191 | t.Fatalf("meshReplicateRoute: %v", err) |
| 192 | } |
| 193 | got, err := readEnvFile(filepath.Join(dir, route.Host+".env")) |
| 194 | if err != nil { |
| 195 | t.Fatalf("receiver wrote no env file: %v", err) |
| 196 | } |
| 197 | if got["LCHAT_ADMIN_TOKEN"] != "tok-abc" || got["LCHAT_ENTITLEMENT"] != "paid" || got["LCHAT_GROUP_NAME"] != "The Campbells" { |
| 198 | t.Errorf("replicated env = %v", got) |
| 199 | } |
| 200 | // Route fields that are ALSO env values must land through the shared |
| 201 | // builder, exactly as materializeInstance writes them locally. |
| 202 | if got["LCHAT_PUSH_RELAY_URL"] != "https://push.example.com" { |
| 203 | t.Errorf("push relay url = %q, want the route's", got["LCHAT_PUSH_RELAY_URL"]) |
| 204 | } |
| 205 | |
| 206 | // A token-less payload (older peer, or a host with no env at the source) |
| 207 | // upserts the route but leaves the existing env alone. |
| 208 | if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", routeToPayload(route)); err != nil { |
| 209 | t.Fatalf("re-replicate: %v", err) |
| 210 | } |
| 211 | if got, _ := readEnvFile(filepath.Join(dir, route.Host+".env")); got["LCHAT_ADMIN_TOKEN"] != "tok-abc" { |
| 212 | t.Errorf("token-less payload clobbered the env: %v", got) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // The tombstone: a valid DELETE removes the row, the instance's in.<host> |
| 217 | // row, and the REPLICATED env file (a destroyed space's admin token is not |
| 218 | // ours to keep) — idempotently, so re-sends and prune sweeps never error. |
| 219 | // Auth gates it like every mesh endpoint. |
| 220 | func TestMeshRouteDeleteHandler(t *testing.T) { |
| 221 | dir := t.TempDir() |
| 222 | oldData := instanceDataDir |
| 223 | instanceDataDir = dir |
| 224 | defer func() { instanceDataDir = oldData }() |
| 225 | |
| 226 | reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| 227 | if err != nil { |
| 228 | t.Fatalf("open registry: %v", err) |
| 229 | } |
| 230 | rt, err := newRouter(reg, nil) |
| 231 | if err != nil { |
| 232 | t.Fatalf("new router: %v", err) |
| 233 | } |
| 234 | mux := http.NewServeMux() |
| 235 | mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) |
| 236 | mux.Handle("DELETE /api/mesh/route/{host}", meshRouteDeleteHandler(reg, rt, "shared-secret")) |
| 237 | srv := httptest.NewServer(mux) |
| 238 | defer srv.Close() |
| 239 | |
| 240 | // Replicate a route (with env) + a webhook-ingest row for it. |
| 241 | route := testRoute("gone.11ch.at") |
| 242 | env := map[string]string{"LCHAT_ADMIN_TOKEN": "tok-dead"} |
| 243 | if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", payloadFromRouteEnv(route, env)); err != nil { |
| 244 | t.Fatalf("replicate: %v", err) |
| 245 | } |
| 246 | if err := reg.upsert(Route{Host: "in.gone.11ch.at", Kind: "hooks", Socket: "/run/lchat/hooks.sock"}); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | |
| 250 | // Wrong bearer never deletes. |
| 251 | if err := meshDeleteRoute(context.Background(), srv.URL, "wrong-secret", route.Host); err == nil { |
| 252 | t.Fatal("wrong token should be rejected") |
| 253 | } |
| 254 | if !rt.allowed(route.Host) { |
| 255 | t.Fatal("unauthorized tombstone deleted the route") |
| 256 | } |
| 257 | |
| 258 | if err := meshDeleteRoute(context.Background(), srv.URL, "shared-secret", route.Host); err != nil { |
| 259 | t.Fatalf("tombstone: %v", err) |
| 260 | } |
| 261 | if rt.allowed(route.Host) { |
| 262 | t.Error("route survived its tombstone") |
| 263 | } |
| 264 | if got, _ := reg.get("in." + route.Host); got != nil { |
| 265 | t.Error("webhook-ingest row survived the tombstone") |
| 266 | } |
| 267 | if _, err := os.Stat(filepath.Join(dir, route.Host+".env")); !os.IsNotExist(err) { |
| 268 | t.Error("replicated env file survived the tombstone") |
| 269 | } |
| 270 | // Idempotent: tombstoning an absent host answers success. |
| 271 | if err := meshDeleteRoute(context.Background(), srv.URL, "shared-secret", route.Host); err != nil { |
| 272 | t.Errorf("re-tombstone of an absent host: %v", err) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // The reconciliation listing exposes only in-scope hibernating instance hosts. |
| 277 | func TestMeshRouteListHandler(t *testing.T) { |
| 278 | reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| 279 | if err != nil { |
| 280 | t.Fatalf("open registry: %v", err) |
| 281 | } |
| 282 | rt, err := newRouter(reg, nil) |
| 283 | if err != nil { |
| 284 | t.Fatalf("new router: %v", err) |
| 285 | } |
| 286 | seed := func(host, kind string, hib bool) { |
| 287 | t.Helper() |
| 288 | if err := reg.upsert(Route{Host: host, Kind: kind, Socket: "/run/lchat/" + host + ".sock", Hibernate: hib}); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | } |
| 292 | seed("a.11ch.at", "instance", true) // in |
| 293 | seed("b.11ch.at", "instance", false) // out: not hibernating |
| 294 | seed("c.example.com", "instance", true) // out: out of scope |
| 295 | seed("d.11ch.at", "service", true) // out: not an instance |
| 296 | srv := httptest.NewServer(meshRouteListHandler(reg, rt, "shared-secret", []string{"11ch.at"})) |
| 297 | defer srv.Close() |
| 298 | |
| 299 | hosts, err := meshListRoutes(context.Background(), srv.URL, "shared-secret", "a.11ch.at") |
| 300 | if err != nil { |
| 301 | t.Fatalf("list: %v", err) |
| 302 | } |
| 303 | if len(hosts) != 1 || hosts[0] != "a.11ch.at" { |
| 304 | t.Errorf("hosts = %v, want [a.11ch.at]", hosts) |
| 305 | } |
| 306 | if _, err := meshListRoutes(context.Background(), srv.URL, "wrong-secret", "a.11ch.at"); err == nil { |
| 307 | t.Error("wrong token should be rejected") |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // pruneCandidates: only in-scope peer hosts the source knows nothing about — |
| 312 | // and NOTHING when the source's own in-scope view is empty (the guard that |
| 313 | // keeps a misread registry from mass-deleting a peer). |
| 314 | func TestPruneCandidates(t *testing.T) { |
| 315 | source := []string{"alive.11ch.at", "flipped.11ch.at"} |
| 316 | peer := []string{"alive.11ch.at", "flipped.11ch.at", "dead.11ch.at", "other.example.com"} |
| 317 | domains := []string{"11ch.at"} |
| 318 | |
| 319 | got := pruneCandidates(source, peer, domains, 2) |
| 320 | if len(got) != 1 || got[0] != "dead.11ch.at" { |
| 321 | t.Errorf("candidates = %v, want [dead.11ch.at]", got) |
| 322 | } |
| 323 | if got := pruneCandidates(nil, peer, domains, 0); got != nil { |
| 324 | t.Errorf("zero-guard failed: %v", got) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // --- region-aware replication (docs/multi-edge.md, Phase 1) --------------------- |
| 329 | |
| 330 | // replicationTargets is the DOMAIN ∪ REGION union that decides which peers get a |
| 331 | // route's registry row. Four rules the multi-edge slice pins: |
| 332 | // |
| 333 | // (a) a route pinned to a region reaches that region's box even when this box's |
| 334 | // LCHAT_MESH_DOMAINS does NOT cover the host — the gap this slice closes; |
| 335 | // (b) an empty home_region is domain-scoped only (== pre-region): out of domain |
| 336 | // ⇒ nobody, in domain ⇒ the whole live fleet, unchanged; |
| 337 | // (c) a route that is both domain-covered and region-owned goes to the union, |
| 338 | // with the region-owner box sent to exactly once (no duplicate); |
| 339 | // (d) self is never a target — even when self advertises the route's region — |
| 340 | // because registerInstance already upserted the local row. |
| 341 | func TestMeshReplicationTargets(t *testing.T) { |
| 342 | clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| 343 | store := newFakeMeshStore(clk.now) |
| 344 | |
| 345 | // self "http://a" is an ie box covering only ie.dev.11ch.at; peer "http://b" |
| 346 | // is an fi box; peer "http://c" is a second ie box. |
| 347 | m := newMesh("http://a", store, []string{"ie.dev.11ch.at"}) |
| 348 | m.now = clk.now |
| 349 | m.region = "ie" |
| 350 | store.setBeat("http://b", clk.now()) |
| 351 | store.setRegion("http://b", "fi") |
| 352 | store.setBeat("http://c", clk.now()) |
| 353 | store.setRegion("http://c", "ie") |
| 354 | m.beat() // load membership + peer regions |
| 355 | |
| 356 | // (a) region-owned but OUT of domain → only the fi box, which domain scope |
| 357 | // would have skipped entirely. |
| 358 | outOfDomain := Route{Host: "s.fi.dev.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "fi"} |
| 359 | if got := m.replicationTargets(outOfDomain); !sameSet(got, []string{"http://b"}) { |
| 360 | t.Fatalf("region-owned out-of-domain: targets = %v, want [http://b]", got) |
| 361 | } |
| 362 | |
| 363 | // (b) empty home_region → domain scope only, both ways. |
| 364 | if got := m.replicationTargets(Route{Host: "s.fi.dev.11ch.at", Kind: "instance", Hibernate: true}); len(got) != 0 { |
| 365 | t.Fatalf("empty home_region out of domain: targets = %v, want none", got) |
| 366 | } |
| 367 | if got := m.replicationTargets(Route{Host: "s.ie.dev.11ch.at", Kind: "instance", Hibernate: true}); !sameSet(got, []string{"http://b", "http://c"}) { |
| 368 | t.Fatalf("empty home_region in domain: targets = %v, want the whole live fleet [b c]", got) |
| 369 | } |
| 370 | |
| 371 | // (c) domain-covered AND region-owned by http://b → union {b,c}, b exactly once. |
| 372 | both := Route{Host: "s.ie.dev.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "fi"} |
| 373 | got := m.replicationTargets(both) |
| 374 | if !sameSet(got, []string{"http://b", "http://c"}) { |
| 375 | t.Fatalf("domain+region union: targets = %v, want [b c]", got) |
| 376 | } |
| 377 | if n := countOccurrences(got, "http://b"); n != 1 { |
| 378 | t.Fatalf("union sent to http://b %d time(s), want exactly 1 (dedup)", n) |
| 379 | } |
| 380 | |
| 381 | // (d) region-owned by our OWN region, out of domain → the other ie box only; |
| 382 | // self is excluded despite matching the region. |
| 383 | ownRegion := Route{Host: "s.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "ie"} |
| 384 | if got := m.replicationTargets(ownRegion); !sameSet(got, []string{"http://c"}) { |
| 385 | t.Fatalf("own-region out-of-domain: targets = %v, want [http://c] (self excluded)", got) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | // A region-agnostic box (no LCHAT_MESH_REGION) never fetches peer regions, so its |
| 390 | // replication is domain-scoped for EVERY route, home_region or not — the |
| 391 | // byte-for-byte pre-region promise. Only the domain arm ever contributes. |
| 392 | func TestMeshReplicationTargetsAgnostic(t *testing.T) { |
| 393 | clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| 394 | store := newFakeMeshStore(clk.now) |
| 395 | m := newMesh("http://a", store, []string{"ie.dev.11ch.at"}) // no region set |
| 396 | m.now = clk.now |
| 397 | store.setBeat("http://b", clk.now()) |
| 398 | store.setRegion("http://b", "fi") // advertised, but an agnostic box never reads it |
| 399 | m.beat() |
| 400 | |
| 401 | // A route carrying a home_region an agnostic box can't act on: out of domain ⇒ |
| 402 | // nobody (not the fi box), in domain ⇒ domain scope only. |
| 403 | if got := m.replicationTargets(Route{Host: "s.fi.dev.11ch.at", Hibernate: true, HomeRegion: "fi"}); len(got) != 0 { |
| 404 | t.Fatalf("agnostic box, out-of-domain region route: targets = %v, want none", got) |
| 405 | } |
| 406 | if got := m.replicationTargets(Route{Host: "s.ie.dev.11ch.at", Hibernate: true, HomeRegion: "fi"}); !sameSet(got, []string{"http://b"}) { |
| 407 | t.Fatalf("agnostic box, in-domain route: targets = %v, want [http://b] (domain only)", got) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // mesh-seed carries the routes a joining box owns by the same DOMAIN ∪ REGION |
| 412 | // union: domain-covered routes always, plus (with --region) routes the joining |
| 413 | // box owns by home_region even when out of this seed's domains. A non-hibernating |
| 414 | // route and an out-of-domain route the joining box does NOT own are left behind. |
| 415 | func TestMeshSeedRegionUnion(t *testing.T) { |
| 416 | // The registry the seeding (existing) box reads from. |
| 417 | regPath := filepath.Join(t.TempDir(), "registry.db") |
| 418 | src, err := openRegistry(regPath) |
| 419 | if err != nil { |
| 420 | t.Fatalf("open source registry: %v", err) |
| 421 | } |
| 422 | seed := func(host, region string, hibernate bool) { |
| 423 | if err := src.upsert(Route{Host: host, Kind: "instance", Socket: "/x.sock", Hibernate: hibernate, HomeRegion: region}); err != nil { |
| 424 | t.Fatalf("upsert %s: %v", host, err) |
| 425 | } |
| 426 | } |
| 427 | seed("dom.ie.dev.11ch.at", "ie", true) // domain-covered → seeded |
| 428 | seed("own.fi.dev.11ch.at", "fi", true) // out of domain but region-owned by the fi joiner → seeded |
| 429 | seed("other.fi.dev.11ch.at", "de", true) // out of domain, not the joiner's region → left behind |
| 430 | seed("live.ie.dev.11ch.at", "ie", false) // domain-covered but not hibernating → left behind |
| 431 | src.sql.Close() // release the file before the seed re-opens it |
| 432 | |
| 433 | // The joining box: a fresh registry + router behind the receive handler. |
| 434 | dst, err := openRegistry(filepath.Join(t.TempDir(), "dst.db")) |
| 435 | if err != nil { |
| 436 | t.Fatalf("open dst registry: %v", err) |
| 437 | } |
| 438 | rt, err := newRouter(dst, nil) |
| 439 | if err != nil { |
| 440 | t.Fatalf("new router: %v", err) |
| 441 | } |
| 442 | mux := http.NewServeMux() |
| 443 | mux.Handle("POST /api/mesh/route", meshRouteHandler(dst, rt, "seed-secret")) |
| 444 | srv := httptest.NewServer(mux) |
| 445 | defer srv.Close() |
| 446 | |
| 447 | t.Setenv("LCHAT_MESH_DOMAINS", "ie.dev.11ch.at") |
| 448 | t.Setenv("LCHAT_MESH_TOKEN", "seed-secret") |
| 449 | runMeshSeed([]string{"--registry", regPath, "--region", "fi", srv.URL}) |
| 450 | |
| 451 | if !rt.allowed("dom.ie.dev.11ch.at") { |
| 452 | t.Fatal("domain-covered route was not seeded") |
| 453 | } |
| 454 | if !rt.allowed("own.fi.dev.11ch.at") { |
| 455 | t.Fatal("region-owned out-of-domain route was not seeded to the box that owns it — the multi-edge gap") |
| 456 | } |
| 457 | if rt.allowed("other.fi.dev.11ch.at") { |
| 458 | t.Fatal("an out-of-domain route the joining box does not own by region was wrongly seeded") |
| 459 | } |
| 460 | if rt.allowed("live.ie.dev.11ch.at") { |
| 461 | t.Fatal("a non-hibernating route was wrongly seeded") |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | // countOccurrences counts how many times want appears in xs — used to prove the |
| 466 | // domain ∪ region union sends to a doubly-qualified box exactly once. |
| 467 | func countOccurrences(xs []string, want string) int { |
| 468 | n := 0 |
| 469 | for _, x := range xs { |
| 470 | if x == want { |
| 471 | n++ |
| 472 | } |
| 473 | } |
| 474 | return n |
| 475 | } |
| 476 | |