eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "fmt"
5 "os"
6 "testing"
7 "time"
8)
9
10func 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
19func 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
32func 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
50func 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.
96func 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.
147func 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
186func 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
213func 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
248func 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).
278func 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
322func 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
335func 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.
363func 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
390func 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.
403func 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
424func 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
435func 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
477func 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.
556func 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