package main import ( "bytes" "database/sql" "encoding/json" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) // fakeControl serves a stand-in control daemon on a unix socket, so the cloud // server dials it exactly as in production. func fakeControl(t *testing.T, handler http.HandlerFunc) string { t.Helper() dir, err := os.MkdirTemp("", "lcc") // short path: sun_path limit if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) sock := filepath.Join(dir, "ctl.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: handler} go srv.Serve(ln) t.Cleanup(func() { srv.Close() }) return sock } // okControl answers every create with a fresh instance, like a healthy box. func okControl(t *testing.T) string { return fakeControl(t, func(w http.ResponseWriter, r *http.Request) { var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#SECRET", }) }) } func newTestCloud(t *testing.T, controlSocket string) (*httptest.Server, *cloudServer) { t.Helper() db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db")) if err != nil { t.Fatal(err) } s := &cloudServer{ db: db, adminHash: hashToken("admin-secret"), controlToken: "ctl-secret", control: controlClient(controlSocket), baseDomain: "example.com", quota: 3, } srv := httptest.NewServer(s.handler()) t.Cleanup(srv.Close) return srv, s } // mintCaller mints a caller token through the admin API (exercising it on the way). func mintCaller(t *testing.T, srv *httptest.Server, label string) string { t.Helper() body, _ := json.Marshal(map[string]string{"label": label}) req, _ := http.NewRequest("POST", srv.URL+"/api/admin/tokens", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer admin-secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("mint: status %d", resp.StatusCode) } var m map[string]any json.NewDecoder(resp.Body).Decode(&m) tok, _ := m["token"].(string) if tok == "" { t.Fatal("mint: no token in response") } return tok } func createSpace(t *testing.T, srv *httptest.Server, token, requester string) *http.Response { t.Helper() body, _ := json.Marshal(map[string]string{"requester": requester}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func TestCloudRequiresCallerToken(t *testing.T) { srv, _ := newTestCloud(t, okControl(t)) if resp := createSpace(t, srv, "", "user-1"); resp.StatusCode != http.StatusUnauthorized { t.Errorf("no token: status = %d, want 401", resp.StatusCode) } if resp := createSpace(t, srv, "nope", "user-1"); resp.StatusCode != http.StatusUnauthorized { t.Errorf("bad token: status = %d, want 401", resp.StatusCode) } } func TestCloudCreateAndList(t *testing.T) { srv, _ := newTestCloud(t, okControl(t)) tok := mintCaller(t, srv, "home.example.com") resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201", resp.StatusCode) } m := decodeBody(t, resp) host, _ := m["host"].(string) if !strings.HasSuffix(host, ".example.com") { t.Errorf("host = %q, want *.example.com", host) } if setup, _ := m["setup_link"].(string); !strings.Contains(setup, "/join/setup#") { t.Errorf("setup_link = %v", m["setup_link"]) } // The ledger recorded it, scoped to this requester. req, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-1", nil) req.Header.Set("Authorization", "Bearer "+tok) lresp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer lresp.Body.Close() var list struct{ Spaces []Space } json.NewDecoder(lresp.Body).Decode(&list) if len(list.Spaces) != 1 || list.Spaces[0].Host != host { t.Errorf("spaces = %+v, want [%s]", list.Spaces, host) } // A different requester sees nothing. req2, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-2", nil) req2.Header.Set("Authorization", "Bearer "+tok) lresp2, err := http.DefaultClient.Do(req2) if err != nil { t.Fatal(err) } defer lresp2.Body.Close() var list2 struct{ Spaces []Space } json.NewDecoder(lresp2.Body).Decode(&list2) if len(list2.Spaces) != 0 { t.Errorf("other requester sees %+v", list2.Spaces) } } func TestCloudQuota(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) tok := mintCaller(t, srv, "home") for i := 0; i < s.quota; i++ { if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { t.Fatalf("create %d: status %d", i, resp.StatusCode) } } resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusConflict { t.Fatalf("over quota: status = %d, want 409", resp.StatusCode) } if m := decodeBody(t, resp); m["error"] != "quota_exceeded" { t.Errorf("error = %v, want quota_exceeded", m["error"]) } // Another user on the same home server is unaffected. if resp := createSpace(t, srv, tok, "user-2"); resp.StatusCode != http.StatusCreated { t.Errorf("other requester: status = %d, want 201", resp.StatusCode) } } func TestCloudRelaysAtCapacity(t *testing.T) { ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", "instance limit reached (40/40)") }) srv, _ := newTestCloud(t, ctl) tok := mintCaller(t, srv, "home") resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", resp.StatusCode) } m := decodeBody(t, resp) if m["error"] != "at_capacity" { t.Errorf("error = %v, want at_capacity", m["error"]) } if m["message"] != spaceUnavailableMsg { t.Errorf("message = %v, want the member-facing capacity message", m["message"]) } } func TestCloudSqidHosts(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) tok := mintCaller(t, srv, "home") // Each create gets a distinct sqid subdomain that decodes to a fresh, // increasing id — no wordlist, no re-roll. Fresh DB → ids 1, 2, 3… var ids []int64 for i := 0; i < s.quota; i++ { resp := createSpace(t, srv, tok, "u") if resp.StatusCode != http.StatusCreated { t.Fatalf("create %d: status %d", i, resp.StatusCode) } host, _ := decodeBody(t, resp)["host"].(string) label, _, ok := strings.Cut(host, ".") if !ok { t.Fatalf("host %q has no label", host) } id, ok := spaceID(label) if !ok { t.Fatalf("label %q of host %q is not a canonical sqid", label, host) } ids = append(ids, id) } if ids[0] != 1 { t.Errorf("first space id = %d, want 1", ids[0]) } for i := 1; i < len(ids); i++ { if ids[i] <= ids[i-1] { t.Errorf("ids not strictly increasing: %v", ids) } } } func TestCloudRequesterRequired(t *testing.T) { srv, _ := newTestCloud(t, okControl(t)) tok := mintCaller(t, srv, "home") if resp := createSpace(t, srv, tok, ""); resp.StatusCode != http.StatusBadRequest { t.Errorf("empty requester: status = %d, want 400", resp.StatusCode) } } func TestCloudDeleteSpace(t *testing.T) { // Control answers create with 201 and delete with 200. ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == "DELETE" { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S", }) }) srv, s := newTestCloud(t, ctl) tok := mintCaller(t, srv, "home") resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("create: %d", resp.StatusCode) } host, _ := decodeBody(t, resp)["host"].(string) del := func(token, h, requester string) *http.Response { req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+h+"?requester="+requester, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } r, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Body.Close() }) return r } // Ownership is the gate: a different requester → 404, nothing deleted. if r := del(tok, host, "someone-else"); r.StatusCode != http.StatusNotFound { t.Fatalf("wrong requester: %d, want 404", r.StatusCode) } // No token → 401. if r := del("", host, "user-1"); r.StatusCode != http.StatusUnauthorized { t.Fatalf("no token: %d, want 401", r.StatusCode) } // The owner deletes → 200 and the quota slot frees. if r := del(tok, host, "user-1"); r.StatusCode != http.StatusOK { t.Fatalf("owner delete: %d, want 200", r.StatusCode) } spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1") if err != nil { t.Fatal(err) } if len(spaces) != 0 { t.Errorf("spaces after delete = %+v, want none (quota freed)", spaces) } // Deleting again → 404 (no longer in the ledger). if r := del(tok, host, "user-1"); r.StatusCode != http.StatusNotFound { t.Fatalf("second delete: %d, want 404", r.StatusCode) } } // mustCallerID digs a caller token's id out of the DB by label. func mustCallerID(t *testing.T, s *cloudServer, label string) string { t.Helper() tokens, err := s.db.listCallerTokens() if err != nil { t.Fatal(err) } for _, tk := range tokens { if tk.Label == label { return tk.ID } } t.Fatalf("no caller token labelled %q", label) return "" } // reclaimTracker scripts the fake control daemon for the self-reclaim tests // and records what got deleted. type reclaimTracker struct { mu sync.Mutex stats instanceStats deleted []string } func (tr *reclaimTracker) count() int { tr.mu.Lock() defer tr.mu.Unlock() return len(tr.deleted) } // reclaimControl answers creates with 201, stats from tr.stats, and records deletes. func reclaimControl(t *testing.T, tr *reclaimTracker) string { return fakeControl(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/stats"): tr.mu.Lock() st := tr.stats tr.mu.Unlock() writeJSON(w, http.StatusOK, st) case r.Method == "DELETE": tr.mu.Lock() tr.deleted = append(tr.deleted, strings.TrimPrefix(r.URL.Path, "/instances/")) tr.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) default: var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S", }) } }) } // A quota-blocked create reclaims the account's own abandoned space (never set // up, past the age gate) instead of failing: the old instance archives via // controld and the fresh one takes its slot. A space younger than the age gate // is never touched — not even consulted. func TestCloudQuotaSelfReclaim(t *testing.T) { tr := &reclaimTracker{} // zero-value stats = never set up, nobody joined, no content srv, s := newTestCloud(t, reclaimControl(t, tr)) s.quota = 1 tok := mintCaller(t, srv, "home") resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("first create: %d", resp.StatusCode) } first, _ := decodeBody(t, resp)["host"].(string) // Fresh space → the age gate holds: 409, and controld is never asked. if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict { t.Fatalf("fresh over-quota create: %d, want 409 (age gate)", r.StatusCode) } if n := tr.count(); n != 0 { t.Fatalf("age-gated create deleted %d space(s) — must not touch a fresh space", n) } // Age it past the gate → the next create reclaims it and succeeds. if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil { t.Fatal(err) } resp = createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("reclaiming create: %d, want 201", resp.StatusCode) } second, _ := decodeBody(t, resp)["host"].(string) if second == first { t.Fatalf("second host = first (%s), want a fresh instance", first) } tr.mu.Lock() deleted := append([]string(nil), tr.deleted...) tr.mu.Unlock() if len(deleted) != 1 || deleted[0] != first { t.Errorf("deleted = %v, want [%s]", deleted, first) } spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1") if err != nil { t.Fatal(err) } if len(spaces) != 1 || spaces[0].Host != second { t.Errorf("ledger = %+v, want just %s", spaces, second) } } // A space in real use — set up, joined, or holding content — is never // self-reclaimed: the quota refusal stands. func TestCloudQuotaSelfReclaimKeepsUsedSpaces(t *testing.T) { tr := &reclaimTracker{stats: instanceStats{Members: 1, SetupComplete: true}} srv, s := newTestCloud(t, reclaimControl(t, tr)) s.quota = 1 tok := mintCaller(t, srv, "home") if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusCreated { t.Fatalf("create: %d", r.StatusCode) } if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil { t.Fatal(err) } if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict { t.Fatalf("over quota vs a set-up space: %d, want 409", r.StatusCode) } if n := tr.count(); n != 0 { t.Errorf("deleted %d space(s) — a set-up space must never be reclaimed", n) } } // A pending row (a create that died between allocate and finalize) past the // age gate frees on the next create instead of holding the slot forever — // ledger-only, no controld call (there is no instance behind it). func TestCloudQuotaSelfReclaimStalePending(t *testing.T) { tr := &reclaimTracker{} srv, s := newTestCloud(t, reclaimControl(t, tr)) s.quota = 1 tok := mintCaller(t, srv, "home") callerID := mustCallerID(t, s, "home") if _, err := s.db.sql.Exec( `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL, ?, ?, ?)`, callerID, "user-1", time.Now().Add(-2*time.Hour).Unix()); err != nil { t.Fatal(err) } resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("create blocked by a stale pending row: %d, want 201", resp.StatusCode) } if n := tr.count(); n != 0 { t.Errorf("controld deletes = %d, want 0 (pending rows are ledger-only)", n) } spaces, err := s.db.listSpaces(callerID, "user-1") if err != nil { t.Fatal(err) } if len(spaces) != 1 { t.Errorf("ledger = %+v, want exactly the new space", spaces) } } func TestCloudAdminTokenLifecycle(t *testing.T) { srv, _ := newTestCloud(t, okControl(t)) // Admin surface is gated. req, _ := http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthed list: status %d, want 401", resp.StatusCode) } _ = mintCaller(t, srv, "home-a") // Listing names the token but never carries its value. req, _ = http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil) req.Header.Set("Authorization", "Bearer admin-secret") resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var list struct{ Tokens []CallerToken } json.NewDecoder(resp.Body).Decode(&list) if len(list.Tokens) != 1 || list.Tokens[0].Label != "home-a" || list.Tokens[0].ID == "" { t.Fatalf("tokens = %+v", list.Tokens) } // Revoke, then the caller token stops working. req, _ = http.NewRequest("DELETE", srv.URL+"/api/admin/tokens/"+list.Tokens[0].ID, nil) req.Header.Set("Authorization", "Bearer admin-secret") resp2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp2.Body.Close() if resp2.StatusCode != http.StatusOK { t.Fatalf("revoke: status %d", resp2.StatusCode) } } func TestCloudDBMigratesOldSpaces(t *testing.T) { path := filepath.Join(t.TempDir(), "cloud.db") // Stand up the ORIGINAL host-PK spaces schema (pre-sqids) with a row, exactly // as a DB provisioned before this change would have. raw, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := raw.Exec(` CREATE TABLE spaces (host TEXT PRIMARY KEY, caller_id TEXT NOT NULL, requester TEXT NOT NULL, created_at INTEGER NOT NULL); INSERT INTO spaces(host, caller_id, requester, created_at) VALUES('old.example.com', 'c', 'r', 100);`); err != nil { t.Fatal(err) } raw.Close() // Reopening through the app migrates the table to the id-keyed schema. db, err := openCloudDB(path) if err != nil { t.Fatalf("openCloudDB (migrate): %v", err) } // The existing row survived (with a fresh id)… spaces, err := db.listSpaces("c", "r") if err != nil { t.Fatal(err) } if len(spaces) != 1 || spaces[0].Host != "old.example.com" { t.Fatalf("after migrate, spaces = %+v, want [old.example.com]", spaces) } // …and new allocations continue past it (ids are AUTOINCREMENT, never reused). id, over, err := db.allocateSpace("c2", "r2", 5) if err != nil || over { t.Fatalf("allocate after migrate: id=%d over=%v err=%v", id, over, err) } if id <= 1 { t.Errorf("new id = %d, want > 1 (the migrated row took id 1)", id) } } func TestSpaceSqidRoundTrip(t *testing.T) { seen := map[string]bool{} for id := int64(1); id <= 1000; id++ { slug, err := spaceSlug(id) if err != nil { t.Fatalf("encode id %d: %v", id, err) } if len(slug) < 3 { t.Errorf("slug %q for id %d is under the 3-char minimum", slug, id) } if !validHost(slug + ".example.com") { t.Errorf("slug %q makes an invalid host", slug) } if got, ok := spaceID(slug); !ok || got != id { t.Errorf("round-trip id %d -> %q -> (%d, %v), want (%d, true)", id, slug, got, ok, id) } if seen[slug] { t.Errorf("slug collision at id %d: %q already seen", id, slug) } seen[slug] = true } // Garbage / non-canonical labels don't decode to an id. for _, bad := range []string{"", "!!!", "ABC", "a-b"} { if _, ok := spaceID(bad); ok { t.Errorf("spaceID(%q) decoded, want not-ok", bad) } } } // regionTracker records the home_region of every create the fake control daemon // receives, so a test can assert what the cloud stamped. type regionTracker struct { mu sync.Mutex regions []string } func (tr *regionTracker) record(r string) { tr.mu.Lock() tr.regions = append(tr.regions, r) tr.mu.Unlock() } func (tr *regionTracker) last() string { tr.mu.Lock() defer tr.mu.Unlock() if len(tr.regions) == 0 { return "" } return tr.regions[len(tr.regions)-1] } // regionCapturingControl answers every create 201 and records its home_region. func regionCapturingControl(t *testing.T, tr *regionTracker) string { return fakeControl(t, func(w http.ResponseWriter, r *http.Request) { var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) tr.record(req.HomeRegion) writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S", }) }) } // createSpaceKind is createSpace with an explicit kind ("" free, "demo"). func createSpaceKind(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { t.Helper() body, _ := json.Marshal(map[string]string{"requester": requester, "kind": kind}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // With LCHAT_CLOUD_REGION set (s.region), a cloud create stamps that region as // the new space's home_region in the control payload — the same on a regular // create and a demo, since both pass through controlCreate (multi-edge Phase 1). func TestCloudStampsHomeRegion(t *testing.T) { tr := ®ionTracker{} srv, s := newTestCloud(t, regionCapturingControl(t, tr)) s.region = "ie" s.demoTTL = 24 * time.Hour // let the kind=demo branch run s.demoMaxLive = 10 // ...without tripping the live-demo cap (default 0) tok := mintCaller(t, srv, "home") if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { t.Fatalf("free create: status %d", resp.StatusCode) } if got := tr.last(); got != "ie" { t.Errorf("free create home_region = %q, want ie", got) } if resp := createSpaceKind(t, srv, tok, "user-2", entDemo); resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: status %d", resp.StatusCode) } if got := tr.last(); got != "ie" { t.Errorf("demo create home_region = %q, want ie", got) } } // With no region configured (the default), a create leaves home_region empty — // byte-for-byte today's region-agnostic behaviour, the non-negotiable back-compat. func TestCloudNoRegionLeavesHomeRegionEmpty(t *testing.T) { tr := ®ionTracker{} srv, _ := newTestCloud(t, regionCapturingControl(t, tr)) // s.region == "" tok := mintCaller(t, srv, "home") if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { t.Fatalf("create: status %d", resp.StatusCode) } if got := tr.last(); got != "" { t.Errorf("home_region = %q, want empty (region-agnostic)", got) } } // The optional display name rides create → controld verbatim (sanitization is // the control seam's job); an oversized one is refused before the dial. func TestCloudCreatePassesName(t *testing.T) { var gotName string ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) gotName = req.Name writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S", }) }) srv, _ := newTestCloud(t, ctl) tok := mintCaller(t, srv, "home") post := func(body map[string]string) *http.Response { payload, _ := json.Marshal(body) req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } if r := post(map[string]string{"requester": "user-1", "name": " The Campbells "}); r.StatusCode != http.StatusCreated { t.Fatalf("create with name: %d, want 201", r.StatusCode) } if gotName != "The Campbells" { t.Errorf("controld saw name %q, want %q", gotName, "The Campbells") } long := strings.Repeat("x", 61) if r := post(map[string]string{"requester": "user-1", "name": long}); r.StatusCode != http.StatusBadRequest { t.Errorf("61-char name: %d, want 400", r.StatusCode) } } // The public price book endpoint (#1374): the book alone, caller-token gated, // billing:false when Stripe isn't configured. func TestPricebookHandler(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) tok := mintCaller(t, srv, "home") get := func() (int, map[string]any) { req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var m map[string]any json.NewDecoder(resp.Body).Decode(&m) return resp.StatusCode, m } // Billing off: the same "no Upgrade UI" signal the listing sends. status, body := get() if status != http.StatusOK { t.Fatalf("status %d", status) } if b, ok := body["billing"].(bool); !ok || b { t.Fatalf("billing = %v, want false with Stripe unconfigured", body["billing"]) } // Billing on: the tiers ride out in publicTiers shape. s.billing = &stripeBilling{tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50 a year", Price: "price_f"}}} _, body = get() b, ok := body["billing"].(map[string]any) if !ok { t.Fatalf("billing = %v, want the tier map", body["billing"]) } tiers, _ := b["tiers"].([]any) if len(tiers) != 1 || tiers[0].(map[string]any)["key"] != "family" { t.Fatalf("tiers = %v, want the configured family tier", tiers) } // No caller token → the gate holds (this is not a public endpoint here; // HOME republishes it publicly). req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("no-token status %d, want 401", resp.StatusCode) } }