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 "encoding/json"
5 "net/http"
6 "strings"
7 "testing"
8)
9
10func 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.
25func 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.
67func 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.
128func 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.
173func 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
212func 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.
244func 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.
268func 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).
295func 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