Files
OwnCord/Server/ws/coverage_boost_test.go
T
J3vbandClaude Opus 5 17b17eb1b3 fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1)

adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)

registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES to delete messages (F4)

The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)

Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): enforce the DM block at every DM interaction sink (F5)

The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)

voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)

The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)

Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)

A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): reserve the [deleted-N] username namespace (F11, F12)

The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): strip Unicode format characters from upload filenames (F13)

The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): reserve the login attempt before the bcrypt compare (F3)

The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.

Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.

Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri

Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:

  tauri-build        2.5.6 -> 2.6.3
  tauri-plugin-fs    2.4.5 -> 2.5.1
  tauri-plugin-http  2.5.7 -> 2.5.9
  tauri-plugin-store 2.4.2 -> 2.4.4
  webpki-roots       1.0.6 -> 1.0.9

All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.

Verified with cargo check --locked --all-targets (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client

Dependabot #1258.

8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:

  UserBar.ts / members.store.ts  "online" as UserStatus -> "online"
                                 (the receiver already accepts the literal)
  media.ts                       drops `as RequestInit` on a literal that is
                                 already assignable
  LoginForm.ts                   drops `as { message: unknown }` made
                                 redundant by the `"message" in err` narrowing

All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:48:47 +02:00

2857 lines
84 KiB
Go

package ws_test
// coverage_boost_test.go adds tests for functions with 0% or low coverage
// to push the ws package above 80%.
import (
"context"
"encoding/json"
"math"
"strings"
"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/service"
"github.com/owncord/server/ws"
)
// ─── schema with voice_states + audit_log for coverage tests ──────────────────
var coverageSchema = append(hubTestSchema, []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
camera INTEGER NOT NULL DEFAULT 0,
screenshare INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel_cov ON voice_states(channel_id);
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'))
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
uploader_id INTEGER REFERENCES users(id),
filename TEXT NOT NULL,
stored_as TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
width INTEGER,
height INTEGER
);
`)...)
func openCoverageDB(t *testing.T) *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: coverageSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database := openCoverageDB(t)
limiter := auth.NewRateLimiter()
st := database
svc := service.New(st, limiter)
hub := ws.NewHub(database, limiter, svc)
// Inject a test LiveKit client so voice_join passes the livekit!=nil guard.
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
}
func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User {
t.Helper()
_, err := database.CreateUser(context.Background(), username, "hash", 1)
if err != nil {
t.Fatalf("seedCoverageOwner CreateUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), username)
if err != nil || user == nil {
t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err)
}
return user
}
// ─── SetClientVoiceChID stores the tracked voice channel ─────────────────────
func TestSetClientVoiceChID_SetsValue(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.SetClientVoiceChID(c, 42)
if got := ws.GetClientVoiceChIDForTest(c); got != 42 {
t.Fatalf("voiceChID = %d, want 42", got)
}
}
func TestSetClientVoiceChID_ZeroClearsVoice(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.SetClientVoiceChID(c, 100)
ws.SetClientVoiceChID(c, 0)
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
t.Fatalf("voiceChID = %d, want 0", got)
}
}
func TestSetClientVoiceChID_LastWriteWins(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.SetClientVoiceChID(c, 7)
ws.SetClientVoiceChID(c, 99)
if got := ws.GetClientVoiceChIDForTest(c); got != 99 {
t.Fatalf("voiceChID = %d, want 99", got)
}
}
// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ────────────────
func TestBuildJSON_UnmarshalableValue_ReturnsFallback(t *testing.T) {
// math.Inf is not valid JSON — forces the error path in buildJSON.
out := ws.BuildJSONForTest(math.Inf(1))
if !json.Valid(out) {
t.Fatalf("fallback output is not valid JSON: %s", out)
}
var m map[string]string
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal fallback: %v", err)
}
if m["type"] != "error" {
t.Errorf("fallback type = %q, want error", m["type"])
}
if m["message"] != "internal marshal error" {
t.Errorf("fallback message = %q, want 'internal marshal error'", m["message"])
}
}
func TestBuildJSON_ChannelValue_ReturnsFallback(t *testing.T) {
// Channels are not JSON-marshalable.
out := ws.BuildJSONForTest(make(chan int))
if !json.Valid(out) {
t.Fatalf("fallback output is not valid JSON: %s", out)
}
}
// ─── GracefulStop with clients having voice state (hub.go:188 — 75%) ─────────
func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "graceful-voice-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Set voice channel ID on the client to simulate voice state.
ws.SetClientVoiceChID(c, 42)
if count := hub.ClientCount(); count != 1 {
t.Fatalf("before GracefulStop: client count = %d, want 1", count)
}
if got := ws.GetClientVoiceChIDForTest(c); got != 42 {
t.Fatalf("voiceChID before stop = %d, want 42", got)
}
hub.GracefulStop()
time.Sleep(20 * time.Millisecond)
// GracefulStop signals clients to close — test clients don't have real
// goroutines so they won't self-unregister, but verify the hub accepted
// the stop without deadlocking on voice-state cleanup.
}
func TestGracefulStop_MultipleClients(t *testing.T) {
hub, database := newCoverageHub(t)
for i := range 5 {
user := seedCoverageOwner(t, database, "graceful-multi-"+string(rune('a'+i)))
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
}
time.Sleep(30 * time.Millisecond)
if count := hub.ClientCount(); count != 5 {
t.Fatalf("before GracefulStop: client count = %d, want 5", count)
}
hub.GracefulStop()
time.Sleep(20 * time.Millisecond)
// Verify GracefulStop completes without deadlock on multiple clients.
}
// ─── handleChatSend additional branches (handlers.go:127 — 76.2%) ────────────
func TestHandleChatSend_EmptyContent(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "empty-content-user")
chID := seedTestChannel(t, database, "empty-content-chan")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": "",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for empty content", code)
}
}
func TestHandleChatSend_ContentTooLong(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "long-content-user")
chID := seedTestChannel(t, database, "long-content-chan")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Content over 4000 characters.
longContent := strings.Repeat("x", 4001)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": longContent,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for content too long", code)
}
}
func TestHandleChatSend_InvalidChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "bad-chid-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": "not-a-number",
"content": "hello",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid channel_id", code)
}
}
func TestHandleChatSend_ChannelNotFound(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "notfound-chan-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": 99999,
"content": "hello",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "NOT_FOUND" {
t.Errorf("error code = %q, want NOT_FOUND for nonexistent channel", code)
}
}
func TestHandleChatSend_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "bad-payload-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid payload", code)
}
}
func TestHandleChatSend_NegativeChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "neg-chid-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": -1,
"content": "hello",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for negative channel_id", code)
}
}
// ─── handleChatSend with reply_to (handlers.go:127 — covers reply_to path) ──
func TestHandleChatSend_WithReplyTo(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "reply-user")
chID := seedTestChannel(t, database, "reply-chan")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Send first message to get an ID.
raw1, _ := json.Marshal(map[string]any{
"type": "chat_send",
"id": "req-1",
"payload": map[string]any{
"channel_id": chID,
"content": "original message",
},
})
hub.HandleMessageForTest(c, raw1)
time.Sleep(50 * time.Millisecond)
// Drain to find the message ID from chat_send_ok.
var msgID float64
timeout := time.After(500 * time.Millisecond)
drainFirst:
for {
select {
case msg := <-send:
var env map[string]any
if err := json.Unmarshal(msg, &env); err == nil {
if env["type"] == "chat_send_ok" {
if p, ok := env["payload"].(map[string]any); ok {
msgID = p["message_id"].(float64)
}
break drainFirst
}
}
case <-timeout:
t.Fatal("did not receive chat_send_ok for first message")
}
}
// Drain remaining messages.
drainChanBuf(send)
// Send reply.
replyTo := int64(msgID)
raw2, _ := json.Marshal(map[string]any{
"type": "chat_send",
"id": "req-2",
"payload": map[string]any{
"channel_id": chID,
"content": "reply message",
"reply_to": replyTo,
},
})
hub.HandleMessageForTest(c, raw2)
time.Sleep(50 * time.Millisecond)
// Should get chat_send_ok for the reply.
found := false
timeout2 := time.After(500 * time.Millisecond)
drainReply:
for {
select {
case msg := <-send:
var env map[string]any
if err := json.Unmarshal(msg, &env); err == nil {
if env["type"] == "chat_send_ok" && env["id"] == "req-2" {
found = true
break drainReply
}
}
case <-timeout2:
break drainReply
}
}
if !found {
t.Error("expected chat_send_ok for reply message")
}
}
// ─── Ping message type (handlers.go — pong response) ─────────────────────────
func TestHandleMessage_Ping_ReturnsPong(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "ping-user")
send := make(chan []byte, 4)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{"type": "ping"})
hub.HandleMessageForTest(c, raw)
time.Sleep(20 * time.Millisecond)
select {
case msg := <-send:
var env map[string]any
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env["type"] != "pong" {
t.Errorf("type = %q, want pong", env["type"])
}
case <-time.After(500 * time.Millisecond):
t.Error("expected pong response")
}
}
// ─── buildReady with voice channel having participants ────────────────────────
func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "ready-voice-user")
role, rErr := database.GetRoleByID(context.Background(), 1)
if rErr != nil || role == nil {
t.Fatalf("GetRoleByID: %v", rErr)
}
// Create a voice channel.
vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel voice: %v", err)
}
// Create another user and join them to voice.
other := seedCoverageOwner(t, database, "ready-voice-other")
if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
if err != nil {
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
}
var env struct {
Payload struct {
VoiceStates []struct {
ChannelID float64 `json:"channel_id"`
UserID float64 `json:"user_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.Errorf("voice_states count = %d, want 1", len(env.Payload.VoiceStates))
}
}
func TestBuildReady_MultipleChannelTypes(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "ready-multi-user")
role, rErr := database.GetRoleByID(context.Background(), 1)
if rErr != nil || role == nil {
t.Fatalf("GetRoleByID: %v", rErr)
}
// Create text and voice channels.
_, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0)
if err != nil {
t.Fatalf("CreateChannel text: %v", err)
}
_, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1)
if err != nil {
t.Fatalf("CreateChannel voice: %v", err)
}
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
if err != nil {
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
}
var env struct {
Payload struct {
Channels []map[string]any `json:"channels"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(env.Payload.Channels) != 2 {
t.Errorf("channels count = %d, want 2", len(env.Payload.Channels))
}
// Text channels should have unread_count; voice channels should not.
for _, ch := range env.Payload.Channels {
if ch["type"] == "text" {
if _, ok := ch["unread_count"]; !ok {
t.Error("text channel missing unread_count")
}
}
}
}
// ─── voice handler edge cases ────────────────────────────────────────────────
func TestHandleVoiceJoin_InvalidChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-bad-chid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": "not-a-number",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleVoiceJoin_NegativeChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-neg-chid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": -1,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleVoiceMute_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vm-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Put client in voice so the "not in voice" guard doesn't fire first.
ws.SetClientVoiceChID(c, 999)
raw, _ := json.Marshal(map[string]any{
"type": "voice_mute",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_mute payload", code)
}
}
func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vd-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Put client in voice so the "not in voice" guard doesn't fire first.
ws.SetClientVoiceChID(c, 999)
raw, _ := json.Marshal(map[string]any{
"type": "voice_deafen",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_deafen payload", code)
}
}
// ─── voice camera and screenshare error paths ────────────────────────────────
func TestHandleVoiceCamera_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vc-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_camera",
"payload": map[string]any{
"enabled": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
func TestHandleVoiceCamera_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vc-bad-payload")
vcID, err := database.CreateChannel(context.Background(), "cam-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Set voice channel so the not-in-voice check passes.
ws.SetClientVoiceChID(c, vcID)
raw, _ := json.Marshal(map[string]any{
"type": "voice_camera",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vs-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_screenshare",
"payload": map[string]any{
"enabled": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vs-bad-payload")
vcID, err := database.CreateChannel(context.Background(), "screen-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
ws.SetClientVoiceChID(c, vcID)
raw, _ := json.Marshal(map[string]any{
"type": "voice_screenshare",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
// ─── channel_focus handler ───────────────────────────────────────────────────
func TestHandleChannelFocus_InvalidChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cf-bad-chid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "channel_focus",
"payload": map[string]any{
"channel_id": "not-a-number",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(20 * time.Millisecond)
// V2 CommandConstructor rejects non-numeric channel_id with BAD_REQUEST.
code := drainForErrorCode(send, 100*time.Millisecond)
if code != "BAD_REQUEST" {
t.Fatalf("expected BAD_REQUEST for non-numeric channel_id, got code=%q", code)
}
}
func TestHandleChannelFocus_ValidChannel(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cf-valid")
chID := seedTestChannel(t, database, "cf-valid-chan")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "channel_focus",
"payload": map[string]any{
"channel_id": chID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(20 * time.Millisecond)
// Valid channel focus should not produce an error message.
code := drainForErrorCode(send, 100*time.Millisecond)
if code != "" {
t.Errorf("expected no error for valid channel_focus, got code=%q", code)
}
}
// ─── presence handler error paths ────────────────────────────────────────────
func TestHandlePresence_InvalidStatus(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "pres-bad-status")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "presence_update",
"payload": map[string]any{
"status": "invisible", // not allowed per CLAUDE.md
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid status", code)
}
}
func TestHandlePresence_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "pres-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "presence_update",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid presence payload", code)
}
}
// ─── typing handler error path ───────────────────────────────────────────────
func TestHandleTyping_InvalidChannelID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "typing-bad-chid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "typing_start",
"payload": map[string]any{
"channel_id": -1,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for invalid typing channel_id", code)
}
}
// ─── message builder coverage ────────────────────────────────────────────────
func TestBuildPresenceMsg_ValidJSON(t *testing.T) {
msg := ws.BuildJSONForTest(map[string]any{
"type": "presence",
"payload": map[string]any{
"user_id": 1,
"status": "online",
},
})
if !json.Valid(msg) {
t.Error("buildPresenceMsg output is not valid JSON")
}
}
func TestBuildChatSendOK_ValidJSON(t *testing.T) {
msg := ws.BuildJSONForTest(map[string]any{
"type": "chat_send_ok",
"id": "req-1",
"payload": map[string]any{
"message_id": 1,
"timestamp": "2024-01-01T00:00:00Z",
},
})
if !json.Valid(msg) {
t.Error("buildChatSendOK output is not valid JSON")
}
}
// ─── SendToUser full buffer path (hub.go:308 — 87.5%) ───────────────────────
func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "send-full-user")
// Create a send channel with buffer size 1.
send := make(chan []byte, 1)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Fill the buffer.
send <- []byte(`{"type":"filler"}`)
// Next send should return false (buffer full).
ok := hub.SendToUser(user.ID, []byte(`{"type":"overflow"}`))
if ok {
t.Error("SendToUser should return false when send buffer is full")
}
}
// ─── handleChatSend with attachments (handlers.go:127 — 76.2%) ──────────────
func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) {
hub, database := newCoverageHub(t)
// Use a member user.
_, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user")
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
chID := seedTestChannel(t, database, "attach-noperm-chan")
// Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4).
_, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID)
if err != nil {
t.Fatalf("INSERT channel_overrides: %v", err)
}
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": "msg with attachment",
"attachments": []string{"att-id-1"},
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "FORBIDDEN" {
t.Errorf("error code = %q, want FORBIDDEN for denied ATTACH_FILES permission", code)
}
}
func TestHandleChatSend_WithAttachments_Success(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "attach-ok-user")
chID := seedTestChannel(t, database, "attach-ok-chan")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"id": "attach-req",
"payload": map[string]any{
"channel_id": chID,
"content": "msg with attachment",
"attachments": []string{"nonexistent-att-id"},
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
// Should still succeed (attachments that don't exist are silently skipped).
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" {
found = true
break
}
}
if !found {
t.Error("expected chat_send_ok even with nonexistent attachment IDs")
}
}
// ─── handleChatSend slow mode for non-mod user (handlers.go:164) ────────────
func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) {
hub, database := newCoverageHub(t)
_, err := database.CreateUser(context.Background(), "slow-member-user", "hash", 4)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), "slow-member-user")
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
// Create channel with slow mode.
chID, err := database.CreateChannel(context.Background(), "slow-chan", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.SetChannelSlowMode(context.Background(), chID, 60); err != nil {
t.Fatalf("SetChannelSlowMode: %v", err)
}
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": "first message",
},
})
// First message should succeed.
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
drainChanBuf(send)
// Second message should be rate limited by slow mode.
raw2, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": "second message",
},
})
hub.HandleMessageForTest(c, raw2)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "SLOW_MODE" {
t.Errorf("error code = %q, want SLOW_MODE", code)
}
}
// ─── handleChatEdit more paths (handlers.go:249 — 89.7%) ────────────────────
func TestHandleChatEdit_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "edit-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_edit",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleChatEdit_InvalidMessageID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "edit-bad-msgid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_edit",
"payload": map[string]any{
"message_id": -1,
"content": "updated",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleChatEdit_EmptyContent(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "edit-empty")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_edit",
"payload": map[string]any{
"message_id": 1,
"content": "",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
// ─── handleChatDelete more paths (handlers.go:298) ───────────────────────────
func TestHandleChatDelete_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "delete-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_delete",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleChatDelete_InvalidMessageID(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "delete-bad-msgid")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_delete",
"payload": map[string]any{
"message_id": -1,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleChatDelete_MessageNotFound(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "delete-notfound")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_delete",
"payload": map[string]any{
"message_id": 99999,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
// Handler returns FORBIDDEN (not NOT_FOUND) to prevent message-ID enumeration.
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "FORBIDDEN" {
t.Errorf("error code = %q, want FORBIDDEN", code)
}
}
// ─── handleReaction more paths (handlers.go:337) ─────────────────────────────
func TestHandleReaction_InvalidPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "react-bad-payload")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "reaction_add",
"payload": "not-an-object",
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleReaction_EmptyEmoji(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "react-empty-emoji")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "reaction_add",
"payload": map[string]any{
"message_id": 1,
"emoji": "",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleReaction_EmojiTooLong(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "react-long-emoji")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "reaction_add",
"payload": map[string]any{
"message_id": 1,
"emoji": strings.Repeat("x", 33),
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleReaction_ControlCharInEmoji(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "react-ctrl-emoji")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "reaction_add",
"payload": map[string]any{
"message_id": 1,
"emoji": "\x00bad",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST for control char emoji", code)
}
}
// ─── handleChannelFocus with message marking (handlers.go:507) ───────────────
func TestHandleChannelFocus_UpdatesReadState(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cf-readstate-user")
chID := seedTestChannel(t, database, "cf-readstate-chan")
// Insert a message so there's a latest_message_id.
_, err := database.CreateMessage(context.Background(), chID, user.ID, "test message", nil)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "channel_focus",
"payload": map[string]any{
"channel_id": chID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
// No error should be sent for a valid channel_focus with existing message.
code := drainForErrorCode(send, 100*time.Millisecond)
if code != "" {
t.Fatalf("expected no error for valid channel_focus, got code=%q", code)
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// drainForErrorCode reads from ch until an error message is found or deadline passes.
func drainForErrorCode(ch <-chan []byte, deadline time.Duration) string {
timer := time.NewTimer(deadline)
defer timer.Stop()
for {
select {
case msg := <-ch:
var env map[string]any
if err := json.Unmarshal(msg, &env); err != nil {
continue
}
if env["type"] == "error" {
if payload, ok := env["payload"].(map[string]any); ok {
code, _ := payload["code"].(string)
return code
}
}
case <-timer.C:
return ""
}
}
}
// drainChanBuf drains all buffered messages from a channel.
func drainChanBuf(ch <-chan []byte) {
for {
select {
case <-ch:
default:
return
}
}
}
// drainChanTimeout reads messages until timeout, returning all collected.
func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte {
var msgs [][]byte
timer := time.NewTimer(d)
defer timer.Stop()
for {
select {
case msg := <-ch:
msgs = append(msgs, msg)
case <-timer.C:
return msgs
}
}
}
// ─── voice join/leave full flow (voice_handlers.go coverage) ─────────────────
func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 {
t.Helper()
id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel voice: %v", err)
}
return id
}
func TestHandleVoiceJoin_FullFlow(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-flow-user")
vcID := seedVoiceChannel(t, database, "vj-flow-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 500*time.Millisecond)
foundState := false
foundConfig := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil {
switch env["type"] {
case "voice_state":
foundState = true
case "voice_config":
foundConfig = true
}
}
}
if !foundState {
t.Error("expected voice_state broadcast after voice_join")
}
if !foundConfig {
t.Error("expected voice_config after voice_join")
}
}
// TestVoiceState_NotDeliveredToRolesDeniedRead locks the visibility invariant
// for voice metadata: voice_state / voice_leave used to go out via
// BroadcastToAll, so every authenticated client learned the membership and
// camera/mute state of voice channels that channel_overrides hides from their
// role — even though the ready payload deliberately filters them out. A member
// who can read the channel must still receive them.
func TestVoiceState_NotDeliveredToRolesDeniedRead(t *testing.T) {
hub, database := newCoverageHub(t)
joiner := seedCoverageOwner(t, database, "vs-joiner")
vcID := seedVoiceChannel(t, database, "vs-private-vc")
// Two plain members (role 4). One is locked out of the channel with the
// override the admin panel writes when "Can access" is unchecked.
newMember := func(name string) *db.User {
t.Helper()
if _, err := database.CreateUser(context.Background(), name, "hash", 4); err != nil {
t.Fatalf("CreateUser %s: %v", name, err)
}
u, err := database.GetUserByUsername(context.Background(), name)
if err != nil || u == nil {
t.Fatalf("GetUserByUsername %s: %v", name, err)
}
return u
}
insider := newMember("vs-insider")
outsiderRole := int64(3) // Moderator: a distinct role so the deny is role-scoped
outsider := newMember("vs-outsider")
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET role_id = ? WHERE id = ?`, outsiderRole, outsider.ID,
); err != nil {
t.Fatalf("reassign outsider role: %v", err)
}
if err := database.UpsertChannelOverride(context.Background(), vcID, outsiderRole, 0,
permissions.ReadMessages|permissions.ConnectVoice); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
insiderSend := make(chan []byte, 64)
outsiderSend := make(chan []byte, 64)
hub.Register(ws.NewTestClientWithUser(hub, insider, 0, insiderSend))
hub.Register(ws.NewTestClientWithUser(hub, outsider, 0, outsiderSend))
joinerSend := make(chan []byte, 64)
jc := ws.NewTestClientWithUser(hub, joiner, 0, joinerSend)
hub.Register(jc)
time.Sleep(30 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{"channel_id": vcID},
})
hub.HandleMessageForTest(jc, raw)
time.Sleep(150 * time.Millisecond)
countVoiceState := func(ch <-chan []byte) int {
n := 0
for _, msg := range drainChanTimeout(ch, 300*time.Millisecond) {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
n++
}
}
return n
}
if got := countVoiceState(insiderSend); got == 0 {
t.Error("a member who may READ the channel must still receive voice_state")
}
if got := countVoiceState(outsiderSend); got != 0 {
t.Errorf("a role denied READ received %d voice_state events, want 0", got)
}
}
func TestHandleVoiceJoin_AlreadyInSameChannel(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-same-user")
vcID := seedVoiceChannel(t, database, "vj-same-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "ALREADY_JOINED" {
t.Errorf("error code = %q, want ALREADY_JOINED", code)
}
}
func TestHandleVoiceJoin_SwitchChannels(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-switch-user")
vc1 := seedVoiceChannel(t, database, "vj-switch-vc1")
vc2 := seedVoiceChannel(t, database, "vj-switch-vc2")
send := make(chan []byte, 128)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw1, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vc1,
},
})
hub.HandleMessageForTest(c, raw1)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
raw2, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vc2,
},
})
hub.HandleMessageForTest(c, raw2)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
foundLeave := false
foundConfig := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil {
switch env["type"] {
case "voice_leave":
foundLeave = true
case "voice_config":
foundConfig = true
}
}
}
if !foundLeave {
t.Error("expected voice_leave broadcast when switching channels")
}
if !foundConfig {
t.Error("expected voice_config for new channel")
}
}
func TestHandleVoiceJoin_ChannelFull(t *testing.T) {
hub, database := newCoverageHub(t)
vcID, err := database.CreateChannel(context.Background(), "full-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
_, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID)
if err != nil {
t.Fatalf("UPDATE channels: %v", err)
}
user1 := seedCoverageOwner(t, database, "vj-full-u1")
send1 := make(chan []byte, 64)
c1 := ws.NewTestClientWithUser(hub, user1, 0, send1)
hub.Register(c1)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c1, raw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send1)
user2 := seedCoverageOwner(t, database, "vj-full-u2")
send2 := make(chan []byte, 64)
c2 := ws.NewTestClientWithUser(hub, user2, 0, send2)
hub.Register(c2)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c2, raw)
time.Sleep(100 * time.Millisecond)
code := drainForErrorCode(send2, 300*time.Millisecond)
if code != "CHANNEL_FULL" {
t.Errorf("error code = %q, want CHANNEL_FULL", code)
}
}
func TestHandleVoiceLeave_ExplicitLeave(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vl-explicit-user")
vcID := seedVoiceChannel(t, database, "vl-explicit-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"})
hub.HandleMessageForTest(c, leaveRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
foundLeave := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" {
foundLeave = true
break
}
}
if !foundLeave {
t.Error("expected voice_leave broadcast after explicit leave")
}
}
func TestHandleVoiceLeave_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vl-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleVoiceLeaveForTest(c)
time.Sleep(20 * time.Millisecond)
// Client should still be connected and have no voice channel set.
if !hub.IsUserConnected(user.ID) {
t.Error("user should still be connected after no-op voice leave")
}
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
t.Errorf("voiceChID = %d, want 0 after leave when not in voice", got)
}
}
func TestHandleVoiceMute_FullFlow(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vm-flow-user")
vcID := seedVoiceChannel(t, database, "vm-flow-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
muteRaw, _ := json.Marshal(map[string]any{
"type": "voice_mute",
"payload": map[string]any{
"muted": true,
},
})
hub.HandleMessageForTest(c, muteRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
found = true
break
}
}
if !found {
t.Error("expected voice_state broadcast after mute")
}
}
func TestHandleVoiceDeafen_FullFlow(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vd-flow-user")
vcID := seedVoiceChannel(t, database, "vd-flow-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
deafenRaw, _ := json.Marshal(map[string]any{
"type": "voice_deafen",
"payload": map[string]any{
"deafened": true,
},
})
hub.HandleMessageForTest(c, deafenRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
found = true
break
}
}
if !found {
t.Error("expected voice_state broadcast after deafen")
}
}
func TestHandleVoiceJoin_ChannelNotFound(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-notfound-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": 99999,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "NOT_FOUND" {
t.Errorf("error code = %q, want NOT_FOUND", code)
}
}
func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-quality-user")
vcID, err := database.CreateChannel(context.Background(), "quality-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
_, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID)
if err != nil {
t.Fatalf("UPDATE: %v", err)
}
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" {
p := env["payload"].(map[string]any)
if p["quality"] != "high" {
t.Errorf("voice_config quality = %v, want high", p["quality"])
}
return
}
}
t.Error("expected voice_config with quality override")
}
func TestHandleVoiceJoin_MultipleParticipants(t *testing.T) {
hub, database := newCoverageHub(t)
vcID := seedVoiceChannel(t, database, "vj-multi-vc")
user1 := seedCoverageOwner(t, database, "vj-multi-u1")
send1 := make(chan []byte, 64)
c1 := ws.NewTestClientWithUser(hub, user1, 0, send1)
hub.Register(c1)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c1, raw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send1)
user2 := seedCoverageOwner(t, database, "vj-multi-u2")
send2 := make(chan []byte, 64)
c2 := ws.NewTestClientWithUser(hub, user2, 0, send2)
hub.Register(c2)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c2, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send2, 300*time.Millisecond)
voiceStateCount := 0
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
voiceStateCount++
}
}
if voiceStateCount < 2 {
t.Errorf("voice_state count = %d, want at least 2", voiceStateCount)
}
}
func TestHandleVoiceLeave_BroadcastsToOtherParticipants(t *testing.T) {
hub, database := newCoverageHub(t)
vcID := seedVoiceChannel(t, database, "vl-bcast-vc")
user1 := seedCoverageOwner(t, database, "vl-bcast-u1")
user2 := seedCoverageOwner(t, database, "vl-bcast-u2")
send1 := make(chan []byte, 64)
send2 := make(chan []byte, 64)
c1 := ws.NewTestClientWithUser(hub, user1, 0, send1)
c2 := ws.NewTestClientWithUser(hub, user2, 0, send2)
hub.Register(c1)
hub.Register(c2)
time.Sleep(30 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c1, joinRaw)
time.Sleep(100 * time.Millisecond)
hub.HandleMessageForTest(c2, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send1)
drainChanBuf(send2)
leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"})
hub.HandleMessageForTest(c1, leaveRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send2, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" {
found = true
break
}
}
if !found {
t.Error("user2 should receive voice_leave when user1 leaves")
}
}
func TestHandleVoiceCamera_FullFlow(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vc-flow-user")
vcID := seedVoiceChannel(t, database, "vc-flow-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
camRaw, _ := json.Marshal(map[string]any{
"type": "voice_camera",
"payload": map[string]any{
"enabled": true,
},
})
hub.HandleMessageForTest(c, camRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
found = true
break
}
}
if !found {
t.Error("expected voice_state after camera toggle")
}
}
func TestHandleVoiceScreenshare_FullFlow(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vs-flow-user")
vcID := seedVoiceChannel(t, database, "vs-flow-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
joinRaw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, joinRaw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
ssRaw, _ := json.Marshal(map[string]any{
"type": "voice_screenshare",
"payload": map[string]any{
"enabled": true,
},
})
hub.HandleMessageForTest(c, ssRaw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" {
found = true
break
}
}
if !found {
t.Error("expected voice_state after screenshare toggle")
}
}
func TestHandleChatSend_WithNilAvatar(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "nil-avatar-user")
chID := seedTestChannel(t, database, "nil-avatar-chan")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"id": "avatar-req",
"payload": map[string]any{
"channel_id": chID,
"content": "hello from nil avatar user",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
found := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" {
found = true
break
}
}
if !found {
t.Error("expected chat_send_ok for nil-avatar user")
}
}
// ─── hasChannelPerm with nil user (handlers.go:454) ──────────────────────────
func TestHasChannelPerm_NilUser_DeniesPermission(t *testing.T) {
hub, database := newCoverageHub(t)
chID := seedTestChannel(t, database, "perm-nil-user-chan")
send := make(chan []byte, 16)
// Create a test client WITHOUT a user (user == nil).
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Try to send a chat message — should get FORBIDDEN due to nil user.
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": chID,
"content": "should fail",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "FORBIDDEN" {
t.Errorf("error code = %q, want FORBIDDEN for nil user", code)
}
}
// ─── deliverBroadcast with full send buffer (hub.go:344) ─────────────────────
func TestDeliverBroadcast_FullBuffer_DropsMessage(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "bcast-full-user")
// Create a tiny send buffer.
send := make(chan []byte, 1)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Fill the buffer.
send <- []byte(`{"type":"filler"}`)
// Broadcasting should not block — message dropped.
hub.BroadcastToAll([]byte(`{"type":"should_be_dropped"}`))
time.Sleep(50 * time.Millisecond)
// Buffer should still contain only the filler message (dropped msg was not enqueued).
if len(send) != 1 {
t.Errorf("send buffer length = %d, want 1 (dropped message should not be enqueued)", len(send))
}
// The client should still be registered despite the dropped message.
if !hub.IsUserConnected(user.ID) {
t.Error("client should remain connected after a dropped broadcast")
}
_ = c // keep c referenced
}
func TestBuildAuthOK_NonNilAvatar(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "authok-avatar-user")
// Set a non-nil avatar.
_, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID)
if err != nil {
t.Fatalf("UPDATE avatar: %v", err)
}
user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user")
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
msg := hub.BuildAuthOKForTest(user, "owner")
var env struct {
Payload struct {
User struct {
Avatar string `json:"avatar"`
} `json:"user"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.User.Avatar != "https://example.com/pic.png" {
t.Errorf("avatar = %q, want https://example.com/pic.png", env.Payload.User.Avatar)
}
}
func TestHandleChatSend_WithNonNilAvatar(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "avatar-user")
// Set a non-nil avatar on the user.
_, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID)
if err != nil {
t.Fatalf("UPDATE avatar: %v", err)
}
// Reload user to get updated avatar.
user, err = database.GetUserByUsername(context.Background(), "avatar-user")
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
chID := seedTestChannel(t, database, "avatar-chan")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"id": "avatar-req2",
"payload": map[string]any{
"channel_id": chID,
"content": "hello from avatar user",
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
foundOK := false
foundBroadcast := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil {
if env["type"] == "chat_send_ok" {
foundOK = true
}
if env["type"] == "chat_message" {
// Verify avatar is present in broadcast.
if p, ok := env["payload"].(map[string]any); ok {
if u, ok := p["user"].(map[string]any); ok {
if u["avatar"] == "https://example.com/avatar.png" {
foundBroadcast = true
}
}
}
}
}
}
if !foundOK {
t.Error("expected chat_send_ok for avatar user")
}
if !foundBroadcast {
t.Error("expected chat_message with non-nil avatar")
}
}
// ─── Webhook parse helpers ──────────────────────────────────────────────────
func TestWebhookParseIdentity_Valid(t *testing.T) {
id, err := ws.ParseIdentityForTest("user-42")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 42 {
t.Errorf("id = %d, want 42", id)
}
}
func TestWebhookParseIdentity_Invalid(t *testing.T) {
_, err := ws.ParseIdentityForTest("invalid")
if err == nil {
t.Fatal("expected error for invalid identity, got nil")
}
}
func TestWebhookParseRoomChannelID_Valid(t *testing.T) {
id, err := ws.ParseRoomChannelIDForTest("channel-5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 5 {
t.Errorf("id = %d, want 5", id)
}
}
func TestWebhookParseRoomChannelID_Invalid(t *testing.T) {
_, err := ws.ParseRoomChannelIDForTest("bad")
if err == nil {
t.Fatal("expected error for invalid room name, got nil")
}
}
// ─── Voice control "not in voice" guards ────────────────────────────────────
func TestHandleVoiceMute_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vm-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_mute",
"payload": map[string]any{
"muted": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
func TestHandleVoiceDeafen_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vd-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_deafen",
"payload": map[string]any{
"deafened": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
// ─── Voice join with invalid quality fallback ───────────────────────────────
func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-badquality-user")
vcID, err := database.CreateChannel(context.Background(), "badquality-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
_, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID)
if err != nil {
t.Fatalf("UPDATE: %v", err)
}
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" {
p := env["payload"].(map[string]any)
if p["quality"] != "medium" {
t.Errorf("voice_config quality = %v, want medium", p["quality"])
}
return
}
}
t.Error("expected voice_config with medium quality fallback")
}
// ─── getLastActivity (client.go:153) ─────────────────────────────────────────
func TestGetLastActivity_ReturnsZeroForNewTestClient(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
got := ws.GetLastActivityForTest(c)
if !got.IsZero() {
t.Fatalf("expected zero time for new test client, got %v", got)
}
}
func TestGetLastActivity_UpdatedByTouch(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
before := time.Now()
ws.TouchForTest(c)
after := time.Now()
got := ws.GetLastActivityForTest(c)
if got.Before(before) || got.After(after) {
t.Fatalf("lastActivity = %v, expected between %v and %v", got, before, after)
}
}
func TestGetLastActivity_MultipleTouch(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.TouchForTest(c)
first := ws.GetLastActivityForTest(c)
time.Sleep(5 * time.Millisecond)
ws.TouchForTest(c)
second := ws.GetLastActivityForTest(c)
if !second.After(first) {
t.Fatalf("second touch (%v) should be after first (%v)", second, first)
}
}
// ─── clearVoiceChID (client.go:203) ─────────────────────────────────────────
func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.SetVoiceChIDForTest(c, 42)
old := ws.ClearVoiceChIDForTest(c)
if old != 42 {
t.Fatalf("clearVoiceChID returned %d, want 42", old)
}
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
t.Fatalf("voiceChID after clear = %d, want 0", got)
}
}
func TestClearVoiceChID_ReturnsZeroWhenNotInVoice(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
old := ws.ClearVoiceChIDForTest(c)
if old != 0 {
t.Fatalf("clearVoiceChID returned %d, want 0", old)
}
}
func TestClearVoiceChID_DoubleClearReturnsZero(t *testing.T) {
hub, _ := newCoverageHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
ws.SetVoiceChIDForTest(c, 99)
first := ws.ClearVoiceChIDForTest(c)
second := ws.ClearVoiceChIDForTest(c)
if first != 99 {
t.Fatalf("first clear = %d, want 99", first)
}
if second != 0 {
t.Fatalf("second clear = %d, want 0", second)
}
}
// ─── voice_token_refresh (now V2 — dispatched via handleMessage) ────────────
func voiceTokenRefreshMsg() []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_token_refresh",
"payload": map[string]any{},
})
return raw
}
func TestHandleVoiceTokenRefresh_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vtr-notinvoice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "BAD_REQUEST" {
t.Errorf("error code = %q, want BAD_REQUEST", code)
}
}
func TestHandleVoiceTokenRefresh_InVoice_ReturnsToken(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vtr-invc")
vcID := seedVoiceChannel(t, database, "vtr-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{"channel_id": vcID},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
drainChanBuf(send)
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
foundToken := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_token" {
foundToken = true
break
}
}
if !foundToken {
t.Error("expected voice_token message after token refresh")
}
}
func TestHandleVoiceTokenRefresh_NilUser(t *testing.T) {
hub, database := newCoverageHub(t)
// The client deliberately carries no *db.User — that is what this test
// covers — but the row must exist so the CONNECT_VOICE re-check can resolve
// a role. Without it the handler stops at FORBIDDEN and never reaches the
// missing-voice-state branch under test.
user := seedCoverageOwner(t, database, "vtr-nil-user")
send := make(chan []byte, 16)
c := ws.NewTestClient(hub, user.ID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
ws.SetVoiceChIDForTest(c, 42)
hub.HandleMessageForTest(c, voiceTokenRefreshMsg())
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "INTERNAL" {
t.Errorf("error code = %q, want INTERNAL", code)
}
}
// ─── rollbackVoiceJoin (voice_join.go:239) ──────────────────────────────────
func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "rb-user")
vcID := seedVoiceChannel(t, database, "rb-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
ws.SetVoiceChIDForTest(c, vcID)
hub.RollbackVoiceJoinForTest(c, vcID)
time.Sleep(100 * time.Millisecond)
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
t.Fatalf("voiceChID after rollback = %d, want 0", got)
}
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state != nil {
t.Fatal("voice state should be nil after rollback")
}
msgs := drainChanTimeout(send, 300*time.Millisecond)
foundLeave := false
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" {
foundLeave = true
break
}
}
if !foundLeave {
t.Error("expected voice_leave broadcast after rollback")
}
}
func TestRollbackVoiceJoin_NoDBState_DoesNotPanic(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "rb-nostate")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
ws.SetVoiceChIDForTest(c, 999)
hub.RollbackVoiceJoinForTest(c, 999)
time.Sleep(50 * time.Millisecond)
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
t.Fatalf("voiceChID after rollback = %d, want 0", got)
}
}
// ─── leaveVoiceChannelWithRetry (voice_leave.go:57) ─────────────────────────
func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "lvcr-ok")
vcID := seedVoiceChannel(t, database, "lvcr-ok-vc")
if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state == nil {
t.Fatal("voice state should exist before leave")
}
err := ws.LeaveVoiceChannelWithRetryForTest(hub, user.ID, vcID, state.JoinedAt)
if err != nil {
t.Fatalf("leaveVoiceChannelWithRetry returned error: %v", err)
}
state, _ = database.GetVoiceState(context.Background(), user.ID)
if state != nil {
t.Fatal("voice state should be nil after successful leave")
}
}
func TestLeaveVoiceChannelWithRetry_NoVoiceState_NilReturn(t *testing.T) {
hub, database := newCoverageHub(t)
_ = seedCoverageOwner(t, database, "lvcr-nostate")
err := ws.LeaveVoiceChannelWithRetryForTest(hub, 9999, 1, "")
if err != nil {
t.Fatalf("expected nil error for non-existent voice state, got: %v", err)
}
}
// ─── CleanupVoiceForChannel (hub.go:237) — additional paths ─────────────────
func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) {
hub, database := newCoverageHub(t)
user1 := seedCoverageOwner(t, database, "cvfc-u1")
user2 := seedCoverageOwner(t, database, "cvfc-u2")
vcID := seedVoiceChannel(t, database, "cvfc-vc")
send1 := make(chan []byte, 64)
send2 := make(chan []byte, 64)
c1 := ws.NewTestClientWithUser(hub, user1, 0, send1)
c2 := ws.NewTestClientWithUser(hub, user2, 0, send2)
hub.Register(c1)
hub.Register(c2)
time.Sleep(20 * time.Millisecond)
if err := database.JoinVoiceChannel(context.Background(), user1.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel u1: %v", err)
}
if err := database.JoinVoiceChannel(context.Background(), user2.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel u2: %v", err)
}
ws.SetVoiceChIDForTest(c1, vcID)
ws.SetVoiceChIDForTest(c2, vcID)
hub.CleanupVoiceForChannel(vcID)
time.Sleep(100 * time.Millisecond)
if got := ws.GetClientVoiceChIDForTest(c1); got != 0 {
t.Errorf("c1 voiceChID = %d, want 0", got)
}
if got := ws.GetClientVoiceChIDForTest(c2); got != 0 {
t.Errorf("c2 voiceChID = %d, want 0", got)
}
states, _ := database.GetChannelVoiceStates(context.Background(), vcID)
if len(states) != 0 {
t.Errorf("expected 0 voice states after cleanup, got %d", len(states))
}
}
func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) {
hub, database := newCoverageHub(t)
vcID := seedVoiceChannel(t, database, "cvfc-empty-vc")
hub.CleanupVoiceForChannel(vcID)
time.Sleep(20 * time.Millisecond)
// After cleanup of an empty channel, voice states should still be empty.
states, err := database.GetChannelVoiceStates(context.Background(), vcID)
if err != nil {
t.Fatalf("GetChannelVoiceStates: %v", err)
}
if len(states) != 0 {
t.Errorf("expected 0 voice states after cleaning empty channel, got %d", len(states))
}
}
func TestCleanupVoiceForChannel_DBStateButNoClient(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cvfc-noclient")
vcID := seedVoiceChannel(t, database, "cvfc-noclient-vc")
if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
hub.CleanupVoiceForChannel(vcID)
time.Sleep(50 * time.Millisecond)
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state != nil {
t.Error("voice state should be nil after cleanup")
}
}
// ─── sweepStaleVoiceStates (hub.go:489) ─────────────────────────────────────
func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "sweep-ghost")
vcID := seedVoiceChannel(t, database, "sweep-ghost-vc")
// Put user in voice in DB but don't register a client — ghost state.
if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
// Verify it exists.
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state == nil {
t.Fatal("voice state should exist before sweep")
}
hub.SweepStaleVoiceStatesForTest()
time.Sleep(100 * time.Millisecond)
// Ghost state should be removed.
state, _ = database.GetVoiceState(context.Background(), user.ID)
if state != nil {
t.Error("ghost voice state should be nil after sweep")
}
}
func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "sweep-active")
vcID := seedVoiceChannel(t, database, "sweep-active-vc")
// Register client and set voice channel.
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
ws.SetVoiceChIDForTest(c, vcID)
hub.SweepStaleVoiceStatesForTest()
time.Sleep(100 * time.Millisecond)
// Active client's state should be preserved.
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state == nil {
t.Error("active client's voice state should be preserved after sweep")
}
}
func TestSweepStaleVoiceStates_NoStatesNoPanic(t *testing.T) {
hub, database := newCoverageHub(t)
hub.SweepStaleVoiceStatesForTest()
time.Sleep(50 * time.Millisecond)
// With no voice states in the DB, sweep should leave the system clean.
// Verify by checking a known user has no voice state.
user := seedCoverageOwner(t, database, "sweep-no-states")
state, err := database.GetVoiceState(context.Background(), user.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state != nil {
t.Error("expected nil voice state for user after sweep with no states")
}
}
func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "sweep-mismatch")
vc1 := seedVoiceChannel(t, database, "sweep-mismatch-vc1")
vc2 := seedVoiceChannel(t, database, "sweep-mismatch-vc2")
// Register client in vc1 but DB says vc2.
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
if err := database.JoinVoiceChannel(context.Background(), user.ID, vc2); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
ws.SetVoiceChIDForTest(c, vc1) // Client thinks vc1, DB says vc2 — mismatch.
hub.SweepStaleVoiceStatesForTest()
time.Sleep(100 * time.Millisecond)
// Mismatched state should be removed from DB.
state, _ := database.GetVoiceState(context.Background(), user.ID)
if state != nil {
t.Error("mismatched voice state should be removed after sweep")
}
}
// TestSweepStaleVoiceStates_EvictsRevokedConnectVoice locks the revocation half
// of the voice-permission invariant: nothing in ws re-validated CONNECT_VOICE
// for a connection that stays open, so stripping the bit blocked future joins
// but left the offender in the room. The sweep must now evict them — DB row
// gone and the client's own voice state cleared.
func TestSweepStaleVoiceStates_EvictsRevokedConnectVoice(t *testing.T) {
hub, database := newCoverageHub(t)
// Member role (id 4), not Owner: admins bypass every channel check.
if _, err := database.CreateUser(context.Background(), "sweep-revoked", "hash", 4); err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), "sweep-revoked")
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
vcID := seedVoiceChannel(t, database, "sweep-revoked-vc")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
if joinErr := database.JoinVoiceChannel(context.Background(), user.ID, vcID); joinErr != nil {
t.Fatalf("JoinVoiceChannel: %v", joinErr)
}
vs, err := database.GetVoiceState(context.Background(), user.ID)
if err != nil || vs == nil {
t.Fatalf("GetVoiceState after join: %v", err)
}
ws.SetClientVoiceStateForTest(c, vcID, vs.JoinedAt)
// Still permitted → the sweep leaves them alone.
hub.SweepStaleVoiceStatesForTest()
time.Sleep(100 * time.Millisecond)
if state, _ := database.GetVoiceState(context.Background(), user.ID); state == nil {
t.Fatal("a permitted participant must survive the sweep")
}
// Moderator revokes CONNECT_VOICE on this channel for the Member role.
if permErr := database.UpsertChannelOverride(
context.Background(), vcID, 4, 0, permissions.ConnectVoice,
); permErr != nil {
t.Fatalf("UpsertChannelOverride: %v", permErr)
}
hub.SweepStaleVoiceStatesForTest()
time.Sleep(200 * time.Millisecond)
if state, _ := database.GetVoiceState(context.Background(), user.ID); state != nil {
t.Error("revoked participant's voice state must be deleted by the sweep")
}
if chID := ws.GetClientVoiceChIDForTest(c); chID != 0 {
t.Errorf("revoked participant's client voice state must be cleared, got channel %d", chID)
}
}
// ─── BroadcastToChannel / BroadcastToAll full-channel path ──────────────────
func TestBroadcastToChannel_DropsWhenFull(t *testing.T) {
hub, _ := newCoverageHub(t)
// Don't start Run() — broadcast channel will fill up.
// The broadcast channel capacity is 256.
for i := 0; i < 260; i++ {
hub.BroadcastToChannel(1, []byte(`{"type":"test"}`))
}
// With no Run() loop draining, some messages are dropped.
// Hub should still be functional after overflow — verify by checking
// that a user lookup still works (hub internals not corrupted).
if hub.IsUserConnected(9999) {
t.Error("expected false for non-existent user after broadcast overflow")
}
}
func TestBroadcastToAll_DropsWhenFull(t *testing.T) {
hub, _ := newCoverageHub(t)
for i := 0; i < 260; i++ {
hub.BroadcastToAll([]byte(`{"type":"test"}`))
}
// Hub should still be functional after overflow — verify hub state is intact.
if hub.IsUserConnected(9999) {
t.Error("expected false for non-existent user after broadcast overflow")
}
}