| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | func 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. |
| 39 | func 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 | |
| 51 | func 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). |
| 71 | func 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 | |
| 93 | func 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 | |
| 108 | func 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 | |
| 118 | func 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 | |
| 164 | func 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 | |
| 186 | func 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 | |
| 206 | func 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 | |
| 239 | func 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 | |
| 247 | func 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. |
| 308 | func 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. |
| 325 | type reclaimTracker struct { |
| 326 | mu sync.Mutex |
| 327 | stats instanceStats |
| 328 | deleted []string |
| 329 | } |
| 330 | |
| 331 | func (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. |
| 338 | func 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. |
| 365 | func 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. |
| 414 | func 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). |
| 437 | func 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 | |
| 465 | func 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 | |
| 508 | func 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(` |
| 517 | CREATE TABLE spaces (host TEXT PRIMARY KEY, caller_id TEXT NOT NULL, requester TEXT NOT NULL, created_at INTEGER NOT NULL); |
| 518 | INSERT 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 | |
| 546 | func 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. |
| 577 | type regionTracker struct { |
| 578 | mu sync.Mutex |
| 579 | regions []string |
| 580 | } |
| 581 | |
| 582 | func (tr *regionTracker) record(r string) { |
| 583 | tr.mu.Lock() |
| 584 | tr.regions = append(tr.regions, r) |
| 585 | tr.mu.Unlock() |
| 586 | } |
| 587 | |
| 588 | func (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. |
| 598 | func 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"). |
| 610 | func 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). |
| 628 | func TestCloudStampsHomeRegion(t *testing.T) { |
| 629 | tr := ®ionTracker{} |
| 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. |
| 653 | func TestCloudNoRegionLeavesHomeRegionEmpty(t *testing.T) { |
| 654 | tr := ®ionTracker{} |
| 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. |
| 668 | func 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. |
| 708 | func 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 | |