eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
13func 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
28func 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.
44func 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
53func 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.
85func 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
126func 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
133func 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
148func 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
180func 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
213func 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
246func 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
264func 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
355func 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
394func 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.
425func 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).
460func 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