package main import ( "encoding/json" "fmt" "net/http" "strings" "testing" "time" ) func modPost(t *testing.T, url, body string) (int, map[string]any) { t.Helper() resp, err := http.Post(url, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", url, err) } defer resp.Body.Close() out := map[string]any{} _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } // dmBody builds a two-member DM create payload (wraps are opaque to the server). func dmBody(tok, a, b string) string { return `{"token":"` + tok + `","kind":"dm","members":[{"user_id":"` + a + `","wrapped":"wa"},{"user_id":"` + b + `","wrapped":"wb"}]}` } // The DM door: 'nobody' stops NEW DMs from ordinary members server-side, while // admins and bots pass (the disclosed carve-out) and existing DMs are untouched. func TestDMPrivacy(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, _ := joinedMember(t, srv, "Alice") tokB, uidB, _ := joinedMember(t, srv, "Bob") _ = tokA // Alice closes her door. if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 { t.Fatalf("set dm_privacy: got %d", code) } // /api/me reflects it. resp, err := http.Get(ts.URL + "/api/me?token=" + tokA) if err != nil { t.Fatalf("me: %v", err) } var me map[string]any _ = json.NewDecoder(resp.Body).Decode(&me) resp.Body.Close() if me["dm_privacy"] != "nobody" { t.Fatalf("me.dm_privacy: got %v", me["dm_privacy"]) } // Bob can't open a NEW DM with Alice. if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 403 { t.Fatalf("member DM against a closed door should be 403, got %d", code) } // Alice can still DM Bob — the door is hers, not his. if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB)); code != 200 { t.Fatalf("Alice opening the DM herself should be 200") } // And with the pair existing, Bob "creating" it dedupes to the existing // thread — history is untouched by the setting. if code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 200 || out["existing"] != true { t.Fatalf("existing DM should dedupe regardless of the door, got %d %v", code, out) } // An admin passes a closed door. tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } // Carol also closes her door (fresh pair, no existing DM). tokC, uidC, _ := joinedMember(t, srv, "Carol") _ = tokC if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokC+`","dm_privacy":"nobody"}`); code != 200 { t.Fatal("set carol dm_privacy") } if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokAdm, uidAdm, uidC)); code != 200 { t.Fatal("admin should pass a closed DM door") } // A bot passes too (Eleven's welcome DM). tokBot, uidBot, _ := joinedMember(t, srv, "Eleven") if err := srv.db.setUserAgent(uidBot); err != nil { t.Fatalf("setUserAgent: %v", err) } tokD, uidD, _ := joinedMember(t, srv, "Dana") _ = tokD if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokD+`","dm_privacy":"nobody"}`); code != 200 { t.Fatal("set dana dm_privacy") } if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokBot, uidBot, uidD)); code != 200 { t.Fatal("bot should pass a closed DM door") } } // Closing a DM ends the conversation server-side: it hides for both, refuses // new messages (REST here; the WS path shares the gate), kills queued // scheduled deliveries, and reopening follows the other side's DM door. func TestCloseDM(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, _ := joinedMember(t, srv, "Alice") tokB, uidB, _ := joinedMember(t, srv, "Bob") // Their DM. code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB)) if code != 200 { t.Fatalf("create dm: %d", code) } tid := out["thread_id"].(string) // Bob schedules a future message into it (due immediately). due := fmt.Sprintf("%d", time.Now().Unix()) if code, _ := modPost(t, ts.URL+"/api/schedule", `{"token":"`+tokB+`","thread_id":"`+tid+`","deliver_at":`+due+`,"blob":"future"}`); code != 200 { t.Fatalf("schedule: %d", code) } // Alice closes the conversation. if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/close-dm", `{"token":"`+tokA+`"}`); code != 200 { t.Fatal("close-dm should succeed for a member") } // New messages bounce (both directions). if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 { t.Fatalf("message into a closed DM should be 403, got %d", code) } if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokA, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 { t.Fatalf("closer's own message should also be 403, got %d", code) } // The queued delivery dies instead of forcing the thread open. if n := srv.sweepDueScheduled(); n != 0 { t.Fatalf("scheduled delivery into a closed DM should not fire, delivered %d", n) } if due, _ := srv.db.dueScheduledMessages(1<<60, 10); len(due) != 0 { t.Fatalf("dead scheduled row should be dropped, still have %d", len(due)) } // Hidden for both; flagged closed for both. for _, tok := range []string{tokA, tokB} { resp, err := http.Get(ts.URL + "/api/threads?token=" + tok) if err != nil { t.Fatal(err) } var got struct { Threads []Thread `json:"threads"` } _ = json.NewDecoder(resp.Body).Decode(&got) resp.Body.Close() found := false for _, th := range got.Threads { if th.ID == tid { found = true if !th.Hidden || !th.DMClosed { t.Fatalf("closed DM should be hidden+dm_closed, got hidden=%v closed=%v", th.Hidden, th.DMClosed) } } } if !found { t.Fatal("closed DM should still be listed (hidden), not gone") } } // Bob can't reopen while Alice's door is shut… if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 { t.Fatal("set door") } if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokB+`"}`); code != 403 { t.Fatal("reopen against a closed door should be 403") } // …Alice can always reopen her own conversation. if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokA+`"}`); code != 200 { t.Fatal("closer reopening should succeed") } if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi again"}`); code != 200 { t.Fatal("messages should flow again after reopen") } // Group threads can't be "closed" — DMs only. gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}}) if err != nil { t.Fatal(err) } if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/close-dm", `{"token":"`+tokA+`"}`); code != 400 { t.Fatal("close-dm on a group should be 400") } }