package main // Tests for meshreplicate.go: the trust boundary in toLocalRoute (only // content fields survive a peer's payload; Socket/DBPath are always derived // locally), the receiving handler's bearer auth, and the full round trip over // a plain HTTP peer (meshReplicateRoute's TLS-SNI-pinned dial for https peers // mirrors mesh.go's own newPeerProxy, already exercised by TestPeerProxyRewrite // — this file proves the request/response/auth/upsert plumbing, not the TLS // mechanics a second time). import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" ) // toLocalRoute must never trust a peer's Socket/DBPath (box-local filesystem // layout) — it derives both itself from Host, exactly like registerInstance // does for a route created locally. func TestMeshRoutePayloadTrustBoundary(t *testing.T) { route := Route{ Host: "space1.11ch.at", Kind: "instance", Socket: "/peer/only/space1.sock", DBPath: "/peer/only/space1.db", Hibernate: true, Version: "v1", AppStoreURL: "https://apps.example.com", HomeRegion: "eu", Replication: replicationDaily, } payload := routeToPayload(route) // Round-trip through JSON, as it travels over the wire. body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal: %v", err) } var decoded meshRoutePayload if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } local, err := decoded.toLocalRoute() if err != nil { t.Fatalf("toLocalRoute: %v", err) } if local.Socket == route.Socket || local.DBPath == route.DBPath { t.Fatalf("peer's Socket/DBPath leaked through: got %q / %q", local.Socket, local.DBPath) } if want := filepath.Join(instanceSocketDir, "space1.11ch.at.sock"); local.Socket != want { t.Fatalf("Socket = %q, want locally-derived %q", local.Socket, want) } if want := filepath.Join(instanceDataDir, "space1.11ch.at.db"); local.DBPath != want { t.Fatalf("DBPath = %q, want locally-derived %q", local.DBPath, want) } if local.Host != route.Host || local.Version != route.Version || local.AppStoreURL != route.AppStoreURL || local.HomeRegion != route.HomeRegion || !local.Hibernate { t.Fatalf("content fields did not survive: %+v", local) } // The replication class rides the mesh too — a space that fails over to a // peer box must keep its durability rung, not silently revert to stream // (safe for the data, wrong for the plan's economics). if local.Replication != replicationDaily { t.Fatalf("replication class did not survive: %q, want %q", local.Replication, replicationDaily) } if local.Status != "" || local.NextDue != 0 || local.CreatedAt != 0 { t.Fatalf("receiver-local lifecycle fields should be zero, got %+v", local) } } // An invalid host in the payload must be rejected, not silently coerced into // a filename/socket path. func TestMeshRoutePayloadRejectsBadHost(t *testing.T) { if _, err := (meshRoutePayload{Host: "not a host!!"}).toLocalRoute(); err == nil { t.Fatal("bad host should be rejected") } } // meshRouteHandler: wrong or missing bearer is 401 and never touches the // registry; a correctly authenticated request upserts the route and reloads // the router so it's immediately servable. func TestMeshRouteHandlerAuth(t *testing.T) { reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } rt, err := newRouter(reg, nil) if err != nil { t.Fatalf("new router: %v", err) } srv := httptest.NewServer(meshRouteHandler(reg, rt, "correct-token")) defer srv.Close() route := Route{Host: "space2.11ch.at", Kind: "instance", Hibernate: true} body, _ := json.Marshal(routeToPayload(route)) post := func(bearer string) int { req, _ := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() return resp.StatusCode } if code := post(""); code != http.StatusUnauthorized { t.Fatalf("no bearer: got %d, want 401", code) } if code := post("wrong-token"); code != http.StatusUnauthorized { t.Fatalf("wrong bearer: got %d, want 401", code) } if rt.allowed(route.Host) { t.Fatal("route must not exist after unauthorized attempts") } if code := post("correct-token"); code != http.StatusNoContent { t.Fatalf("correct bearer: got %d, want 204", code) } if !rt.allowed(route.Host) { t.Fatal("route should be servable immediately after a successful replication (reload)") } } // The full round trip over a plain HTTP peer: meshReplicateRoute dials the // peer's raw address and the handler on the other end upserts + reloads. func TestMeshReplicateRouteOverHTTP(t *testing.T) { reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } rt, err := newRouter(reg, nil) if err != nil { t.Fatalf("new router: %v", err) } mux := http.NewServeMux() mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) srv := httptest.NewServer(mux) defer srv.Close() route := testRoute("space3.11ch.at") if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", routeToPayload(route)); err != nil { t.Fatalf("meshReplicateRoute: %v", err) } if !rt.allowed(route.Host) { t.Fatal("route was not replicated") } if err := meshReplicateRoute(context.Background(), srv.URL, "wrong-secret", routeToPayload(route)); err == nil { t.Fatal("wrong token should be rejected") } } // The instance env rides replication: the receiving box writes /data/.env // from the payload, so its first wake of the host boots with the SAME admin // token the creating box minted — without it, the creator's setup link could // never authenticate against a peer-woken instance. A payload without a token // (an older peer) must never clobber an env that already exists. func TestMeshReplicationCarriesInstanceEnv(t *testing.T) { dir := t.TempDir() oldData := instanceDataDir instanceDataDir = dir defer func() { instanceDataDir = oldData }() reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } rt, err := newRouter(reg, nil) if err != nil { t.Fatalf("new router: %v", err) } mux := http.NewServeMux() mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) srv := httptest.NewServer(mux) defer srv.Close() route := testRoute("space4.11ch.at") route.PushRelayURL = "https://push.example.com" env := map[string]string{ "LCHAT_ADMIN_TOKEN": "tok-abc", "LCHAT_ENTITLEMENT": "paid", "LCHAT_GROUP_NAME": "The Campbells", } if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", payloadFromRouteEnv(route, env)); err != nil { t.Fatalf("meshReplicateRoute: %v", err) } got, err := readEnvFile(filepath.Join(dir, route.Host+".env")) if err != nil { t.Fatalf("receiver wrote no env file: %v", err) } if got["LCHAT_ADMIN_TOKEN"] != "tok-abc" || got["LCHAT_ENTITLEMENT"] != "paid" || got["LCHAT_GROUP_NAME"] != "The Campbells" { t.Errorf("replicated env = %v", got) } // Route fields that are ALSO env values must land through the shared // builder, exactly as materializeInstance writes them locally. if got["LCHAT_PUSH_RELAY_URL"] != "https://push.example.com" { t.Errorf("push relay url = %q, want the route's", got["LCHAT_PUSH_RELAY_URL"]) } // A token-less payload (older peer, or a host with no env at the source) // upserts the route but leaves the existing env alone. if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", routeToPayload(route)); err != nil { t.Fatalf("re-replicate: %v", err) } if got, _ := readEnvFile(filepath.Join(dir, route.Host+".env")); got["LCHAT_ADMIN_TOKEN"] != "tok-abc" { t.Errorf("token-less payload clobbered the env: %v", got) } } // The tombstone: a valid DELETE removes the row, the instance's in. // row, and the REPLICATED env file (a destroyed space's admin token is not // ours to keep) — idempotently, so re-sends and prune sweeps never error. // Auth gates it like every mesh endpoint. func TestMeshRouteDeleteHandler(t *testing.T) { dir := t.TempDir() oldData := instanceDataDir instanceDataDir = dir defer func() { instanceDataDir = oldData }() reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } rt, err := newRouter(reg, nil) if err != nil { t.Fatalf("new router: %v", err) } mux := http.NewServeMux() mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) mux.Handle("DELETE /api/mesh/route/{host}", meshRouteDeleteHandler(reg, rt, "shared-secret")) srv := httptest.NewServer(mux) defer srv.Close() // Replicate a route (with env) + a webhook-ingest row for it. route := testRoute("gone.11ch.at") env := map[string]string{"LCHAT_ADMIN_TOKEN": "tok-dead"} if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", payloadFromRouteEnv(route, env)); err != nil { t.Fatalf("replicate: %v", err) } if err := reg.upsert(Route{Host: "in.gone.11ch.at", Kind: "hooks", Socket: "/run/lchat/hooks.sock"}); err != nil { t.Fatal(err) } // Wrong bearer never deletes. if err := meshDeleteRoute(context.Background(), srv.URL, "wrong-secret", route.Host); err == nil { t.Fatal("wrong token should be rejected") } if !rt.allowed(route.Host) { t.Fatal("unauthorized tombstone deleted the route") } if err := meshDeleteRoute(context.Background(), srv.URL, "shared-secret", route.Host); err != nil { t.Fatalf("tombstone: %v", err) } if rt.allowed(route.Host) { t.Error("route survived its tombstone") } if got, _ := reg.get("in." + route.Host); got != nil { t.Error("webhook-ingest row survived the tombstone") } if _, err := os.Stat(filepath.Join(dir, route.Host+".env")); !os.IsNotExist(err) { t.Error("replicated env file survived the tombstone") } // Idempotent: tombstoning an absent host answers success. if err := meshDeleteRoute(context.Background(), srv.URL, "shared-secret", route.Host); err != nil { t.Errorf("re-tombstone of an absent host: %v", err) } } // The reconciliation listing exposes only in-scope hibernating instance hosts. func TestMeshRouteListHandler(t *testing.T) { reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } rt, err := newRouter(reg, nil) if err != nil { t.Fatalf("new router: %v", err) } seed := func(host, kind string, hib bool) { t.Helper() if err := reg.upsert(Route{Host: host, Kind: kind, Socket: "/run/lchat/" + host + ".sock", Hibernate: hib}); err != nil { t.Fatal(err) } } seed("a.11ch.at", "instance", true) // in seed("b.11ch.at", "instance", false) // out: not hibernating seed("c.example.com", "instance", true) // out: out of scope seed("d.11ch.at", "service", true) // out: not an instance srv := httptest.NewServer(meshRouteListHandler(reg, rt, "shared-secret", []string{"11ch.at"})) defer srv.Close() hosts, err := meshListRoutes(context.Background(), srv.URL, "shared-secret", "a.11ch.at") if err != nil { t.Fatalf("list: %v", err) } if len(hosts) != 1 || hosts[0] != "a.11ch.at" { t.Errorf("hosts = %v, want [a.11ch.at]", hosts) } if _, err := meshListRoutes(context.Background(), srv.URL, "wrong-secret", "a.11ch.at"); err == nil { t.Error("wrong token should be rejected") } } // pruneCandidates: only in-scope peer hosts the source knows nothing about — // and NOTHING when the source's own in-scope view is empty (the guard that // keeps a misread registry from mass-deleting a peer). func TestPruneCandidates(t *testing.T) { source := []string{"alive.11ch.at", "flipped.11ch.at"} peer := []string{"alive.11ch.at", "flipped.11ch.at", "dead.11ch.at", "other.example.com"} domains := []string{"11ch.at"} got := pruneCandidates(source, peer, domains, 2) if len(got) != 1 || got[0] != "dead.11ch.at" { t.Errorf("candidates = %v, want [dead.11ch.at]", got) } if got := pruneCandidates(nil, peer, domains, 0); got != nil { t.Errorf("zero-guard failed: %v", got) } } // --- region-aware replication (docs/multi-edge.md, Phase 1) --------------------- // replicationTargets is the DOMAIN ∪ REGION union that decides which peers get a // route's registry row. Four rules the multi-edge slice pins: // // (a) a route pinned to a region reaches that region's box even when this box's // LCHAT_MESH_DOMAINS does NOT cover the host — the gap this slice closes; // (b) an empty home_region is domain-scoped only (== pre-region): out of domain // ⇒ nobody, in domain ⇒ the whole live fleet, unchanged; // (c) a route that is both domain-covered and region-owned goes to the union, // with the region-owner box sent to exactly once (no duplicate); // (d) self is never a target — even when self advertises the route's region — // because registerInstance already upserted the local row. func TestMeshReplicationTargets(t *testing.T) { clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} store := newFakeMeshStore(clk.now) // self "http://a" is an ie box covering only ie.dev.11ch.at; peer "http://b" // is an fi box; peer "http://c" is a second ie box. m := newMesh("http://a", store, []string{"ie.dev.11ch.at"}) m.now = clk.now m.region = "ie" store.setBeat("http://b", clk.now()) store.setRegion("http://b", "fi") store.setBeat("http://c", clk.now()) store.setRegion("http://c", "ie") m.beat() // load membership + peer regions // (a) region-owned but OUT of domain → only the fi box, which domain scope // would have skipped entirely. outOfDomain := Route{Host: "s.fi.dev.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "fi"} if got := m.replicationTargets(outOfDomain); !sameSet(got, []string{"http://b"}) { t.Fatalf("region-owned out-of-domain: targets = %v, want [http://b]", got) } // (b) empty home_region → domain scope only, both ways. if got := m.replicationTargets(Route{Host: "s.fi.dev.11ch.at", Kind: "instance", Hibernate: true}); len(got) != 0 { t.Fatalf("empty home_region out of domain: targets = %v, want none", got) } if got := m.replicationTargets(Route{Host: "s.ie.dev.11ch.at", Kind: "instance", Hibernate: true}); !sameSet(got, []string{"http://b", "http://c"}) { t.Fatalf("empty home_region in domain: targets = %v, want the whole live fleet [b c]", got) } // (c) domain-covered AND region-owned by http://b → union {b,c}, b exactly once. both := Route{Host: "s.ie.dev.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "fi"} got := m.replicationTargets(both) if !sameSet(got, []string{"http://b", "http://c"}) { t.Fatalf("domain+region union: targets = %v, want [b c]", got) } if n := countOccurrences(got, "http://b"); n != 1 { t.Fatalf("union sent to http://b %d time(s), want exactly 1 (dedup)", n) } // (d) region-owned by our OWN region, out of domain → the other ie box only; // self is excluded despite matching the region. ownRegion := Route{Host: "s.11ch.at", Kind: "instance", Hibernate: true, HomeRegion: "ie"} if got := m.replicationTargets(ownRegion); !sameSet(got, []string{"http://c"}) { t.Fatalf("own-region out-of-domain: targets = %v, want [http://c] (self excluded)", got) } } // A region-agnostic box (no LCHAT_MESH_REGION) never fetches peer regions, so its // replication is domain-scoped for EVERY route, home_region or not — the // byte-for-byte pre-region promise. Only the domain arm ever contributes. func TestMeshReplicationTargetsAgnostic(t *testing.T) { clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} store := newFakeMeshStore(clk.now) m := newMesh("http://a", store, []string{"ie.dev.11ch.at"}) // no region set m.now = clk.now store.setBeat("http://b", clk.now()) store.setRegion("http://b", "fi") // advertised, but an agnostic box never reads it m.beat() // A route carrying a home_region an agnostic box can't act on: out of domain ⇒ // nobody (not the fi box), in domain ⇒ domain scope only. if got := m.replicationTargets(Route{Host: "s.fi.dev.11ch.at", Hibernate: true, HomeRegion: "fi"}); len(got) != 0 { t.Fatalf("agnostic box, out-of-domain region route: targets = %v, want none", got) } if got := m.replicationTargets(Route{Host: "s.ie.dev.11ch.at", Hibernate: true, HomeRegion: "fi"}); !sameSet(got, []string{"http://b"}) { t.Fatalf("agnostic box, in-domain route: targets = %v, want [http://b] (domain only)", got) } } // mesh-seed carries the routes a joining box owns by the same DOMAIN ∪ REGION // union: domain-covered routes always, plus (with --region) routes the joining // box owns by home_region even when out of this seed's domains. A non-hibernating // route and an out-of-domain route the joining box does NOT own are left behind. func TestMeshSeedRegionUnion(t *testing.T) { // The registry the seeding (existing) box reads from. regPath := filepath.Join(t.TempDir(), "registry.db") src, err := openRegistry(regPath) if err != nil { t.Fatalf("open source registry: %v", err) } seed := func(host, region string, hibernate bool) { if err := src.upsert(Route{Host: host, Kind: "instance", Socket: "/x.sock", Hibernate: hibernate, HomeRegion: region}); err != nil { t.Fatalf("upsert %s: %v", host, err) } } seed("dom.ie.dev.11ch.at", "ie", true) // domain-covered → seeded seed("own.fi.dev.11ch.at", "fi", true) // out of domain but region-owned by the fi joiner → seeded seed("other.fi.dev.11ch.at", "de", true) // out of domain, not the joiner's region → left behind seed("live.ie.dev.11ch.at", "ie", false) // domain-covered but not hibernating → left behind src.sql.Close() // release the file before the seed re-opens it // The joining box: a fresh registry + router behind the receive handler. dst, err := openRegistry(filepath.Join(t.TempDir(), "dst.db")) if err != nil { t.Fatalf("open dst registry: %v", err) } rt, err := newRouter(dst, nil) if err != nil { t.Fatalf("new router: %v", err) } mux := http.NewServeMux() mux.Handle("POST /api/mesh/route", meshRouteHandler(dst, rt, "seed-secret")) srv := httptest.NewServer(mux) defer srv.Close() t.Setenv("LCHAT_MESH_DOMAINS", "ie.dev.11ch.at") t.Setenv("LCHAT_MESH_TOKEN", "seed-secret") runMeshSeed([]string{"--registry", regPath, "--region", "fi", srv.URL}) if !rt.allowed("dom.ie.dev.11ch.at") { t.Fatal("domain-covered route was not seeded") } if !rt.allowed("own.fi.dev.11ch.at") { t.Fatal("region-owned out-of-domain route was not seeded to the box that owns it — the multi-edge gap") } if rt.allowed("other.fi.dev.11ch.at") { t.Fatal("an out-of-domain route the joining box does not own by region was wrongly seeded") } if rt.allowed("live.ie.dev.11ch.at") { t.Fatal("a non-hibernating route was wrongly seeded") } } // countOccurrences counts how many times want appears in xs — used to prove the // domain ∪ region union sends to a doubly-qualified box exactly once. func countOccurrences(xs []string, want string) int { n := 0 for _, x := range xs { if x == want { n++ } } return n }