From d2705dff9299e3773f88bf5fe48ba8ea74142b5d Mon Sep 17 00:00:00 2001 From: jevb Date: Thu, 2 Apr 2026 11:40:16 +0200 Subject: [PATCH] fix: filter voice states by channel visibility in ready payload (BUG-095) Voice states were loaded with GetAllVoiceStates across the entire server, leaking who was in hidden voice channels. Now voice states are filtered through the visible channel set before inclusion in the ready payload. Updated tests to use explicit roles where voice state visibility matters. --- Server/ws/coverage_boost_test.go | 8 +++- Server/ws/serve.go | 16 +++++-- Server/ws/serve_test.go | 77 +++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index b30de0bb..f6d2c451 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -484,6 +484,10 @@ func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "ready-voice-user") + role, rErr := database.GetRoleByID(1) + if rErr != nil || role == nil { + t.Fatalf("GetRoleByID: %v", rErr) + } // Create a voice channel. vcID, err := database.CreateChannel("voice-room", "voice", "", "", 0) @@ -497,9 +501,9 @@ func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { t.Fatalf("JoinVoiceChannel: %v", err) } - msg, err := hub.BuildReadyForTest(database, user.ID) + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) if err != nil { - t.Fatalf("BuildReadyForTest: %v", err) + t.Fatalf("BuildReadyWithRoleForTest: %v", err) } var env struct { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index a5e6c9d3..a53d4ffe 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -456,12 +456,22 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, channelPayloads = append(channelPayloads, entry) } - // Collect all active voice states across every voice channel. - voiceStates, err := collectAllVoiceStates(database, channels) + // Collect voice states, filtered to only visible channels (BUG-095). + allVoiceStates, err := collectAllVoiceStates(database, channels) if err != nil { // Non-fatal: send empty list rather than failing the whole ready payload. slog.Warn("buildReady collectAllVoiceStates", "err", err) - voiceStates = []db.VoiceState{} + allVoiceStates = []db.VoiceState{} + } + visibleSet := make(map[int64]struct{}, len(visibleChannels)) + for i := range visibleChannels { + visibleSet[visibleChannels[i].ID] = struct{}{} + } + voiceStates := make([]db.VoiceState, 0, len(allVoiceStates)) + for i := range allVoiceStates { + if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok { + voiceStates = append(voiceStates, allVoiceStates[i]) + } } // Load open DM channels for this user. diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go index 63162290..c61f8c95 100644 --- a/Server/ws/serve_test.go +++ b/Server/ws/serve_test.go @@ -389,6 +389,7 @@ func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) { func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { hub, database := newServeHub(t) + role := ownerRole(t, database) user1 := seedServeUser(t, database, "collect-voice-u1") user2 := seedServeUser(t, database, "collect-voice-u2") @@ -407,9 +408,9 @@ func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { t.Fatalf("JoinVoiceChannel user2: %v", err) } - msg, err := hub.BuildReadyForTest(database, requester.ID) + msg, err := hub.BuildReadyWithRoleForTest(database, requester.ID, role) if err != nil { - t.Fatalf("BuildReadyForTest: %v", err) + t.Fatalf("BuildReadyWithRoleForTest: %v", err) } var env struct { Payload struct { @@ -432,6 +433,78 @@ func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { } } +// ─── Voice state filtering by channel visibility (BUG-095) ─────────────────── + +func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) { + hub, database := newServeHub(t) + + // Create a member user (role 4, permissions=1635, includes ReadMessages). + _, err := database.CreateUser("vs-member", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + member, err := database.GetUserByUsername("vs-member") + if err != nil || member == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + memberRole, err := database.GetRoleByID(4) + if err != nil || memberRole == nil { + t.Fatalf("GetRoleByID: %v", err) + } + + // Create two voice channels: one visible, one denied. + visibleCh, err := database.CreateChannel("public-voice", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel visible: %v", err) + } + hiddenCh, err := database.CreateChannel("hidden-voice", "voice", "", "", 1) + if err != nil { + t.Fatalf("CreateChannel hidden: %v", err) + } + + // Deny READ_MESSAGES on the hidden channel for Member role (role 4). + _, err = database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, + hiddenCh, + ) + if err != nil { + t.Fatalf("insert channel_override: %v", err) + } + + // Create users in both voice channels. + u1 := seedServeUser(t, database, "vs-visible-user") + u2 := seedServeUser(t, database, "vs-hidden-user") + if err := database.JoinVoiceChannel(u1.ID, visibleCh); err != nil { + t.Fatalf("JoinVoiceChannel visible: %v", err) + } + if err := database.JoinVoiceChannel(u2.ID, hiddenCh); err != nil { + t.Fatalf("JoinVoiceChannel hidden: %v", err) + } + + // Build ready for the member — should only see voice states for visible channel. + msg, err := hub.BuildReadyWithRoleForTest(database, member.ID, memberRole) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + + var env struct { + Payload struct { + VoiceStates []struct { + ChannelID int64 `json:"channel_id"` + } `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 1 { + t.Fatalf("voice_states count = %d, want 1 (only visible channel)", len(env.Payload.VoiceStates)) + } + if env.Payload.VoiceStates[0].ChannelID != visibleCh { + t.Errorf("voice_state channel_id = %d, want %d (visible)", env.Payload.VoiceStates[0].ChannelID, visibleCh) + } +} + // ─── getCachedSettings ──────────────────────────────────────────────────────── func TestGetCachedSettings_CacheHit(t *testing.T) {