package main import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/coder/websocket" ) // A setup invite (grants_admin) makes its claimer an admin; ordinary invites // leave the claimer a member. See docs/conversational-onboarding.md. func TestSetupInviteGrantsAdminRole(t *testing.T) { ts, srv := newTestServer(t) // Minted the way add-instance does: admin-authed create with grants_admin. r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"setup-inv","claim_hash":"`+testClaimHash+`","grants_admin":true}`) if r.StatusCode != http.StatusOK { t.Fatalf("create setup invite: want 200, got %d", r.StatusCode) } var created struct { UserID string `json:"user_id"` } json.NewDecoder(r.Body).Decode(&created) // The join page learns it's a setup invite from the (public) inspect. var info struct { OK bool `json:"ok"` GrantsAdmin bool `json:"grants_admin"` } resp, _ := http.Get(ts.URL + "/api/key-invite/setup-inv") json.NewDecoder(resp.Body).Decode(&info) if !info.OK || !info.GrantsAdmin { t.Fatalf("inspect: want ok+grants_admin, got %+v", info) } if _, ok, _ := srv.db.claimKeyInvite("setup-inv", "", "", testClaimSecret); !ok { t.Fatalf("claim failed") } if role := srv.db.userRole(created.UserID); role != "admin" { t.Fatalf("claimer role: want admin, got %q", role) } // An ordinary invite must not grant anything. adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"plain-inv","claim_hash":"`+testClaimHash+`"}`) resp, _ = http.Get(ts.URL + "/api/key-invite/plain-inv") info = struct { OK bool `json:"ok"` GrantsAdmin bool `json:"grants_admin"` }{} json.NewDecoder(resp.Body).Decode(&info) if info.GrantsAdmin { t.Fatalf("plain invite must not grant admin") } uid, _ := srv.db.createUser("") srv.db.createKeyInvite("plain-inv2", uid, "", testClaimHash, 0) srv.db.claimKeyInvite("plain-inv2", "", "", testClaimSecret) if role := srv.db.userRole(uid); role != "member" { t.Fatalf("ordinary claimer role: want member, got %q", role) } } // Promoting an EXISTING member to admin (adopting a grants_admin invite on an // already-joined account, as opposed to a fresh claim) must tell open tabs // right away — otherwise a stale roster elsewhere (e.g. someone mid-way // through filing a report, building its admin-encryption list from their own // cached roster) silently omits the newly-promoted admin. func TestAdminPromotionBroadcastsRoleChanged(t *testing.T) { ts, srv := newTestServer(t) danaTok, danaID, _ := joinedMember(t, srv, "Dana") erinTok, _, erinTid := joinedMember(t, srv, "Erin") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Erin's tab is already open, watching, when Dana gets promoted. obs := dialWS(t, ctx, ts.URL, erinTok, erinTid) defer obs.Close(websocket.StatusNormalClosure, "") // Minted the way an admin would to promote an existing member. r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"promote-dana","claim_hash":"`+testClaimHash+`","grants_admin":true}`) if r.StatusCode != http.StatusOK { t.Fatalf("create admin invite: want 200, got %d", r.StatusCode) } resp, err := http.Post(ts.URL+"/api/key-invite/promote-dana/adopt", "application/json", strings.NewReader(`{"token":"`+danaTok+`","claim_secret":"`+testClaimSecret+`"}`)) if err != nil { t.Fatalf("adopt: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("adopt: want 200, got %d", resp.StatusCode) } if role := srv.db.userRole(danaID); role != "admin" { t.Fatalf("role after adopt: want admin, got %q", role) } for { _, data, err := obs.Read(ctx) if err != nil { t.Fatalf("read broadcast: %v", err) } var msg struct { Type string `json:"type"` UserID string `json:"user_id"` } if json.Unmarshal(data, &msg) == nil && msg.Type == "role_changed" { if msg.UserID != danaID { t.Fatalf("role_changed user_id: want %q, got %q", danaID, msg.UserID) } return } } } // Admin endpoints accept a member device token when the member's user holds the // admin role — and still reject everyone else's. func TestRoleBasedAdminAuth(t *testing.T) { ts, srv := newTestServer(t) adminUID, _ := srv.db.createUser("") srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0) srv.db.setInviteGrantsAdmin("adm") adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret) memberUID, _ := srv.db.createUser("") srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0) memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret) get := func(bearer string) int { req, _ := http.NewRequest("GET", ts.URL+"/api/admin/settings", nil) req.Header.Set("Authorization", "Bearer "+bearer) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } resp.Body.Close() return resp.StatusCode } if code := get(adminTok); code != http.StatusOK { t.Fatalf("admin-role device token: want 200, got %d", code) } if code := get(memberTok); code != http.StatusUnauthorized { t.Fatalf("member device token: want 401, got %d", code) } if code := get("admintok"); code != http.StatusOK { t.Fatalf("admin bearer token: want 200, got %d", code) } // A role-admin can write settings with their device token too — the setup // conversation's whole write path. req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(`{"setup_complete":true}`)) req.Header.Set("Authorization", "Bearer "+adminTok) req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) if resp.StatusCode != http.StatusOK { t.Fatalf("role-admin set settings: want 200, got %d", resp.StatusCode) } if !srv.db.setupComplete() { t.Fatalf("setup_complete not persisted") } } // Established instances (any device token at upgrade time) get setup_complete // backfilled ONCE, so promoting someone to admin later never triggers first-run // onboarding on a live space; a fresh instance stays pending for its first // claimer, and a reopen mid-setup doesn't re-run the backfill. func TestSetupCompleteBackfill(t *testing.T) { dir := t.TempDir() // Fresh DB: no devices → backfill marker recorded, setup stays pending. db, err := openDB(dir + "/fresh.db") if err != nil { t.Fatalf("openDB: %v", err) } if db.setupComplete() { t.Fatal("fresh instance: setup should be pending for the first claimer") } // Claim a device (mid-setup), then simulate a restart: the marker must stop // the check from re-running and wrongly completing an in-progress setup. uid, _ := db.createUser("") db.createKeyInvite("inv", uid, "", testClaimHash, 0) db.claimKeyInvite("inv", "", "", testClaimSecret) db.sql.Close() db, err = openDB(dir + "/fresh.db") if err != nil { t.Fatalf("reopen: %v", err) } if db.setupComplete() { t.Fatal("restart mid-setup must not mark setup complete (backfill runs once)") } db.sql.Close() // Established DB shape: a device token exists but the backfill never ran // (simulates a pre-roles instance upgrading). Clearing the marker and // reopening must complete setup. db, err = openDB(dir + "/fresh.db") if err != nil { t.Fatalf("reopen 2: %v", err) } db.sql.Exec("DELETE FROM config WHERE key IN ('setup_backfilled', 'setup_complete')") db.sql.Close() db, err = openDB(dir + "/fresh.db") if err != nil { t.Fatalf("reopen 3: %v", err) } if !db.setupComplete() { t.Fatal("established instance (devices exist, no marker) should backfill setup_complete") } } // LCHAT_SKIP_SETUP seeds a usable test instance: setup marked complete, a // group name and member invites defaulted — but only where nothing was set, // so restarts don't clobber in-test edits. func TestApplySkipSetup(t *testing.T) { db, err := openDB(t.TempDir() + "/skip.db") if err != nil { t.Fatalf("openDB: %v", err) } t.Setenv("LCHAT_SKIP_SETUP", "1") t.Setenv("LCHAT_GROUP_NAME", "Canary Nest") // Boot runs the pair: applySeededName consumes LCHAT_GROUP_NAME (for every // instance, not just skip-setup ones — see TestApplySeededName), then // applySkipSetup backfills the dev defaults around it. if err := applySeededName(db); err != nil { t.Fatalf("applySeededName: %v", err) } if err := applySkipSetup(db); err != nil { t.Fatalf("applySkipSetup: %v", err) } if !db.setupComplete() { t.Fatal("setup should be marked complete") } if got := db.getGroupName(); got != "Canary Nest" { t.Fatalf("group name: got %q", got) } if !db.usersCanInvite() { t.Fatal("member invites should default on") } // A restart must not clobber values changed during the test session. db.setGroupName("Renamed") db.setUsersCanInvite(false) if err := applySkipSetup(db); err != nil { t.Fatalf("re-apply: %v", err) } if got := db.getGroupName(); got != "Renamed" { t.Fatalf("restart clobbered the group name: %q", got) } if db.usersCanInvite() { t.Fatal("restart clobbered the invites toggle") } // Unset → no-op. t.Setenv("LCHAT_SKIP_SETUP", "") db2, _ := openDB(t.TempDir() + "/plain.db") applySkipSetup(db2) if db2.setupComplete() { t.Fatal("without the env var, setup must stay pending") } } // /api/me tells a member their role and whether first-run setup is done — the // client-side gate for the setup conversation. func TestMeExposesRoleAndSetupState(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("") srv.db.createKeyInvite("adm", uid, "", testClaimHash, 0) srv.db.setInviteGrantsAdmin("adm") tok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret) me := func() (role string, setup bool) { resp, _ := http.Get(ts.URL + "/api/me?token=" + tok) var body struct { Role string `json:"role"` SetupComplete bool `json:"setup_complete"` } json.NewDecoder(resp.Body).Decode(&body) return body.Role, body.SetupComplete } role, setup := me() if role != "admin" || setup { t.Fatalf("fresh admin: want admin/incomplete, got %q/%v", role, setup) } srv.db.setSetupComplete(true) if _, setup = me(); !setup { t.Fatalf("want setup_complete true after set") } } // A profile photo's alt text rides as an opaque encrypted blob, set with the // photo and cleared with it — the server never sees a readable description. func TestAvatarAltBlob(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("") srv.db.createKeyInvite("av", uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("av", "", "", testClaimSecret) srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) // roster lists joined (keyed) members only body := `{"token":"` + tok + `","blob_id":"blob1","alt_blob":"enc-alt"}` resp, err := http.Post(ts.URL+"/api/avatar", "application/json", strings.NewReader(body)) if err != nil || resp.StatusCode != 200 { t.Fatalf("post avatar: %v %d", err, resp.StatusCode) } roster, _ := srv.db.roster() var e *RosterEntry for i := range roster { if roster[i].UserID == uid { e = &roster[i] } } if e == nil || e.Avatar != "blob1" || e.AvatarAltBlob != "enc-alt" { t.Fatalf("roster entry: %+v", e) } // Removing the photo removes its description with it. if err := srv.db.setAvatar(uid, "", ""); err != nil { t.Fatalf("clear: %v", err) } roster, _ = srv.db.roster() for i := range roster { if roster[i].UserID == uid && (roster[i].Avatar != "" || roster[i].AvatarAltBlob != "") { t.Fatalf("clear left residue: %+v", roster[i]) } } } // /api/me tells an admin whether they hold the ONLY admin key (sole_admin), so // the home server's client can refuse to delete the last recovery copy without // a teardown — and answers CORS for exactly the home origin, which is the only // cross-origin caller that legitimately holds a device token. func TestMeSoleAdmin(t *testing.T) { ts, srv := newTestServer(t) adminUID, _ := srv.db.createUser("") srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0) srv.db.setInviteGrantsAdmin("adm") adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret) memberUID, _ := srv.db.createUser("") srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0) memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret) me := func(tok, origin string) (map[string]any, *http.Response) { req, _ := http.NewRequest("GET", ts.URL+"/api/me?token="+tok, nil) if origin != "" { req.Header.Set("Origin", origin) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } var m map[string]any json.NewDecoder(resp.Body).Decode(&m) resp.Body.Close() return m, resp } // One admin on the instance → sole_admin true, and the home origin gets CORS. m, resp := me(adminTok, defaultHomeOrigin) if m["role"] != "admin" || m["sole_admin"] != true { t.Fatalf("single admin: want role=admin sole_admin=true, got %+v", m) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != defaultHomeOrigin { t.Fatalf("home-origin CORS: want %q, got %q", defaultHomeOrigin, got) } // Any other origin gets no CORS grant. _, resp = me(adminTok, "https://evil.example") if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("foreign origin must get no CORS grant, got %q", got) } // Members never see the admin-headcount fact. m, _ = me(memberTok, "") if _, present := m["sole_admin"]; present || m["role"] != "member" { t.Fatalf("member: want role=member and no sole_admin, got %+v", m) } // A second admin appears → no longer sole. srv.db.setUserRole(memberUID, "admin") m, _ = me(adminTok, "") if m["sole_admin"] != false { t.Fatalf("two admins: want sole_admin=false, got %+v", m) } }