| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | func 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. |
| 72 | func 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. |
| 123 | func 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. |
| 173 | func 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. |
| 220 | func 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. |
| 268 | func 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. |
| 296 | func 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. |
| 335 | func 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 | |