package main import ( "fmt" "os" "testing" "time" ) func newTestDB(t *testing.T) *DB { t.Helper() db, err := openDB(t.TempDir() + "/test.db") if err != nil { t.Fatalf("openDB: %v", err) } return db } func TestAdminToken(t *testing.T) { db := newTestDB(t) if _, err := db.ensureAdminToken("s3cret"); err != nil { t.Fatalf("ensureAdminToken: %v", err) } if !db.checkAdmin("s3cret") { t.Fatal("correct token rejected") } if db.checkAdmin("wrong") || db.checkAdmin("") { t.Fatal("bad token accepted") } } func TestMessages(t *testing.T) { db := newTestDB(t) m1, err := db.insertMessage("t1", "blob1", "author-a") if err != nil || m1.ID == 0 { t.Fatalf("insertMessage: %v id=%d", err, m1.ID) } db.insertMessage("t1", "blob2", "author-a") all, err := db.messagesAfter("t1", 0, 100) if err != nil || len(all) != 2 { t.Fatalf("messagesAfter(0): %v len=%d", err, len(all)) } after, _ := db.messagesAfter("t1", m1.ID, 100) if len(after) != 1 || after[0].Blob != "blob2" { t.Fatalf("messagesAfter(id): %+v", after) } } func TestDeleteMessage(t *testing.T) { db := newTestDB(t) m1, _ := db.insertMessage("t1", "blob1", "author-a") m2, _ := db.insertMessage("t1", "blob2", "author-b") // A non-author can't delete it (and no tombstone appears). if ok, err := db.deleteMessage(m1.ID, "author-b"); err != nil || ok { t.Fatalf("non-author delete: ok=%v err=%v (want ok=false)", ok, err) } if all, _ := db.messagesAfter("t1", 0, 100); len(all) != 2 { t.Fatalf("message wrongly deleted by a non-author") } // The author can delete it: the ciphertext row goes, the other message // survives, and a tombstone row appears with a fresh id above everything — // so a client syncing forward from any watermark learns of the delete. if ok, err := db.deleteMessage(m1.ID, "author-a"); err != nil || !ok { t.Fatalf("author delete: ok=%v err=%v (want ok=true)", ok, err) } all, _ := db.messagesAfter("t1", 0, 100) if len(all) != 2 || all[0].Blob != "blob2" { t.Fatalf("after delete: %+v (want blob2 + tombstone)", all) } tomb := all[1] if tomb.TombstoneOf != m1.ID || tomb.Blob != "" || tomb.ID <= m2.ID || tomb.CreatedAt == 0 { t.Fatalf("tombstone: %+v (want tombstone_of=%d, empty blob, id>%d)", tomb, m1.ID, m2.ID) } // The tombstone reaches a client that had already caught up past m2. caught, _ := db.messagesAfter("t1", m2.ID, 100) if len(caught) != 1 || caught[0].TombstoneOf != m1.ID { t.Fatalf("catch-up after %d: %+v (want just the tombstone)", m2.ID, caught) } // Tombstones themselves can't be deleted or re-tombstoned (no author). if ok, err := db.deleteMessage(tomb.ID, "author-a"); err != nil || ok { t.Fatalf("deleting a tombstone: ok=%v err=%v (want ok=false)", ok, err) } // Non-tombstone rows never carry the field on the wire (omitempty relies // on 0), and ordinary reads still return it as 0. if all[0].TombstoneOf != 0 { t.Fatalf("live message has tombstone_of=%d", all[0].TombstoneOf) } } // Removing a member tombstones every message the purge deletes (#765): a // device that was offline for the removal must still learn to drop its local // copies — the exact propagation #581 built for single unsends, at the moment // it matters most. Threads torn down whole need (and keep) no tombstones. func TestRemoveUserTombstonesMessages(t *testing.T) { db := newTestDB(t) uidA, _ := db.createUser("Alice") uidB, _ := db.createUser("Bea") db.setUserPublicKey(uidA, "pub-a", "") db.setUserPublicKey(uidB, "pub-b", "") shared, err := db.createThread("group", "n", []ThreadMemberKey{ {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}, }) if err != nil { t.Fatalf("createThread: %v", err) } // author_hash stores the author's USER id (what hub.go passes) — the // purge matches on it, despite the column's historical name. m1, _ := db.insertMessage(shared, "bea-1", uidB) db.insertMessage(shared, "alice-1", uidA) m3, _ := db.insertMessage(shared, "bea-2", uidB) // A thread only Bea was in — torn down whole on removal, no tombstones. solo, err := db.createThread("self", "", []ThreadMemberKey{{UserID: uidB, Wrapped: "ws"}}) if err != nil { t.Fatalf("createThread solo: %v", err) } db.insertMessage(solo, "solo-note", uidB) if err := db.removeUser(uidB); err != nil { t.Fatalf("removeUser: %v", err) } rows, _ := db.messagesAfter(shared, 0, 100) var tombs []int64 for _, m := range rows { if m.TombstoneOf != 0 { tombs = append(tombs, m.TombstoneOf) if m.Blob != "" { t.Fatalf("tombstone %d carries a blob", m.ID) } } else if m.Blob != "alice-1" { t.Fatalf("Bea's ciphertext survived the purge: %+v", m) } } if len(tombs) != 2 || tombs[0] != m1.ID || tombs[1] != m3.ID { t.Fatalf("tombstones %v, want [%d %d]", tombs, m1.ID, m3.ID) } // The solo thread went entirely — including any would-be tombstones. if left, _ := db.messagesAfter(solo, 0, 100); len(left) != 0 { t.Fatalf("solo thread rows survived teardown: %+v", left) } } // A delete must not read as fresh activity: the thread list's preview and // recency ordering skip tombstone rows (#581) — deleting an old message // neither blanks the preview nor bumps the thread above others. func TestTombstoneNotThreadActivity(t *testing.T) { db := newTestDB(t) uid, err := db.createUser("Paul") if err != nil { t.Fatalf("createUser: %v", err) } t1, err := db.createThread("group", "n1", []ThreadMemberKey{{UserID: uid, Wrapped: "w1"}}) if err != nil { t.Fatalf("createThread: %v", err) } t2, err := db.createThread("group", "n2", []ThreadMemberKey{{UserID: uid, Wrapped: "w2"}}) if err != nil { t.Fatalf("createThread: %v", err) } old, _ := db.insertMessage(t1, "old", "author-a") db.insertMessage(t1, "latest-t1", "author-a") db.insertMessage(t2, "latest-t2", "author-a") // t2 has the newest message → sorts first. ts, _ := db.threadsForUser(uid) if len(ts) != 2 || ts[0].ID != t2 { t.Fatalf("pre-delete order: %+v (want t2 first)", ts) } // Deleting t1's OLD message adds a tombstone with the newest id of all — // but t2 must stay first, and t1's preview must stay its surviving message. if ok, _ := db.deleteMessage(old.ID, "author-a"); !ok { t.Fatal("delete failed") } ts, _ = db.threadsForUser(uid) if ts[0].ID != t2 { t.Fatalf("tombstone bumped thread order: %+v", ts) } for _, th := range ts { if th.ID == t1 && th.LastBlob != "latest-t1" { t.Fatalf("t1 preview: %q (want latest-t1)", th.LastBlob) } } } func TestMessagesPagination(t *testing.T) { db := newTestDB(t) ids := make([]int64, 0, 5) for i := 0; i < 5; i++ { m, _ := db.insertMessage("t1", "b", "author-a") ids = append(ids, m.ID) } // after==0 returns the most recent `limit`, ascending. last2, _ := db.messagesAfter("t1", 0, 2) if len(last2) != 2 || last2[0].ID != ids[3] || last2[1].ID != ids[4] { t.Fatalf("messagesAfter(0,2): %+v", last2) } // before returns the `limit` messages just older than the cursor, ascending. before, _ := db.messagesBefore("t1", ids[3], 2) if len(before) != 2 || before[0].ID != ids[1] || before[1].ID != ids[2] { t.Fatalf("messagesBefore(ids[3],2): %+v", before) } // Fewer than `limit` near the start signals no more older messages. head, _ := db.messagesBefore("t1", ids[1], 5) if len(head) != 1 || head[0].ID != ids[0] { t.Fatalf("messagesBefore(ids[1],5): %+v", head) } } func TestEditMessage(t *testing.T) { db := newTestDB(t) m, err := db.insertMessage("t1", "orig", "author-a") if err != nil { t.Fatalf("insertMessage: %v", err) } // Wrong author can't edit, even within the window. if _, ok, err := db.editMessage(m.ID, "hacked", "author-b", time.Hour); err != nil || ok { t.Fatalf("edit by wrong author should fail: ok=%v err=%v", ok, err) } // Right author within the window succeeds and stamps edited_at. edited, ok, err := db.editMessage(m.ID, "fixed", "author-a", time.Hour) if err != nil || !ok || edited.Blob != "fixed" || edited.EditedAt == 0 { t.Fatalf("edit by author should succeed: ok=%v err=%v m=%+v", ok, err, edited) } got, _ := db.messagesAfter("t1", 0, 100) if len(got) != 1 || got[0].Blob != "fixed" || got[0].EditedAt == 0 { t.Fatalf("edit not persisted: %+v", got) } // Past the window the author is still refused. Backdate created_at so the // real window logic is exercised (a fresh insert is always "now"). old, _ := db.insertMessage("t1", "old", "author-a") if _, err := db.sql.Exec( "UPDATE messages SET created_at = ? WHERE id = ?", time.Now().Add(-time.Hour).Unix(), old.ID, ); err != nil { t.Fatalf("backdate: %v", err) } if _, ok, err := db.editMessage(old.ID, "toolate", "author-a", 15*time.Minute); err != nil || ok { t.Fatalf("edit past window should fail: ok=%v err=%v", ok, err) } } func TestPushSubscriptions(t *testing.T) { db := newTestDB(t) tok := mintDeviceToken(t, db, "x") db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"}) db.addSubscription(PushSub{Endpoint: "https://e/2", Token: tok, P256dh: "p", Auth: "a"}) if subs, _ := db.listSubscriptions(); len(subs) != 2 { t.Fatalf("expected 2 subs, got %d", len(subs)) } // Same endpoint upserts, not duplicates. db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p2", Auth: "a2"}) if subs, _ := db.listSubscriptions(); len(subs) != 2 { t.Fatalf("upsert duplicated: %d", len(subs)) } db.deleteSubscription("https://e/1") if subs, _ := db.listSubscriptions(); len(subs) != 1 { t.Fatalf("after delete: %d", len(subs)) } db.deleteSubscriptionsForToken(tok) if subs, _ := db.listSubscriptions(); len(subs) != 0 { t.Fatalf("after token delete: %d", len(subs)) } } // A message must never push to any of the sender's own devices — not just the // one it was sent from. Each device carries its own token now, so excluding by // the exact sending token alone let the sender's *other* devices buzz for their // own message (the reported regression). func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) { db := newTestDB(t) s := &Server{db: db} mkDevice := func(id, user string) string { if err := db.createKeyInvite(id, user, "w", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", id, err) } tok, ok, err := db.claimKeyInvite(id, "dev", "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", id, ok, err) } return tok } aliceLaptop := mkDevice("a1", "alice") alicePhone := mkDevice("a2", "alice") bobPhone := mkDevice("b1", "bob") bobLaptop := mkDevice("b2", "bob") for i, tok := range []string{aliceLaptop, alicePhone, bobPhone, bobLaptop} { db.addSubscription(PushSub{Endpoint: fmt.Sprintf("https://e/%d", i), Token: tok, P256dh: "p", Auth: "a"}) } subs, _ := db.listSubscriptions() // Alice sends from her laptop → both of Bob's devices are notified; neither of // Alice's own devices is (per-user sender exclusion). got := s.selectRecipients(subs, aliceLaptop, true, nil) if len(got) != 2 { t.Fatalf("expected Bob's two devices, got %d: %+v", len(got), got) } for _, g := range got { if g.Token == aliceLaptop || g.Token == alicePhone { t.Fatalf("a sender device was included: %+v", got) } } // Bob starts reading on his laptop → NONE of Bob's devices get pushed, not just // the laptop (per-user active suppression — the reported requirement). s.markActive(bobLaptop) if got := s.selectRecipients(subs, aliceLaptop, true, nil); len(got) != 0 { t.Fatalf("all of an active user's devices should be skipped, got %d: %+v", len(got), got) } } func TestGroupName(t *testing.T) { db := newTestDB(t) if got := db.getGroupName(); got != defaultGroupName { t.Fatalf("default group name = %q", got) } if err := db.setGroupName("Family Chat"); err != nil { t.Fatalf("setGroupName: %v", err) } if got := db.getGroupName(); got != "Family Chat" { t.Fatalf("group name = %q", got) } } func TestVAPIDPersists(t *testing.T) { db := newTestDB(t) pub, priv, err := db.ensureVAPID() if err != nil || pub == "" || priv == "" { t.Fatalf("ensureVAPID: %v pub=%q", err, pub) } pub2, priv2, _ := db.ensureVAPID() if pub != pub2 || priv != priv2 { t.Fatal("VAPID keys should persist across calls") } // The pair lives in the sidecar keyfile, never the DB (the DB replicates // to S3 under hibernation; the private key must not ride along). if fi, err := os.Stat(db.vapidPath); err != nil { t.Fatalf("vapid keyfile: %v", err) } else if fi.Mode().Perm() != 0o600 { t.Fatalf("vapid keyfile mode = %v, want 0600", fi.Mode().Perm()) } if _, ok, _ := db.getConfig("vapid_private"); ok { t.Fatal("vapid_private must not be stored in the config table") } if _, ok, _ := db.getConfig("vapid_public"); ok { t.Fatal("vapid_public must not be stored in the config table") } } // Pre-keyfile instances hold the pair in the config table; first boot must move // it into the file (same keys — regenerating would break every subscription) // and scrub the DB copy. func TestVAPIDMigratesLegacyConfigRows(t *testing.T) { db := newTestDB(t) if err := db.setConfig("vapid_public", "legacy-pub"); err != nil { t.Fatal(err) } if err := db.setConfig("vapid_private", "legacy-priv"); err != nil { t.Fatal(err) } pub, priv, err := db.ensureVAPID() if err != nil || pub != "legacy-pub" || priv != "legacy-priv" { t.Fatalf("ensureVAPID: %v pub=%q priv=%q, want the legacy pair", err, pub, priv) } if _, ok, _ := db.getConfig("vapid_private"); ok { t.Fatal("legacy vapid_private row should be deleted after migration") } // A restored older DB snapshot may resurrect the rows; the file stays // authoritative and the rows are scrubbed again. db.setConfig("vapid_private", "resurrected") pub2, priv2, err := db.ensureVAPID() if err != nil || pub2 != "legacy-pub" || priv2 != "legacy-priv" { t.Fatalf("keyfile should win over resurrected config rows: %v %q %q", err, pub2, priv2) } if _, ok, _ := db.getConfig("vapid_private"); ok { t.Fatal("resurrected vapid_private row should be scrubbed") } } func TestVAPIDMalformedKeyfileFailsLoud(t *testing.T) { db := newTestDB(t) if err := os.WriteFile(db.vapidPath, []byte("not json"), 0o600); err != nil { t.Fatal(err) } if _, _, err := db.ensureVAPID(); err == nil { t.Fatal("a corrupt keyfile must error, not silently re-mint (that breaks push subscriptions)") } } // A keyfile that exists but can't be READ (root-owned after an operator copy, // disk trouble) must error too — falling through to the mint path would rename // a fresh pair over the real one and silently break every subscription. func TestVAPIDUnreadableKeyfileFailsLoud(t *testing.T) { if os.Getuid() == 0 { t.Skip("root reads through file modes") } db := newTestDB(t) if err := os.WriteFile(db.vapidPath, []byte(`{"Public":"p","Private":"k"}`), 0o000); err != nil { t.Fatal(err) } if _, _, err := db.ensureVAPID(); err == nil { t.Fatal("an unreadable keyfile must error, not mint over the real pair") } // The file survives untouched for the operator to fix. if err := os.Chmod(db.vapidPath, 0o600); err != nil { t.Fatal(err) } pub, _, err := db.ensureVAPID() if err != nil || pub != "p" { t.Fatalf("keyfile should be intact after the failed boot: %v pub=%q", err, pub) } } func findUser(t *testing.T, users []User, id string) User { t.Helper() for _, u := range users { if u.ID == id { return u } } t.Fatalf("user %s not in list", id) return User{} } func TestUserStatus(t *testing.T) { db := newTestDB(t) uid, err := db.createUser("Tester") if err != nil { t.Fatalf("createUser: %v", err) } if err := db.createKeyInvite("inv1", uid, "wrapped", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } // Invited but not joined: no devices, no join time, link still outstanding. users, err := db.listUsers() if err != nil { t.Fatalf("listUsers: %v", err) } if u := findUser(t, users, uid); u.Devices != 0 || u.JoinedAt != nil || !u.Pending { t.Fatalf("invited: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) } // Claim it: now joined on one device, no longer pending. if _, ok, err := db.claimKeyInvite("inv1", "phone", "", testClaimSecret); err != nil || !ok { t.Fatalf("claim inv1: ok=%v err=%v", ok, err) } users, _ = db.listUsers() if u := findUser(t, users, uid); u.Devices != 1 || u.JoinedAt == nil || u.Pending { t.Fatalf("joined: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) } // A second device link, claimed → two devices. if err := db.createKeyInvite("inv2", uid, "wrapped", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite inv2: %v", err) } if _, ok, err := db.claimKeyInvite("inv2", "laptop", "", testClaimSecret); err != nil || !ok { t.Fatalf("claim inv2: ok=%v err=%v", ok, err) } users, _ = db.listUsers() if u := findUser(t, users, uid); u.Devices != 2 || u.Pending { t.Fatalf("two devices: got devices=%d pending=%v", u.Devices, u.Pending) } } func TestThreadsAndRoster(t *testing.T) { db := newTestDB(t) // Three users join (register a public key) → they appear in the roster. ids := map[string]string{} for _, name := range []string{"Paul", "Wife", "Kid1"} { 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: %v", err) } ids[name] = id } // A user with no public key is not yet in the roster. if _, err := db.createUser("Invited"); err != nil { t.Fatalf("createUser Invited: %v", err) } r, err := db.roster() if err != nil { t.Fatalf("roster: %v", err) } if len(r) != 3 { t.Fatalf("roster: want 3 joined users, got %d", len(r)) } // A DM between Paul and Wife: each gets the key wrapped to them. dm, err := db.createThread("dm", "", []ThreadMemberKey{ {UserID: ids["Paul"], Wrapped: "wrap-paul"}, {UserID: ids["Wife"], Wrapped: "wrap-wife"}, }) if err != nil { t.Fatalf("createThread dm: %v", err) } if err := db.recordDMPair(ids["Paul"], ids["Wife"], dm); err != nil { t.Fatalf("recordDMPair: %v", err) } // Membership gate + per-member wrapped key. if !db.threadMember(dm, ids["Paul"]) || !db.threadMember(dm, ids["Wife"]) { t.Fatal("both DM members should pass the membership gate") } if db.threadMember(dm, ids["Kid1"]) { t.Fatal("a non-member must not pass the gate") } if w, ok, _ := db.wrappedKeyFor(dm, ids["Wife"]); !ok || w != "wrap-wife" { t.Fatalf("wrappedKeyFor: got %q ok=%v", w, ok) } // DM dedupe: the pair resolves back to the same thread, order-independent. if got, ok, _ := db.dmThreadFor(ids["Wife"], ids["Paul"]); !ok || got != dm { t.Fatalf("dmThreadFor: got %q ok=%v want %q", got, ok, dm) } // A group of all three, then add nobody new but verify listing. grp, err := db.createThread("group", "name-blob", []ThreadMemberKey{ {UserID: ids["Paul"], Wrapped: "g-paul"}, {UserID: ids["Wife"], Wrapped: "g-wife"}, {UserID: ids["Kid1"], Wrapped: "g-kid1"}, }) if err != nil { t.Fatalf("createThread group: %v", err) } // Paul is in both threads; Kid1 only in the group. if ts, _ := db.threadsForUser(ids["Paul"]); len(ts) != 2 { t.Fatalf("Paul threads: want 2, got %d", len(ts)) } if ts, _ := db.threadsForUser(ids["Kid1"]); len(ts) != 1 || ts[0].ID != grp { t.Fatalf("Kid1 threads: want [group], got %v", ts) } if m, _ := db.memberUserIDs(grp); len(m) != 3 { t.Fatalf("group members: want 3, got %d", len(m)) } } // The reactions table is vestigial (reactions became encrypted message events) // and legacy rows held raw session tokens — migrate must drop it from old DBs, // and a fresh DB must never create it. func TestReactionsTableDropped(t *testing.T) { dir := t.TempDir() // Fresh DB: the table must not exist at all. fresh := newTestDB(t) var n int if err := fresh.sql.QueryRow( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil { t.Fatalf("sqlite_master: %v", err) } if n != 0 { t.Fatal("fresh DB created a reactions table") } // Old DB: seed a legacy reactions table (with a raw-token row, as the old // schema stored), then reopen — migrate must drop it. path := dir + "/old.db" old, err := openDB(path) if err != nil { t.Fatalf("openDB: %v", err) } if _, err := old.sql.Exec(`CREATE TABLE reactions ( message_id INTEGER NOT NULL, token TEXT NOT NULL, emoji TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (message_id, token, emoji)); INSERT INTO reactions VALUES (1, 'raw-session-token', '👍', 0)`); err != nil { t.Fatalf("seed legacy table: %v", err) } old.sql.Close() reopened, err := openDB(path) if err != nil { t.Fatalf("reopen: %v", err) } defer reopened.sql.Close() if err := reopened.sql.QueryRow( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil { t.Fatalf("sqlite_master after migrate: %v", err) } if n != 0 { t.Fatal("migrate left the legacy reactions table (and its raw tokens) in place") } }