| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | func newTestDB(t *testing.T) *DB { |
| 11 | t.Helper() |
| 12 | db, err := openDB(t.TempDir() + "/test.db") |
| 13 | if err != nil { |
| 14 | t.Fatalf("openDB: %v", err) |
| 15 | } |
| 16 | return db |
| 17 | } |
| 18 | |
| 19 | func TestAdminToken(t *testing.T) { |
| 20 | db := newTestDB(t) |
| 21 | if _, err := db.ensureAdminToken("s3cret"); err != nil { |
| 22 | t.Fatalf("ensureAdminToken: %v", err) |
| 23 | } |
| 24 | if !db.checkAdmin("s3cret") { |
| 25 | t.Fatal("correct token rejected") |
| 26 | } |
| 27 | if db.checkAdmin("wrong") || db.checkAdmin("") { |
| 28 | t.Fatal("bad token accepted") |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func TestMessages(t *testing.T) { |
| 33 | db := newTestDB(t) |
| 34 | m1, err := db.insertMessage("t1", "blob1", "author-a") |
| 35 | if err != nil || m1.ID == 0 { |
| 36 | t.Fatalf("insertMessage: %v id=%d", err, m1.ID) |
| 37 | } |
| 38 | db.insertMessage("t1", "blob2", "author-a") |
| 39 | |
| 40 | all, err := db.messagesAfter("t1", 0, 100) |
| 41 | if err != nil || len(all) != 2 { |
| 42 | t.Fatalf("messagesAfter(0): %v len=%d", err, len(all)) |
| 43 | } |
| 44 | after, _ := db.messagesAfter("t1", m1.ID, 100) |
| 45 | if len(after) != 1 || after[0].Blob != "blob2" { |
| 46 | t.Fatalf("messagesAfter(id): %+v", after) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestDeleteMessage(t *testing.T) { |
| 51 | db := newTestDB(t) |
| 52 | m1, _ := db.insertMessage("t1", "blob1", "author-a") |
| 53 | m2, _ := db.insertMessage("t1", "blob2", "author-b") |
| 54 | |
| 55 | // A non-author can't delete it (and no tombstone appears). |
| 56 | if ok, err := db.deleteMessage(m1.ID, "author-b"); err != nil || ok { |
| 57 | t.Fatalf("non-author delete: ok=%v err=%v (want ok=false)", ok, err) |
| 58 | } |
| 59 | if all, _ := db.messagesAfter("t1", 0, 100); len(all) != 2 { |
| 60 | t.Fatalf("message wrongly deleted by a non-author") |
| 61 | } |
| 62 | // The author can delete it: the ciphertext row goes, the other message |
| 63 | // survives, and a tombstone row appears with a fresh id above everything — |
| 64 | // so a client syncing forward from any watermark learns of the delete. |
| 65 | if ok, err := db.deleteMessage(m1.ID, "author-a"); err != nil || !ok { |
| 66 | t.Fatalf("author delete: ok=%v err=%v (want ok=true)", ok, err) |
| 67 | } |
| 68 | all, _ := db.messagesAfter("t1", 0, 100) |
| 69 | if len(all) != 2 || all[0].Blob != "blob2" { |
| 70 | t.Fatalf("after delete: %+v (want blob2 + tombstone)", all) |
| 71 | } |
| 72 | tomb := all[1] |
| 73 | if tomb.TombstoneOf != m1.ID || tomb.Blob != "" || tomb.ID <= m2.ID || tomb.CreatedAt == 0 { |
| 74 | t.Fatalf("tombstone: %+v (want tombstone_of=%d, empty blob, id>%d)", tomb, m1.ID, m2.ID) |
| 75 | } |
| 76 | // The tombstone reaches a client that had already caught up past m2. |
| 77 | caught, _ := db.messagesAfter("t1", m2.ID, 100) |
| 78 | if len(caught) != 1 || caught[0].TombstoneOf != m1.ID { |
| 79 | t.Fatalf("catch-up after %d: %+v (want just the tombstone)", m2.ID, caught) |
| 80 | } |
| 81 | // Tombstones themselves can't be deleted or re-tombstoned (no author). |
| 82 | if ok, err := db.deleteMessage(tomb.ID, "author-a"); err != nil || ok { |
| 83 | t.Fatalf("deleting a tombstone: ok=%v err=%v (want ok=false)", ok, err) |
| 84 | } |
| 85 | // Non-tombstone rows never carry the field on the wire (omitempty relies |
| 86 | // on 0), and ordinary reads still return it as 0. |
| 87 | if all[0].TombstoneOf != 0 { |
| 88 | t.Fatalf("live message has tombstone_of=%d", all[0].TombstoneOf) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // Removing a member tombstones every message the purge deletes (#765): a |
| 93 | // device that was offline for the removal must still learn to drop its local |
| 94 | // copies — the exact propagation #581 built for single unsends, at the moment |
| 95 | // it matters most. Threads torn down whole need (and keep) no tombstones. |
| 96 | func TestRemoveUserTombstonesMessages(t *testing.T) { |
| 97 | db := newTestDB(t) |
| 98 | uidA, _ := db.createUser("Alice") |
| 99 | uidB, _ := db.createUser("Bea") |
| 100 | db.setUserPublicKey(uidA, "pub-a", "") |
| 101 | db.setUserPublicKey(uidB, "pub-b", "") |
| 102 | shared, err := db.createThread("group", "n", []ThreadMemberKey{ |
| 103 | {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}, |
| 104 | }) |
| 105 | if err != nil { |
| 106 | t.Fatalf("createThread: %v", err) |
| 107 | } |
| 108 | // author_hash stores the author's USER id (what hub.go passes) — the |
| 109 | // purge matches on it, despite the column's historical name. |
| 110 | m1, _ := db.insertMessage(shared, "bea-1", uidB) |
| 111 | db.insertMessage(shared, "alice-1", uidA) |
| 112 | m3, _ := db.insertMessage(shared, "bea-2", uidB) |
| 113 | // A thread only Bea was in — torn down whole on removal, no tombstones. |
| 114 | solo, err := db.createThread("self", "", []ThreadMemberKey{{UserID: uidB, Wrapped: "ws"}}) |
| 115 | if err != nil { |
| 116 | t.Fatalf("createThread solo: %v", err) |
| 117 | } |
| 118 | db.insertMessage(solo, "solo-note", uidB) |
| 119 | |
| 120 | if err := db.removeUser(uidB); err != nil { |
| 121 | t.Fatalf("removeUser: %v", err) |
| 122 | } |
| 123 | rows, _ := db.messagesAfter(shared, 0, 100) |
| 124 | var tombs []int64 |
| 125 | for _, m := range rows { |
| 126 | if m.TombstoneOf != 0 { |
| 127 | tombs = append(tombs, m.TombstoneOf) |
| 128 | if m.Blob != "" { |
| 129 | t.Fatalf("tombstone %d carries a blob", m.ID) |
| 130 | } |
| 131 | } else if m.Blob != "alice-1" { |
| 132 | t.Fatalf("Bea's ciphertext survived the purge: %+v", m) |
| 133 | } |
| 134 | } |
| 135 | if len(tombs) != 2 || tombs[0] != m1.ID || tombs[1] != m3.ID { |
| 136 | t.Fatalf("tombstones %v, want [%d %d]", tombs, m1.ID, m3.ID) |
| 137 | } |
| 138 | // The solo thread went entirely — including any would-be tombstones. |
| 139 | if left, _ := db.messagesAfter(solo, 0, 100); len(left) != 0 { |
| 140 | t.Fatalf("solo thread rows survived teardown: %+v", left) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // A delete must not read as fresh activity: the thread list's preview and |
| 145 | // recency ordering skip tombstone rows (#581) — deleting an old message |
| 146 | // neither blanks the preview nor bumps the thread above others. |
| 147 | func TestTombstoneNotThreadActivity(t *testing.T) { |
| 148 | db := newTestDB(t) |
| 149 | uid, err := db.createUser("Paul") |
| 150 | if err != nil { |
| 151 | t.Fatalf("createUser: %v", err) |
| 152 | } |
| 153 | t1, err := db.createThread("group", "n1", []ThreadMemberKey{{UserID: uid, Wrapped: "w1"}}) |
| 154 | if err != nil { |
| 155 | t.Fatalf("createThread: %v", err) |
| 156 | } |
| 157 | t2, err := db.createThread("group", "n2", []ThreadMemberKey{{UserID: uid, Wrapped: "w2"}}) |
| 158 | if err != nil { |
| 159 | t.Fatalf("createThread: %v", err) |
| 160 | } |
| 161 | old, _ := db.insertMessage(t1, "old", "author-a") |
| 162 | db.insertMessage(t1, "latest-t1", "author-a") |
| 163 | db.insertMessage(t2, "latest-t2", "author-a") |
| 164 | |
| 165 | // t2 has the newest message → sorts first. |
| 166 | ts, _ := db.threadsForUser(uid) |
| 167 | if len(ts) != 2 || ts[0].ID != t2 { |
| 168 | t.Fatalf("pre-delete order: %+v (want t2 first)", ts) |
| 169 | } |
| 170 | // Deleting t1's OLD message adds a tombstone with the newest id of all — |
| 171 | // but t2 must stay first, and t1's preview must stay its surviving message. |
| 172 | if ok, _ := db.deleteMessage(old.ID, "author-a"); !ok { |
| 173 | t.Fatal("delete failed") |
| 174 | } |
| 175 | ts, _ = db.threadsForUser(uid) |
| 176 | if ts[0].ID != t2 { |
| 177 | t.Fatalf("tombstone bumped thread order: %+v", ts) |
| 178 | } |
| 179 | for _, th := range ts { |
| 180 | if th.ID == t1 && th.LastBlob != "latest-t1" { |
| 181 | t.Fatalf("t1 preview: %q (want latest-t1)", th.LastBlob) |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestMessagesPagination(t *testing.T) { |
| 187 | db := newTestDB(t) |
| 188 | ids := make([]int64, 0, 5) |
| 189 | for i := 0; i < 5; i++ { |
| 190 | m, _ := db.insertMessage("t1", "b", "author-a") |
| 191 | ids = append(ids, m.ID) |
| 192 | } |
| 193 | |
| 194 | // after==0 returns the most recent `limit`, ascending. |
| 195 | last2, _ := db.messagesAfter("t1", 0, 2) |
| 196 | if len(last2) != 2 || last2[0].ID != ids[3] || last2[1].ID != ids[4] { |
| 197 | t.Fatalf("messagesAfter(0,2): %+v", last2) |
| 198 | } |
| 199 | |
| 200 | // before returns the `limit` messages just older than the cursor, ascending. |
| 201 | before, _ := db.messagesBefore("t1", ids[3], 2) |
| 202 | if len(before) != 2 || before[0].ID != ids[1] || before[1].ID != ids[2] { |
| 203 | t.Fatalf("messagesBefore(ids[3],2): %+v", before) |
| 204 | } |
| 205 | |
| 206 | // Fewer than `limit` near the start signals no more older messages. |
| 207 | head, _ := db.messagesBefore("t1", ids[1], 5) |
| 208 | if len(head) != 1 || head[0].ID != ids[0] { |
| 209 | t.Fatalf("messagesBefore(ids[1],5): %+v", head) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestEditMessage(t *testing.T) { |
| 214 | db := newTestDB(t) |
| 215 | m, err := db.insertMessage("t1", "orig", "author-a") |
| 216 | if err != nil { |
| 217 | t.Fatalf("insertMessage: %v", err) |
| 218 | } |
| 219 | |
| 220 | // Wrong author can't edit, even within the window. |
| 221 | if _, ok, err := db.editMessage(m.ID, "hacked", "author-b", time.Hour); err != nil || ok { |
| 222 | t.Fatalf("edit by wrong author should fail: ok=%v err=%v", ok, err) |
| 223 | } |
| 224 | |
| 225 | // Right author within the window succeeds and stamps edited_at. |
| 226 | edited, ok, err := db.editMessage(m.ID, "fixed", "author-a", time.Hour) |
| 227 | if err != nil || !ok || edited.Blob != "fixed" || edited.EditedAt == 0 { |
| 228 | t.Fatalf("edit by author should succeed: ok=%v err=%v m=%+v", ok, err, edited) |
| 229 | } |
| 230 | got, _ := db.messagesAfter("t1", 0, 100) |
| 231 | if len(got) != 1 || got[0].Blob != "fixed" || got[0].EditedAt == 0 { |
| 232 | t.Fatalf("edit not persisted: %+v", got) |
| 233 | } |
| 234 | |
| 235 | // Past the window the author is still refused. Backdate created_at so the |
| 236 | // real window logic is exercised (a fresh insert is always "now"). |
| 237 | old, _ := db.insertMessage("t1", "old", "author-a") |
| 238 | if _, err := db.sql.Exec( |
| 239 | "UPDATE messages SET created_at = ? WHERE id = ?", time.Now().Add(-time.Hour).Unix(), old.ID, |
| 240 | ); err != nil { |
| 241 | t.Fatalf("backdate: %v", err) |
| 242 | } |
| 243 | if _, ok, err := db.editMessage(old.ID, "toolate", "author-a", 15*time.Minute); err != nil || ok { |
| 244 | t.Fatalf("edit past window should fail: ok=%v err=%v", ok, err) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | func TestPushSubscriptions(t *testing.T) { |
| 249 | db := newTestDB(t) |
| 250 | tok := mintDeviceToken(t, db, "x") |
| 251 | |
| 252 | db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"}) |
| 253 | db.addSubscription(PushSub{Endpoint: "https://e/2", Token: tok, P256dh: "p", Auth: "a"}) |
| 254 | if subs, _ := db.listSubscriptions(); len(subs) != 2 { |
| 255 | t.Fatalf("expected 2 subs, got %d", len(subs)) |
| 256 | } |
| 257 | |
| 258 | // Same endpoint upserts, not duplicates. |
| 259 | db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p2", Auth: "a2"}) |
| 260 | if subs, _ := db.listSubscriptions(); len(subs) != 2 { |
| 261 | t.Fatalf("upsert duplicated: %d", len(subs)) |
| 262 | } |
| 263 | |
| 264 | db.deleteSubscription("https://e/1") |
| 265 | if subs, _ := db.listSubscriptions(); len(subs) != 1 { |
| 266 | t.Fatalf("after delete: %d", len(subs)) |
| 267 | } |
| 268 | db.deleteSubscriptionsForToken(tok) |
| 269 | if subs, _ := db.listSubscriptions(); len(subs) != 0 { |
| 270 | t.Fatalf("after token delete: %d", len(subs)) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // A message must never push to any of the sender's own devices — not just the |
| 275 | // one it was sent from. Each device carries its own token now, so excluding by |
| 276 | // the exact sending token alone let the sender's *other* devices buzz for their |
| 277 | // own message (the reported regression). |
| 278 | func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) { |
| 279 | db := newTestDB(t) |
| 280 | s := &Server{db: db} |
| 281 | |
| 282 | mkDevice := func(id, user string) string { |
| 283 | if err := db.createKeyInvite(id, user, "w", testClaimHash, 0); err != nil { |
| 284 | t.Fatalf("createKeyInvite %s: %v", id, err) |
| 285 | } |
| 286 | tok, ok, err := db.claimKeyInvite(id, "dev", "", testClaimSecret) |
| 287 | if err != nil || !ok { |
| 288 | t.Fatalf("claimKeyInvite %s: ok=%v err=%v", id, ok, err) |
| 289 | } |
| 290 | return tok |
| 291 | } |
| 292 | aliceLaptop := mkDevice("a1", "alice") |
| 293 | alicePhone := mkDevice("a2", "alice") |
| 294 | bobPhone := mkDevice("b1", "bob") |
| 295 | bobLaptop := mkDevice("b2", "bob") |
| 296 | |
| 297 | for i, tok := range []string{aliceLaptop, alicePhone, bobPhone, bobLaptop} { |
| 298 | db.addSubscription(PushSub{Endpoint: fmt.Sprintf("https://e/%d", i), Token: tok, P256dh: "p", Auth: "a"}) |
| 299 | } |
| 300 | subs, _ := db.listSubscriptions() |
| 301 | |
| 302 | // Alice sends from her laptop → both of Bob's devices are notified; neither of |
| 303 | // Alice's own devices is (per-user sender exclusion). |
| 304 | got := s.selectRecipients(subs, aliceLaptop, true, nil) |
| 305 | if len(got) != 2 { |
| 306 | t.Fatalf("expected Bob's two devices, got %d: %+v", len(got), got) |
| 307 | } |
| 308 | for _, g := range got { |
| 309 | if g.Token == aliceLaptop || g.Token == alicePhone { |
| 310 | t.Fatalf("a sender device was included: %+v", got) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // Bob starts reading on his laptop → NONE of Bob's devices get pushed, not just |
| 315 | // the laptop (per-user active suppression — the reported requirement). |
| 316 | s.markActive(bobLaptop) |
| 317 | if got := s.selectRecipients(subs, aliceLaptop, true, nil); len(got) != 0 { |
| 318 | t.Fatalf("all of an active user's devices should be skipped, got %d: %+v", len(got), got) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | func TestGroupName(t *testing.T) { |
| 323 | db := newTestDB(t) |
| 324 | if got := db.getGroupName(); got != defaultGroupName { |
| 325 | t.Fatalf("default group name = %q", got) |
| 326 | } |
| 327 | if err := db.setGroupName("Family Chat"); err != nil { |
| 328 | t.Fatalf("setGroupName: %v", err) |
| 329 | } |
| 330 | if got := db.getGroupName(); got != "Family Chat" { |
| 331 | t.Fatalf("group name = %q", got) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | func TestVAPIDPersists(t *testing.T) { |
| 336 | db := newTestDB(t) |
| 337 | pub, priv, err := db.ensureVAPID() |
| 338 | if err != nil || pub == "" || priv == "" { |
| 339 | t.Fatalf("ensureVAPID: %v pub=%q", err, pub) |
| 340 | } |
| 341 | pub2, priv2, _ := db.ensureVAPID() |
| 342 | if pub != pub2 || priv != priv2 { |
| 343 | t.Fatal("VAPID keys should persist across calls") |
| 344 | } |
| 345 | // The pair lives in the sidecar keyfile, never the DB (the DB replicates |
| 346 | // to S3 under hibernation; the private key must not ride along). |
| 347 | if fi, err := os.Stat(db.vapidPath); err != nil { |
| 348 | t.Fatalf("vapid keyfile: %v", err) |
| 349 | } else if fi.Mode().Perm() != 0o600 { |
| 350 | t.Fatalf("vapid keyfile mode = %v, want 0600", fi.Mode().Perm()) |
| 351 | } |
| 352 | if _, ok, _ := db.getConfig("vapid_private"); ok { |
| 353 | t.Fatal("vapid_private must not be stored in the config table") |
| 354 | } |
| 355 | if _, ok, _ := db.getConfig("vapid_public"); ok { |
| 356 | t.Fatal("vapid_public must not be stored in the config table") |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // Pre-keyfile instances hold the pair in the config table; first boot must move |
| 361 | // it into the file (same keys — regenerating would break every subscription) |
| 362 | // and scrub the DB copy. |
| 363 | func TestVAPIDMigratesLegacyConfigRows(t *testing.T) { |
| 364 | db := newTestDB(t) |
| 365 | if err := db.setConfig("vapid_public", "legacy-pub"); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | if err := db.setConfig("vapid_private", "legacy-priv"); err != nil { |
| 369 | t.Fatal(err) |
| 370 | } |
| 371 | pub, priv, err := db.ensureVAPID() |
| 372 | if err != nil || pub != "legacy-pub" || priv != "legacy-priv" { |
| 373 | t.Fatalf("ensureVAPID: %v pub=%q priv=%q, want the legacy pair", err, pub, priv) |
| 374 | } |
| 375 | if _, ok, _ := db.getConfig("vapid_private"); ok { |
| 376 | t.Fatal("legacy vapid_private row should be deleted after migration") |
| 377 | } |
| 378 | // A restored older DB snapshot may resurrect the rows; the file stays |
| 379 | // authoritative and the rows are scrubbed again. |
| 380 | db.setConfig("vapid_private", "resurrected") |
| 381 | pub2, priv2, err := db.ensureVAPID() |
| 382 | if err != nil || pub2 != "legacy-pub" || priv2 != "legacy-priv" { |
| 383 | t.Fatalf("keyfile should win over resurrected config rows: %v %q %q", err, pub2, priv2) |
| 384 | } |
| 385 | if _, ok, _ := db.getConfig("vapid_private"); ok { |
| 386 | t.Fatal("resurrected vapid_private row should be scrubbed") |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | func TestVAPIDMalformedKeyfileFailsLoud(t *testing.T) { |
| 391 | db := newTestDB(t) |
| 392 | if err := os.WriteFile(db.vapidPath, []byte("not json"), 0o600); err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | if _, _, err := db.ensureVAPID(); err == nil { |
| 396 | t.Fatal("a corrupt keyfile must error, not silently re-mint (that breaks push subscriptions)") |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | // A keyfile that exists but can't be READ (root-owned after an operator copy, |
| 401 | // disk trouble) must error too — falling through to the mint path would rename |
| 402 | // a fresh pair over the real one and silently break every subscription. |
| 403 | func TestVAPIDUnreadableKeyfileFailsLoud(t *testing.T) { |
| 404 | if os.Getuid() == 0 { |
| 405 | t.Skip("root reads through file modes") |
| 406 | } |
| 407 | db := newTestDB(t) |
| 408 | if err := os.WriteFile(db.vapidPath, []byte(`{"Public":"p","Private":"k"}`), 0o000); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | if _, _, err := db.ensureVAPID(); err == nil { |
| 412 | t.Fatal("an unreadable keyfile must error, not mint over the real pair") |
| 413 | } |
| 414 | // The file survives untouched for the operator to fix. |
| 415 | if err := os.Chmod(db.vapidPath, 0o600); err != nil { |
| 416 | t.Fatal(err) |
| 417 | } |
| 418 | pub, _, err := db.ensureVAPID() |
| 419 | if err != nil || pub != "p" { |
| 420 | t.Fatalf("keyfile should be intact after the failed boot: %v pub=%q", err, pub) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | func findUser(t *testing.T, users []User, id string) User { |
| 425 | t.Helper() |
| 426 | for _, u := range users { |
| 427 | if u.ID == id { |
| 428 | return u |
| 429 | } |
| 430 | } |
| 431 | t.Fatalf("user %s not in list", id) |
| 432 | return User{} |
| 433 | } |
| 434 | |
| 435 | func TestUserStatus(t *testing.T) { |
| 436 | db := newTestDB(t) |
| 437 | |
| 438 | uid, err := db.createUser("Tester") |
| 439 | if err != nil { |
| 440 | t.Fatalf("createUser: %v", err) |
| 441 | } |
| 442 | if err := db.createKeyInvite("inv1", uid, "wrapped", testClaimHash, 0); err != nil { |
| 443 | t.Fatalf("createKeyInvite: %v", err) |
| 444 | } |
| 445 | |
| 446 | // Invited but not joined: no devices, no join time, link still outstanding. |
| 447 | users, err := db.listUsers() |
| 448 | if err != nil { |
| 449 | t.Fatalf("listUsers: %v", err) |
| 450 | } |
| 451 | if u := findUser(t, users, uid); u.Devices != 0 || u.JoinedAt != nil || !u.Pending { |
| 452 | t.Fatalf("invited: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) |
| 453 | } |
| 454 | |
| 455 | // Claim it: now joined on one device, no longer pending. |
| 456 | if _, ok, err := db.claimKeyInvite("inv1", "phone", "", testClaimSecret); err != nil || !ok { |
| 457 | t.Fatalf("claim inv1: ok=%v err=%v", ok, err) |
| 458 | } |
| 459 | users, _ = db.listUsers() |
| 460 | if u := findUser(t, users, uid); u.Devices != 1 || u.JoinedAt == nil || u.Pending { |
| 461 | t.Fatalf("joined: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) |
| 462 | } |
| 463 | |
| 464 | // A second device link, claimed → two devices. |
| 465 | if err := db.createKeyInvite("inv2", uid, "wrapped", testClaimHash, 0); err != nil { |
| 466 | t.Fatalf("createKeyInvite inv2: %v", err) |
| 467 | } |
| 468 | if _, ok, err := db.claimKeyInvite("inv2", "laptop", "", testClaimSecret); err != nil || !ok { |
| 469 | t.Fatalf("claim inv2: ok=%v err=%v", ok, err) |
| 470 | } |
| 471 | users, _ = db.listUsers() |
| 472 | if u := findUser(t, users, uid); u.Devices != 2 || u.Pending { |
| 473 | t.Fatalf("two devices: got devices=%d pending=%v", u.Devices, u.Pending) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | func TestThreadsAndRoster(t *testing.T) { |
| 478 | db := newTestDB(t) |
| 479 | |
| 480 | // Three users join (register a public key) → they appear in the roster. |
| 481 | ids := map[string]string{} |
| 482 | for _, name := range []string{"Paul", "Wife", "Kid1"} { |
| 483 | id, err := db.createUser(name) |
| 484 | if err != nil { |
| 485 | t.Fatalf("createUser %s: %v", name, err) |
| 486 | } |
| 487 | if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| 488 | t.Fatalf("setUserPublicKey: %v", err) |
| 489 | } |
| 490 | ids[name] = id |
| 491 | } |
| 492 | // A user with no public key is not yet in the roster. |
| 493 | if _, err := db.createUser("Invited"); err != nil { |
| 494 | t.Fatalf("createUser Invited: %v", err) |
| 495 | } |
| 496 | r, err := db.roster() |
| 497 | if err != nil { |
| 498 | t.Fatalf("roster: %v", err) |
| 499 | } |
| 500 | if len(r) != 3 { |
| 501 | t.Fatalf("roster: want 3 joined users, got %d", len(r)) |
| 502 | } |
| 503 | |
| 504 | // A DM between Paul and Wife: each gets the key wrapped to them. |
| 505 | dm, err := db.createThread("dm", "", []ThreadMemberKey{ |
| 506 | {UserID: ids["Paul"], Wrapped: "wrap-paul"}, |
| 507 | {UserID: ids["Wife"], Wrapped: "wrap-wife"}, |
| 508 | }) |
| 509 | if err != nil { |
| 510 | t.Fatalf("createThread dm: %v", err) |
| 511 | } |
| 512 | if err := db.recordDMPair(ids["Paul"], ids["Wife"], dm); err != nil { |
| 513 | t.Fatalf("recordDMPair: %v", err) |
| 514 | } |
| 515 | |
| 516 | // Membership gate + per-member wrapped key. |
| 517 | if !db.threadMember(dm, ids["Paul"]) || !db.threadMember(dm, ids["Wife"]) { |
| 518 | t.Fatal("both DM members should pass the membership gate") |
| 519 | } |
| 520 | if db.threadMember(dm, ids["Kid1"]) { |
| 521 | t.Fatal("a non-member must not pass the gate") |
| 522 | } |
| 523 | if w, ok, _ := db.wrappedKeyFor(dm, ids["Wife"]); !ok || w != "wrap-wife" { |
| 524 | t.Fatalf("wrappedKeyFor: got %q ok=%v", w, ok) |
| 525 | } |
| 526 | |
| 527 | // DM dedupe: the pair resolves back to the same thread, order-independent. |
| 528 | if got, ok, _ := db.dmThreadFor(ids["Wife"], ids["Paul"]); !ok || got != dm { |
| 529 | t.Fatalf("dmThreadFor: got %q ok=%v want %q", got, ok, dm) |
| 530 | } |
| 531 | |
| 532 | // A group of all three, then add nobody new but verify listing. |
| 533 | grp, err := db.createThread("group", "name-blob", []ThreadMemberKey{ |
| 534 | {UserID: ids["Paul"], Wrapped: "g-paul"}, |
| 535 | {UserID: ids["Wife"], Wrapped: "g-wife"}, |
| 536 | {UserID: ids["Kid1"], Wrapped: "g-kid1"}, |
| 537 | }) |
| 538 | if err != nil { |
| 539 | t.Fatalf("createThread group: %v", err) |
| 540 | } |
| 541 | // Paul is in both threads; Kid1 only in the group. |
| 542 | if ts, _ := db.threadsForUser(ids["Paul"]); len(ts) != 2 { |
| 543 | t.Fatalf("Paul threads: want 2, got %d", len(ts)) |
| 544 | } |
| 545 | if ts, _ := db.threadsForUser(ids["Kid1"]); len(ts) != 1 || ts[0].ID != grp { |
| 546 | t.Fatalf("Kid1 threads: want [group], got %v", ts) |
| 547 | } |
| 548 | if m, _ := db.memberUserIDs(grp); len(m) != 3 { |
| 549 | t.Fatalf("group members: want 3, got %d", len(m)) |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | // The reactions table is vestigial (reactions became encrypted message events) |
| 554 | // and legacy rows held raw session tokens — migrate must drop it from old DBs, |
| 555 | // and a fresh DB must never create it. |
| 556 | func TestReactionsTableDropped(t *testing.T) { |
| 557 | dir := t.TempDir() |
| 558 | |
| 559 | // Fresh DB: the table must not exist at all. |
| 560 | fresh := newTestDB(t) |
| 561 | var n int |
| 562 | if err := fresh.sql.QueryRow( |
| 563 | "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil { |
| 564 | t.Fatalf("sqlite_master: %v", err) |
| 565 | } |
| 566 | if n != 0 { |
| 567 | t.Fatal("fresh DB created a reactions table") |
| 568 | } |
| 569 | |
| 570 | // Old DB: seed a legacy reactions table (with a raw-token row, as the old |
| 571 | // schema stored), then reopen — migrate must drop it. |
| 572 | path := dir + "/old.db" |
| 573 | old, err := openDB(path) |
| 574 | if err != nil { |
| 575 | t.Fatalf("openDB: %v", err) |
| 576 | } |
| 577 | if _, err := old.sql.Exec(`CREATE TABLE reactions ( |
| 578 | message_id INTEGER NOT NULL, token TEXT NOT NULL, emoji TEXT NOT NULL, |
| 579 | created_at INTEGER NOT NULL, PRIMARY KEY (message_id, token, emoji)); |
| 580 | INSERT INTO reactions VALUES (1, 'raw-session-token', '👍', 0)`); err != nil { |
| 581 | t.Fatalf("seed legacy table: %v", err) |
| 582 | } |
| 583 | old.sql.Close() |
| 584 | |
| 585 | reopened, err := openDB(path) |
| 586 | if err != nil { |
| 587 | t.Fatalf("reopen: %v", err) |
| 588 | } |
| 589 | defer reopened.sql.Close() |
| 590 | if err := reopened.sql.QueryRow( |
| 591 | "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil { |
| 592 | t.Fatalf("sqlite_master after migrate: %v", err) |
| 593 | } |
| 594 | if n != 0 { |
| 595 | t.Fatal("migrate left the legacy reactions table (and its raw tokens) in place") |
| 596 | } |
| 597 | } |
| 598 | |