Files
OwnCord/Server/ws/dm_group_call_test.go
T

420 lines
16 KiB
Go
Raw Normal View History

2026-08-01 22:06:14 +02:00
package ws_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
// absenceWindow is how long an "it must NOT arrive" assertion waits. The
// dispatch it is watching for is synchronous, so anything that was going to
// land has landed well inside it.
const absenceWindow = 100 * time.Millisecond
// ─── helpers ────────────────────────────────────────────────────────────────
// seedGroupDM creates a group DM containing every listed user.
func seedGroupDM(t *testing.T, database *db.DB, name string, userIDs ...int64) int64 {
t.Helper()
ch, err := database.CreateGroupDMChannel(context.Background(), name, userIDs)
if err != nil {
t.Fatalf("seedGroupDM: %v", err)
}
return ch.ID
}
func callMsg(msgType string, channelID int64) []byte {
raw, _ := json.Marshal(map[string]any{
"type": msgType,
"payload": map[string]any{"channel_id": channelID},
})
return raw
}
// ─── group DM fan-out ───────────────────────────────────────────────────────
// A chat_send into a group DM must reach every other participant, not just
// "the recipient" — the single-recipient assumption the DM path started with.
func TestGroupDM_ChatSendFansOutToAllParticipants(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "grp-send-alice")
bob := seedMemberUser(t, database, "grp-send-bob")
carol := seedMemberUser(t, database, "grp-send-carol")
chID := seedGroupDM(t, database, "Trio", alice.ID, bob.ID, carol.ID)
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
sendCarol := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol)
hub.Register(cAlice)
hub.Register(cBob)
hub.Register(cCarol)
waitRegistered(t, hub, cCarol)
hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "hello everyone"))
if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil {
t.Error("bob did not receive the group message")
}
if dmWaitMsgType(sendCarol, "chat_message", waitTimeout) == nil {
t.Error("carol did not receive the group message")
}
}
// Every recipient's dm_channel_open must describe the group from *their* seat:
// they never appear in their own recipients list, and the list is the other two.
func TestGroupDM_ChannelOpenIsPerViewer(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "grp-open-alice")
bob := seedMemberUser(t, database, "grp-open-bob")
carol := seedMemberUser(t, database, "grp-open-carol")
chID := seedGroupDM(t, database, "Openers", alice.ID, bob.ID, carol.ID)
// A closed DM is what makes the send re-open it and emit dm_channel_open.
if err := database.CloseDM(context.Background(), bob.ID, chID); err != nil {
t.Fatalf("CloseDM: %v", err)
}
if err := database.CloseDM(context.Background(), carol.ID, chID); err != nil {
t.Fatalf("CloseDM: %v", err)
}
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
hub.Register(cAlice)
hub.Register(cBob)
waitRegistered(t, hub, cBob)
hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "ping"))
env := dmWaitMsgType(sendBob, "dm_channel_open", waitTimeout)
if env == nil {
t.Fatal("bob did not receive dm_channel_open")
}
payload, _ := env["payload"].(map[string]any)
if payload["is_group"] != true {
t.Errorf("expected is_group=true, got %v", payload["is_group"])
}
if payload["name"] != "Openers" {
t.Errorf("expected the group name on the wire, got %v", payload["name"])
}
recips, _ := payload["recipients"].([]any)
if len(recips) != 2 {
t.Fatalf("expected bob to see 2 other participants, got %d", len(recips))
}
for _, r := range recips {
m, _ := r.(map[string]any)
if int64(m["id"].(float64)) == bob.ID {
t.Error("bob appears in his own recipients list")
}
}
}
// Typing already fanned out over dm_participants; this pins that a group's
// third member is included and the sender is not.
func TestGroupDM_TypingReachesAllOthers(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "grp-type-alice")
bob := seedMemberUser(t, database, "grp-type-bob")
carol := seedMemberUser(t, database, "grp-type-carol")
chID := seedGroupDM(t, database, "Typers", alice.ID, bob.ID, carol.ID)
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
sendCarol := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol)
hub.Register(cAlice)
hub.Register(cBob)
hub.Register(cCarol)
waitRegistered(t, hub, cCarol)
hub.HandleMessageForTest(cAlice, dmTypingMsg(chID))
if dmWaitMsgType(sendBob, "typing", waitTimeout) == nil {
t.Error("bob did not receive the typing indicator")
}
if dmWaitMsgType(sendCarol, "typing", waitTimeout) == nil {
t.Error("carol did not receive the typing indicator")
}
if got := dmFindMsgType(dmDrainAll(sendAlice), "typing"); got != nil {
t.Error("the typist received their own typing indicator")
}
}
// A block between two members of a group must NOT silence the group: the
// composer gate is a 1:1 rule, and dropping one member's messages for one
// other member would leave them reading different conversations.
func TestGroupDM_BlockDoesNotSilenceGroupSend(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "grp-blk-alice")
bob := seedMemberUser(t, database, "grp-blk-bob")
carol := seedMemberUser(t, database, "grp-blk-carol")
chID := seedGroupDM(t, database, "Blockers", alice.ID, bob.ID, carol.ID)
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
t.Fatalf("BlockUser: %v", err)
}
sendAlice := make(chan []byte, 64)
sendCarol := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol)
hub.Register(cAlice)
hub.Register(cCarol)
waitRegistered(t, hub, cCarol)
hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "still a room"))
if dmWaitMsgType(sendAlice, "chat_send_ok", waitTimeout) == nil {
t.Error("a group send was refused because of a block between two members")
}
if dmWaitMsgType(sendCarol, "chat_message", waitTimeout) == nil {
t.Error("carol did not receive the group message")
}
}
// A 1:1 block must still bite — the group exemption is not a general one.
func TestOneToOneDM_BlockStillRefusesSend(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "one-blk-alice")
bob := seedMemberUser(t, database, "one-blk-bob")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
t.Fatalf("BlockUser: %v", err)
}
sendAlice := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
hub.Register(cAlice)
waitRegistered(t, hub, cAlice)
hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "blocked"))
if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "FORBIDDEN" {
t.Errorf("expected FORBIDDEN on a blocked 1:1 send, got %q", code)
}
}
// ─── call ringing ───────────────────────────────────────────────────────────
func TestCallRing_ForwardsToOtherParticipants(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ring-alice")
bob := seedMemberUser(t, database, "ring-bob")
carol := seedMemberUser(t, database, "ring-carol")
chID := seedGroupDM(t, database, "Ringers", alice.ID, bob.ID, carol.ID)
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
sendCarol := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol)
hub.Register(cAlice)
hub.Register(cBob)
hub.Register(cCarol)
waitRegistered(t, hub, cCarol)
hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID))
env := dmWaitMsgType(sendBob, "call_incoming", waitTimeout)
if env == nil {
t.Fatal("bob did not receive call_incoming")
}
payload, _ := env["payload"].(map[string]any)
if int64(payload["channel_id"].(float64)) != chID {
t.Errorf("call_incoming carried channel %v, want %d", payload["channel_id"], chID)
}
if int64(payload["from_user"].(float64)) != alice.ID {
t.Errorf("call_incoming carried from_user %v, want %d", payload["from_user"], alice.ID)
}
if dmWaitMsgType(sendCarol, "call_incoming", waitTimeout) == nil {
t.Error("carol did not receive call_incoming")
}
// The ringer must not ring themselves.
if got := dmFindMsgType(dmDrainAll(sendAlice), "call_incoming"); got != nil {
t.Error("the ringer received their own call_incoming")
}
}
func TestCallRing_NonParticipantForbidden(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ring-perm-alice")
bob := seedMemberUser(t, database, "ring-perm-bob")
mallory := seedMemberUser(t, database, "ring-perm-mallory")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
sendMallory := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
cMallory := ws.NewTestClientWithUser(hub, mallory, chID, sendMallory)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
hub.Register(cMallory)
hub.Register(cBob)
waitRegistered(t, hub, cBob)
hub.HandleMessageForTest(cMallory, callMsg("call_ring", chID))
if code := dmFindErrorCode(dmCollectAll(sendMallory, absenceWindow)); code != "FORBIDDEN" {
t.Errorf("expected FORBIDDEN ringing a DM she is not in, got %q", code)
}
if got := dmFindMsgType(dmDrainAll(sendBob), "call_incoming"); got != nil {
t.Error("a non-participant's ring reached a participant")
}
}
func TestCallDecline_ForwardsToOtherParticipants(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "decline-alice")
bob := seedMemberUser(t, database, "decline-bob")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
hub.Register(cAlice)
hub.Register(cBob)
waitRegistered(t, hub, cBob)
hub.HandleMessageForTest(cBob, callMsg("call_decline", chID))
env := dmWaitMsgType(sendAlice, "call_declined", waitTimeout)
if env == nil {
t.Fatal("the ringer did not receive call_declined")
}
payload, _ := env["payload"].(map[string]any)
if int64(payload["from_user"].(float64)) != bob.ID {
t.Errorf("call_declined carried from_user %v, want %d", payload["from_user"], bob.ID)
}
}
func TestCallRing_RateLimited(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ringlimit-alice")
bob := seedMemberUser(t, database, "ringlimit-bob")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
sendAlice := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
hub.Register(cAlice)
waitRegistered(t, hub, cAlice)
hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID))
hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID))
if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "RATE_LIMITED" {
t.Errorf("expected RATE_LIMITED on a second immediate ring, got %q", code)
}
}
func TestCallDecline_RateLimited(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "declinelimit-alice")
bob := seedMemberUser(t, database, "declinelimit-bob")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
sendAlice := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
hub.Register(cAlice)
waitRegistered(t, hub, cAlice)
// Same shape as call_ring: each frame costs participant + block lookups
// and a fan-out to every other participant, so it needs the same limit.
hub.HandleMessageForTest(cAlice, callMsg("call_decline", chID))
hub.HandleMessageForTest(cAlice, callMsg("call_decline", chID))
if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "RATE_LIMITED" {
t.Errorf("expected RATE_LIMITED on a second immediate decline, got %q", code)
}
_ = bob
}
// A block must silence the 1:1 ring like every other DM sink: without it a
// blocked user could still make the blocker's client ring (A-2026-08-03).
func TestCallRing_BlockedOneToOneForbidden(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ring-blk-alice")
bob := seedMemberUser(t, database, "ring-blk-bob")
chID := seedDMChannel(t, database, alice.ID, bob.ID)
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
t.Fatalf("BlockUser: %v", err)
}
sendAlice := make(chan []byte, 64)
sendBob := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
hub.Register(cAlice)
hub.Register(cBob)
waitRegistered(t, hub, cBob)
hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID))
if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "FORBIDDEN" {
t.Errorf("expected FORBIDDEN ringing a blocked 1:1 DM, got %q", code)
}
if got := dmFindMsgType(dmDrainAll(sendBob), "call_incoming"); got != nil {
t.Error("a blocked user's ring reached the blocker")
}
}
// The group exemption applies to rings exactly as it does to sends: a block
// between two members must not silence the room's call signal for everyone.
func TestCallRing_GroupWithInternalBlockStillRings(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ring-gblk-alice")
bob := seedMemberUser(t, database, "ring-gblk-bob")
carol := seedMemberUser(t, database, "ring-gblk-carol")
chID := seedGroupDM(t, database, "BlockedRingers", alice.ID, bob.ID, carol.ID)
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
t.Fatalf("BlockUser: %v", err)
}
sendBob := make(chan []byte, 64)
sendCarol := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, chID, make(chan []byte, 64))
cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob)
cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol)
hub.Register(cAlice)
hub.Register(cBob)
hub.Register(cCarol)
waitRegistered(t, hub, cCarol)
hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID))
if dmWaitMsgType(sendBob, "call_incoming", waitTimeout) == nil {
t.Error("a group ring was silenced by a block between two members")
}
if dmWaitMsgType(sendCarol, "call_incoming", waitTimeout) == nil {
t.Error("carol did not receive the group call_incoming")
}
}
2026-08-01 22:06:14 +02:00
func TestCallRing_RejectsNonPositiveChannel(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "ringbad-alice")
sendAlice := make(chan []byte, 64)
cAlice := ws.NewTestClientWithUser(hub, alice, 0, sendAlice)
hub.Register(cAlice)
waitRegistered(t, hub, cAlice)
hub.HandleMessageForTest(cAlice, callMsg("call_ring", 0))
if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "BAD_REQUEST" {
t.Errorf("expected BAD_REQUEST for channel_id 0, got %q", code)
}
}