| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | func 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). |
| 25 | func 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. |
| 31 | func 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. |
| 101 | func 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 | |