| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // joinTestMember creates a user, claims a device token for them, and registers a |
| 11 | // public key so they appear on the roster (joined members only). |
| 12 | func joinTestMember(t *testing.T, srv *Server, inviteID string) (uid, tok string) { |
| 13 | t.Helper() |
| 14 | uid, _ = srv.db.createUser("") |
| 15 | srv.db.createKeyInvite(inviteID, uid, "", testClaimHash, 0) |
| 16 | tok, _, _ = srv.db.claimKeyInvite(inviteID, "", "", testClaimSecret) |
| 17 | srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) |
| 18 | return uid, tok |
| 19 | } |
| 20 | |
| 21 | func TestPersonDescriptionBlob(t *testing.T) { |
| 22 | ts, srv := newTestServer(t) |
| 23 | uid, tok := joinTestMember(t, srv, "desc") |
| 24 | |
| 25 | post := func(body string) *http.Response { |
| 26 | resp, err := http.Post(ts.URL+"/api/description", "application/json", strings.NewReader(body)) |
| 27 | if err != nil { |
| 28 | t.Fatalf("post description: %v", err) |
| 29 | } |
| 30 | return resp |
| 31 | } |
| 32 | |
| 33 | // Set: the roster carries the opaque blob. |
| 34 | if resp := post(`{"token":"` + tok + `","description_blob":"enc-about"}`); resp.StatusCode != 200 { |
| 35 | t.Fatalf("set: want 200, got %d", resp.StatusCode) |
| 36 | } |
| 37 | roster, _ := srv.db.roster() |
| 38 | var e *RosterEntry |
| 39 | for i := range roster { |
| 40 | if roster[i].UserID == uid { |
| 41 | e = &roster[i] |
| 42 | } |
| 43 | } |
| 44 | if e == nil || e.DescriptionBlob != "enc-about" { |
| 45 | t.Fatalf("roster entry after set: %+v", e) |
| 46 | } |
| 47 | |
| 48 | // Clear: "" empties it again. |
| 49 | if resp := post(`{"token":"` + tok + `","description_blob":""}`); resp.StatusCode != 200 { |
| 50 | t.Fatalf("clear: want 200, got %d", resp.StatusCode) |
| 51 | } |
| 52 | roster, _ = srv.db.roster() |
| 53 | for i := range roster { |
| 54 | if roster[i].UserID == uid && roster[i].DescriptionBlob != "" { |
| 55 | t.Fatalf("clear left residue: %+v", roster[i]) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // Oversized blobs and non-members are rejected. |
| 60 | if resp := post(`{"token":"` + tok + `","description_blob":"` + strings.Repeat("x", 5000) + `"}`); resp.StatusCode != 400 { |
| 61 | t.Fatalf("oversize: want 400, got %d", resp.StatusCode) |
| 62 | } |
| 63 | if resp := post(`{"token":"nope","description_blob":"enc"}`); resp.StatusCode != 403 { |
| 64 | t.Fatalf("bad token: want 403, got %d", resp.StatusCode) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestGroupDescription(t *testing.T) { |
| 69 | ts, srv := newTestServer(t) |
| 70 | _, memberTok := joinTestMember(t, srv, "gd-member") |
| 71 | adminUID, adminTok := joinTestMember(t, srv, "gd-admin") |
| 72 | srv.db.sql.Exec("UPDATE users SET role = 'admin' WHERE id = ?", adminUID) |
| 73 | |
| 74 | post := func(bearer, body string) *http.Response { |
| 75 | req, _ := http.NewRequest("POST", ts.URL+"/api/group-description", strings.NewReader(body)) |
| 76 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 77 | req.Header.Set("Content-Type", "application/json") |
| 78 | resp, err := http.DefaultClient.Do(req) |
| 79 | if err != nil { |
| 80 | t.Fatalf("post group description: %v", err) |
| 81 | } |
| 82 | return resp |
| 83 | } |
| 84 | getJSON := func(url string) map[string]any { |
| 85 | resp, err := http.Get(url) |
| 86 | if err != nil { |
| 87 | t.Fatalf("get %s: %v", url, err) |
| 88 | } |
| 89 | var out map[string]any |
| 90 | json.NewDecoder(resp.Body).Decode(&out) |
| 91 | return out |
| 92 | } |
| 93 | |
| 94 | // An ordinary member can't set it; a role-admin's own device token can. |
| 95 | if resp := post(memberTok, `{"description":"We talk about cheese."}`); resp.StatusCode != 401 { |
| 96 | t.Fatalf("member set: want 401, got %d", resp.StatusCode) |
| 97 | } |
| 98 | if resp := post(adminTok, `{"description":" We talk about cheese. "}`); resp.StatusCode != 200 { |
| 99 | t.Fatalf("role-admin set: want 200, got %d", resp.StatusCode) |
| 100 | } |
| 101 | |
| 102 | // Members read it (trimmed) from /api/group; the unauthenticated onboarding |
| 103 | // read never includes it — invited people get it via the invite lookup below. |
| 104 | if g := getJSON(ts.URL + "/api/group?token=" + memberTok); g["description"] != "We talk about cheese." { |
| 105 | t.Fatalf("member read: %v", g["description"]) |
| 106 | } |
| 107 | if g := getJSON(ts.URL + "/api/group"); g["description"] != nil { |
| 108 | t.Fatalf("public read leaked the description: %v", g["description"]) |
| 109 | } |
| 110 | |
| 111 | // A valid (unclaimed) invite serves it, so the join card can greet with it; |
| 112 | // a bogus invite id gets nothing. |
| 113 | pendingUID, _ := srv.db.createUser("") |
| 114 | srv.db.createKeyInvite("gd-pending", pendingUID, "", testClaimHash, 0) |
| 115 | if ki := getJSON(ts.URL + "/api/key-invite/gd-pending"); ki["ok"] != true || ki["description"] != "We talk about cheese." { |
| 116 | t.Fatalf("invite read: %v", ki) |
| 117 | } |
| 118 | if ki := getJSON(ts.URL + "/api/key-invite/no-such-invite"); ki["description"] != nil { |
| 119 | t.Fatalf("bogus invite got the description: %v", ki) |
| 120 | } |
| 121 | |
| 122 | // The break-glass admin bearer token also passes, and "" clears it. |
| 123 | if resp := adminPost(t, ts.URL+"/api/group-description", `{"description":""}`); resp.StatusCode != 200 { |
| 124 | t.Fatalf("admin clear: want 200, got %d", resp.StatusCode) |
| 125 | } |
| 126 | if g := getJSON(ts.URL + "/api/group?token=" + memberTok); g["description"] != "" { |
| 127 | t.Fatalf("clear left residue: %v", g["description"]) |
| 128 | } |
| 129 | |
| 130 | // Oversized descriptions are rejected. |
| 131 | if resp := post(adminTok, `{"description":"`+strings.Repeat("x", 1500)+`"}`); resp.StatusCode != 400 { |
| 132 | t.Fatalf("oversize: want 400, got %d", resp.StatusCode) |
| 133 | } |
| 134 | } |
| 135 | |