| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // fakeCloud stands in for the cloud provisioner and captures what home sends. |
| 13 | func fakeCloud(t *testing.T, status int, body map[string]any) (*httptest.Server, *http.Request, *[]byte) { |
| 14 | t.Helper() |
| 15 | var captured http.Request |
| 16 | var capturedBody []byte |
| 17 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 18 | captured = *r |
| 19 | buf := new(bytes.Buffer) |
| 20 | buf.ReadFrom(r.Body) |
| 21 | capturedBody = buf.Bytes() |
| 22 | writeJSON(w, status, body) |
| 23 | })) |
| 24 | t.Cleanup(srv.Close) |
| 25 | return srv, &captured, &capturedBody |
| 26 | } |
| 27 | |
| 28 | func postSpaces(t *testing.T, ts *httptest.Server, token string) *http.Response { |
| 29 | t.Helper() |
| 30 | req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", nil) |
| 31 | if token != "" { |
| 32 | req.Header.Set("Authorization", "Bearer "+token) |
| 33 | } |
| 34 | resp, err := http.DefaultClient.Do(req) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | t.Cleanup(func() { resp.Body.Close() }) |
| 39 | return resp |
| 40 | } |
| 41 | |
| 42 | // seedMember is a seedUser who already has a saved space (a backup blob) — the |
| 43 | // state the create gate requires, and the realistic state of any signed-in user. |
| 44 | func seedMember(t *testing.T, s *Server) (userID, token string) { |
| 45 | t.Helper() |
| 46 | userID, token = seedUser(t, s) |
| 47 | if _, _, err := s.store.putBlob(userID, []byte("vault"), "0"); err != nil { |
| 48 | t.Fatalf("putBlob: %v", err) |
| 49 | } |
| 50 | return userID, token |
| 51 | } |
| 52 | |
| 53 | func TestFeaturesReflectsProvisioner(t *testing.T) { |
| 54 | ts, s := newTestServer(t) |
| 55 | |
| 56 | // Unconfigured: spaces off (and the endpoint needs no auth). |
| 57 | resp, err := http.Get(ts.URL + "/api/features") |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | defer resp.Body.Close() |
| 62 | var f map[string]any |
| 63 | json.NewDecoder(resp.Body).Decode(&f) |
| 64 | if f["spaces"] != false { |
| 65 | t.Errorf("unconfigured: spaces = %v, want false", f["spaces"]) |
| 66 | } |
| 67 | |
| 68 | // Configured: spaces on. |
| 69 | s.cloud = newCloudProvisioner("https://cloud.example.com", "tok") |
| 70 | resp2, err := http.Get(ts.URL + "/api/features") |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | defer resp2.Body.Close() |
| 75 | var f2 map[string]any |
| 76 | json.NewDecoder(resp2.Body).Decode(&f2) |
| 77 | if f2["spaces"] != true { |
| 78 | t.Errorf("configured: spaces = %v, want true", f2["spaces"]) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // The doors block tells native clients which web-flow doors are lit — and |
| 83 | // carries GitHub's per-instance client id, the one value they can't compile |
| 84 | // in. Dark doors read false / absent; never an error. |
| 85 | func TestFeaturesAdvertisesSigninDoors(t *testing.T) { |
| 86 | ts, s := newTestServer(t) |
| 87 | |
| 88 | get := func() map[string]any { |
| 89 | t.Helper() |
| 90 | resp, err := http.Get(ts.URL + "/api/features") |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | defer resp.Body.Close() |
| 95 | var f struct { |
| 96 | Doors map[string]any `json:"doors"` |
| 97 | } |
| 98 | json.NewDecoder(resp.Body).Decode(&f) |
| 99 | if f.Doors == nil { |
| 100 | t.Fatal("features: no doors block") |
| 101 | } |
| 102 | return f.Doors |
| 103 | } |
| 104 | |
| 105 | dark := get() |
| 106 | if dark["google"] != false || dark["microsoft"] != false { |
| 107 | t.Errorf("dark: google=%v microsoft=%v, want false/false", dark["google"], dark["microsoft"]) |
| 108 | } |
| 109 | if _, ok := dark["github_client_id"]; ok { |
| 110 | t.Errorf("dark: github_client_id present, want absent") |
| 111 | } |
| 112 | |
| 113 | s.oidc = map[string]*oidcProvider{ |
| 114 | "google": newOIDCProvider("google", []string{"cid"}, []byte("pepper")), |
| 115 | } |
| 116 | s.github = &githubDoor{clientID: "Ov23liTESTID", secret: "sec", pepper: []byte("p")} |
| 117 | lit := get() |
| 118 | if lit["google"] != true || lit["microsoft"] != false { |
| 119 | t.Errorf("lit: google=%v microsoft=%v, want true/false", lit["google"], lit["microsoft"]) |
| 120 | } |
| 121 | if lit["github_client_id"] != "Ov23liTESTID" { |
| 122 | t.Errorf("lit: github_client_id = %v, want Ov23liTESTID", lit["github_client_id"]) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestCreateSpaceRequiresAuth(t *testing.T) { |
| 127 | ts, _ := newTestServer(t) |
| 128 | if resp := postSpaces(t, ts, ""); resp.StatusCode != http.StatusUnauthorized { |
| 129 | t.Errorf("no token: status = %d, want 401", resp.StatusCode) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestCreateSpaceUnconfigured(t *testing.T) { |
| 134 | ts, s := newTestServer(t) |
| 135 | _, token := seedUser(t, s) |
| 136 | |
| 137 | resp := postSpaces(t, ts, token) |
| 138 | if resp.StatusCode != http.StatusNotImplemented { |
| 139 | t.Fatalf("status = %d, want 501", resp.StatusCode) |
| 140 | } |
| 141 | var m map[string]any |
| 142 | json.NewDecoder(resp.Body).Decode(&m) |
| 143 | if m["error"] != "unavailable" { |
| 144 | t.Errorf("error = %v, want unavailable", m["error"]) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestCreateSpaceRelaysSuccess(t *testing.T) { |
| 149 | ts, s := newTestServer(t) |
| 150 | uid, token := seedMember(t, s) |
| 151 | |
| 152 | cloud, captured, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{ |
| 153 | "host": "amber-fox-42.example.com", "url": "https://amber-fox-42.example.com", |
| 154 | "setup_link": "https://amber-fox-42.example.com/join/setup#S", |
| 155 | }) |
| 156 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 157 | |
| 158 | resp := postSpaces(t, ts, token) |
| 159 | if resp.StatusCode != http.StatusCreated { |
| 160 | t.Fatalf("status = %d, want 201", resp.StatusCode) |
| 161 | } |
| 162 | var m map[string]any |
| 163 | json.NewDecoder(resp.Body).Decode(&m) |
| 164 | if m["host"] != "amber-fox-42.example.com" || m["setup_link"] != "https://amber-fox-42.example.com/join/setup#S" { |
| 165 | t.Errorf("relayed body = %v", m) |
| 166 | } |
| 167 | |
| 168 | // Home authenticated with its caller token and forwarded the user id (and |
| 169 | // nothing else) as the requester. |
| 170 | if got := captured.Header.Get("Authorization"); got != "Bearer caller-token" { |
| 171 | t.Errorf("cloud saw Authorization %q", got) |
| 172 | } |
| 173 | var sent map[string]string |
| 174 | json.Unmarshal(*capturedBody, &sent) |
| 175 | if sent["requester"] != uid || len(sent) != 1 { |
| 176 | t.Errorf("cloud saw body %v, want just requester=%s", sent, uid) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestCreateSpaceRequiresExistingSpace(t *testing.T) { |
| 181 | ts, s := newTestServer(t) |
| 182 | uid, token := seedUser(t, s) // a signed-in user with NO saved space (no blob) |
| 183 | |
| 184 | cloud, captured, _ := fakeCloud(t, http.StatusCreated, map[string]any{ |
| 185 | "host": "h.example.com", "url": "https://h.example.com", |
| 186 | "setup_link": "https://h.example.com/join/setup#S", |
| 187 | }) |
| 188 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 189 | |
| 190 | // No space yet → gated with 403 not_eligible, and the provisioner is never called. |
| 191 | resp := postSpaces(t, ts, token) |
| 192 | if resp.StatusCode != http.StatusForbidden { |
| 193 | t.Fatalf("no-space create = %d, want 403", resp.StatusCode) |
| 194 | } |
| 195 | var m map[string]any |
| 196 | json.NewDecoder(resp.Body).Decode(&m) |
| 197 | if m["error"] != "not_eligible" { |
| 198 | t.Errorf("error = %v, want not_eligible", m["error"]) |
| 199 | } |
| 200 | if captured.Method != "" { |
| 201 | t.Error("provisioner must not be called when the caller is ineligible") |
| 202 | } |
| 203 | |
| 204 | // Once they have a saved space (a blob), creation goes through. |
| 205 | if _, _, err := s.store.putBlob(uid, []byte("vault"), "0"); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if resp2 := postSpaces(t, ts, token); resp2.StatusCode != http.StatusCreated { |
| 209 | t.Fatalf("with-space create = %d, want 201", resp2.StatusCode) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestCreateSpaceRelaysQuotaAndCapacity(t *testing.T) { |
| 214 | ts, s := newTestServer(t) |
| 215 | _, token := seedMember(t, s) |
| 216 | |
| 217 | quota, _, _ := fakeCloud(t, http.StatusConflict, map[string]any{ |
| 218 | "error": "quota_exceeded", "message": "this account already has 3 spaces (the limit)", |
| 219 | }) |
| 220 | s.cloud = newCloudProvisioner(quota.URL, "caller-token") |
| 221 | resp := postSpaces(t, ts, token) |
| 222 | if resp.StatusCode != http.StatusConflict { |
| 223 | t.Fatalf("quota: status = %d, want 409", resp.StatusCode) |
| 224 | } |
| 225 | var m map[string]any |
| 226 | json.NewDecoder(resp.Body).Decode(&m) |
| 227 | if m["error"] != "quota_exceeded" { |
| 228 | t.Errorf("quota: error = %v", m["error"]) |
| 229 | } |
| 230 | |
| 231 | full, _, _ := fakeCloud(t, http.StatusServiceUnavailable, map[string]any{ |
| 232 | "error": "at_capacity", "message": "We can't create a new Eleven space right now — please try again later.", |
| 233 | }) |
| 234 | s.cloud = newCloudProvisioner(full.URL, "caller-token") |
| 235 | resp = postSpaces(t, ts, token) |
| 236 | if resp.StatusCode != http.StatusServiceUnavailable { |
| 237 | t.Fatalf("capacity: status = %d, want 503", resp.StatusCode) |
| 238 | } |
| 239 | var m2 map[string]any |
| 240 | json.NewDecoder(resp.Body).Decode(&m2) |
| 241 | if m2["error"] != "at_capacity" || m2["message"] == "" { |
| 242 | t.Errorf("capacity: body = %v", m2) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestCreateSpaceFlattensUnexpectedStatus(t *testing.T) { |
| 247 | ts, s := newTestServer(t) |
| 248 | _, token := seedMember(t, s) |
| 249 | |
| 250 | weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"}) |
| 251 | s.cloud = newCloudProvisioner(weird.URL, "caller-token") |
| 252 | |
| 253 | resp := postSpaces(t, ts, token) |
| 254 | if resp.StatusCode != http.StatusBadGateway { |
| 255 | t.Fatalf("status = %d, want 502", resp.StatusCode) |
| 256 | } |
| 257 | var m map[string]any |
| 258 | json.NewDecoder(resp.Body).Decode(&m) |
| 259 | if m["error"] != "create_failed" { |
| 260 | t.Errorf("error = %v, want create_failed", m["error"]) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestCheckoutSpaceProxies(t *testing.T) { |
| 265 | ts, s := newTestServer(t) |
| 266 | uid, token := seedUser(t, s) |
| 267 | |
| 268 | post := func(tok string) *http.Response { |
| 269 | req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", |
| 270 | strings.NewReader(`{"tier":"friends","band":"reduced"}`)) |
| 271 | if tok != "" { |
| 272 | req.Header.Set("Authorization", "Bearer "+tok) |
| 273 | } |
| 274 | resp, err := http.DefaultClient.Do(req) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | t.Cleanup(func() { resp.Body.Close() }) |
| 279 | return resp |
| 280 | } |
| 281 | |
| 282 | // No auth → 401; no provisioner → 501. |
| 283 | if r := post(""); r.StatusCode != http.StatusUnauthorized { |
| 284 | t.Fatalf("no auth: %d, want 401", r.StatusCode) |
| 285 | } |
| 286 | if r := post(token); r.StatusCode != http.StatusNotImplemented { |
| 287 | t.Fatalf("unconfigured: %d, want 501", r.StatusCode) |
| 288 | } |
| 289 | |
| 290 | cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{ |
| 291 | "url": "https://checkout.stripe.com/c/pay/cs_x", |
| 292 | }) |
| 293 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 294 | |
| 295 | resp := post(token) |
| 296 | if resp.StatusCode != http.StatusOK { |
| 297 | t.Fatalf("status = %d, want 200", resp.StatusCode) |
| 298 | } |
| 299 | var m map[string]any |
| 300 | json.NewDecoder(resp.Body).Decode(&m) |
| 301 | if !strings.Contains(m["url"].(string), "checkout.stripe.com") { |
| 302 | t.Errorf("relayed url = %v", m["url"]) |
| 303 | } |
| 304 | |
| 305 | // Home forwarded the uid as requester and return URLs on its own origin — |
| 306 | // success carries the host back so the page can thank inline. |
| 307 | if captured.URL.Path != "/api/instances/a4f.example.com/checkout" { |
| 308 | t.Errorf("cloud saw path %s", captured.URL.Path) |
| 309 | } |
| 310 | var sent map[string]string |
| 311 | json.Unmarshal(*capturedBody, &sent) |
| 312 | if sent["requester"] != uid { |
| 313 | t.Errorf("requester = %q, want %s", sent["requester"], uid) |
| 314 | } |
| 315 | if sent["tier"] != "friends" { |
| 316 | t.Errorf("tier = %q, want friends", sent["tier"]) |
| 317 | } |
| 318 | // The honor band rides through verbatim — the provisioner's book validates. |
| 319 | if sent["band"] != "reduced" { |
| 320 | t.Errorf("band = %q, want reduced", sent["band"]) |
| 321 | } |
| 322 | // A missing tier is rejected before the provisioner is ever called. |
| 323 | noTier, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", nil) |
| 324 | noTier.Header.Set("Authorization", "Bearer "+token) |
| 325 | ntResp, err := http.DefaultClient.Do(noTier) |
| 326 | if err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | ntResp.Body.Close() |
| 330 | if ntResp.StatusCode != http.StatusBadRequest { |
| 331 | t.Errorf("no tier: %d, want 400", ntResp.StatusCode) |
| 332 | } |
| 333 | if !strings.HasPrefix(sent["success_url"], "http://localhost:1748/?upgraded=") || |
| 334 | !strings.Contains(sent["success_url"], "a4f.example.com") { |
| 335 | t.Errorf("success_url = %q", sent["success_url"]) |
| 336 | } |
| 337 | if sent["cancel_url"] != "http://localhost:1748/" { |
| 338 | t.Errorf("cancel_url = %q", sent["cancel_url"]) |
| 339 | } |
| 340 | |
| 341 | // Stable downstream codes relay as-is (409 already upgraded)… |
| 342 | conflict, _, _ := fakeCloud(t, http.StatusConflict, map[string]any{"error": "already_upgraded"}) |
| 343 | s.cloud = newCloudProvisioner(conflict.URL, "caller-token") |
| 344 | if r := post(token); r.StatusCode != http.StatusConflict { |
| 345 | t.Errorf("conflict relay: %d, want 409", r.StatusCode) |
| 346 | } |
| 347 | // …anything unexpected flattens to 502. |
| 348 | weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"}) |
| 349 | s.cloud = newCloudProvisioner(weird.URL, "caller-token") |
| 350 | if r := post(token); r.StatusCode != http.StatusBadGateway { |
| 351 | t.Errorf("weird relay: %d, want 502", r.StatusCode) |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | func TestDeleteSpaceRequiresFreshCeremony(t *testing.T) { |
| 356 | ts, s := newTestServer(t) |
| 357 | uid, linkToken := seedUser(t, s) |
| 358 | |
| 359 | cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true}) |
| 360 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 361 | |
| 362 | del := func(headers map[string]string) *http.Response { |
| 363 | req, _ := http.NewRequest("DELETE", ts.URL+"/api/spaces/a4f.example.com", nil) |
| 364 | for k, v := range headers { |
| 365 | req.Header.Set(k, v) |
| 366 | } |
| 367 | resp, err := http.DefaultClient.Do(req) |
| 368 | if err != nil { |
| 369 | t.Fatal(err) |
| 370 | } |
| 371 | t.Cleanup(func() { resp.Body.Close() }) |
| 372 | return resp |
| 373 | } |
| 374 | |
| 375 | // No auth → 401. A LINK TOKEN → still 401: destroying a space demands a |
| 376 | // fresh passkey ceremony, never the bearer a device holds all day. |
| 377 | if r := del(nil); r.StatusCode != http.StatusUnauthorized { |
| 378 | t.Fatalf("no auth: %d, want 401", r.StatusCode) |
| 379 | } |
| 380 | if r := del(map[string]string{"Authorization": "Bearer " + linkToken}); r.StatusCode != http.StatusUnauthorized { |
| 381 | t.Fatalf("link token: %d, want 401", r.StatusCode) |
| 382 | } |
| 383 | |
| 384 | // A fresh ceremony proof authorises it, and home forwards the requester uid. |
| 385 | proof := s.keys.mintCeremonyProof(uid) |
| 386 | if r := del(map[string]string{"X-Ceremony-Proof": proof}); r.StatusCode != http.StatusOK { |
| 387 | t.Fatalf("with ceremony: %d, want 200", r.StatusCode) |
| 388 | } |
| 389 | if captured.Method != "DELETE" || !strings.Contains(captured.URL.RawQuery, "requester="+uid) { |
| 390 | t.Errorf("cloud saw %s %s?%s, want DELETE with requester=%s", captured.Method, captured.URL.Path, captured.URL.RawQuery, uid) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | func TestListSpacesProxiesLedger(t *testing.T) { |
| 395 | ts, s := newTestServer(t) |
| 396 | uid, linkToken := seedUser(t, s) |
| 397 | |
| 398 | cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| 399 | "spaces": []map[string]any{{"host": "a4f.example.com", "url": "https://a4f.example.com"}}, |
| 400 | }) |
| 401 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 402 | |
| 403 | req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) |
| 404 | req.Header.Set("Authorization", "Bearer "+linkToken) |
| 405 | resp, err := http.DefaultClient.Do(req) |
| 406 | if err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | defer resp.Body.Close() |
| 410 | if resp.StatusCode != http.StatusOK { |
| 411 | t.Fatalf("status %d, want 200", resp.StatusCode) |
| 412 | } |
| 413 | var m struct{ Spaces []struct{ Host string } } |
| 414 | json.NewDecoder(resp.Body).Decode(&m) |
| 415 | if len(m.Spaces) != 1 || m.Spaces[0].Host != "a4f.example.com" { |
| 416 | t.Errorf("spaces = %+v", m.Spaces) |
| 417 | } |
| 418 | if !strings.Contains(captured.URL.RawQuery, "requester="+uid) { |
| 419 | t.Errorf("cloud saw ?%s, want requester=%s", captured.URL.RawQuery, uid) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // The optional space name rides the create body through to the provisioner; |
| 424 | // an oversized one is refused before any dial. |
| 425 | func TestCreateSpaceForwardsName(t *testing.T) { |
| 426 | ts, s := newTestServer(t) |
| 427 | _, token := seedMember(t, s) |
| 428 | cloud, _, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{ |
| 429 | "host": "h.example.com", "url": "https://h.example.com", |
| 430 | }) |
| 431 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 432 | |
| 433 | post := func(body string) *http.Response { |
| 434 | req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) |
| 435 | req.Header.Set("Authorization", "Bearer "+token) |
| 436 | resp, err := http.DefaultClient.Do(req) |
| 437 | if err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | t.Cleanup(func() { resp.Body.Close() }) |
| 441 | return resp |
| 442 | } |
| 443 | |
| 444 | if r := post(`{"name":" Book Club "}`); r.StatusCode != http.StatusCreated { |
| 445 | t.Fatalf("create with name: %d, want 201", r.StatusCode) |
| 446 | } |
| 447 | var sent map[string]string |
| 448 | json.Unmarshal(*capturedBody, &sent) |
| 449 | if sent["name"] != "Book Club" { |
| 450 | t.Errorf("provisioner saw name %q, want %q", sent["name"], "Book Club") |
| 451 | } |
| 452 | |
| 453 | if r := post(`{"name":"` + strings.Repeat("x", 61) + `"}`); r.StatusCode != http.StatusBadRequest { |
| 454 | t.Errorf("61-char name: %d, want 400", r.StatusCode) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | // The pay-first pair forwards with the session-id template in the redirect and |
| 459 | // relays the provisioner's answers (including the claim's retriable message). |
| 460 | func TestCheckoutCreateAndClaimForward(t *testing.T) { |
| 461 | ts, s := newTestServer(t) |
| 462 | uid, token := seedMember(t, s) |
| 463 | cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{ |
| 464 | "url": "https://checkout.stripe.com/c/pay/cs_1", |
| 465 | }) |
| 466 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 467 | |
| 468 | post := func(path, body string) *http.Response { |
| 469 | req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader(body)) |
| 470 | req.Header.Set("Authorization", "Bearer "+token) |
| 471 | resp, err := http.DefaultClient.Do(req) |
| 472 | if err != nil { |
| 473 | t.Fatal(err) |
| 474 | } |
| 475 | t.Cleanup(func() { resp.Body.Close() }) |
| 476 | return resp |
| 477 | } |
| 478 | |
| 479 | if r := post("/api/spaces/checkout-create", `{"tier":"family","band":"base","name":"Book Club"}`); r.StatusCode != http.StatusOK { |
| 480 | t.Fatalf("checkout-create: %d, want 200", r.StatusCode) |
| 481 | } |
| 482 | if captured.URL.Path != "/api/instances/checkout-create" { |
| 483 | t.Errorf("forwarded to %s", captured.URL.Path) |
| 484 | } |
| 485 | var sent map[string]string |
| 486 | json.Unmarshal(*capturedBody, &sent) |
| 487 | if sent["requester"] != uid || sent["tier"] != "family" || sent["name"] != "Book Club" { |
| 488 | t.Errorf("forwarded body = %v", sent) |
| 489 | } |
| 490 | if !strings.Contains(sent["success_url"], "?paidcreate={CHECKOUT_SESSION_ID}") { |
| 491 | t.Errorf("success_url = %q, want the session-id template", sent["success_url"]) |
| 492 | } |
| 493 | |
| 494 | // Missing tier → 400 before any dial; claim without a session likewise. |
| 495 | if r := post("/api/spaces/checkout-create", `{"name":"X"}`); r.StatusCode != http.StatusBadRequest { |
| 496 | t.Errorf("no tier: %d, want 400", r.StatusCode) |
| 497 | } |
| 498 | if r := post("/api/spaces/claim-create", `{}`); r.StatusCode != http.StatusBadRequest { |
| 499 | t.Errorf("no session: %d, want 400", r.StatusCode) |
| 500 | } |
| 501 | |
| 502 | if r := post("/api/spaces/claim-create", `{"session_id":"cs_1"}`); r.StatusCode != http.StatusOK { |
| 503 | t.Fatalf("claim relay: %d, want 200", r.StatusCode) |
| 504 | } |
| 505 | json.Unmarshal(*capturedBody, &sent) |
| 506 | if sent["requester"] != uid || sent["session_id"] != "cs_1" { |
| 507 | t.Errorf("claim body = %v", sent) |
| 508 | } |
| 509 | } |
| 510 | |