Files
OwnCord/Server/ws/voice_dm_access_test.go
T
J3vbandClaude Opus 5 d6cc57af56 fix(ws): require DM participation to join a DM voice room (F11)
The voice gate authorized a client-supplied channel_id with role bits only,
and DM channels carry no channel_overrides rows, so any member's base
CONNECT_VOICE bit minted a LiveKit RoomJoin and CanSubscribe token for any DM.
Both voice entry points now go through a gate that re-runs the old role
predicate and additionally requires DM participation, delegating that rule to
the existing permissions.Checker.RequireChannelAccess rather than adding a
second implementation of it.

Verified by a panel of agents; a negative control of the base tree plus only
the new test file fails both non-participant tests with a LiveKit room token
issued for a DM the user is not a participant of, while both participant tests
pass on base and patched alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:18:08 +02:00

148 lines
4.6 KiB
Go

package ws_test
import (
"context"
"testing"
"time"
"github.com/owncord/server/ws"
)
// F11: voice_join and voice_token_refresh authorized the client-supplied channel
// id with a role-only permission check. DM channels carry no channel_overrides,
// so a default Member's base CONNECT_VOICE bit satisfied that check for ANY dm
// channel id and the server minted a LiveKit RoomJoin+CanSubscribe token for a
// conversation the caller is not part of (and then fed them the other
// participants' voice_e2ee_announce keys). Both entry points must consult DM
// membership; both must still work for a genuine participant.
// assertNoVoiceToken asserts that no LiveKit token reached the client and that a
// FORBIDDEN error did.
func assertNoVoiceToken(t *testing.T, msgs [][]byte) {
t.Helper()
for _, m := range msgs {
if extractType(t, m) == "voice_token" {
t.Fatal("a LiveKit room token was issued for a DM the user is not a participant of")
}
}
found := false
for _, m := range msgs {
if extractCode(t, m) == "FORBIDDEN" {
found = true
break
}
}
if !found {
t.Error("expected a FORBIDDEN error for the non-participant")
}
}
func hasVoiceToken(t *testing.T, msgs [][]byte) bool {
t.Helper()
for _, m := range msgs {
if extractType(t, m) == "voice_token" {
return true
}
}
return false
}
func TestVoiceJoin_DMNonParticipant_GetsNoTokenAndNoVoiceState(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "dmvoice-alice")
bob := seedMemberUser(t, database, "dmvoice-bob")
mallory := seedMemberUser(t, database, "dmvoice-mallory")
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, mallory, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
time.Sleep(50 * time.Millisecond)
assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond))
state, err := database.GetVoiceState(context.Background(), mallory.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state != nil {
t.Fatalf("non-participant was persisted into the DM's voice channel (%d)", state.ChannelID)
}
}
func TestVoiceJoin_DMParticipant_StillJoins(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "dmvoice-ok-alice")
bob := seedMemberUser(t, database, "dmvoice-ok-bob")
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, alice, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
time.Sleep(50 * time.Millisecond)
if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) {
t.Error("a DM participant must still receive a voice token for their own DM")
}
state, err := database.GetVoiceState(context.Background(), alice.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil || state.ChannelID != dmID {
t.Fatalf("participant voice state = %+v, want channel %d", state, dmID)
}
}
func TestVoiceTokenRefresh_DMNonParticipant_Refused(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "dmrefresh-alice")
bob := seedMemberUser(t, database, "dmrefresh-bob")
mallory := seedMemberUser(t, database, "dmrefresh-mallory")
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, mallory, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Second entry point: the refresh mints a token from the session's own voice
// channel id, so it must re-run the same membership check rather than trust
// that a join once passed.
ws.SetVoiceChIDForTest(c, dmID)
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
time.Sleep(50 * time.Millisecond)
assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond))
}
func TestVoiceTokenRefresh_DMParticipant_StillRefreshes(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "dmrefresh-ok-alice")
bob := seedMemberUser(t, database, "dmrefresh-ok-bob")
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, alice, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
time.Sleep(50 * time.Millisecond)
drainChanBuf(send)
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
time.Sleep(50 * time.Millisecond)
if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) {
t.Error("a DM participant must still be able to refresh their voice token")
}
}