package ws_test import ( "context" "encoding/json" "fmt" "sync" "testing" "testing/fstest" "time" "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/ws" ) // ─── test helpers ───────────────────────────────────────────────────────────── func openTestDB(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: hubTestSchema}, } if err := db.MigrateFS(database, migrFS); err != nil { t.Fatalf("MigrateFS: %v", err) } return database } func newTestHub(t *testing.T) (*ws.Hub, *db.DB) { t.Helper() database := openTestDB(t) limiter := auth.NewRateLimiter() hub := ws.NewHub(database, limiter, nil) return hub, database } // seedTestUser inserts a Member-role user and returns its ID. func seedTestUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() id, err := database.CreateUser(username, "hash", 4) if err != nil { t.Fatalf("seedUser: %v", err) } return id } // seedOwnerUser inserts an Owner-role user and returns the full *db.User. // Owner role (id=1) has all permissions (0x7FFFFFFF), so it passes all checks. func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner if err != nil { t.Fatalf("seedOwnerUser: %v", err) } user, err := database.GetUserByUsername(username) if err != nil || user == nil { t.Fatalf("seedOwnerUser GetUserByUsername: %v", err) } return user } // seedTestChannel inserts a channel and returns its ID. func seedTestChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() id, err := database.CreateChannel(name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannel: %v", err) } return id } // ─── Hub lifecycle ──────────────────────────────────────────────────────────── func TestNewHub_NotNil(t *testing.T) { hub, _ := newTestHub(t) if hub == nil { t.Fatal("NewHub returned nil") } } func TestHub_RunStops(t *testing.T) { hub, _ := newTestHub(t) done := make(chan struct{}) go func() { hub.Run() close(done) }() // Give the goroutine a moment to start, then stop the hub. time.Sleep(10 * time.Millisecond) hub.Stop() select { case <-done: // ok case <-time.After(2 * time.Second): t.Error("hub.Run() did not stop after hub.Stop()") } } // ─── Register / Unregister ──────────────────────────────────────────────────── func TestHub_RegisterIncrementsCount(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "alice") send := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, userID, send)) time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d, want 1", hub.ClientCount()) } } func TestHub_UnregisterDecrementsCount(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "bob") send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) time.Sleep(20 * time.Millisecond) hub.Unregister(c) time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 0 { t.Errorf("ClientCount = %d, want 0", hub.ClientCount()) } } func TestHub_RegisterSameUserTwice(t *testing.T) { // Second registration for same userID should replace the first. hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "carol") send1 := make(chan []byte, 4) send2 := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, userID, send1)) hub.Register(ws.NewTestClient(hub, userID, send2)) time.Sleep(30 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after double register, want 1", hub.ClientCount()) } } // ─── BroadcastToAll ─────────────────────────────────────────────────────────── func TestHub_BroadcastToAll_DeliversToAllClients(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() u1 := seedTestUser(t, database, "dave") u2 := seedTestUser(t, database, "eve") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, u1, s1)) hub.Register(ws.NewTestClient(hub, u2, s2)) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"presence","payload":{}}`) hub.BroadcastToAll(msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "client 1") assertReceived(t, s2, msg, "client 2") } func TestHub_BroadcastToAll_NoClients(t *testing.T) { hub, _ := newTestHub(t) go hub.Run() defer hub.Stop() // Should not panic. hub.BroadcastToAll([]byte(`{}`)) } // ─── BroadcastToChannel ─────────────────────────────────────────────────────── func TestHub_BroadcastToChannel_OnlySendsToChannelMembers(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() chID := seedTestChannel(t, database, "general") u1 := seedTestUser(t, database, "frank") u2 := seedTestUser(t, database, "grace") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) c1 := ws.NewTestClientWithChannel(hub, u1, chID, s1) c2 := ws.NewTestClientWithChannel(hub, u2, 999, s2) // different channel hub.Register(c1) hub.Register(c2) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"chat_message","payload":{}}`) hub.BroadcastToChannel(chID, msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "channel member") assertNotReceived(t, s2, "non-member") } func TestHub_BroadcastToChannel_ZeroChannelSendsToAll(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() u1 := seedTestUser(t, database, "henry") s1 := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, u1, s1)) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"presence","payload":{}}`) hub.BroadcastToChannel(0, msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "client") } // ─── BUG-122: Unfocused client must NOT receive channel-scoped broadcasts ──── func TestHub_BroadcastToChannel_SkipsUnfocusedClient(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() chID := seedTestChannel(t, database, "secret") u1 := seedTestUser(t, database, "focused") u2 := seedTestUser(t, database, "unfocused") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) c1 := ws.NewTestClientWithChannel(hub, u1, chID, s1) // focused on channel c2 := ws.NewTestClient(hub, u2, s2) // channelID == 0 (unfocused) hub.Register(c1) hub.Register(c2) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"chat_message","payload":{"content":"secret"}}`) hub.BroadcastToChannel(chID, msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "focused client") assertNotReceived(t, s2, "unfocused client must NOT receive channel broadcast") } func TestHub_BroadcastToChannel_DeliversToVoiceClient(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() chID := seedTestChannel(t, database, "voice-text") u1 := seedTestUser(t, database, "voiceuser") s1 := make(chan []byte, 4) c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0 ws.SetClientVoiceChID(c1, chID) // but in voice on this channel hub.Register(c1) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"chat_message","payload":{"content":"hello"}}`) hub.BroadcastToChannel(chID, msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "voice client should receive channel broadcast") } func TestHub_BroadcastToAll_StillDeliversToUnfocusedClient(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() u1 := seedTestUser(t, database, "globaluser") s1 := make(chan []byte, 4) c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0 (unfocused) hub.Register(c1) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"presence","payload":{"status":"online"}}`) hub.BroadcastToAll(msg) time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "unfocused client must still receive global broadcasts") } func TestHub_BroadcastToChannel_UnfocusedDoesNotReceiveAnyChannel(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() ch1 := seedTestChannel(t, database, "chan1") ch2 := seedTestChannel(t, database, "chan2") u1 := seedTestUser(t, database, "snooper") s1 := make(chan []byte, 8) c1 := ws.NewTestClient(hub, u1, s1) // unfocused hub.Register(c1) time.Sleep(20 * time.Millisecond) msg1 := []byte(`{"type":"chat_message","payload":{"channel":"1"}}`) msg2 := []byte(`{"type":"chat_message","payload":{"channel":"2"}}`) hub.BroadcastToChannel(ch1, msg1) hub.BroadcastToChannel(ch2, msg2) time.Sleep(20 * time.Millisecond) assertNotReceived(t, s1, "unfocused client must NOT receive ch1 broadcast") } // ─── SendToUser ─────────────────────────────────────────────────────────────── func TestHub_SendToUser_ExistingClient(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "ivan") send := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, userID, send)) time.Sleep(20 * time.Millisecond) msg := []byte(`{"type":"chat_send_ok","payload":{}}`) ok := hub.SendToUser(userID, msg) if !ok { t.Error("SendToUser returned false for existing client") } time.Sleep(20 * time.Millisecond) assertReceived(t, send, msg, "target user") } func TestHub_SendToUser_MissingClient(t *testing.T) { hub, _ := newTestHub(t) go hub.Run() defer hub.Stop() ok := hub.SendToUser(9999, []byte(`{}`)) if ok { t.Error("SendToUser should return false for absent client") } } // ─── Message dispatch ───────────────────────────────────────────────────────── func TestHub_HandleMessage_UnknownType_SendsError(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "julia") send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) time.Sleep(20 * time.Millisecond) raw := []byte(`{"type":"totally_unknown","payload":{}}`) hub.HandleMessageForTest(c, raw) time.Sleep(20 * time.Millisecond) select { case got := <-send: var resp map[string]any if err := json.Unmarshal(got, &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp["type"] != "error" { t.Errorf("type = %q, want 'error'", resp["type"]) } case <-time.After(500 * time.Millisecond): t.Error("expected error response for unknown message type") } } func TestHub_HandleMessage_InvalidJSON(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() userID := seedTestUser(t, database, "kim") send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) time.Sleep(20 * time.Millisecond) hub.HandleMessageForTest(c, []byte(`NOT JSON`)) time.Sleep(20 * time.Millisecond) select { case got := <-send: var resp map[string]any if err := json.Unmarshal(got, &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp["type"] != "error" { t.Errorf("type = %q, want 'error'", resp["type"]) } case <-time.After(500 * time.Millisecond): t.Error("expected error response for invalid JSON") } } // ─── Rate limiting ──────────────────────────────────────────────────────────── func TestHub_ChatSend_RateLimit(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() user := seedOwnerUser(t, database, "larry") chID := seedTestChannel(t, database, "rl-test") send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) time.Sleep(20 * time.Millisecond) payload := map[string]any{ "channel_id": chID, "content": "hi", } raw, _ := json.Marshal(map[string]any{ "type": "chat_send", "payload": payload, }) // Send 12 messages rapidly — 11th and beyond should be rate-limited. for range 12 { hub.HandleMessageForTest(c, raw) } time.Sleep(100 * time.Millisecond) // Drain all messages, count errors. errCount := 0 drainLoop: for { select { case got := <-send: var resp map[string]any if err := json.Unmarshal(got, &resp); err == nil { if resp["type"] == "error" { errCount++ } } default: break drainLoop } } if errCount == 0 { t.Error("expected at least one rate-limit error response") } } // ─── Concurrency ───────────────────────────────────────────────────────────── func TestHub_ConcurrentRegisterUnregister(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() var wg sync.WaitGroup for i := range 20 { wg.Add(1) go func(i int) { defer wg.Done() username := fmt.Sprintf("user%d", i) userID := seedTestUser(t, database, username) send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) time.Sleep(5 * time.Millisecond) hub.Unregister(c) }(i) } wg.Wait() // The hub loop drains register/unregister asynchronously; poll instead of // a fixed sleep, which flakes under -race on slow runners. deadline := time.Now().Add(5 * time.Second) for hub.ClientCount() != 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if hub.ClientCount() != 0 { t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount()) } } // ─── GetClient ─────────────────────────────────────────────────────────────── func TestHub_GetClient(t *testing.T) { hub, _ := newTestHub(t) send := make(chan []byte, 256) client := ws.NewTestClient(hub, 42, send) hub.Register(client) go hub.Run() defer hub.Stop() time.Sleep(10 * time.Millisecond) got := hub.GetClient(42) if got == nil { t.Fatal("GetClient(42) returned nil") } got2 := hub.GetClient(999) if got2 != nil { t.Fatal("GetClient(999) should return nil") } } // ─── assertion helpers ──────────────────────────────────────────────────────── // assertReceived checks that a message was received and contains the same JSON // fields as want (ignoring the "seq" field injected by broadcast delivery). func assertReceived(t *testing.T, ch <-chan []byte, want []byte, label string) { t.Helper() select { case got := <-ch: var gotMap map[string]json.RawMessage if err := json.Unmarshal(got, &gotMap); err != nil { t.Errorf("%s: unmarshal got: %v", label, err) return } var wantMap map[string]json.RawMessage if err := json.Unmarshal(want, &wantMap); err != nil { t.Errorf("%s: unmarshal want: %v", label, err) return } // Strip seq before comparing — broadcasts have it, direct sends don't. delete(gotMap, "seq") for k, wv := range wantMap { gv, ok := gotMap[k] if !ok { t.Errorf("%s: missing key %q in received message", label, k) continue } if string(gv) != string(wv) { t.Errorf("%s: key %q: got %s, want %s", label, k, gv, wv) } } case <-time.After(500 * time.Millisecond): t.Errorf("%s: did not receive expected message within timeout", label) } } func assertNotReceived(t *testing.T, ch <-chan []byte, label string) { t.Helper() select { case got := <-ch: t.Errorf("%s: received unexpected message: %q", label, got) case <-time.After(100 * time.Millisecond): // ok — nothing received } } // ─── LiveKit lifecycle ──────────────────────────────────────────────────────── func TestHub_SetLiveKit_NilSafe(t *testing.T) { hub, _ := newTestHub(t) // Setting a nil LiveKit client must not panic. hub.SetLiveKit(nil) } // ─── GracefulStop ───────────────────────────────────────────────────────────── func TestHub_GracefulStop_StopsHub(t *testing.T) { hub, _ := newTestHub(t) done := make(chan struct{}) go func() { hub.Run() close(done) }() time.Sleep(10 * time.Millisecond) hub.GracefulStop() select { case <-done: // ok — hub stopped case <-time.After(2 * time.Second): t.Error("hub.Run() did not stop after GracefulStop()") } } func TestHub_GracefulStop_NoPanic(t *testing.T) { hub, _ := newTestHub(t) go hub.Run() // Must not panic with no LiveKit process. hub.GracefulStop() } func TestHub_GracefulStop_Idempotent(t *testing.T) { // BUG-087: GracefulStop must be safe to call concurrently/twice. // Without sync.Once protection, double lkProcess.Stop() can panic. hub, _ := newTestHub(t) go hub.Run() var wg sync.WaitGroup wg.Add(2) for range 2 { go func() { defer wg.Done() hub.GracefulStop() }() } done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: // Success: no panic from concurrent GracefulStop. case <-time.After(15 * time.Second): t.Fatal("concurrent GracefulStop calls deadlocked") } } // ─── CleanupVoiceForChannel ─────────────────────────────────────────────────── func TestHub_CleanupVoiceForChannel_NoVoiceState_NoPanic(t *testing.T) { hub, _ := newTestHub(t) // Must not panic when channel has no voice state in DB. hub.CleanupVoiceForChannel(9999) } // TestHub_Register_CleansUpOldVoiceState was removed because duplicate // logins are now rejected at the WebSocket handshake level (commit 00bbb46) // before hub.Register is called. The hub's register case simply overwrites // the client map entry; voice cleanup for disconnects is handled by // handleVoiceLeave called from readPump/ICE monitor. // ─── sweepStaleClients ────────────────────────────────────────────────────── func TestHub_SweepStaleClients_RemovesInactiveClients(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() u1 := seedTestUser(t, database, "stale-alice") u2 := seedTestUser(t, database, "fresh-bob") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) c1 := ws.NewTestClient(hub, u1, s1) c2 := ws.NewTestClient(hub, u2, s2) hub.Register(c1) hub.Register(c2) time.Sleep(20 * time.Millisecond) ws.SetClientLastActivityForTest(c1, time.Now().Add(-2*time.Minute)) ws.SetClientLastActivityForTest(c2, time.Now()) hub.SweepStaleClientsForTest() time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after sweep, want 1", hub.ClientCount()) } if hub.GetClient(u1) != nil { t.Error("stale client should have been removed") } if hub.GetClient(u2) == nil { t.Error("fresh client should still be present") } } func TestHub_SweepStaleClients_NoClientsNoPanic(t *testing.T) { hub, _ := newTestHub(t) hub.SweepStaleClientsForTest() } func TestHub_SweepStaleClients_AllFresh(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() u1 := seedTestUser(t, database, "fresh-carol") s1 := make(chan []byte, 4) c1 := ws.NewTestClient(hub, u1, s1) hub.Register(c1) time.Sleep(20 * time.Millisecond) ws.SetClientLastActivityForTest(c1, time.Now()) hub.SweepStaleClientsForTest() time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after sweep of fresh clients, want 1", hub.ClientCount()) } } // ─── Session sweep (BUG-109) ────────────────────────────────────────────── // TestHub_SweepRevokedSessions_KicksRevokedClient verifies that the periodic // session sweep disconnects clients whose sessions have been deleted from the // database (e.g. after logout on another device). func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() // Create two users with sessions. uid1, err := database.CreateUser("alice-revoke", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } uid2, err := database.CreateUser("bob-valid", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } u1, _ := database.GetUserByID(uid1) u2, _ := database.GetUserByID(uid2) token1 := "revoke-token-1" token2 := "valid-token-2" hash1 := auth.HashToken(token1) hash2 := auth.HashToken(token2) if _, err := database.CreateSession(uid1, hash1, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 1: %v", err) } if _, err := database.CreateSession(uid2, hash2, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 2: %v", err) } s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) c1 := ws.NewTestClientWithTokenHash(hub, u1, hash1, 0, s1) c2 := ws.NewTestClientWithTokenHash(hub, u2, hash2, 0, s2) hub.Register(c1) hub.Register(c2) time.Sleep(20 * time.Millisecond) // Delete alice's session (simulating logout from another device). if err := database.DeleteSession(hash1); err != nil { t.Fatalf("DeleteSession: %v", err) } // Run the session sweep. hub.SweepRevokedSessionsForTest() time.Sleep(20 * time.Millisecond) // Alice should be kicked, Bob should remain. if hub.GetClient(uid1) != nil { t.Error("revoked client alice should have been kicked") } if hub.GetClient(uid2) == nil { t.Error("valid client bob should still be connected") } if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d, want 1", hub.ClientCount()) } } // TestHub_SweepRevokedSessions_NoDBNoPanic verifies the sweep is a no-op // when the hub has no database (nil-safe). func TestHub_SweepRevokedSessions_NoDBNoPanic(t *testing.T) { hub := ws.NewHubForTest() hub.SweepRevokedSessionsForTest() // should not panic } // TestHub_SweepRevokedSessions_EmptyTokenHashSkipped verifies that clients // without a token hash (e.g. test clients) are not kicked by the sweep. func TestHub_SweepRevokedSessions_EmptyTokenHashSkipped(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() uid := seedTestUser(t, database, "no-hash-user") s := make(chan []byte, 4) c := ws.NewTestClient(hub, uid, s) hub.Register(c) time.Sleep(20 * time.Millisecond) hub.SweepRevokedSessionsForTest() time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d, want 1 (client without token hash should survive)", hub.ClientCount()) } } // ─── LiveKitHealthCheck ───────────────────────────────────────────────────── func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) { hub, _ := newTestHub(t) ok, err := hub.LiveKitHealthCheck(context.Background()) if ok { t.Error("expected ok=false when LiveKit is nil") } if err == nil { t.Error("expected non-nil error when LiveKit is nil") } } // ─── SetLiveKitProcess ────────────────────────────────────────────────────── func TestHub_SetLiveKitProcess(t *testing.T) { hub, _ := newTestHub(t) hub.SetLiveKitProcess(nil) go hub.Run() hub.GracefulStop() } // ─── VoiceSessionCount ───────────────────────────────────────────────────── func TestHub_VoiceSessionCount(t *testing.T) { tests := []struct { name string voiceChs []int64 wantCount int }{ {"no clients", nil, 0}, {"all in voice", []int64{100, 200, 300}, 3}, {"none in voice", []int64{0, 0}, 0}, {"mixed", []int64{100, 0, 200, 0}, 2}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() for i, vch := range tc.voiceChs { username := fmt.Sprintf("voice-%s-%d", tc.name, i) uid := seedTestUser(t, database, username) send := make(chan []byte, 4) c := ws.NewTestClient(hub, uid, send) if vch != 0 { ws.SetClientVoiceChID(c, vch) } hub.Register(c) } time.Sleep(30 * time.Millisecond) got := hub.VoiceSessionCount() if got != tc.wantCount { t.Errorf("VoiceSessionCount() = %d, want %d", got, tc.wantCount) } }) } } // ─── RefreshChannelVisibility ───────────────────────────────────────────────── // drainForMsgType reads messages from send until one with the given type // arrives or the timeout expires. Returns the decoded payload-bearing message. func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any { t.Helper() deadline := time.After(500 * time.Millisecond) for { select { case raw := <-send: var msg map[string]any if err := json.Unmarshal(raw, &msg); err != nil { continue } if msg["type"] == msgType { return msg } case <-deadline: t.Fatalf("timed out waiting for %q message", msgType) return nil } } } // assertNoMsgType asserts that no message of the given type is buffered. func assertNoMsgType(t *testing.T, send chan []byte, msgType string) { t.Helper() for { select { case raw := <-send: var msg map[string]any if err := json.Unmarshal(raw, &msg); err != nil { continue } if msg["type"] == msgType { t.Fatalf("unexpected %q message", msgType) } default: return } } } func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { hub, database := newTestHub(t) go hub.Run() defer hub.Stop() chID := seedTestChannel(t, database, "secret-room") ch, err := database.GetChannel(chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } owner := seedOwnerUser(t, database, "vis-owner") memberID := seedTestUser(t, database, "vis-member") member, err := database.GetUserByID(memberID) if err != nil || member == nil { t.Fatalf("GetUserByID: %v", err) } ownerSend := make(chan []byte, 16) memberSend := make(chan []byte, 16) ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend) memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend) hub.Register(ownerClient) hub.Register(memberClient) time.Sleep(30 * time.Millisecond) // Hide the channel from the Member role (deny ReadMessages). if _, err := database.Exec( `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, chID, ); err != nil { t.Fatalf("insert override: %v", err) } hub.RefreshChannelVisibility(ch) // Member loses the channel; owner (admin bit) keeps it. drainForMsgType(t, memberSend, "channel_delete") drainForMsgType(t, ownerSend, "channel_create") // Restore visibility — the member gets the channel back. if _, err := database.Exec( `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID, ); err != nil { t.Fatalf("delete override: %v", err) } hub.RefreshChannelVisibility(ch) drainForMsgType(t, memberSend, "channel_create") assertNoMsgType(t, memberSend, "channel_delete") } func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) { hub, database := newTestHub(t) chID := seedTestChannel(t, database, "watermark-room") ch, err := database.GetChannel(chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } // No visibility change yet — resume is allowed regardless of seq. if hub.MustFullResyncForTest(1) { t.Error("expected replay allowed before any visibility change") } hub.SeedSeq(41) hub.RefreshChannelVisibility(ch) // Clients resuming from at/before the change must take the full path. if !hub.MustFullResyncForTest(41) { t.Error("expected forced full resync for lastSeq at the watermark") } if !hub.MustFullResyncForTest(10) { t.Error("expected forced full resync for lastSeq before the watermark") } // Clients that saw sequenced traffic after the change may replay. if hub.MustFullResyncForTest(42) { t.Error("expected replay allowed for lastSeq after the watermark") } } // hubTestSchema is the minimal schema needed for hub tests. var hubTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT, permissions INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES (1, 'Owner', '#E74C3C', 2147483647, 100, 0), (2, 'Admin', '#F39C12', 1073741823, 80, 0), (3, 'Moderator', '#3498DB', 1048575, 60, 0), (4, 'Member', NULL, 1635, 40, 1); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, avatar TEXT, role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), totp_secret TEXT, status TEXT NOT NULL DEFAULT 'offline', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, device TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', category TEXT, topic TEXT, position INTEGER NOT NULL DEFAULT 0, slow_mode INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, allow INTEGER NOT NULL DEFAULT 0, deny INTEGER NOT NULL DEFAULT 0, UNIQUE(channel_id, role_id) ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), content TEXT NOT NULL, reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, timestamp TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, content='messages', content_rowid='id' ); CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); END; CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, emoji TEXT NOT NULL, UNIQUE(message_id, user_id, emoji) ); CREATE TABLE IF NOT EXISTS read_states ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, last_message_id INTEGER NOT NULL DEFAULT 0, mention_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, channel_id) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); INSERT OR IGNORE INTO settings (key, value) VALUES ('server_name', 'OwnCord Server'), ('motd', 'Welcome!'); CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (channel_id, user_id) ); CREATE TABLE IF NOT EXISTS dm_open_state ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, opened_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, channel_id) ); CREATE TABLE IF NOT EXISTS user_blocks ( blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (blocker_id, blocked_id), CHECK (blocker_id != blocked_id) ); `)