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.
This commit is contained in:
jevb
2026-04-02 11:40:16 +02:00
parent 125512d591
commit d2705dff92
3 changed files with 94 additions and 7 deletions
+6 -2
View File
@@ -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 {
+13 -3
View File
@@ -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.
+75 -2
View File
@@ -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) {