Files
OwnCord/Server/ws/voice_moderation_test.go
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

736 lines
27 KiB
Go

package ws_test
import (
"context"
"encoding/json"
"slices"
"testing"
"testing/fstest"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/ws"
)
// voiceModSchema is voiceSchema plus audit_log, so the moderation handlers'
// db.WriteAudit calls land in a real table instead of erroring out.
var voiceModSchema = append(append([]byte{}, voiceSchema...), []byte(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)...)
// newVoiceModHub mirrors newVoiceHub but on the audit-capable schema.
func newVoiceModHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{"001_schema.sql": {Data: voiceModSchema}}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "test-api-key-12345",
LiveKitAPISecret: "test-api-secret-67890abcdef",
LiveKitURL: "ws://localhost:7880",
})
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub.SetLiveKit(lk)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
return hub, database
}
// seedVoiceUserWithRole inserts a user with an explicit role id from the
// default role table (1 Owner/pos 100, 2 Admin/pos 80, 3 Moderator/pos 60,
// 4 Member/pos 40). Admin holds MUTE_MEMBERS without ADMINISTRATOR; Moderator
// holds neither, which is what the denial cases below rely on.
func seedVoiceUserWithRole(t *testing.T, database *db.DB, username string, roleID int) *db.User {
t.Helper()
if _, err := database.CreateUser(context.Background(), username, "hash", roleID); err != nil {
t.Fatalf("seedVoiceUserWithRole CreateUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), username)
if err != nil || user == nil {
t.Fatalf("seedVoiceUserWithRole GetUserByUsername: %v", err)
}
return user
}
// joinVoice registers a client for user and puts them in chanID via voice_join,
// returning the client and its send channel drained of the join traffic.
func joinVoice(t *testing.T, hub *ws.Hub, user *db.User, chanID int64) (*ws.Client, chan []byte) {
t.Helper()
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
drainChanTimeout(send, 30*time.Millisecond)
return c, send
}
func voiceModMuteMsg(channelID, userID int64, muted bool) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_mod_mute",
"payload": map[string]any{"channel_id": channelID, "user_id": userID, "muted": muted},
})
return raw
}
func voiceModDeafenMsg(channelID, userID int64, deafened bool) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_mod_deafen",
"payload": map[string]any{"channel_id": channelID, "user_id": userID, "deafened": deafened},
})
return raw
}
func voiceModMoveMsg(userID, toChannelID int64) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_mod_move",
"payload": map[string]any{"user_id": userID, "to_channel_id": toChannelID},
})
return raw
}
func voiceModKickMsg(userID int64) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_mod_kick",
"payload": map[string]any{"user_id": userID},
})
return raw
}
// auditActions returns the actions recorded in the audit log, newest first.
func auditActions(t *testing.T, database *db.DB) []string {
t.Helper()
entries, err := database.GetAuditLog(context.Background(), 50, 0)
if err != nil {
t.Fatalf("GetAuditLog: %v", err)
}
actions := make([]string, 0, len(entries))
for _, e := range entries {
actions = append(actions, e.Action)
}
return actions
}
// ─── authorization ────────────────────────────────────────────────────────────
func TestVoiceMod_Mute_WithoutMutePermission_Forbidden(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-perm")
actor := seedVoiceUserWithRole(t, database, "mod-noperm", 3) // Moderator: no MUTE_MEMBERS
target := seedVoiceUserWithRole(t, database, "target-perm", 4) // Member
joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" {
t.Fatalf("error code = %q, want FORBIDDEN", code)
}
state, _ := database.GetVoiceState(context.Background(), target.ID)
if state == nil || state.ServerMuted {
t.Error("target must not be server muted after a refused action")
}
}
func TestVoiceMod_Mute_TargetOutranksActor_Forbidden(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-rank")
actor := seedVoiceUserWithRole(t, database, "admin-rank", 2) // Admin, position 80
target := seedVoiceUserWithRole(t, database, "owner-rank", 1) // Owner, position 100
joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" {
t.Fatalf("error code = %q, want FORBIDDEN", code)
}
state, _ := database.GetVoiceState(context.Background(), target.ID)
if state == nil || state.ServerMuted {
t.Error("higher-ranked target must not be server muted")
}
}
func TestVoiceMod_Mute_TargetNotInVoice_VoiceError(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-absent")
actor := seedVoiceUserWithRole(t, database, "admin-absent", 2)
target := seedVoiceUserWithRole(t, database, "member-absent", 4)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" {
t.Fatalf("error code = %q, want VOICE_ERROR", code)
}
}
func TestVoiceMod_Mute_WrongChannel_VoiceError(t *testing.T) {
hub, database := newVoiceModHub(t)
chanA := seedVoiceChan(t, database, "vc-wrong-a")
chanB := seedVoiceChan(t, database, "vc-wrong-b")
actor := seedVoiceUserWithRole(t, database, "admin-wrong", 2)
target := seedVoiceUserWithRole(t, database, "member-wrong", 4)
joinVoice(t, hub, target, chanA)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanA, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanB, target.ID, true))
if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" {
t.Fatalf("error code = %q, want VOICE_ERROR", code)
}
}
func TestVoiceMod_Kick_Self_BadRequest(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-self")
actor := seedVoiceUserWithRole(t, database, "admin-self", 2)
c, send := joinVoice(t, hub, actor, chanID)
hub.HandleMessageForTest(c, voiceModKickMsg(actor.ID))
if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" {
t.Fatalf("error code = %q, want BAD_REQUEST", code)
}
}
// voice_mod_kick and friends carry no channel id for the target, so
// MUTE_MEMBERS alone let a moderator reach into a private DM call they are
// not a participant of by targeting a user id. The refusal must look exactly
// like "target not in voice" — a VOICE_ERROR, not FORBIDDEN — so the actor
// cannot use the response to learn the target is in a DM call at all.
func TestVoiceMod_Mute_TargetInDMCallActorNotParticipant_VoiceError(t *testing.T) {
hub, database := newVoiceModHub(t)
alice := seedVoiceUserWithRole(t, database, "dm-alice", 4) // Member, DM participant
bob := seedVoiceUserWithRole(t, database, "dm-bob", 4) // Member, DM participant (target)
mallory := seedVoiceUserWithRole(t, database, "dm-mallory", 2) // Admin: has MUTE_MEMBERS, not a participant
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
joinVoice(t, hub, bob, dmID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, mallory, 0, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(dmID, bob.ID, true))
if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" {
t.Fatalf("error code = %q, want VOICE_ERROR (must not disclose the DM call via FORBIDDEN)", code)
}
state, _ := database.GetVoiceState(context.Background(), bob.ID)
if state == nil || state.ServerMuted {
t.Error("target in a DM call the actor is not part of must not be server muted")
}
}
// A MUTE_MEMBERS holder who genuinely IS a participant of the DM call may
// still moderate it, same as any other voice channel.
func TestVoiceMod_Mute_TargetInDMCallActorIsParticipant_Allowed(t *testing.T) {
hub, database := newVoiceModHub(t)
admin := seedVoiceUserWithRole(t, database, "dm-admin", 2) // Admin: MUTE_MEMBERS, DM participant
member := seedVoiceUserWithRole(t, database, "dm-member", 4) // Member (target)
dmID := seedDMChannel(t, database, admin.ID, member.ID)
joinVoice(t, hub, member, dmID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, admin, 0, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(dmID, member.ID, true))
state, err := database.GetVoiceState(context.Background(), member.ID)
if err != nil || state == nil || !state.ServerMuted {
t.Fatalf("expected target to be server muted, state=%+v err=%v", state, err)
}
}
// ─── happy paths ──────────────────────────────────────────────────────────────
func TestVoiceMod_Mute_SetsServerMutedAndBroadcasts(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-mute")
actor := seedVoiceUserWithRole(t, database, "admin-mute", 2)
target := seedVoiceUserWithRole(t, database, "member-mute", 4)
_, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil || state == nil {
t.Fatalf("GetVoiceState: %v", err)
}
if !state.ServerMuted {
t.Error("ServerMuted = false, want true")
}
if !state.Muted {
t.Error("Muted = false, want true (server mute implies muted)")
}
payload := receiveMsgOfType(targetSend, "voice_state", waitTimeout)
if payload == nil {
t.Fatal("no voice_state broadcast after voice_mod_mute")
}
if payload["server_muted"] != true {
t.Errorf("broadcast server_muted = %v, want true", payload["server_muted"])
}
if !slices.Contains(auditActions(t, database), "voice_mod_mute") {
t.Error("voice_mod_mute audit entry missing")
}
}
func TestVoiceMod_Mute_Clear_LeavesSelfMuteAlone(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-unmute")
actor := seedVoiceUserWithRole(t, database, "admin-unmute", 2)
target := seedVoiceUserWithRole(t, database, "member-unmute", 4)
joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, false))
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil || state == nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state.ServerMuted {
t.Error("ServerMuted = true after clear, want false")
}
if !state.Muted {
t.Error("Muted = false, want true: clearing a server mute must not unmute for the user")
}
}
func TestVoiceMod_Deafen_SetsServerDeafenedAndMutes(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-deafen")
actor := seedVoiceUserWithRole(t, database, "admin-deafen", 2)
target := seedVoiceUserWithRole(t, database, "member-deafen", 4)
_, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true))
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil || state == nil {
t.Fatalf("GetVoiceState: %v", err)
}
if !state.ServerDeafened || !state.Deafened {
t.Errorf("ServerDeafened=%v Deafened=%v, want both true", state.ServerDeafened, state.Deafened)
}
if !state.ServerMuted {
t.Error("ServerMuted = false, want true: a server deafen also silences the microphone")
}
payload := receiveMsgOfType(targetSend, "voice_state", waitTimeout)
if payload == nil {
t.Fatal("no voice_state broadcast after voice_mod_deafen")
}
if payload["server_deafened"] != true {
t.Errorf("broadcast server_deafened = %v, want true", payload["server_deafened"])
}
if !slices.Contains(auditActions(t, database), "voice_mod_deafen") {
t.Error("voice_mod_deafen audit entry missing")
}
}
// Lifting a server deafen must also lift the mute it implied, or the target
// stays SFU-muted (and refused their own unmute) after the deafen is gone.
func TestVoiceMod_Deafen_ClearingRestoresSelfUnmute(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-undeafen")
actor := seedVoiceUserWithRole(t, database, "admin-undeafen", 2)
target := seedVoiceUserWithRole(t, database, "member-undeafen", 4)
targetClient, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
// Server-deafen the target: implies a server mute too.
hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true))
drainChanTimeout(targetSend, 30*time.Millisecond)
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil || state == nil || !state.ServerDeafened || !state.ServerMuted {
t.Fatalf("precondition: expected server_deafened and server_muted both set, state=%+v err=%v", state, err)
}
// Lift the deafen.
hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, false))
drainChanTimeout(targetSend, 30*time.Millisecond)
state, err = database.GetVoiceState(context.Background(), target.ID)
if err != nil || state == nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state.ServerDeafened {
t.Error("ServerDeafened = true after clearing, want false")
}
if state.ServerMuted {
t.Error("ServerMuted = true after clearing the deafen that implied it, want false")
}
// The target must now be able to self-unmute without SERVER_MUTED.
hub.HandleMessageForTest(targetClient, voiceMuteMsg(false))
if code := receiveErrorCode(targetSend, 200*time.Millisecond); code != "" {
t.Fatalf("self-unmute refused with %q after deafen was cleared, want no error", code)
}
}
func TestVoiceMod_Kick_RemovesFromVoiceAndNotifiesTarget(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-kick")
actor := seedVoiceUserWithRole(t, database, "admin-kick", 2)
target := seedVoiceUserWithRole(t, database, "member-kick", 4)
_, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModKickMsg(target.ID))
waitFor(t, waitTimeout, func() bool {
state, err := database.GetVoiceState(context.Background(), target.ID)
return err == nil && state == nil
}, "target's voice_states row to be deleted")
if receiveMsgOfType(targetSend, "voice_disconnected", waitTimeout) == nil {
t.Error("target did not receive voice_disconnected")
}
if !slices.Contains(auditActions(t, database), "voice_mod_kick") {
t.Error("voice_mod_kick audit entry missing")
}
}
func TestVoiceMod_Move_DisconnectsAndSendsVoiceMoved(t *testing.T) {
hub, database := newVoiceModHub(t)
fromID := seedVoiceChan(t, database, "vc-move-from")
toID := seedVoiceChan(t, database, "vc-move-to")
actor := seedVoiceUserWithRole(t, database, "admin-move", 2)
target := seedVoiceUserWithRole(t, database, "member-move", 4)
_, targetSend := joinVoice(t, hub, target, fromID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, fromID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID))
payload := receiveMsgOfType(targetSend, "voice_moved", waitTimeout)
if payload == nil {
t.Fatal("target did not receive voice_moved")
}
if int64(payload["to_channel_id"].(float64)) != toID {
t.Errorf("to_channel_id = %v, want %d", payload["to_channel_id"], toID)
}
waitFor(t, waitTimeout, func() bool {
state, err := database.GetVoiceState(context.Background(), target.ID)
return err == nil && state == nil
}, "target to be removed from the old channel pending re-join")
if !slices.Contains(auditActions(t, database), "voice_mod_move") {
t.Error("voice_mod_move audit entry missing")
}
}
func TestVoiceMod_Move_TextChannelDestination_BadRequest(t *testing.T) {
hub, database := newVoiceModHub(t)
fromID := seedVoiceChan(t, database, "vc-move-bad")
textID, err := database.CreateChannel(context.Background(), "general-move", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
actor := seedVoiceUserWithRole(t, database, "admin-move-bad", 2)
target := seedVoiceUserWithRole(t, database, "member-move-bad", 4)
joinVoice(t, hub, target, fromID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, fromID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, textID))
if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" {
t.Fatalf("error code = %q, want BAD_REQUEST", code)
}
state, _ := database.GetVoiceState(context.Background(), target.ID)
if state == nil {
t.Error("a refused move must leave the target in voice")
}
}
// TestVoiceMod_Move_ArchivedDestination_BadRequest locks OC-0072: the move
// pre-flight validates destination type, target access, and capacity, but
// skipped dest.Archived even though the re-join it hands off to
// (handleVoiceJoin) refuses an archived channel outright. Without the gate,
// the pre-flight commits the destructive half of the move — the target is
// dropped from their current channel — for a re-join guaranteed to bounce,
// leaving the target stranded out of voice entirely.
func TestVoiceMod_Move_ArchivedDestination_BadRequest(t *testing.T) {
hub, database := newVoiceModHub(t)
fromID := seedVoiceChan(t, database, "vc-move-archived-from")
toID := seedVoiceChan(t, database, "vc-move-archived-to")
if err := database.AdminUpdateChannel(context.Background(), toID, db.ChannelUpdate{
Name: "vc-move-archived-to",
Archived: true,
}); err != nil {
t.Fatalf("AdminUpdateChannel: %v", err)
}
actor := seedVoiceUserWithRole(t, database, "admin-move-archived", 2)
target := seedVoiceUserWithRole(t, database, "member-move-archived", 4)
joinVoice(t, hub, target, fromID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, fromID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID))
if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" {
t.Fatalf("error code = %q, want BAD_REQUEST", code)
}
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Error("a refused move must leave the target in voice")
} else if state.ChannelID != fromID {
t.Errorf("target channel = %d, want %d (unchanged)", state.ChannelID, fromID)
}
}
// TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden locks the
// destination gate that is evaluated against the TARGET's access, not the
// moderator's: a move must not become a way to place someone in a channel they
// could not join themselves. The actor keeps MUTE_MEMBERS and still outranks
// the target, so only the target's missing CONNECT_VOICE can refuse this.
func TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden(t *testing.T) {
hub, database := newVoiceModHub(t)
fromID := seedVoiceChan(t, database, "vc-move-noconnect-from")
toID := seedVoiceChan(t, database, "vc-move-noconnect-to")
actor := seedVoiceUserWithRole(t, database, "admin-move-noconnect", 2)
target := seedVoiceUserWithRole(t, database, "member-move-noconnect", 4)
// Destination denies CONNECT_VOICE to the target's role (Member, id 4).
if err := database.UpsertChannelOverride(
context.Background(), toID, 4, 0, permissions.ConnectVoice,
); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
_, targetSend := joinVoice(t, hub, target, fromID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, fromID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID))
if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" {
t.Fatalf("error code = %q, want FORBIDDEN", code)
}
if payload := receiveMsgOfType(targetSend, "voice_moved", 100*time.Millisecond); payload != nil {
t.Errorf("target must not receive voice_moved for a refused move, got %v", payload)
}
state, err := database.GetVoiceState(context.Background(), target.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("a refused move must leave the target in voice")
} else if state.ChannelID != fromID {
t.Errorf("target channel = %d, want %d (unchanged)", state.ChannelID, fromID)
}
if slices.Contains(auditActions(t, database), "voice_mod_move") {
t.Error("a refused move must not write a voice_mod_move audit entry")
}
}
// TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel locks the fix for
// v024: voiceModTarget authorizes against a DB snapshot, but the eviction ran
// through the unscoped VoiceModerator.DisconnectFromVoice, which drops the
// target from whatever channel their live connection reports at that instant.
// A channel switch committed on the target's own read-pump goroutine while the
// moderator's checks were in flight therefore redirected the kick onto a
// channel nobody authorized — up to a DM call the actor is not part of.
//
// The interleaving itself is microseconds wide and cannot be forced from a
// test, so the post-condition it produces is staged directly: the DB row (what
// the moderator authorized against) names channel A while the client's
// in-memory voice state — the only thing DisconnectFromVoice reads — already
// names channel B. The eviction must refuse rather than tear the target out of
// B.
func TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel(t *testing.T) {
hub, database := newVoiceModHub(t)
chanA := seedVoiceChan(t, database, "vc-kick-scope-a")
chanB := seedVoiceChan(t, database, "vc-kick-scope-b")
actor := seedVoiceUserWithRole(t, database, "admin-kick-scope", 2)
target := seedVoiceUserWithRole(t, database, "member-kick-scope", 4)
targetClient, _ := joinVoice(t, hub, target, chanA)
ws.SetClientVoiceStateForTest(targetClient, chanB, "join-token-b")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanA, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModKickMsg(target.ID))
if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" {
t.Fatalf("error code = %q, want VOICE_ERROR", code)
}
if got := ws.GetClientVoiceChIDForTest(targetClient); got != chanB {
t.Errorf("target voice channel = %d after a kick authorized for channel %d, want %d (the newer membership must survive)",
got, chanA, chanB)
}
}
// ─── self-service controls under a server mute ───────────────────────────────
func TestVoiceMute_SelfUnmuteWhileServerMuted_Refused(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-selfunmute")
actor := seedVoiceUserWithRole(t, database, "admin-selfunmute", 2)
target := seedVoiceUserWithRole(t, database, "member-selfunmute", 4)
targetClient, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
drainChanTimeout(targetSend, 30*time.Millisecond)
hub.HandleMessageForTest(targetClient, voiceMuteMsg(false))
if code := receiveErrorCode(targetSend, waitTimeout); code != "SERVER_MUTED" {
t.Fatalf("error code = %q, want SERVER_MUTED", code)
}
state, _ := database.GetVoiceState(context.Background(), target.ID)
if state == nil || !state.Muted {
t.Error("target must still be muted after a refused self-unmute")
}
}
func TestVoiceDeafen_SelfUndeafenWhileServerDeafened_Refused(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-selfundeaf")
actor := seedVoiceUserWithRole(t, database, "admin-selfundeaf", 2)
target := seedVoiceUserWithRole(t, database, "member-selfundeaf", 4)
targetClient, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true))
drainChanTimeout(targetSend, 30*time.Millisecond)
hub.HandleMessageForTest(targetClient, voiceDeafenMsg(false))
if code := receiveErrorCode(targetSend, waitTimeout); code != "SERVER_DEAFENED" {
t.Fatalf("error code = %q, want SERVER_DEAFENED", code)
}
}
func TestVoiceMute_SelfMuteWhileServerMuted_Allowed(t *testing.T) {
hub, database := newVoiceModHub(t)
chanID := seedVoiceChan(t, database, "vc-selfmute-ok")
actor := seedVoiceUserWithRole(t, database, "admin-selfmute-ok", 2)
target := seedVoiceUserWithRole(t, database, "member-selfmute-ok", 4)
targetClient, targetSend := joinVoice(t, hub, target, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, actor, chanID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true))
drainChanTimeout(targetSend, 30*time.Millisecond)
hub.HandleMessageForTest(targetClient, voiceMuteMsg(true))
if code := receiveErrorCode(targetSend, 200*time.Millisecond); code != "" {
t.Fatalf("self-mute refused with %q, want no error", code)
}
}