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 "context"
5 "encoding/json"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/coder/websocket"
12)
13
14// A setup invite (grants_admin) makes its claimer an admin; ordinary invites
15// leave the claimer a member. See docs/conversational-onboarding.md.
16func TestSetupInviteGrantsAdminRole(t *testing.T) {
17 ts, srv := newTestServer(t)
18
19 // Minted the way add-instance does: admin-authed create with grants_admin.
20 r := adminPost(t, ts.URL+"/api/admin/key-invites",
21 `{"id":"setup-inv","claim_hash":"`+testClaimHash+`","grants_admin":true}`)
22 if r.StatusCode != http.StatusOK {
23 t.Fatalf("create setup invite: want 200, got %d", r.StatusCode)
24 }
25 var created struct {
26 UserID string `json:"user_id"`
27 }
28 json.NewDecoder(r.Body).Decode(&created)
29
30 // The join page learns it's a setup invite from the (public) inspect.
31 var info struct {
32 OK bool `json:"ok"`
33 GrantsAdmin bool `json:"grants_admin"`
34 }
35 resp, _ := http.Get(ts.URL + "/api/key-invite/setup-inv")
36 json.NewDecoder(resp.Body).Decode(&info)
37 if !info.OK || !info.GrantsAdmin {
38 t.Fatalf("inspect: want ok+grants_admin, got %+v", info)
39 }
40
41 if _, ok, _ := srv.db.claimKeyInvite("setup-inv", "", "", testClaimSecret); !ok {
42 t.Fatalf("claim failed")
43 }
44 if role := srv.db.userRole(created.UserID); role != "admin" {
45 t.Fatalf("claimer role: want admin, got %q", role)
46 }
47
48 // An ordinary invite must not grant anything.
49 adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"plain-inv","claim_hash":"`+testClaimHash+`"}`)
50 resp, _ = http.Get(ts.URL + "/api/key-invite/plain-inv")
51 info = struct {
52 OK bool `json:"ok"`
53 GrantsAdmin bool `json:"grants_admin"`
54 }{}
55 json.NewDecoder(resp.Body).Decode(&info)
56 if info.GrantsAdmin {
57 t.Fatalf("plain invite must not grant admin")
58 }
59 uid, _ := srv.db.createUser("")
60 srv.db.createKeyInvite("plain-inv2", uid, "", testClaimHash, 0)
61 srv.db.claimKeyInvite("plain-inv2", "", "", testClaimSecret)
62 if role := srv.db.userRole(uid); role != "member" {
63 t.Fatalf("ordinary claimer role: want member, got %q", role)
64 }
65}
66
67// Promoting an EXISTING member to admin (adopting a grants_admin invite on an
68// already-joined account, as opposed to a fresh claim) must tell open tabs
69// right away — otherwise a stale roster elsewhere (e.g. someone mid-way
70// through filing a report, building its admin-encryption list from their own
71// cached roster) silently omits the newly-promoted admin.
72func TestAdminPromotionBroadcastsRoleChanged(t *testing.T) {
73 ts, srv := newTestServer(t)
74
75 danaTok, danaID, _ := joinedMember(t, srv, "Dana")
76 erinTok, _, erinTid := joinedMember(t, srv, "Erin")
77
78 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
79 defer cancel()
80 // Erin's tab is already open, watching, when Dana gets promoted.
81 obs := dialWS(t, ctx, ts.URL, erinTok, erinTid)
82 defer obs.Close(websocket.StatusNormalClosure, "")
83
84 // Minted the way an admin would to promote an existing member.
85 r := adminPost(t, ts.URL+"/api/admin/key-invites",
86 `{"id":"promote-dana","claim_hash":"`+testClaimHash+`","grants_admin":true}`)
87 if r.StatusCode != http.StatusOK {
88 t.Fatalf("create admin invite: want 200, got %d", r.StatusCode)
89 }
90
91 resp, err := http.Post(ts.URL+"/api/key-invite/promote-dana/adopt", "application/json",
92 strings.NewReader(`{"token":"`+danaTok+`","claim_secret":"`+testClaimSecret+`"}`))
93 if err != nil {
94 t.Fatalf("adopt: %v", err)
95 }
96 if resp.StatusCode != http.StatusOK {
97 t.Fatalf("adopt: want 200, got %d", resp.StatusCode)
98 }
99 if role := srv.db.userRole(danaID); role != "admin" {
100 t.Fatalf("role after adopt: want admin, got %q", role)
101 }
102
103 for {
104 _, data, err := obs.Read(ctx)
105 if err != nil {
106 t.Fatalf("read broadcast: %v", err)
107 }
108 var msg struct {
109 Type string `json:"type"`
110 UserID string `json:"user_id"`
111 }
112 if json.Unmarshal(data, &msg) == nil && msg.Type == "role_changed" {
113 if msg.UserID != danaID {
114 t.Fatalf("role_changed user_id: want %q, got %q", danaID, msg.UserID)
115 }
116 return
117 }
118 }
119}
120
121// Admin endpoints accept a member device token when the member's user holds the
122// admin role — and still reject everyone else's.
123func TestRoleBasedAdminAuth(t *testing.T) {
124 ts, srv := newTestServer(t)
125
126 adminUID, _ := srv.db.createUser("")
127 srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0)
128 srv.db.setInviteGrantsAdmin("adm")
129 adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret)
130
131 memberUID, _ := srv.db.createUser("")
132 srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0)
133 memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret)
134
135 get := func(bearer string) int {
136 req, _ := http.NewRequest("GET", ts.URL+"/api/admin/settings", nil)
137 req.Header.Set("Authorization", "Bearer "+bearer)
138 resp, err := http.DefaultClient.Do(req)
139 if err != nil {
140 t.Fatalf("request: %v", err)
141 }
142 resp.Body.Close()
143 return resp.StatusCode
144 }
145 if code := get(adminTok); code != http.StatusOK {
146 t.Fatalf("admin-role device token: want 200, got %d", code)
147 }
148 if code := get(memberTok); code != http.StatusUnauthorized {
149 t.Fatalf("member device token: want 401, got %d", code)
150 }
151 if code := get("admintok"); code != http.StatusOK {
152 t.Fatalf("admin bearer token: want 200, got %d", code)
153 }
154
155 // A role-admin can write settings with their device token too — the setup
156 // conversation's whole write path.
157 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(`{"setup_complete":true}`))
158 req.Header.Set("Authorization", "Bearer "+adminTok)
159 req.Header.Set("Content-Type", "application/json")
160 resp, _ := http.DefaultClient.Do(req)
161 if resp.StatusCode != http.StatusOK {
162 t.Fatalf("role-admin set settings: want 200, got %d", resp.StatusCode)
163 }
164 if !srv.db.setupComplete() {
165 t.Fatalf("setup_complete not persisted")
166 }
167}
168
169// Established instances (any device token at upgrade time) get setup_complete
170// backfilled ONCE, so promoting someone to admin later never triggers first-run
171// onboarding on a live space; a fresh instance stays pending for its first
172// claimer, and a reopen mid-setup doesn't re-run the backfill.
173func TestSetupCompleteBackfill(t *testing.T) {
174 dir := t.TempDir()
175
176 // Fresh DB: no devices → backfill marker recorded, setup stays pending.
177 db, err := openDB(dir + "/fresh.db")
178 if err != nil {
179 t.Fatalf("openDB: %v", err)
180 }
181 if db.setupComplete() {
182 t.Fatal("fresh instance: setup should be pending for the first claimer")
183 }
184 // Claim a device (mid-setup), then simulate a restart: the marker must stop
185 // the check from re-running and wrongly completing an in-progress setup.
186 uid, _ := db.createUser("")
187 db.createKeyInvite("inv", uid, "", testClaimHash, 0)
188 db.claimKeyInvite("inv", "", "", testClaimSecret)
189 db.sql.Close()
190 db, err = openDB(dir + "/fresh.db")
191 if err != nil {
192 t.Fatalf("reopen: %v", err)
193 }
194 if db.setupComplete() {
195 t.Fatal("restart mid-setup must not mark setup complete (backfill runs once)")
196 }
197 db.sql.Close()
198
199 // Established DB shape: a device token exists but the backfill never ran
200 // (simulates a pre-roles instance upgrading). Clearing the marker and
201 // reopening must complete setup.
202 db, err = openDB(dir + "/fresh.db")
203 if err != nil {
204 t.Fatalf("reopen 2: %v", err)
205 }
206 db.sql.Exec("DELETE FROM config WHERE key IN ('setup_backfilled', 'setup_complete')")
207 db.sql.Close()
208 db, err = openDB(dir + "/fresh.db")
209 if err != nil {
210 t.Fatalf("reopen 3: %v", err)
211 }
212 if !db.setupComplete() {
213 t.Fatal("established instance (devices exist, no marker) should backfill setup_complete")
214 }
215}
216
217// LCHAT_SKIP_SETUP seeds a usable test instance: setup marked complete, a
218// group name and member invites defaulted — but only where nothing was set,
219// so restarts don't clobber in-test edits.
220func TestApplySkipSetup(t *testing.T) {
221 db, err := openDB(t.TempDir() + "/skip.db")
222 if err != nil {
223 t.Fatalf("openDB: %v", err)
224 }
225 t.Setenv("LCHAT_SKIP_SETUP", "1")
226 t.Setenv("LCHAT_GROUP_NAME", "Canary Nest")
227 // Boot runs the pair: applySeededName consumes LCHAT_GROUP_NAME (for every
228 // instance, not just skip-setup ones — see TestApplySeededName), then
229 // applySkipSetup backfills the dev defaults around it.
230 if err := applySeededName(db); err != nil {
231 t.Fatalf("applySeededName: %v", err)
232 }
233 if err := applySkipSetup(db); err != nil {
234 t.Fatalf("applySkipSetup: %v", err)
235 }
236 if !db.setupComplete() {
237 t.Fatal("setup should be marked complete")
238 }
239 if got := db.getGroupName(); got != "Canary Nest" {
240 t.Fatalf("group name: got %q", got)
241 }
242 if !db.usersCanInvite() {
243 t.Fatal("member invites should default on")
244 }
245 // A restart must not clobber values changed during the test session.
246 db.setGroupName("Renamed")
247 db.setUsersCanInvite(false)
248 if err := applySkipSetup(db); err != nil {
249 t.Fatalf("re-apply: %v", err)
250 }
251 if got := db.getGroupName(); got != "Renamed" {
252 t.Fatalf("restart clobbered the group name: %q", got)
253 }
254 if db.usersCanInvite() {
255 t.Fatal("restart clobbered the invites toggle")
256 }
257 // Unset → no-op.
258 t.Setenv("LCHAT_SKIP_SETUP", "")
259 db2, _ := openDB(t.TempDir() + "/plain.db")
260 applySkipSetup(db2)
261 if db2.setupComplete() {
262 t.Fatal("without the env var, setup must stay pending")
263 }
264}
265
266// /api/me tells a member their role and whether first-run setup is done — the
267// client-side gate for the setup conversation.
268func TestMeExposesRoleAndSetupState(t *testing.T) {
269 ts, srv := newTestServer(t)
270 uid, _ := srv.db.createUser("")
271 srv.db.createKeyInvite("adm", uid, "", testClaimHash, 0)
272 srv.db.setInviteGrantsAdmin("adm")
273 tok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret)
274
275 me := func() (role string, setup bool) {
276 resp, _ := http.Get(ts.URL + "/api/me?token=" + tok)
277 var body struct {
278 Role string `json:"role"`
279 SetupComplete bool `json:"setup_complete"`
280 }
281 json.NewDecoder(resp.Body).Decode(&body)
282 return body.Role, body.SetupComplete
283 }
284 role, setup := me()
285 if role != "admin" || setup {
286 t.Fatalf("fresh admin: want admin/incomplete, got %q/%v", role, setup)
287 }
288 srv.db.setSetupComplete(true)
289 if _, setup = me(); !setup {
290 t.Fatalf("want setup_complete true after set")
291 }
292}
293
294// A profile photo's alt text rides as an opaque encrypted blob, set with the
295// photo and cleared with it — the server never sees a readable description.
296func TestAvatarAltBlob(t *testing.T) {
297 ts, srv := newTestServer(t)
298 uid, _ := srv.db.createUser("")
299 srv.db.createKeyInvite("av", uid, "", testClaimHash, 0)
300 tok, _, _ := srv.db.claimKeyInvite("av", "", "", testClaimSecret)
301 srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) // roster lists joined (keyed) members only
302
303 body := `{"token":"` + tok + `","blob_id":"blob1","alt_blob":"enc-alt"}`
304 resp, err := http.Post(ts.URL+"/api/avatar", "application/json", strings.NewReader(body))
305 if err != nil || resp.StatusCode != 200 {
306 t.Fatalf("post avatar: %v %d", err, resp.StatusCode)
307 }
308 roster, _ := srv.db.roster()
309 var e *RosterEntry
310 for i := range roster {
311 if roster[i].UserID == uid {
312 e = &roster[i]
313 }
314 }
315 if e == nil || e.Avatar != "blob1" || e.AvatarAltBlob != "enc-alt" {
316 t.Fatalf("roster entry: %+v", e)
317 }
318
319 // Removing the photo removes its description with it.
320 if err := srv.db.setAvatar(uid, "", ""); err != nil {
321 t.Fatalf("clear: %v", err)
322 }
323 roster, _ = srv.db.roster()
324 for i := range roster {
325 if roster[i].UserID == uid && (roster[i].Avatar != "" || roster[i].AvatarAltBlob != "") {
326 t.Fatalf("clear left residue: %+v", roster[i])
327 }
328 }
329}
330
331// /api/me tells an admin whether they hold the ONLY admin key (sole_admin), so
332// the home server's client can refuse to delete the last recovery copy without
333// a teardown — and answers CORS for exactly the home origin, which is the only
334// cross-origin caller that legitimately holds a device token.
335func TestMeSoleAdmin(t *testing.T) {
336 ts, srv := newTestServer(t)
337
338 adminUID, _ := srv.db.createUser("")
339 srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0)
340 srv.db.setInviteGrantsAdmin("adm")
341 adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret)
342
343 memberUID, _ := srv.db.createUser("")
344 srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0)
345 memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret)
346
347 me := func(tok, origin string) (map[string]any, *http.Response) {
348 req, _ := http.NewRequest("GET", ts.URL+"/api/me?token="+tok, nil)
349 if origin != "" {
350 req.Header.Set("Origin", origin)
351 }
352 resp, err := http.DefaultClient.Do(req)
353 if err != nil {
354 t.Fatalf("request: %v", err)
355 }
356 var m map[string]any
357 json.NewDecoder(resp.Body).Decode(&m)
358 resp.Body.Close()
359 return m, resp
360 }
361
362 // One admin on the instance → sole_admin true, and the home origin gets CORS.
363 m, resp := me(adminTok, defaultHomeOrigin)
364 if m["role"] != "admin" || m["sole_admin"] != true {
365 t.Fatalf("single admin: want role=admin sole_admin=true, got %+v", m)
366 }
367 if got := resp.Header.Get("Access-Control-Allow-Origin"); got != defaultHomeOrigin {
368 t.Fatalf("home-origin CORS: want %q, got %q", defaultHomeOrigin, got)
369 }
370
371 // Any other origin gets no CORS grant.
372 _, resp = me(adminTok, "https://evil.example")
373 if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
374 t.Fatalf("foreign origin must get no CORS grant, got %q", got)
375 }
376
377 // Members never see the admin-headcount fact.
378 m, _ = me(memberTok, "")
379 if _, present := m["sole_admin"]; present || m["role"] != "member" {
380 t.Fatalf("member: want role=member and no sole_admin, got %+v", m)
381 }
382
383 // A second admin appears → no longer sole.
384 srv.db.setUserRole(memberUID, "admin")
385 m, _ = me(adminTok, "")
386 if m["sole_admin"] != false {
387 t.Fatalf("two admins: want sole_admin=false, got %+v", m)
388 }
389}
390