package main import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // twoRegions wires s with an ie + fi provisioner pointed at the given fake // clouds, default ie — the per-country shape the dev mesh runs. func twoRegions(s *Server, ieURL, fiURL string) { s.regions = []cloudRegion{ {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(ieURL, "ie-tok")}, {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiURL, "fi-tok")}, } s.defaultRegion = "ie" s.cloud = s.cloudForRegion("ie") } func postSpacesJSON(t *testing.T, ts *httptest.Server, token, body string) *http.Response { t.Helper() req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func createdHost(t *testing.T, resp *http.Response) string { t.Helper() if resp.StatusCode != http.StatusCreated { t.Fatalf("create status = %d, want 201", resp.StatusCode) } var m map[string]any json.NewDecoder(resp.Body).Decode(&m) host, _ := m["host"].(string) return host } func TestParseCloudRegions(t *testing.T) { // Empty → single-region: no regions, no default, no error. if regs, def, err := parseCloudRegions("", ""); err != nil || regs != nil || def != "" { t.Errorf("empty: got regs=%v def=%q err=%v", regs, def, err) } j := `[{"code":"ie","name":"Ireland","url":"https://cloud.ie","token":"a"},{"code":"fi","name":"Finland","url":"https://cloud.fi","token":"b"}]` regs, def, err := parseCloudRegions(j, "fi") if err != nil { t.Fatalf("parse: %v", err) } if len(regs) != 2 || def != "fi" { t.Fatalf("regs=%d default=%q", len(regs), def) } if regs[0].Code != "ie" || regs[0].Name != "Ireland" || regs[0].prov == nil { t.Errorf("regs[0] = %+v", regs[0]) } // Empty default → the first region. if _, def2, err := parseCloudRegions(j, ""); err != nil || def2 != "ie" { t.Errorf("empty default = %q (err %v), want ie", def2, err) } // A missing name defaults to the code. if regs3, _, err := parseCloudRegions(`[{"code":"nl","url":"https://c","token":"t"}]`, ""); err != nil || regs3[0].Name != "nl" { t.Errorf("missing name should default to code: %+v (err %v)", regs3, err) } for _, bad := range []struct{ j, def, why string }{ {`[{"code":"ie","url":"https://c"}]`, "", "missing token"}, {`[{"code":"ie","name":"x","token":"t"}]`, "", "missing url"}, {`[{"code":"ie","url":"u","token":"t"}]`, "zz", "unknown default"}, {`[{"code":"ie","url":"u","token":"t"},{"code":"ie","url":"u","token":"t"}]`, "", "duplicate code"}, {`not json`, "", "bad json"}, {`[{"url":"u","token":"t"}]`, "", "missing code"}, } { if _, _, err := parseCloudRegions(bad.j, bad.def); err == nil { t.Errorf("%s: expected an error, got nil", bad.why) } } } func TestCreateSpaceRoutesByRegion(t *testing.T) { ts, s := newTestServer(t) _, token := seedMember(t, s) // Each region's fake reports a host in its own country, so the create // response alone says which provisioner handled it — no capture ordering. ieCloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{ "host": "x.ie.example.com", "setup_link": "https://x.ie.example.com/join#S", }) fiCloud, fiCaptured, fiBody := fakeCloud(t, http.StatusCreated, map[string]any{ "host": "x.fi.example.com", "setup_link": "https://x.fi.example.com/join#S", }) twoRegions(s, ieCloud.URL, fiCloud.URL) // Owner override: an explicit region places the space there. if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"fi"}`)); host != "x.fi.example.com" { t.Errorf("region=fi landed on %q, want the fi cloud", host) } if got := fiCaptured.Header.Get("Authorization"); got != "Bearer fi-tok" { t.Errorf("fi create used %q, want the fi region's own token", got) } // The region is home's routing concern — the provisioner still receives only // the opaque requester, nothing else. var sent map[string]string json.Unmarshal(*fiBody, &sent) if _, leaked := sent["region"]; leaked || len(sent) != 1 { t.Errorf("provisioner saw %v, want just requester", sent) } // Auto-assign: no region → the default region (ie). if host := createdHost(t, postSpacesJSON(t, ts, token, `{}`)); host != "x.ie.example.com" { t.Errorf("no region landed on %q, want the default ie cloud", host) } // An unknown region quietly auto-assigns rather than failing. if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"zz"}`)); host != "x.ie.example.com" { t.Errorf("unknown region landed on %q, want the default ie cloud", host) } } func TestListSpacesFansOutAcrossRegions(t *testing.T) { ts, s := newTestServer(t) _, token := seedUser(t, s) ieCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{{"host": "a.ie.example.com"}}, }) fiCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{{"host": "b.fi.example.com"}}, }) twoRegions(s, ieCloud.URL, fiCloud.URL) req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var m struct{ Spaces []struct{ Host string } } json.NewDecoder(resp.Body).Decode(&m) if len(m.Spaces) != 2 { t.Fatalf("merged spaces = %d, want 2 (one per region): %+v", len(m.Spaces), m.Spaces) } hosts := map[string]bool{m.Spaces[0].Host: true, m.Spaces[1].Host: true} if !hosts["a.ie.example.com"] || !hosts["b.fi.example.com"] { t.Errorf("merged hosts = %+v, want both regions", m.Spaces) } } func TestListSpacesSurvivesOneRegionDown(t *testing.T) { ts, s := newTestServer(t) _, token := seedUser(t, s) up, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{{"host": "a.ie.example.com"}}, }) down, _, _ := fakeCloud(t, http.StatusBadGateway, map[string]any{"error": "boom"}) twoRegions(s, up.URL, down.URL) // ie up, fi failing req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() // One region up → still 200 with that region's spaces (best-effort). if resp.StatusCode != http.StatusOK { t.Fatalf("one region down: status = %d, want 200", resp.StatusCode) } var m struct{ Spaces []struct{ Host string } } json.NewDecoder(resp.Body).Decode(&m) if len(m.Spaces) != 1 || m.Spaces[0].Host != "a.ie.example.com" { t.Errorf("spaces = %+v, want just the healthy region's", m.Spaces) } } // listSpaces returns the decoded /api/spaces response body. func listSpaces(t *testing.T, ts *httptest.Server, token string) map[string]any { t.Helper() req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200", resp.StatusCode) } var m map[string]any json.NewDecoder(resp.Body).Decode(&m) return m } // tierKey digs the first tier's key out of a list response's billing block. func tierKey(t *testing.T, m map[string]any) string { t.Helper() b, ok := m["billing"].(map[string]any) if !ok { t.Fatalf("no billing in list response: %v", m) } tiers, _ := b["tiers"].([]any) if len(tiers) == 0 { t.Fatalf("billing has no tiers: %v", b) } first, _ := tiers[0].(map[string]any) key, _ := first["key"].(string) return key } func TestListSpacesCarriesBilling(t *testing.T) { ts, s := newTestServer(t) _, token := seedUser(t, s) ieCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{{"host": "a.ie.example.com"}}, "billing": map[string]any{"tiers": []map[string]any{{"key": "family"}}}, }) fiCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{}, "billing": map[string]any{"tiers": []map[string]any{{"key": "fi-only"}}}, }) // The production shape: one unpartitioned cloud with a price book. The book // must ride the list response — home.js reads r.billing, and dropping it // silently removes the Upgrade UI (the #1083 regression this test pins). s.cloud = newCloudProvisioner(ieCloud.URL, "tok") if got := tierKey(t, listSpaces(t, ts, token)); got != "family" { t.Errorf("single region: tier = %q, want family", got) } // Partitioned: the DEFAULT region's book wins, wherever it sits in the // fan-out order (ie deliberately listed last here). s.regions = []cloudRegion{ {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiCloud.URL, "fi-tok")}, {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(ieCloud.URL, "ie-tok")}, } s.defaultRegion = "ie" s.cloud = s.cloudForRegion("ie") if got := tierKey(t, listSpaces(t, ts, token)); got != "family" { t.Errorf("two regions: tier = %q, want the default region's family", got) } // Default region billing-less (reports false) → another region's book is // the fallback rather than no Upgrade UI at all. noBilling, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ "spaces": []map[string]any{}, "billing": false, }) s.regions = []cloudRegion{ {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(noBilling.URL, "ie-tok")}, {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiCloud.URL, "fi-tok")}, } s.defaultRegion = "ie" s.cloud = s.cloudForRegion("ie") if got := tierKey(t, listSpaces(t, ts, token)); got != "fi-only" { t.Errorf("default billing-less: tier = %q, want the fi fallback", got) } // No cloud offers billing → the key is absent, not false/null (home.js // treats absence as billing off). s.regions = nil s.defaultRegion = "" s.cloud = newCloudProvisioner(noBilling.URL, "tok") if m := listSpaces(t, ts, token); m["billing"] != nil { t.Errorf("billing-less: billing = %v, want absent", m["billing"]) } } func TestManageRoutesByHostRegion(t *testing.T) { ts, s := newTestServer(t) uid, _ := seedUser(t, s) ieCloud, ieCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true}) fiCloud, fiCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true}) twoRegions(s, ieCloud.URL, fiCloud.URL) del := func(host string) { req, _ := http.NewRequest("DELETE", ts.URL+"/api/spaces/"+host, nil) req.Header.Set("X-Ceremony-Proof", s.keys.mintCeremonyProof(uid)) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("delete %s = %d, want 200", host, resp.StatusCode) } } // A .fi host routes to the fi provisioner, and not the ie one. del("y.fi.example.com") if fiCap.Method != "DELETE" { t.Error("delete of a .fi host did not reach the fi provisioner") } if ieCap.Method != "" { t.Error("delete of a .fi host wrongly reached the ie provisioner") } // A .ie host routes to the ie provisioner. del("z.ie.example.com") if ieCap.Method != "DELETE" { t.Error("delete of a .ie host did not reach the ie provisioner") } } func TestFeaturesExposesRegions(t *testing.T) { ts, s := newTestServer(t) // Single-region: no regions key at all. s.cloud = newCloudProvisioner("https://cloud.example.com", "tok") var f map[string]any getJSON(t, ts.URL+"/api/features", &f) if _, ok := f["regions"]; ok { t.Errorf("single-region features must not carry a regions key: %v", f) } // Multi-region: regions + default_region, code+name only — never url/token. twoRegions(s, "https://cloud.ie", "https://cloud.fi") var f2 map[string]any getJSON(t, ts.URL+"/api/features", &f2) if f2["default_region"] != "ie" { t.Errorf("default_region = %v, want ie", f2["default_region"]) } regs, ok := f2["regions"].([]any) if !ok || len(regs) != 2 { t.Fatalf("regions = %v, want 2", f2["regions"]) } first, _ := regs[0].(map[string]any) if first["code"] != "ie" || first["name"] != "Ireland" { t.Errorf("region[0] = %v", first) } if _, leaked := first["url"]; leaked { t.Errorf("features leaked a region url: %v", first) } if _, leaked := first["token"]; leaked { t.Errorf("features leaked a region token: %v", first) } } func getJSON(t *testing.T, url string, v any) { t.Helper() resp, err := http.Get(url) if err != nil { t.Fatal(err) } defer resp.Body.Close() json.NewDecoder(resp.Body).Decode(v) }