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 "fmt"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10)
11
12func modPost(t *testing.T, url, body string) (int, map[string]any) {
13 t.Helper()
14 resp, err := http.Post(url, "application/json", strings.NewReader(body))
15 if err != nil {
16 t.Fatalf("post %s: %v", url, err)
17 }
18 defer resp.Body.Close()
19 out := map[string]any{}
20 _ = json.NewDecoder(resp.Body).Decode(&out)
21 return resp.StatusCode, out
22}
23
24// dmBody builds a two-member DM create payload (wraps are opaque to the server).
25func dmBody(tok, a, b string) string {
26 return `{"token":"` + tok + `","kind":"dm","members":[{"user_id":"` + a + `","wrapped":"wa"},{"user_id":"` + b + `","wrapped":"wb"}]}`
27}
28
29// The DM door: 'nobody' stops NEW DMs from ordinary members server-side, while
30// admins and bots pass (the disclosed carve-out) and existing DMs are untouched.
31func TestDMPrivacy(t *testing.T) {
32 ts, srv := newTestServer(t)
33
34 tokA, uidA, _ := joinedMember(t, srv, "Alice")
35 tokB, uidB, _ := joinedMember(t, srv, "Bob")
36 _ = tokA
37
38 // Alice closes her door.
39 if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 {
40 t.Fatalf("set dm_privacy: got %d", code)
41 }
42 // /api/me reflects it.
43 resp, err := http.Get(ts.URL + "/api/me?token=" + tokA)
44 if err != nil {
45 t.Fatalf("me: %v", err)
46 }
47 var me map[string]any
48 _ = json.NewDecoder(resp.Body).Decode(&me)
49 resp.Body.Close()
50 if me["dm_privacy"] != "nobody" {
51 t.Fatalf("me.dm_privacy: got %v", me["dm_privacy"])
52 }
53
54 // Bob can't open a NEW DM with Alice.
55 if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 403 {
56 t.Fatalf("member DM against a closed door should be 403, got %d", code)
57 }
58 // Alice can still DM Bob — the door is hers, not his.
59 if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB)); code != 200 {
60 t.Fatalf("Alice opening the DM herself should be 200")
61 }
62 // And with the pair existing, Bob "creating" it dedupes to the existing
63 // thread — history is untouched by the setting.
64 if code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 200 || out["existing"] != true {
65 t.Fatalf("existing DM should dedupe regardless of the door, got %d %v", code, out)
66 }
67
68 // An admin passes a closed door.
69 tokAdm, uidAdm, _ := joinedMember(t, srv, "Root")
70 if err := srv.db.setUserRole(uidAdm, "admin"); err != nil {
71 t.Fatalf("setUserRole: %v", err)
72 }
73 // Carol also closes her door (fresh pair, no existing DM).
74 tokC, uidC, _ := joinedMember(t, srv, "Carol")
75 _ = tokC
76 if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokC+`","dm_privacy":"nobody"}`); code != 200 {
77 t.Fatal("set carol dm_privacy")
78 }
79 if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokAdm, uidAdm, uidC)); code != 200 {
80 t.Fatal("admin should pass a closed DM door")
81 }
82
83 // A bot passes too (Eleven's welcome DM).
84 tokBot, uidBot, _ := joinedMember(t, srv, "Eleven")
85 if err := srv.db.setUserAgent(uidBot); err != nil {
86 t.Fatalf("setUserAgent: %v", err)
87 }
88 tokD, uidD, _ := joinedMember(t, srv, "Dana")
89 _ = tokD
90 if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokD+`","dm_privacy":"nobody"}`); code != 200 {
91 t.Fatal("set dana dm_privacy")
92 }
93 if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokBot, uidBot, uidD)); code != 200 {
94 t.Fatal("bot should pass a closed DM door")
95 }
96}
97
98// Closing a DM ends the conversation server-side: it hides for both, refuses
99// new messages (REST here; the WS path shares the gate), kills queued
100// scheduled deliveries, and reopening follows the other side's DM door.
101func TestCloseDM(t *testing.T) {
102 ts, srv := newTestServer(t)
103
104 tokA, uidA, _ := joinedMember(t, srv, "Alice")
105 tokB, uidB, _ := joinedMember(t, srv, "Bob")
106
107 // Their DM.
108 code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB))
109 if code != 200 {
110 t.Fatalf("create dm: %d", code)
111 }
112 tid := out["thread_id"].(string)
113
114 // Bob schedules a future message into it (due immediately).
115 due := fmt.Sprintf("%d", time.Now().Unix())
116 if code, _ := modPost(t, ts.URL+"/api/schedule", `{"token":"`+tokB+`","thread_id":"`+tid+`","deliver_at":`+due+`,"blob":"future"}`); code != 200 {
117 t.Fatalf("schedule: %d", code)
118 }
119
120 // Alice closes the conversation.
121 if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/close-dm", `{"token":"`+tokA+`"}`); code != 200 {
122 t.Fatal("close-dm should succeed for a member")
123 }
124
125 // New messages bounce (both directions).
126 if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 {
127 t.Fatalf("message into a closed DM should be 403, got %d", code)
128 }
129 if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokA, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 {
130 t.Fatalf("closer's own message should also be 403, got %d", code)
131 }
132
133 // The queued delivery dies instead of forcing the thread open.
134 if n := srv.sweepDueScheduled(); n != 0 {
135 t.Fatalf("scheduled delivery into a closed DM should not fire, delivered %d", n)
136 }
137 if due, _ := srv.db.dueScheduledMessages(1<<60, 10); len(due) != 0 {
138 t.Fatalf("dead scheduled row should be dropped, still have %d", len(due))
139 }
140
141 // Hidden for both; flagged closed for both.
142 for _, tok := range []string{tokA, tokB} {
143 resp, err := http.Get(ts.URL + "/api/threads?token=" + tok)
144 if err != nil {
145 t.Fatal(err)
146 }
147 var got struct {
148 Threads []Thread `json:"threads"`
149 }
150 _ = json.NewDecoder(resp.Body).Decode(&got)
151 resp.Body.Close()
152 found := false
153 for _, th := range got.Threads {
154 if th.ID == tid {
155 found = true
156 if !th.Hidden || !th.DMClosed {
157 t.Fatalf("closed DM should be hidden+dm_closed, got hidden=%v closed=%v", th.Hidden, th.DMClosed)
158 }
159 }
160 }
161 if !found {
162 t.Fatal("closed DM should still be listed (hidden), not gone")
163 }
164 }
165
166 // Bob can't reopen while Alice's door is shut…
167 if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 {
168 t.Fatal("set door")
169 }
170 if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokB+`"}`); code != 403 {
171 t.Fatal("reopen against a closed door should be 403")
172 }
173 // …Alice can always reopen her own conversation.
174 if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokA+`"}`); code != 200 {
175 t.Fatal("closer reopening should succeed")
176 }
177 if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi again"}`); code != 200 {
178 t.Fatal("messages should flow again after reopen")
179 }
180
181 // Group threads can't be "closed" — DMs only.
182 gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
183 if err != nil {
184 t.Fatal(err)
185 }
186 if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/close-dm", `{"token":"`+tokA+`"}`); code != 400 {
187 t.Fatal("close-dm on a group should be 400")
188 }
189}
190