package main import ( "encoding/json" "net/http" "strings" "testing" ) func TestUserInviteSettingDefaultsOff(t *testing.T) { db := newTestDB(t) if db.usersCanInvite() { t.Fatal("user invites should default off (safest posture)") } if err := db.setUsersCanInvite(true); err != nil { t.Fatalf("setUsersCanInvite: %v", err) } if !db.usersCanInvite() { t.Fatal("expected user invites on after enabling") } } // connections is the per-member roster: self + anyone sharing a thread, never the // whole instance. func TestConnectionsRoster(t *testing.T) { db := newTestDB(t) mk := func(name string) string { id, err := db.createUser(name) if err != nil { t.Fatalf("createUser %s: %v", name, err) } if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", name, err) } return id } paul := mk("Paul") wife := mk("Wife") stranger := mk("Stranger") // shares no thread with Paul if _, err := db.createThread("dm", "", []ThreadMemberKey{ {UserID: paul, Wrapped: "wp"}, {UserID: wife, Wrapped: "ww"}, }); err != nil { t.Fatalf("createThread: %v", err) } conns, err := db.connections(paul) if err != nil { t.Fatalf("connections: %v", err) } got := map[string]bool{} for _, e := range conns { got[e.UserID] = true } if !got[paul] || !got[wife] { t.Fatalf("connections should include self + Wife: %v", got) } if got[stranger] { t.Fatal("connections must NOT include a stranger with no shared thread") } if len(conns) != 2 { t.Fatalf("want 2 connections (self + Wife), got %d", len(conns)) } } // A user invite: a brand-new person is dropped into a pending DM, then joins it. func TestUserInviteFreshJoin(t *testing.T) { db := newTestDB(t) inviter, err := db.createUser("Paul") if err != nil { t.Fatalf("createUser: %v", err) } if err := db.setUserPublicKey(inviter, "pub-paul", ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot-wrapped", "self-wrapped", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } // Pending DM: inviter is keyed; invitee is a keyless member (no key until join). if !db.threadMember(threadID, inviter) || !db.threadMember(threadID, invitee) { t.Fatal("both should be members of the pending DM") } if w, ok, _ := db.wrappedKeyFor(threadID, inviter); !ok || w != "self-wrapped" { t.Fatalf("inviter key: %q ok=%v", w, ok) } if _, ok, _ := db.wrappedKeyFor(threadID, invitee); ok { t.Fatal("invitee should have no key until they join") } // The keyless invitee is a connection of the inviter (so the pending DM has a // name), but has no public key yet. conns, _ := db.connections(inviter) var pending *RosterEntry for i := range conns { if conns[i].UserID == invitee { pending = &conns[i] } } if pending == nil { t.Fatal("pending invitee should appear as a connection") } if pending.PublicKey != "" || pending.LabelBlob != "Grandma" { t.Fatalf("pending invitee should be keyless with the given (encrypted) label: %+v", pending) } // Fresh join: the placeholder becomes them — token minted, DM key stored. tok, ok, err := db.claimKeyInvite("inv1", "Grandma", "grandma-wrapped", testClaimSecret) if err != nil || !ok || tok == "" { t.Fatalf("claim: ok=%v tok=%q err=%v", ok, tok, err) } if uid, ok := db.deviceTokenUser(tok); !ok || uid != invitee { t.Fatal("the minted token should belong to the invitee") } if w, ok, _ := db.wrappedKeyFor(threadID, invitee); !ok || w != "grandma-wrapped" { t.Fatalf("invitee key after join: %q ok=%v", w, ok) } // DM dedupe holds for the now-joined pair. if got, ok, _ := db.dmThreadFor(inviter, invitee); !ok || got != threadID { t.Fatalf("dmThreadFor after join: %q ok=%v", got, ok) } } // An existing member opening a user invite joins the pending DM as themselves; the // placeholder is swapped out and deleted. func TestUserInviteAdoptExisting(t *testing.T) { db := newTestDB(t) mk := func(name string) string { id, _ := db.createUser(name) db.setUserPublicKey(id, "pub-"+name, "") return id } inviter := mk("Paul") existing := mk("Bob") threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) if err != nil || !ok { t.Fatalf("adopt: ok=%v err=%v", ok, err) } if tid != threadID { t.Fatalf("adopt should reuse the pending thread, got %q want %q", tid, threadID) } if !db.threadMember(threadID, existing) { t.Fatal("existing user should be a member") } if db.threadMember(threadID, placeholder) { t.Fatal("placeholder should be removed from the thread") } if db.userExists(placeholder) { t.Fatal("placeholder user should be deleted") } if w, ok, _ := db.wrappedKeyFor(threadID, existing); !ok || w != "bob-wrapped" { t.Fatalf("existing user key: %q ok=%v", w, ok) } if got, ok, _ := db.dmThreadFor(inviter, existing); !ok || got != threadID { t.Fatalf("dm pair should rekey to (inviter, existing): %q ok=%v", got, ok) } // Single-use: a second adopt fails. if _, ok, _ := db.adoptUserInvite("inv1", existing, "x", testClaimSecret); ok { t.Fatal("invite should be single-use") } } // If the adopting member already shares a DM with the inviter, reuse it and discard // the pending thread. func TestUserInviteAdoptDedupesExistingDM(t *testing.T) { db := newTestDB(t) mk := func(name string) string { id, _ := db.createUser(name) db.setUserPublicKey(id, "pub-"+name, "") return id } inviter := mk("Paul") existing := mk("Bob") priorDM, err := db.createThread("dm", "", []ThreadMemberKey{ {UserID: inviter, Wrapped: "wp"}, {UserID: existing, Wrapped: "wb"}, }) if err != nil { t.Fatalf("createThread: %v", err) } if err := db.recordDMPair(inviter, existing, priorDM); err != nil { t.Fatalf("recordDMPair: %v", err) } pendingThread, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) if err != nil || !ok { t.Fatalf("adopt: ok=%v err=%v", ok, err) } if tid != priorDM { t.Fatalf("adopt should reuse the existing DM, got %q want %q", tid, priorDM) } if db.threadKind(pendingThread) != "" { t.Fatal("pending thread should be deleted") } if db.userExists(placeholder) { t.Fatal("placeholder should be deleted") } } func TestCancelUserInvite(t *testing.T) { db := newTestDB(t) inviter, _ := db.createUser("Paul") db.setUserPublicKey(inviter, "pub-paul", "") threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } // Only the inviter may cancel. other, _ := db.createUser("Nosy") if ok, _ := db.cancelUserInvite(threadID, other); ok { t.Fatal("a non-member must not be able to cancel") } done, err := db.cancelUserInvite(threadID, inviter) if err != nil || !done { t.Fatalf("cancel: done=%v err=%v", done, err) } if db.threadKind(threadID) != "" { t.Fatal("thread should be gone") } if db.userExists(placeholder) { t.Fatal("placeholder user should be gone") } if _, found, _ := db.getKeyInvite("inv1"); found { t.Fatal("invite should be gone") } } // Security: once a user-invite's invitee has joined, a second still-unused invite // for them (e.g. a re-send whose earlier link was already used) must NOT be claimable // — otherwise it would mint a token as them and overwrite their DM key. func TestUserInviteClaimBlockedAfterJoin(t *testing.T) { db := newTestDB(t) inviter, _ := db.createUser("Paul") db.setUserPublicKey(inviter, "pub-paul", "") threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } // A second, still-unused invite pointing at the same pending DM (a re-send). if err := db.createKeyInviteWithThread("invB", invitee, "boot2", threadID, testClaimHash, 0); err != nil { t.Fatalf("createKeyInviteWithThread: %v", err) } // Invitee joins via invite A. if _, ok, err := db.claimKeyInvite("invA", "Grandma", "grandma-key", testClaimSecret); err != nil || !ok { t.Fatalf("claim A: ok=%v err=%v", ok, err) } // Invite B must now be dead — the invitee already has a device token. if tok, ok, _ := db.claimKeyInvite("invB", "Attacker", "attacker-key", testClaimSecret); ok || tok != "" { t.Fatal("a second user-invite must not be claimable after the invitee joined") } } // Removing the inviter of a still-pending DM must tear the whole thing down (thread + // keyless placeholder + invite), so the invitee can't join a dead one-person chat. func TestRemoveUserTearsDownPendingInvite(t *testing.T) { db := newTestDB(t) inviter, _ := db.createUser("Paul") db.setUserPublicKey(inviter, "pub-paul", "") threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) if err != nil { t.Fatalf("createUserInvite: %v", err) } if err := db.removeUser(inviter); err != nil { t.Fatalf("removeUser: %v", err) } if db.threadKind(threadID) != "" { t.Fatal("pending DM should be torn down") } if db.userExists(invitee) { t.Fatal("keyless placeholder should be deleted") } if _, found, _ := db.getKeyInvite("inv1"); found { t.Fatal("the pending invite should be gone") } if db.userExists(inviter) { t.Fatal("the removed user should be gone") } } // The /api/invites endpoint is gated by the admin setting, and the roster returned // to a member is their connections (self + the keyless invitee they just created). func TestUserInviteHTTPGateAndRoster(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("Paul") srv.db.createKeyInvite("inv-paul", uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) srv.db.setUserPublicKey(uid, "pub-paul", "") body := func(id string) string { return `{"token":"` + tok + `","label_blob":"Grandma","id":"` + id + `","wrapped":"boot","self_wrapped":"self","claim_hash":"` + testClaimHash + `"}` } // Setting off (default) → forbidden. resp, _ := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv1"))) if resp.StatusCode != http.StatusForbidden { t.Fatalf("invite while off: want 403, got %d", resp.StatusCode) } // Turn it on (admin), then invite succeeds. if r := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); r.StatusCode != http.StatusOK { t.Fatalf("enable setting: got %d", r.StatusCode) } resp, _ = http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv2"))) if resp.StatusCode != http.StatusOK { t.Fatalf("invite while on: want 200, got %d", resp.StatusCode) } var out struct { ThreadID string `json:"thread_id"` } json.NewDecoder(resp.Body).Decode(&out) if out.ThreadID == "" { t.Fatal("expected a thread_id from the invite") } // Paul's roster is his connections: himself + the keyless pending invitee. rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) var rout struct { Roster []RosterEntry `json:"roster"` } json.NewDecoder(rr.Body).Decode(&rout) var self, pending bool for _, e := range rout.Roster { if e.UserID == uid { self = true } if e.LabelBlob == "Grandma" && e.PublicKey == "" { pending = true } } if !self || !pending { t.Fatalf("roster should include self + keyless invitee: %+v", rout.Roster) } }