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 "bytes"
5 "database/sql"
6 "encoding/json"
7 "net"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16)
17
18// fakeControl serves a stand-in control daemon on a unix socket, so the cloud
19// server dials it exactly as in production.
20func fakeControl(t *testing.T, handler http.HandlerFunc) string {
21 t.Helper()
22 dir, err := os.MkdirTemp("", "lcc") // short path: sun_path limit
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { os.RemoveAll(dir) })
27 sock := filepath.Join(dir, "ctl.sock")
28 ln, err := net.Listen("unix", sock)
29 if err != nil {
30 t.Fatal(err)
31 }
32 srv := &http.Server{Handler: handler}
33 go srv.Serve(ln)
34 t.Cleanup(func() { srv.Close() })
35 return sock
36}
37
38// okControl answers every create with a fresh instance, like a healthy box.
39func okControl(t *testing.T) string {
40 return fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
41 var req controlCreateReq
42 json.NewDecoder(r.Body).Decode(&req)
43 writeJSON(w, http.StatusCreated, map[string]any{
44 "host": req.Host,
45 "url": "https://" + req.Host,
46 "setup_link": "https://" + req.Host + "/join/setup#SECRET",
47 })
48 })
49}
50
51func newTestCloud(t *testing.T, controlSocket string) (*httptest.Server, *cloudServer) {
52 t.Helper()
53 db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db"))
54 if err != nil {
55 t.Fatal(err)
56 }
57 s := &cloudServer{
58 db: db,
59 adminHash: hashToken("admin-secret"),
60 controlToken: "ctl-secret",
61 control: controlClient(controlSocket),
62 baseDomain: "example.com",
63 quota: 3,
64 }
65 srv := httptest.NewServer(s.handler())
66 t.Cleanup(srv.Close)
67 return srv, s
68}
69
70// mintCaller mints a caller token through the admin API (exercising it on the way).
71func mintCaller(t *testing.T, srv *httptest.Server, label string) string {
72 t.Helper()
73 body, _ := json.Marshal(map[string]string{"label": label})
74 req, _ := http.NewRequest("POST", srv.URL+"/api/admin/tokens", bytes.NewReader(body))
75 req.Header.Set("Authorization", "Bearer admin-secret")
76 resp, err := http.DefaultClient.Do(req)
77 if err != nil {
78 t.Fatal(err)
79 }
80 defer resp.Body.Close()
81 if resp.StatusCode != http.StatusOK {
82 t.Fatalf("mint: status %d", resp.StatusCode)
83 }
84 var m map[string]any
85 json.NewDecoder(resp.Body).Decode(&m)
86 tok, _ := m["token"].(string)
87 if tok == "" {
88 t.Fatal("mint: no token in response")
89 }
90 return tok
91}
92
93func createSpace(t *testing.T, srv *httptest.Server, token, requester string) *http.Response {
94 t.Helper()
95 body, _ := json.Marshal(map[string]string{"requester": requester})
96 req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body))
97 if token != "" {
98 req.Header.Set("Authorization", "Bearer "+token)
99 }
100 resp, err := http.DefaultClient.Do(req)
101 if err != nil {
102 t.Fatal(err)
103 }
104 t.Cleanup(func() { resp.Body.Close() })
105 return resp
106}
107
108func TestCloudRequiresCallerToken(t *testing.T) {
109 srv, _ := newTestCloud(t, okControl(t))
110 if resp := createSpace(t, srv, "", "user-1"); resp.StatusCode != http.StatusUnauthorized {
111 t.Errorf("no token: status = %d, want 401", resp.StatusCode)
112 }
113 if resp := createSpace(t, srv, "nope", "user-1"); resp.StatusCode != http.StatusUnauthorized {
114 t.Errorf("bad token: status = %d, want 401", resp.StatusCode)
115 }
116}
117
118func TestCloudCreateAndList(t *testing.T) {
119 srv, _ := newTestCloud(t, okControl(t))
120 tok := mintCaller(t, srv, "home.example.com")
121
122 resp := createSpace(t, srv, tok, "user-1")
123 if resp.StatusCode != http.StatusCreated {
124 t.Fatalf("status = %d, want 201", resp.StatusCode)
125 }
126 m := decodeBody(t, resp)
127 host, _ := m["host"].(string)
128 if !strings.HasSuffix(host, ".example.com") {
129 t.Errorf("host = %q, want *.example.com", host)
130 }
131 if setup, _ := m["setup_link"].(string); !strings.Contains(setup, "/join/setup#") {
132 t.Errorf("setup_link = %v", m["setup_link"])
133 }
134
135 // The ledger recorded it, scoped to this requester.
136 req, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-1", nil)
137 req.Header.Set("Authorization", "Bearer "+tok)
138 lresp, err := http.DefaultClient.Do(req)
139 if err != nil {
140 t.Fatal(err)
141 }
142 defer lresp.Body.Close()
143 var list struct{ Spaces []Space }
144 json.NewDecoder(lresp.Body).Decode(&list)
145 if len(list.Spaces) != 1 || list.Spaces[0].Host != host {
146 t.Errorf("spaces = %+v, want [%s]", list.Spaces, host)
147 }
148
149 // A different requester sees nothing.
150 req2, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-2", nil)
151 req2.Header.Set("Authorization", "Bearer "+tok)
152 lresp2, err := http.DefaultClient.Do(req2)
153 if err != nil {
154 t.Fatal(err)
155 }
156 defer lresp2.Body.Close()
157 var list2 struct{ Spaces []Space }
158 json.NewDecoder(lresp2.Body).Decode(&list2)
159 if len(list2.Spaces) != 0 {
160 t.Errorf("other requester sees %+v", list2.Spaces)
161 }
162}
163
164func TestCloudQuota(t *testing.T) {
165 srv, s := newTestCloud(t, okControl(t))
166 tok := mintCaller(t, srv, "home")
167
168 for i := 0; i < s.quota; i++ {
169 if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated {
170 t.Fatalf("create %d: status %d", i, resp.StatusCode)
171 }
172 }
173 resp := createSpace(t, srv, tok, "user-1")
174 if resp.StatusCode != http.StatusConflict {
175 t.Fatalf("over quota: status = %d, want 409", resp.StatusCode)
176 }
177 if m := decodeBody(t, resp); m["error"] != "quota_exceeded" {
178 t.Errorf("error = %v, want quota_exceeded", m["error"])
179 }
180 // Another user on the same home server is unaffected.
181 if resp := createSpace(t, srv, tok, "user-2"); resp.StatusCode != http.StatusCreated {
182 t.Errorf("other requester: status = %d, want 201", resp.StatusCode)
183 }
184}
185
186func TestCloudRelaysAtCapacity(t *testing.T) {
187 ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
188 writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", "instance limit reached (40/40)")
189 })
190 srv, _ := newTestCloud(t, ctl)
191 tok := mintCaller(t, srv, "home")
192
193 resp := createSpace(t, srv, tok, "user-1")
194 if resp.StatusCode != http.StatusServiceUnavailable {
195 t.Fatalf("status = %d, want 503", resp.StatusCode)
196 }
197 m := decodeBody(t, resp)
198 if m["error"] != "at_capacity" {
199 t.Errorf("error = %v, want at_capacity", m["error"])
200 }
201 if m["message"] != spaceUnavailableMsg {
202 t.Errorf("message = %v, want the member-facing capacity message", m["message"])
203 }
204}
205
206func TestCloudSqidHosts(t *testing.T) {
207 srv, s := newTestCloud(t, okControl(t))
208 tok := mintCaller(t, srv, "home")
209
210 // Each create gets a distinct sqid subdomain that decodes to a fresh,
211 // increasing id — no wordlist, no re-roll. Fresh DB → ids 1, 2, 3…
212 var ids []int64
213 for i := 0; i < s.quota; i++ {
214 resp := createSpace(t, srv, tok, "u")
215 if resp.StatusCode != http.StatusCreated {
216 t.Fatalf("create %d: status %d", i, resp.StatusCode)
217 }
218 host, _ := decodeBody(t, resp)["host"].(string)
219 label, _, ok := strings.Cut(host, ".")
220 if !ok {
221 t.Fatalf("host %q has no label", host)
222 }
223 id, ok := spaceID(label)
224 if !ok {
225 t.Fatalf("label %q of host %q is not a canonical sqid", label, host)
226 }
227 ids = append(ids, id)
228 }
229 if ids[0] != 1 {
230 t.Errorf("first space id = %d, want 1", ids[0])
231 }
232 for i := 1; i < len(ids); i++ {
233 if ids[i] <= ids[i-1] {
234 t.Errorf("ids not strictly increasing: %v", ids)
235 }
236 }
237}
238
239func TestCloudRequesterRequired(t *testing.T) {
240 srv, _ := newTestCloud(t, okControl(t))
241 tok := mintCaller(t, srv, "home")
242 if resp := createSpace(t, srv, tok, ""); resp.StatusCode != http.StatusBadRequest {
243 t.Errorf("empty requester: status = %d, want 400", resp.StatusCode)
244 }
245}
246
247func TestCloudDeleteSpace(t *testing.T) {
248 // Control answers create with 201 and delete with 200.
249 ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
250 if r.Method == "DELETE" {
251 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
252 return
253 }
254 var req controlCreateReq
255 json.NewDecoder(r.Body).Decode(&req)
256 writeJSON(w, http.StatusCreated, map[string]any{
257 "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
258 })
259 })
260 srv, s := newTestCloud(t, ctl)
261 tok := mintCaller(t, srv, "home")
262
263 resp := createSpace(t, srv, tok, "user-1")
264 if resp.StatusCode != http.StatusCreated {
265 t.Fatalf("create: %d", resp.StatusCode)
266 }
267 host, _ := decodeBody(t, resp)["host"].(string)
268
269 del := func(token, h, requester string) *http.Response {
270 req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+h+"?requester="+requester, nil)
271 if token != "" {
272 req.Header.Set("Authorization", "Bearer "+token)
273 }
274 r, err := http.DefaultClient.Do(req)
275 if err != nil {
276 t.Fatal(err)
277 }
278 t.Cleanup(func() { r.Body.Close() })
279 return r
280 }
281
282 // Ownership is the gate: a different requester → 404, nothing deleted.
283 if r := del(tok, host, "someone-else"); r.StatusCode != http.StatusNotFound {
284 t.Fatalf("wrong requester: %d, want 404", r.StatusCode)
285 }
286 // No token → 401.
287 if r := del("", host, "user-1"); r.StatusCode != http.StatusUnauthorized {
288 t.Fatalf("no token: %d, want 401", r.StatusCode)
289 }
290 // The owner deletes → 200 and the quota slot frees.
291 if r := del(tok, host, "user-1"); r.StatusCode != http.StatusOK {
292 t.Fatalf("owner delete: %d, want 200", r.StatusCode)
293 }
294 spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1")
295 if err != nil {
296 t.Fatal(err)
297 }
298 if len(spaces) != 0 {
299 t.Errorf("spaces after delete = %+v, want none (quota freed)", spaces)
300 }
301 // Deleting again → 404 (no longer in the ledger).
302 if r := del(tok, host, "user-1"); r.StatusCode != http.StatusNotFound {
303 t.Fatalf("second delete: %d, want 404", r.StatusCode)
304 }
305}
306
307// mustCallerID digs a caller token's id out of the DB by label.
308func mustCallerID(t *testing.T, s *cloudServer, label string) string {
309 t.Helper()
310 tokens, err := s.db.listCallerTokens()
311 if err != nil {
312 t.Fatal(err)
313 }
314 for _, tk := range tokens {
315 if tk.Label == label {
316 return tk.ID
317 }
318 }
319 t.Fatalf("no caller token labelled %q", label)
320 return ""
321}
322
323// reclaimTracker scripts the fake control daemon for the self-reclaim tests
324// and records what got deleted.
325type reclaimTracker struct {
326 mu sync.Mutex
327 stats instanceStats
328 deleted []string
329}
330
331func (tr *reclaimTracker) count() int {
332 tr.mu.Lock()
333 defer tr.mu.Unlock()
334 return len(tr.deleted)
335}
336
337// reclaimControl answers creates with 201, stats from tr.stats, and records deletes.
338func reclaimControl(t *testing.T, tr *reclaimTracker) string {
339 return fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
340 switch {
341 case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/stats"):
342 tr.mu.Lock()
343 st := tr.stats
344 tr.mu.Unlock()
345 writeJSON(w, http.StatusOK, st)
346 case r.Method == "DELETE":
347 tr.mu.Lock()
348 tr.deleted = append(tr.deleted, strings.TrimPrefix(r.URL.Path, "/instances/"))
349 tr.mu.Unlock()
350 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
351 default:
352 var req controlCreateReq
353 json.NewDecoder(r.Body).Decode(&req)
354 writeJSON(w, http.StatusCreated, map[string]any{
355 "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
356 })
357 }
358 })
359}
360
361// A quota-blocked create reclaims the account's own abandoned space (never set
362// up, past the age gate) instead of failing: the old instance archives via
363// controld and the fresh one takes its slot. A space younger than the age gate
364// is never touched — not even consulted.
365func TestCloudQuotaSelfReclaim(t *testing.T) {
366 tr := &reclaimTracker{} // zero-value stats = never set up, nobody joined, no content
367 srv, s := newTestCloud(t, reclaimControl(t, tr))
368 s.quota = 1
369 tok := mintCaller(t, srv, "home")
370
371 resp := createSpace(t, srv, tok, "user-1")
372 if resp.StatusCode != http.StatusCreated {
373 t.Fatalf("first create: %d", resp.StatusCode)
374 }
375 first, _ := decodeBody(t, resp)["host"].(string)
376
377 // Fresh space → the age gate holds: 409, and controld is never asked.
378 if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict {
379 t.Fatalf("fresh over-quota create: %d, want 409 (age gate)", r.StatusCode)
380 }
381 if n := tr.count(); n != 0 {
382 t.Fatalf("age-gated create deleted %d space(s) — must not touch a fresh space", n)
383 }
384
385 // Age it past the gate → the next create reclaims it and succeeds.
386 if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil {
387 t.Fatal(err)
388 }
389 resp = createSpace(t, srv, tok, "user-1")
390 if resp.StatusCode != http.StatusCreated {
391 t.Fatalf("reclaiming create: %d, want 201", resp.StatusCode)
392 }
393 second, _ := decodeBody(t, resp)["host"].(string)
394 if second == first {
395 t.Fatalf("second host = first (%s), want a fresh instance", first)
396 }
397 tr.mu.Lock()
398 deleted := append([]string(nil), tr.deleted...)
399 tr.mu.Unlock()
400 if len(deleted) != 1 || deleted[0] != first {
401 t.Errorf("deleted = %v, want [%s]", deleted, first)
402 }
403 spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1")
404 if err != nil {
405 t.Fatal(err)
406 }
407 if len(spaces) != 1 || spaces[0].Host != second {
408 t.Errorf("ledger = %+v, want just %s", spaces, second)
409 }
410}
411
412// A space in real use — set up, joined, or holding content — is never
413// self-reclaimed: the quota refusal stands.
414func TestCloudQuotaSelfReclaimKeepsUsedSpaces(t *testing.T) {
415 tr := &reclaimTracker{stats: instanceStats{Members: 1, SetupComplete: true}}
416 srv, s := newTestCloud(t, reclaimControl(t, tr))
417 s.quota = 1
418 tok := mintCaller(t, srv, "home")
419
420 if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusCreated {
421 t.Fatalf("create: %d", r.StatusCode)
422 }
423 if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil {
424 t.Fatal(err)
425 }
426 if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict {
427 t.Fatalf("over quota vs a set-up space: %d, want 409", r.StatusCode)
428 }
429 if n := tr.count(); n != 0 {
430 t.Errorf("deleted %d space(s) — a set-up space must never be reclaimed", n)
431 }
432}
433
434// A pending row (a create that died between allocate and finalize) past the
435// age gate frees on the next create instead of holding the slot forever —
436// ledger-only, no controld call (there is no instance behind it).
437func TestCloudQuotaSelfReclaimStalePending(t *testing.T) {
438 tr := &reclaimTracker{}
439 srv, s := newTestCloud(t, reclaimControl(t, tr))
440 s.quota = 1
441 tok := mintCaller(t, srv, "home")
442 callerID := mustCallerID(t, s, "home")
443
444 if _, err := s.db.sql.Exec(
445 `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL, ?, ?, ?)`,
446 callerID, "user-1", time.Now().Add(-2*time.Hour).Unix()); err != nil {
447 t.Fatal(err)
448 }
449 resp := createSpace(t, srv, tok, "user-1")
450 if resp.StatusCode != http.StatusCreated {
451 t.Fatalf("create blocked by a stale pending row: %d, want 201", resp.StatusCode)
452 }
453 if n := tr.count(); n != 0 {
454 t.Errorf("controld deletes = %d, want 0 (pending rows are ledger-only)", n)
455 }
456 spaces, err := s.db.listSpaces(callerID, "user-1")
457 if err != nil {
458 t.Fatal(err)
459 }
460 if len(spaces) != 1 {
461 t.Errorf("ledger = %+v, want exactly the new space", spaces)
462 }
463}
464
465func TestCloudAdminTokenLifecycle(t *testing.T) {
466 srv, _ := newTestCloud(t, okControl(t))
467
468 // Admin surface is gated.
469 req, _ := http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil)
470 resp, err := http.DefaultClient.Do(req)
471 if err != nil {
472 t.Fatal(err)
473 }
474 resp.Body.Close()
475 if resp.StatusCode != http.StatusUnauthorized {
476 t.Fatalf("unauthed list: status %d, want 401", resp.StatusCode)
477 }
478
479 _ = mintCaller(t, srv, "home-a")
480
481 // Listing names the token but never carries its value.
482 req, _ = http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil)
483 req.Header.Set("Authorization", "Bearer admin-secret")
484 resp, err = http.DefaultClient.Do(req)
485 if err != nil {
486 t.Fatal(err)
487 }
488 defer resp.Body.Close()
489 var list struct{ Tokens []CallerToken }
490 json.NewDecoder(resp.Body).Decode(&list)
491 if len(list.Tokens) != 1 || list.Tokens[0].Label != "home-a" || list.Tokens[0].ID == "" {
492 t.Fatalf("tokens = %+v", list.Tokens)
493 }
494
495 // Revoke, then the caller token stops working.
496 req, _ = http.NewRequest("DELETE", srv.URL+"/api/admin/tokens/"+list.Tokens[0].ID, nil)
497 req.Header.Set("Authorization", "Bearer admin-secret")
498 resp2, err := http.DefaultClient.Do(req)
499 if err != nil {
500 t.Fatal(err)
501 }
502 resp2.Body.Close()
503 if resp2.StatusCode != http.StatusOK {
504 t.Fatalf("revoke: status %d", resp2.StatusCode)
505 }
506}
507
508func TestCloudDBMigratesOldSpaces(t *testing.T) {
509 path := filepath.Join(t.TempDir(), "cloud.db")
510 // Stand up the ORIGINAL host-PK spaces schema (pre-sqids) with a row, exactly
511 // as a DB provisioned before this change would have.
512 raw, err := sql.Open("sqlite", path)
513 if err != nil {
514 t.Fatal(err)
515 }
516 if _, err := raw.Exec(`
517CREATE TABLE spaces (host TEXT PRIMARY KEY, caller_id TEXT NOT NULL, requester TEXT NOT NULL, created_at INTEGER NOT NULL);
518INSERT INTO spaces(host, caller_id, requester, created_at) VALUES('old.example.com', 'c', 'r', 100);`); err != nil {
519 t.Fatal(err)
520 }
521 raw.Close()
522
523 // Reopening through the app migrates the table to the id-keyed schema.
524 db, err := openCloudDB(path)
525 if err != nil {
526 t.Fatalf("openCloudDB (migrate): %v", err)
527 }
528 // The existing row survived (with a fresh id)…
529 spaces, err := db.listSpaces("c", "r")
530 if err != nil {
531 t.Fatal(err)
532 }
533 if len(spaces) != 1 || spaces[0].Host != "old.example.com" {
534 t.Fatalf("after migrate, spaces = %+v, want [old.example.com]", spaces)
535 }
536 // …and new allocations continue past it (ids are AUTOINCREMENT, never reused).
537 id, over, err := db.allocateSpace("c2", "r2", 5)
538 if err != nil || over {
539 t.Fatalf("allocate after migrate: id=%d over=%v err=%v", id, over, err)
540 }
541 if id <= 1 {
542 t.Errorf("new id = %d, want > 1 (the migrated row took id 1)", id)
543 }
544}
545
546func TestSpaceSqidRoundTrip(t *testing.T) {
547 seen := map[string]bool{}
548 for id := int64(1); id <= 1000; id++ {
549 slug, err := spaceSlug(id)
550 if err != nil {
551 t.Fatalf("encode id %d: %v", id, err)
552 }
553 if len(slug) < 3 {
554 t.Errorf("slug %q for id %d is under the 3-char minimum", slug, id)
555 }
556 if !validHost(slug + ".example.com") {
557 t.Errorf("slug %q makes an invalid host", slug)
558 }
559 if got, ok := spaceID(slug); !ok || got != id {
560 t.Errorf("round-trip id %d -> %q -> (%d, %v), want (%d, true)", id, slug, got, ok, id)
561 }
562 if seen[slug] {
563 t.Errorf("slug collision at id %d: %q already seen", id, slug)
564 }
565 seen[slug] = true
566 }
567 // Garbage / non-canonical labels don't decode to an id.
568 for _, bad := range []string{"", "!!!", "ABC", "a-b"} {
569 if _, ok := spaceID(bad); ok {
570 t.Errorf("spaceID(%q) decoded, want not-ok", bad)
571 }
572 }
573}
574
575// regionTracker records the home_region of every create the fake control daemon
576// receives, so a test can assert what the cloud stamped.
577type regionTracker struct {
578 mu sync.Mutex
579 regions []string
580}
581
582func (tr *regionTracker) record(r string) {
583 tr.mu.Lock()
584 tr.regions = append(tr.regions, r)
585 tr.mu.Unlock()
586}
587
588func (tr *regionTracker) last() string {
589 tr.mu.Lock()
590 defer tr.mu.Unlock()
591 if len(tr.regions) == 0 {
592 return "<none>"
593 }
594 return tr.regions[len(tr.regions)-1]
595}
596
597// regionCapturingControl answers every create 201 and records its home_region.
598func regionCapturingControl(t *testing.T, tr *regionTracker) string {
599 return fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
600 var req controlCreateReq
601 json.NewDecoder(r.Body).Decode(&req)
602 tr.record(req.HomeRegion)
603 writeJSON(w, http.StatusCreated, map[string]any{
604 "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
605 })
606 })
607}
608
609// createSpaceKind is createSpace with an explicit kind ("" free, "demo").
610func createSpaceKind(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response {
611 t.Helper()
612 body, _ := json.Marshal(map[string]string{"requester": requester, "kind": kind})
613 req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body))
614 if token != "" {
615 req.Header.Set("Authorization", "Bearer "+token)
616 }
617 resp, err := http.DefaultClient.Do(req)
618 if err != nil {
619 t.Fatal(err)
620 }
621 t.Cleanup(func() { resp.Body.Close() })
622 return resp
623}
624
625// With LCHAT_CLOUD_REGION set (s.region), a cloud create stamps that region as
626// the new space's home_region in the control payload — the same on a regular
627// create and a demo, since both pass through controlCreate (multi-edge Phase 1).
628func TestCloudStampsHomeRegion(t *testing.T) {
629 tr := &regionTracker{}
630 srv, s := newTestCloud(t, regionCapturingControl(t, tr))
631 s.region = "ie"
632 s.demoTTL = 24 * time.Hour // let the kind=demo branch run
633 s.demoMaxLive = 10 // ...without tripping the live-demo cap (default 0)
634 tok := mintCaller(t, srv, "home")
635
636 if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated {
637 t.Fatalf("free create: status %d", resp.StatusCode)
638 }
639 if got := tr.last(); got != "ie" {
640 t.Errorf("free create home_region = %q, want ie", got)
641 }
642
643 if resp := createSpaceKind(t, srv, tok, "user-2", entDemo); resp.StatusCode != http.StatusCreated {
644 t.Fatalf("demo create: status %d", resp.StatusCode)
645 }
646 if got := tr.last(); got != "ie" {
647 t.Errorf("demo create home_region = %q, want ie", got)
648 }
649}
650
651// With no region configured (the default), a create leaves home_region empty —
652// byte-for-byte today's region-agnostic behaviour, the non-negotiable back-compat.
653func TestCloudNoRegionLeavesHomeRegionEmpty(t *testing.T) {
654 tr := &regionTracker{}
655 srv, _ := newTestCloud(t, regionCapturingControl(t, tr)) // s.region == ""
656 tok := mintCaller(t, srv, "home")
657
658 if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated {
659 t.Fatalf("create: status %d", resp.StatusCode)
660 }
661 if got := tr.last(); got != "" {
662 t.Errorf("home_region = %q, want empty (region-agnostic)", got)
663 }
664}
665
666// The optional display name rides create → controld verbatim (sanitization is
667// the control seam's job); an oversized one is refused before the dial.
668func TestCloudCreatePassesName(t *testing.T) {
669 var gotName string
670 ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
671 var req controlCreateReq
672 json.NewDecoder(r.Body).Decode(&req)
673 gotName = req.Name
674 writeJSON(w, http.StatusCreated, map[string]any{
675 "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
676 })
677 })
678 srv, _ := newTestCloud(t, ctl)
679 tok := mintCaller(t, srv, "home")
680
681 post := func(body map[string]string) *http.Response {
682 payload, _ := json.Marshal(body)
683 req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(payload))
684 req.Header.Set("Authorization", "Bearer "+tok)
685 resp, err := http.DefaultClient.Do(req)
686 if err != nil {
687 t.Fatal(err)
688 }
689 t.Cleanup(func() { resp.Body.Close() })
690 return resp
691 }
692
693 if r := post(map[string]string{"requester": "user-1", "name": " The Campbells "}); r.StatusCode != http.StatusCreated {
694 t.Fatalf("create with name: %d, want 201", r.StatusCode)
695 }
696 if gotName != "The Campbells" {
697 t.Errorf("controld saw name %q, want %q", gotName, "The Campbells")
698 }
699
700 long := strings.Repeat("x", 61)
701 if r := post(map[string]string{"requester": "user-1", "name": long}); r.StatusCode != http.StatusBadRequest {
702 t.Errorf("61-char name: %d, want 400", r.StatusCode)
703 }
704}
705
706// The public price book endpoint (#1374): the book alone, caller-token gated,
707// billing:false when Stripe isn't configured.
708func TestPricebookHandler(t *testing.T) {
709 srv, s := newTestCloud(t, okControl(t))
710 tok := mintCaller(t, srv, "home")
711
712 get := func() (int, map[string]any) {
713 req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil)
714 req.Header.Set("Authorization", "Bearer "+tok)
715 resp, err := http.DefaultClient.Do(req)
716 if err != nil {
717 t.Fatal(err)
718 }
719 defer resp.Body.Close()
720 var m map[string]any
721 json.NewDecoder(resp.Body).Decode(&m)
722 return resp.StatusCode, m
723 }
724
725 // Billing off: the same "no Upgrade UI" signal the listing sends.
726 status, body := get()
727 if status != http.StatusOK {
728 t.Fatalf("status %d", status)
729 }
730 if b, ok := body["billing"].(bool); !ok || b {
731 t.Fatalf("billing = %v, want false with Stripe unconfigured", body["billing"])
732 }
733
734 // Billing on: the tiers ride out in publicTiers shape.
735 s.billing = &stripeBilling{tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50 a year", Price: "price_f"}}}
736 _, body = get()
737 b, ok := body["billing"].(map[string]any)
738 if !ok {
739 t.Fatalf("billing = %v, want the tier map", body["billing"])
740 }
741 tiers, _ := b["tiers"].([]any)
742 if len(tiers) != 1 || tiers[0].(map[string]any)["key"] != "family" {
743 t.Fatalf("tiers = %v, want the configured family tier", tiers)
744 }
745
746 // No caller token → the gate holds (this is not a public endpoint here;
747 // HOME republishes it publicly).
748 req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil)
749 resp, err := http.DefaultClient.Do(req)
750 if err != nil {
751 t.Fatal(err)
752 }
753 resp.Body.Close()
754 if resp.StatusCode != http.StatusUnauthorized {
755 t.Fatalf("no-token status %d, want 401", resp.StatusCode)
756 }
757}
758