| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func TestUserInviteSettingDefaultsOff(t *testing.T) { |
| 11 | db := newTestDB(t) |
| 12 | if db.usersCanInvite() { |
| 13 | t.Fatal("user invites should default off (safest posture)") |
| 14 | } |
| 15 | if err := db.setUsersCanInvite(true); err != nil { |
| 16 | t.Fatalf("setUsersCanInvite: %v", err) |
| 17 | } |
| 18 | if !db.usersCanInvite() { |
| 19 | t.Fatal("expected user invites on after enabling") |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // connections is the per-member roster: self + anyone sharing a thread, never the |
| 24 | // whole instance. |
| 25 | func TestConnectionsRoster(t *testing.T) { |
| 26 | db := newTestDB(t) |
| 27 | mk := func(name string) string { |
| 28 | id, err := db.createUser(name) |
| 29 | if err != nil { |
| 30 | t.Fatalf("createUser %s: %v", name, err) |
| 31 | } |
| 32 | if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| 33 | t.Fatalf("setUserPublicKey %s: %v", name, err) |
| 34 | } |
| 35 | return id |
| 36 | } |
| 37 | paul := mk("Paul") |
| 38 | wife := mk("Wife") |
| 39 | stranger := mk("Stranger") // shares no thread with Paul |
| 40 | |
| 41 | if _, err := db.createThread("dm", "", []ThreadMemberKey{ |
| 42 | {UserID: paul, Wrapped: "wp"}, {UserID: wife, Wrapped: "ww"}, |
| 43 | }); err != nil { |
| 44 | t.Fatalf("createThread: %v", err) |
| 45 | } |
| 46 | |
| 47 | conns, err := db.connections(paul) |
| 48 | if err != nil { |
| 49 | t.Fatalf("connections: %v", err) |
| 50 | } |
| 51 | got := map[string]bool{} |
| 52 | for _, e := range conns { |
| 53 | got[e.UserID] = true |
| 54 | } |
| 55 | if !got[paul] || !got[wife] { |
| 56 | t.Fatalf("connections should include self + Wife: %v", got) |
| 57 | } |
| 58 | if got[stranger] { |
| 59 | t.Fatal("connections must NOT include a stranger with no shared thread") |
| 60 | } |
| 61 | if len(conns) != 2 { |
| 62 | t.Fatalf("want 2 connections (self + Wife), got %d", len(conns)) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // A user invite: a brand-new person is dropped into a pending DM, then joins it. |
| 67 | func TestUserInviteFreshJoin(t *testing.T) { |
| 68 | db := newTestDB(t) |
| 69 | inviter, err := db.createUser("Paul") |
| 70 | if err != nil { |
| 71 | t.Fatalf("createUser: %v", err) |
| 72 | } |
| 73 | if err := db.setUserPublicKey(inviter, "pub-paul", ""); err != nil { |
| 74 | t.Fatalf("setUserPublicKey: %v", err) |
| 75 | } |
| 76 | |
| 77 | threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot-wrapped", "self-wrapped", testClaimHash, 0) |
| 78 | if err != nil { |
| 79 | t.Fatalf("createUserInvite: %v", err) |
| 80 | } |
| 81 | |
| 82 | // Pending DM: inviter is keyed; invitee is a keyless member (no key until join). |
| 83 | if !db.threadMember(threadID, inviter) || !db.threadMember(threadID, invitee) { |
| 84 | t.Fatal("both should be members of the pending DM") |
| 85 | } |
| 86 | if w, ok, _ := db.wrappedKeyFor(threadID, inviter); !ok || w != "self-wrapped" { |
| 87 | t.Fatalf("inviter key: %q ok=%v", w, ok) |
| 88 | } |
| 89 | if _, ok, _ := db.wrappedKeyFor(threadID, invitee); ok { |
| 90 | t.Fatal("invitee should have no key until they join") |
| 91 | } |
| 92 | |
| 93 | // The keyless invitee is a connection of the inviter (so the pending DM has a |
| 94 | // name), but has no public key yet. |
| 95 | conns, _ := db.connections(inviter) |
| 96 | var pending *RosterEntry |
| 97 | for i := range conns { |
| 98 | if conns[i].UserID == invitee { |
| 99 | pending = &conns[i] |
| 100 | } |
| 101 | } |
| 102 | if pending == nil { |
| 103 | t.Fatal("pending invitee should appear as a connection") |
| 104 | } |
| 105 | if pending.PublicKey != "" || pending.LabelBlob != "Grandma" { |
| 106 | t.Fatalf("pending invitee should be keyless with the given (encrypted) label: %+v", pending) |
| 107 | } |
| 108 | |
| 109 | // Fresh join: the placeholder becomes them — token minted, DM key stored. |
| 110 | tok, ok, err := db.claimKeyInvite("inv1", "Grandma", "grandma-wrapped", testClaimSecret) |
| 111 | if err != nil || !ok || tok == "" { |
| 112 | t.Fatalf("claim: ok=%v tok=%q err=%v", ok, tok, err) |
| 113 | } |
| 114 | if uid, ok := db.deviceTokenUser(tok); !ok || uid != invitee { |
| 115 | t.Fatal("the minted token should belong to the invitee") |
| 116 | } |
| 117 | if w, ok, _ := db.wrappedKeyFor(threadID, invitee); !ok || w != "grandma-wrapped" { |
| 118 | t.Fatalf("invitee key after join: %q ok=%v", w, ok) |
| 119 | } |
| 120 | // DM dedupe holds for the now-joined pair. |
| 121 | if got, ok, _ := db.dmThreadFor(inviter, invitee); !ok || got != threadID { |
| 122 | t.Fatalf("dmThreadFor after join: %q ok=%v", got, ok) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // An existing member opening a user invite joins the pending DM as themselves; the |
| 127 | // placeholder is swapped out and deleted. |
| 128 | func TestUserInviteAdoptExisting(t *testing.T) { |
| 129 | db := newTestDB(t) |
| 130 | mk := func(name string) string { |
| 131 | id, _ := db.createUser(name) |
| 132 | db.setUserPublicKey(id, "pub-"+name, "") |
| 133 | return id |
| 134 | } |
| 135 | inviter := mk("Paul") |
| 136 | existing := mk("Bob") |
| 137 | |
| 138 | threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) |
| 139 | if err != nil { |
| 140 | t.Fatalf("createUserInvite: %v", err) |
| 141 | } |
| 142 | |
| 143 | tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) |
| 144 | if err != nil || !ok { |
| 145 | t.Fatalf("adopt: ok=%v err=%v", ok, err) |
| 146 | } |
| 147 | if tid != threadID { |
| 148 | t.Fatalf("adopt should reuse the pending thread, got %q want %q", tid, threadID) |
| 149 | } |
| 150 | if !db.threadMember(threadID, existing) { |
| 151 | t.Fatal("existing user should be a member") |
| 152 | } |
| 153 | if db.threadMember(threadID, placeholder) { |
| 154 | t.Fatal("placeholder should be removed from the thread") |
| 155 | } |
| 156 | if db.userExists(placeholder) { |
| 157 | t.Fatal("placeholder user should be deleted") |
| 158 | } |
| 159 | if w, ok, _ := db.wrappedKeyFor(threadID, existing); !ok || w != "bob-wrapped" { |
| 160 | t.Fatalf("existing user key: %q ok=%v", w, ok) |
| 161 | } |
| 162 | if got, ok, _ := db.dmThreadFor(inviter, existing); !ok || got != threadID { |
| 163 | t.Fatalf("dm pair should rekey to (inviter, existing): %q ok=%v", got, ok) |
| 164 | } |
| 165 | // Single-use: a second adopt fails. |
| 166 | if _, ok, _ := db.adoptUserInvite("inv1", existing, "x", testClaimSecret); ok { |
| 167 | t.Fatal("invite should be single-use") |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // If the adopting member already shares a DM with the inviter, reuse it and discard |
| 172 | // the pending thread. |
| 173 | func TestUserInviteAdoptDedupesExistingDM(t *testing.T) { |
| 174 | db := newTestDB(t) |
| 175 | mk := func(name string) string { |
| 176 | id, _ := db.createUser(name) |
| 177 | db.setUserPublicKey(id, "pub-"+name, "") |
| 178 | return id |
| 179 | } |
| 180 | inviter := mk("Paul") |
| 181 | existing := mk("Bob") |
| 182 | priorDM, err := db.createThread("dm", "", []ThreadMemberKey{ |
| 183 | {UserID: inviter, Wrapped: "wp"}, {UserID: existing, Wrapped: "wb"}, |
| 184 | }) |
| 185 | if err != nil { |
| 186 | t.Fatalf("createThread: %v", err) |
| 187 | } |
| 188 | if err := db.recordDMPair(inviter, existing, priorDM); err != nil { |
| 189 | t.Fatalf("recordDMPair: %v", err) |
| 190 | } |
| 191 | |
| 192 | pendingThread, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) |
| 193 | if err != nil { |
| 194 | t.Fatalf("createUserInvite: %v", err) |
| 195 | } |
| 196 | |
| 197 | tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) |
| 198 | if err != nil || !ok { |
| 199 | t.Fatalf("adopt: ok=%v err=%v", ok, err) |
| 200 | } |
| 201 | if tid != priorDM { |
| 202 | t.Fatalf("adopt should reuse the existing DM, got %q want %q", tid, priorDM) |
| 203 | } |
| 204 | if db.threadKind(pendingThread) != "" { |
| 205 | t.Fatal("pending thread should be deleted") |
| 206 | } |
| 207 | if db.userExists(placeholder) { |
| 208 | t.Fatal("placeholder should be deleted") |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestCancelUserInvite(t *testing.T) { |
| 213 | db := newTestDB(t) |
| 214 | inviter, _ := db.createUser("Paul") |
| 215 | db.setUserPublicKey(inviter, "pub-paul", "") |
| 216 | threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| 217 | if err != nil { |
| 218 | t.Fatalf("createUserInvite: %v", err) |
| 219 | } |
| 220 | |
| 221 | // Only the inviter may cancel. |
| 222 | other, _ := db.createUser("Nosy") |
| 223 | if ok, _ := db.cancelUserInvite(threadID, other); ok { |
| 224 | t.Fatal("a non-member must not be able to cancel") |
| 225 | } |
| 226 | done, err := db.cancelUserInvite(threadID, inviter) |
| 227 | if err != nil || !done { |
| 228 | t.Fatalf("cancel: done=%v err=%v", done, err) |
| 229 | } |
| 230 | if db.threadKind(threadID) != "" { |
| 231 | t.Fatal("thread should be gone") |
| 232 | } |
| 233 | if db.userExists(placeholder) { |
| 234 | t.Fatal("placeholder user should be gone") |
| 235 | } |
| 236 | if _, found, _ := db.getKeyInvite("inv1"); found { |
| 237 | t.Fatal("invite should be gone") |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // Security: once a user-invite's invitee has joined, a second still-unused invite |
| 242 | // for them (e.g. a re-send whose earlier link was already used) must NOT be claimable |
| 243 | // — otherwise it would mint a token as them and overwrite their DM key. |
| 244 | func TestUserInviteClaimBlockedAfterJoin(t *testing.T) { |
| 245 | db := newTestDB(t) |
| 246 | inviter, _ := db.createUser("Paul") |
| 247 | db.setUserPublicKey(inviter, "pub-paul", "") |
| 248 | threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", testClaimHash, 0) |
| 249 | if err != nil { |
| 250 | t.Fatalf("createUserInvite: %v", err) |
| 251 | } |
| 252 | // A second, still-unused invite pointing at the same pending DM (a re-send). |
| 253 | if err := db.createKeyInviteWithThread("invB", invitee, "boot2", threadID, testClaimHash, 0); err != nil { |
| 254 | t.Fatalf("createKeyInviteWithThread: %v", err) |
| 255 | } |
| 256 | // Invitee joins via invite A. |
| 257 | if _, ok, err := db.claimKeyInvite("invA", "Grandma", "grandma-key", testClaimSecret); err != nil || !ok { |
| 258 | t.Fatalf("claim A: ok=%v err=%v", ok, err) |
| 259 | } |
| 260 | // Invite B must now be dead — the invitee already has a device token. |
| 261 | if tok, ok, _ := db.claimKeyInvite("invB", "Attacker", "attacker-key", testClaimSecret); ok || tok != "" { |
| 262 | t.Fatal("a second user-invite must not be claimable after the invitee joined") |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // Removing the inviter of a still-pending DM must tear the whole thing down (thread + |
| 267 | // keyless placeholder + invite), so the invitee can't join a dead one-person chat. |
| 268 | func TestRemoveUserTearsDownPendingInvite(t *testing.T) { |
| 269 | db := newTestDB(t) |
| 270 | inviter, _ := db.createUser("Paul") |
| 271 | db.setUserPublicKey(inviter, "pub-paul", "") |
| 272 | threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| 273 | if err != nil { |
| 274 | t.Fatalf("createUserInvite: %v", err) |
| 275 | } |
| 276 | if err := db.removeUser(inviter); err != nil { |
| 277 | t.Fatalf("removeUser: %v", err) |
| 278 | } |
| 279 | if db.threadKind(threadID) != "" { |
| 280 | t.Fatal("pending DM should be torn down") |
| 281 | } |
| 282 | if db.userExists(invitee) { |
| 283 | t.Fatal("keyless placeholder should be deleted") |
| 284 | } |
| 285 | if _, found, _ := db.getKeyInvite("inv1"); found { |
| 286 | t.Fatal("the pending invite should be gone") |
| 287 | } |
| 288 | if db.userExists(inviter) { |
| 289 | t.Fatal("the removed user should be gone") |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // The /api/invites endpoint is gated by the admin setting, and the roster returned |
| 294 | // to a member is their connections (self + the keyless invitee they just created). |
| 295 | func TestUserInviteHTTPGateAndRoster(t *testing.T) { |
| 296 | ts, srv := newTestServer(t) |
| 297 | uid, _ := srv.db.createUser("Paul") |
| 298 | srv.db.createKeyInvite("inv-paul", uid, "", testClaimHash, 0) |
| 299 | tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| 300 | srv.db.setUserPublicKey(uid, "pub-paul", "") |
| 301 | |
| 302 | body := func(id string) string { |
| 303 | return `{"token":"` + tok + `","label_blob":"Grandma","id":"` + id + |
| 304 | `","wrapped":"boot","self_wrapped":"self","claim_hash":"` + testClaimHash + `"}` |
| 305 | } |
| 306 | |
| 307 | // Setting off (default) → forbidden. |
| 308 | resp, _ := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv1"))) |
| 309 | if resp.StatusCode != http.StatusForbidden { |
| 310 | t.Fatalf("invite while off: want 403, got %d", resp.StatusCode) |
| 311 | } |
| 312 | |
| 313 | // Turn it on (admin), then invite succeeds. |
| 314 | if r := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); r.StatusCode != http.StatusOK { |
| 315 | t.Fatalf("enable setting: got %d", r.StatusCode) |
| 316 | } |
| 317 | resp, _ = http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv2"))) |
| 318 | if resp.StatusCode != http.StatusOK { |
| 319 | t.Fatalf("invite while on: want 200, got %d", resp.StatusCode) |
| 320 | } |
| 321 | var out struct { |
| 322 | ThreadID string `json:"thread_id"` |
| 323 | } |
| 324 | json.NewDecoder(resp.Body).Decode(&out) |
| 325 | if out.ThreadID == "" { |
| 326 | t.Fatal("expected a thread_id from the invite") |
| 327 | } |
| 328 | |
| 329 | // Paul's roster is his connections: himself + the keyless pending invitee. |
| 330 | rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) |
| 331 | var rout struct { |
| 332 | Roster []RosterEntry `json:"roster"` |
| 333 | } |
| 334 | json.NewDecoder(rr.Body).Decode(&rout) |
| 335 | var self, pending bool |
| 336 | for _, e := range rout.Roster { |
| 337 | if e.UserID == uid { |
| 338 | self = true |
| 339 | } |
| 340 | if e.LabelBlob == "Grandma" && e.PublicKey == "" { |
| 341 | pending = true |
| 342 | } |
| 343 | } |
| 344 | if !self || !pending { |
| 345 | t.Fatalf("roster should include self + keyless invitee: %+v", rout.Roster) |
| 346 | } |
| 347 | } |
| 348 | |