mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020) * fix(db): 1 defect(s) (OC-0096) * fix(admin): 1 defect(s) (OC-0097) * fix(auth): 2 defect(s) (OC-0099, OC-0021) * fix(voice): 1 defect(s) (OC-0018) * fix(admin): 1 defect(s) (OC-0045) * fix(api): 1 defect(s) (OC-0103) * fix(client): 1 defect(s) (OC-0105) * fix(client): 1 defect(s) (OC-0107) * fix(api): 1 defect(s) (OC-0109) * fix(api): 1 defect(s) (OC-0112) * test(admin): compare restore bytes with bytes.Equal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0095, OC-0014) OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext. OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token. * fix(profile): 2 defect(s) (OC-0100, OC-0102) * fix(service): 1 defect(s) (OC-0022) Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate. * fix(api): 1 defect(s) (OC-0048) * chore(workflows): correct stale model labels in bughunt-fix phase details Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): 1 defect(s) (OC-0015) * fix(voice): 1 defect(s) (OC-0002) * test: fix two CI-only failures in the batch-4 test suite The delete-account broadcast test now observes member_ban on a second client's socket: the hub broadcasts and then force-disconnects the target, so on a slow runner the close could beat the target's own copy of the frame. The observer is also the party the event exists for. The voice e2e mock now echoes the real joined channel id on voice_leave (it hardcoded channel_id 0, which the dispatcher's channel-matched self-leave teardown correctly ignores), and the rejoin test waits for the mock's delayed echoes to settle before clicking the row again — clicking inside the echo window toggled a leave instead of a join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
297 lines
10 KiB
Go
297 lines
10 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)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
|
|
|
|
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)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
|
|
|
|
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)
|
|
waitRegistered(t, hub, c)
|
|
|
|
// 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())
|
|
|
|
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)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
|
|
drainChanTimeout(send, 50*time.Millisecond)
|
|
|
|
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
|
|
|
|
if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) {
|
|
t.Error("a DM participant must still be able to refresh their voice token")
|
|
}
|
|
}
|
|
|
|
// Group DMs need no separate voice authorization path: dm_participants holds
|
|
// one row per participant and the gate is a lookup on (user_id, channel_id).
|
|
// These two pin that the existing path genuinely covers the N-participant case
|
|
// — a third member gets in, and an outsider still does not.
|
|
func TestVoiceJoin_GroupDMParticipant_Joins(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
alice := seedMemberUser(t, database, "grpvoice-alice")
|
|
bob := seedMemberUser(t, database, "grpvoice-bob")
|
|
carol := seedMemberUser(t, database, "grpvoice-carol")
|
|
chID := seedGroupDM(t, database, "Callers", alice.ID, bob.ID, carol.ID)
|
|
|
|
send := make(chan []byte, 32)
|
|
c := ws.NewTestClientWithUser(hub, carol, 0, send)
|
|
hub.Register(c)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(chID))
|
|
|
|
if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) {
|
|
t.Error("the third member of a group DM must receive a voice token")
|
|
}
|
|
|
|
state, err := database.GetVoiceState(context.Background(), carol.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
}
|
|
if state == nil || state.ChannelID != chID {
|
|
t.Fatalf("group participant voice state = %+v, want channel %d", state, chID)
|
|
}
|
|
}
|
|
|
|
// channelReadAudience used to resolve a DM's audience via the role scan (DMs
|
|
// carry no channel_overrides), so any connected user whose base role held
|
|
// READ_MESSAGES received the DM call's voice_state/voice_leave events —
|
|
// leaking who is in a private call and their mute/camera state to the whole
|
|
// server. The audience must be the DM's participants, not a role-wide scan.
|
|
func TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
alice := seedMemberUser(t, database, "dmleak-alice")
|
|
bob := seedMemberUser(t, database, "dmleak-bob")
|
|
mallory := seedMemberUser(t, database, "dmleak-mallory") // connected, has READ_MESSAGES, NOT a participant
|
|
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
|
|
|
|
aliceSend := make(chan []byte, 32)
|
|
bobSend := make(chan []byte, 32)
|
|
mallorySend := make(chan []byte, 32)
|
|
aliceClient := ws.NewTestClientWithUser(hub, alice, 0, aliceSend)
|
|
bobClient := ws.NewTestClientWithUser(hub, bob, 0, bobSend)
|
|
malloryClient := ws.NewTestClientWithUser(hub, mallory, 0, mallorySend)
|
|
hub.Register(aliceClient)
|
|
hub.Register(bobClient)
|
|
hub.Register(malloryClient)
|
|
waitRegistered(t, hub, malloryClient)
|
|
|
|
hub.HandleMessageForTest(aliceClient, voiceJoinMsg(dmID))
|
|
|
|
bobMsgs := drainChanTimeout(bobSend, 300*time.Millisecond)
|
|
foundVoiceState := false
|
|
for _, m := range bobMsgs {
|
|
if extractType(t, m) == "voice_state" {
|
|
foundVoiceState = true
|
|
}
|
|
}
|
|
if !foundVoiceState {
|
|
t.Error("a DM participant must still receive voice_state for their own DM call")
|
|
}
|
|
|
|
malloryMsgs := drainChanTimeout(mallorySend, 300*time.Millisecond)
|
|
for _, m := range malloryMsgs {
|
|
if extractType(t, m) == "voice_state" {
|
|
t.Fatal("voice_state for a DM call leaked to a connected non-participant")
|
|
}
|
|
}
|
|
}
|
|
|
|
// OC-0018: voice_join into a 1:1 DM had no block gate. Every other 1:1-DM
|
|
// interaction sink (send, edit, react, pin, typing, call_ring) routes through
|
|
// service.requireDMNotBlocked; voice was the one gap. Blocking never touches
|
|
// dm_participants (service/block.go), so IsDMParticipant still passes a
|
|
// blocked user straight through into the blocker's DM voice room.
|
|
func TestVoiceJoin_DMBlocked_Refused(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
alice := seedMemberUser(t, database, "dmblock-alice")
|
|
bob := seedMemberUser(t, database, "dmblock-bob")
|
|
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
|
|
|
|
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
|
|
t.Fatalf("BlockUser: %v", err)
|
|
}
|
|
|
|
send := make(chan []byte, 32)
|
|
c := ws.NewTestClientWithUser(hub, alice, 0, send)
|
|
hub.Register(c)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
|
|
|
|
assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond))
|
|
|
|
state, err := database.GetVoiceState(context.Background(), alice.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
}
|
|
if state != nil {
|
|
t.Fatalf("blocked user was persisted into the DM's voice channel (%d)", state.ChannelID)
|
|
}
|
|
}
|
|
|
|
// Second entry point: a block imposed mid-session must also evict on the next
|
|
// token refresh, not just refuse the initial join. Alice joins while still
|
|
// unblocked (so the join succeeds and a real voice_states row exists), then
|
|
// bob blocks her; the refresh must re-check and evict rather than keep
|
|
// minting fresh SFU room-join credentials for the old session.
|
|
func TestVoiceTokenRefresh_DMBlocked_Refused(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
alice := seedMemberUser(t, database, "dmblockrefresh-alice")
|
|
bob := seedMemberUser(t, database, "dmblockrefresh-bob")
|
|
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
|
|
|
|
send := make(chan []byte, 32)
|
|
c := ws.NewTestClientWithUser(hub, alice, 0, send)
|
|
hub.Register(c)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(dmID))
|
|
drainChanTimeout(send, 50*time.Millisecond)
|
|
|
|
if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil {
|
|
t.Fatalf("BlockUser: %v", err)
|
|
}
|
|
|
|
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
|
|
|
|
assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond))
|
|
}
|
|
|
|
func TestVoiceJoin_GroupDMNonParticipant_Refused(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
alice := seedMemberUser(t, database, "grpvoice-x-alice")
|
|
bob := seedMemberUser(t, database, "grpvoice-x-bob")
|
|
carol := seedMemberUser(t, database, "grpvoice-x-carol")
|
|
mallory := seedMemberUser(t, database, "grpvoice-x-mallory")
|
|
chID := seedGroupDM(t, database, "Callers", alice.ID, bob.ID, carol.ID)
|
|
|
|
send := make(chan []byte, 32)
|
|
c := ws.NewTestClientWithUser(hub, mallory, 0, send)
|
|
hub.Register(c)
|
|
waitRegistered(t, hub, c)
|
|
|
|
hub.HandleMessageForTest(c, voiceJoinMsg(chID))
|
|
|
|
assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond))
|
|
}
|