diff --git a/Server/admin/admin.go b/Server/admin/admin.go index b9ecad2b..be61d913 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -56,11 +56,3 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. return r } - -// Handler returns the admin panel http.Handler using a nil database. -// -// Deprecated: use NewHandler instead. Kept for backwards-compat with any -// caller that already imported this symbol before Phase 6. -func Handler() http.Handler { - return http.FileServer(http.FS(staticFiles)) -} diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 37b68e14..2772100d 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -115,33 +115,6 @@ func TestNewHandler_WithUpdater(t *testing.T) { } } -// ─── Handler (deprecated) ──────────────────────────────────────────────────── - -// TestHandler_ReturnsNonNil verifies the deprecated Handler() function returns -// a non-nil http.Handler (it serves the embedded static files). -func TestHandler_ReturnsNonNil(t *testing.T) { - h := admin.Handler() - if h == nil { - t.Fatal("Handler() returned nil") - } -} - -// TestHandler_ServesEmbeddedFiles verifies that the deprecated Handler() serves -// a response (the embedded static FS) without panicking. -func TestHandler_ServesEmbeddedFiles(t *testing.T) { - h := admin.Handler() - - req := httptest.NewRequest(http.MethodGet, "/index.html", nil) - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - - // http.FileServer returns 200 for a found file or 301/404 for others; - // the important thing is it doesn't panic and returns a valid HTTP status. - if w.Code == 0 { - t.Error("Handler() response has zero status code") - } -} - // ─── ownerOnlyMiddleware (tested via API endpoints that use it) ─────────────── // TestOwnerOnlyMiddleware_OwnerAllowed verifies that a user with Owner role diff --git a/Server/admin/export_test.go b/Server/admin/export_test.go new file mode 100644 index 00000000..a067703c --- /dev/null +++ b/Server/admin/export_test.go @@ -0,0 +1,5 @@ +package admin + +// SetBackupBaseDir overrides backupBaseDir so tests can point backup handlers +// at a temp dir. Lives here so it stays out of the production binary. +func SetBackupBaseDir(dir string) { backupBaseDir = dir } diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index fbf11256..888286df 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -28,9 +28,6 @@ func init() { } } -// SetBackupBaseDir overrides backupBaseDir. Intended for tests only. -func SetBackupBaseDir(dir string) { backupBaseDir = dir } - // ─── Backup Handlers ───────────────────────────────────────────────────────── func handleBackup(database *db.DB) http.Handler { diff --git a/Server/api/export_test.go b/Server/api/export_test.go index 37c6db1d..ba9cb746 100644 --- a/Server/api/export_test.go +++ b/Server/api/export_test.go @@ -44,3 +44,9 @@ func SetGIFUpstreamForTest(baseURL string, client *http.Client) func() { gifAPIBase, gifClient = baseURL, client return func() { gifAPIBase, gifClient = prevBase, prevClient } } + +// SecurityHeaders is SecurityHeadersWithTLS with TLS disabled (no HSTS). +// Test-only convenience — production always goes through SecurityHeadersWithTLS. +func SecurityHeaders(next http.Handler) http.Handler { + return SecurityHeadersWithTLS("")(next) +} diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 1e218112..38d1fbc6 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -375,12 +375,6 @@ func SecurityHeadersWithTLS(tlsMode string) func(http.Handler) http.Handler { } } -// SecurityHeaders is a convenience wrapper for SecurityHeadersWithTLS with TLS -// disabled (no HSTS header). Kept for backwards compatibility with tests. -func SecurityHeaders(next http.Handler) http.Handler { - return SecurityHeadersWithTLS("")(next) -} - // MaxBodySize wraps r.Body with http.MaxBytesReader so that reads beyond // maxBytes return an error. This prevents clients from exhausting server memory // by sending arbitrarily large request bodies. diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go index ea8d363b..6db5da3e 100644 --- a/Server/telemetry/telemetry.go +++ b/Server/telemetry/telemetry.go @@ -58,9 +58,6 @@ func String(k, v string) Attr { return Attr{Key: k, Value: v} } // Int64 constructs an int64 attribute. func Int64(k string, v int64) Attr { return Attr{Key: k, Value: v} } -// Float64 constructs a float64 attribute. -func Float64(k string, v float64) Attr { return Attr{Key: k, Value: v} } - // Span is a single tracing span. type Span interface { End() diff --git a/Server/ws/client.go b/Server/ws/client.go index 24aab94a..9b8a2bae 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -83,92 +83,6 @@ func (c *Client) GetTokenHash() string { return c.tokenHash } -// NewTestClient creates a client with a caller-supplied send channel. -// Intended for unit tests only — conn is nil. -func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: userID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// NewTestClientWithChannel creates a test client subscribed to a specific channel. -func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: userID, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// NewTestClientWithUser creates a test client with an authenticated user record set. -// Use this when tests need the client to pass permission checks. -func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: user.ID, - user: user, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// SetClientVoiceChID sets the voiceChID field on a client. For test use only. -func SetClientVoiceChID(c *Client, channelID int64) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = channelID - if channelID == 0 { - c.voiceJoinToken = "" - } -} - -// SetClientVoiceStateForTest sets both the voice channel and join token. -// For test use only. -func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = channelID - c.voiceJoinToken = joinToken -} - -// SetClientE2EEPubKeyForTest sets the E2EE public key on a client. For test use only. -func SetClientE2EEPubKeyForTest(c *Client, key string) { - c.setE2EEPubKey(key) -} - -// GetClientE2EEPubKeyForTest returns the E2EE public key from a client. For test use only. -func GetClientE2EEPubKeyForTest(c *Client) string { - return c.getE2EEPubKey() -} - -// NewTestClientWithTokenHash creates a test client that carries a session token -// hash. Use this when tests need to exercise the periodic session-expiry check. -func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: user.ID, - user: user, - tokenHash: tokenHash, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - // touch updates the last activity timestamp and increments the received counter. func (c *Client) touch() { c.mu.Lock() @@ -198,28 +112,12 @@ func (c *Client) getVoiceChID() int64 { return c.voiceChID } -func (c *Client) getVoiceJoinToken() string { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - return c.voiceJoinToken -} - func (c *Client) getVoiceState() (int64, string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() return c.voiceChID, c.voiceJoinToken } -// setVoiceChID sets the voice channel ID atomically. -func (c *Client) setVoiceChID(chID int64) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = chID - if chID == 0 { - c.voiceJoinToken = "" - } -} - func (c *Client) setVoiceState(chID int64, joinToken string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() diff --git a/Server/ws/coverage_boost2_test.go b/Server/ws/coverage_boost2_test.go index 267d0047..ebbea0cc 100644 --- a/Server/ws/coverage_boost2_test.go +++ b/Server/ws/coverage_boost2_test.go @@ -162,75 +162,6 @@ func TestBuildDMChannelOpen_NilAvatar(t *testing.T) { } } -// ─── broadcastVoiceStateUpdate ────────────────────────────────────────────── - -func TestBroadcastVoiceStateUpdate_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bvsu-noop") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // User not in voice — should be a no-op. - hub.BroadcastVoiceStateUpdateForTest(c) - time.Sleep(20 * time.Millisecond) - - // No voice_state message should have been sent since user is not in voice. - for len(send) > 0 { - msg := <-send - var m struct { - Type string `json:"type"` - } - _ = json.Unmarshal(msg, &m) - if m.Type == "voice_state" { - t.Error("expected no voice_state broadcast when user is not in voice") - } - } -} - -func TestBroadcastVoiceStateUpdate_InVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bvsu-voice") - - // Create a voice channel. - chanID, err := database.CreateChannel("bvsu-ch", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - - // Join the voice channel in DB. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - ws.SetClientVoiceChID(c, chanID) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Should broadcast a voice_state message. - hub.BroadcastVoiceStateUpdateForTest(c) - - // Drain the channel and check for voice_state message. - time.Sleep(20 * time.Millisecond) - found := false - for len(send) > 0 { - msg := <-send - var m struct { - Type string `json:"type"` - } - _ = json.Unmarshal(msg, &m) - if m.Type == "voice_state" { - found = true - } - } - if !found { - t.Error("expected voice_state broadcast") - } -} - // ─── handleVoiceMute via HandleMessageForTest ─────────────────────────────── func TestHandleVoiceMute_NotInVoice2(t *testing.T) { diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 12060914..691aa621 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -2300,43 +2300,6 @@ func TestGetLastActivity_MultipleTouch(t *testing.T) { } } -// ─── setVoiceChID (client.go:186) ─────────────────────────────────────────── - -func TestSetVoiceChID_SetsAndGetsValue(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 77) - if got := ws.GetClientVoiceChIDForTest(c); got != 77 { - t.Fatalf("voiceChID = %d, want 77", got) - } -} - -func TestSetVoiceChID_OverwritesPreviousValue(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 10) - ws.SetVoiceChIDForTest(c, 20) - if got := ws.GetClientVoiceChIDForTest(c); got != 20 { - t.Fatalf("voiceChID = %d, want 20", got) - } -} - -func TestSetVoiceChID_ZeroMeansNotInVoice(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 50) - ws.SetVoiceChIDForTest(c, 0) - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID = %d, want 0", got) - } -} - // ─── clearVoiceChID (client.go:203) ───────────────────────────────────────── func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) { diff --git a/Server/ws/event.go b/Server/ws/event.go index 8d26d3ae..bbcbc57d 100644 --- a/Server/ws/event.go +++ b/Server/ws/event.go @@ -259,17 +259,6 @@ type VoiceStateEvent struct { func (e VoiceStateEvent) EventType() string { return MsgTypeVoiceState } func (e VoiceStateEvent) Payload() []byte { return e.payload } -// VoiceLeaveEvent is a voice_leave broadcast to all connected clients. -// NOTE: Currently unused — the voice_leave V2 handler triggers the hub's -// handleVoiceLeave routine (via Result.LeaveVoice), which broadcasts the leave -// directly. Retained as forward-compatible scaffolding. -type VoiceLeaveEvent struct { - payload []byte -} - -func (e VoiceLeaveEvent) EventType() string { return MsgTypeVoiceLeaveBC } -func (e VoiceLeaveEvent) Payload() []byte { return e.payload } - // PluginBroadcastEvent is a plugin slash-command result broadcast to a channel // (sequenced, replayable). Emitted by the chat_command handler after the // invoking user's post permission is verified. diff --git a/Server/ws/event_test.go b/Server/ws/event_test.go index 62d59245..4b72defe 100644 --- a/Server/ws/event_test.go +++ b/Server/ws/event_test.go @@ -83,7 +83,6 @@ func TestEventTypes(t *testing.T) { {"ReactionChannelEvent", ReactionChannelEvent{}, MsgTypeReactionUpdate}, {"ReactionDMEvent", ReactionDMEvent{}, MsgTypeReactionUpdate}, {"VoiceStateEvent", VoiceStateEvent{}, MsgTypeVoiceState}, - {"VoiceLeaveEvent", VoiceLeaveEvent{}, MsgTypeVoiceLeaveBC}, {"VoiceE2EEAnnounceEvent", VoiceE2EEAnnounceEvent{}, MsgTypeVoiceE2EEAnnounceBC}, {"VoiceE2EEOfferGuardedEvent", VoiceE2EEOfferGuardedEvent{}, MsgTypeVoiceE2EEOfferRelay}, {"DMChannelOpenEvent", DMChannelOpenEvent{}, MsgTypeDMChannelOpen}, @@ -207,7 +206,6 @@ func TestBroadcastAllEventInterface(t *testing.T) { }{ {"PresenceEvent", PresenceEvent{payload: []byte("p")}}, {"VoiceStateEvent", VoiceStateEvent{payload: []byte("vs")}}, - {"VoiceLeaveEvent", VoiceLeaveEvent{payload: []byte("vl")}}, } for _, tt := range events { t.Run(tt.name, func(t *testing.T) { diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 18f459e0..9a115869 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -49,9 +49,95 @@ func ClearVoiceChIDForTest(c *Client) int64 { return c.clearVoiceChID() } -// SetVoiceChIDForTest exposes Client.setVoiceChID for external tests. +// SetVoiceChIDForTest sets the voice channel ID atomically, clearing the join +// token when leaving (chID 0) — the same contract production keeps via +// setVoiceState. Test-only: production has no set-channel-without-token path. func SetVoiceChIDForTest(c *Client, chID int64) { - c.setVoiceChID(chID) + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = chID + if chID == 0 { + c.voiceJoinToken = "" + } +} + +// SetClientVoiceChID is an alias kept for existing tests. +func SetClientVoiceChID(c *Client, channelID int64) { + SetVoiceChIDForTest(c, channelID) +} + +// SetClientVoiceStateForTest sets both the voice channel and join token. +func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = channelID + c.voiceJoinToken = joinToken +} + +// SetClientE2EEPubKeyForTest sets the E2EE public key on a client. +func SetClientE2EEPubKeyForTest(c *Client, key string) { + c.setE2EEPubKey(key) +} + +// GetClientE2EEPubKeyForTest returns the E2EE public key from a client. +func GetClientE2EEPubKeyForTest(c *Client) string { + return c.getE2EEPubKey() +} + +// NewTestClient creates a client with a caller-supplied send channel; conn is nil. +func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: userID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithChannel creates a test client subscribed to a specific channel. +func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: userID, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithUser creates a test client with an authenticated user record +// set. Use this when tests need the client to pass permission checks. +func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: user.ID, + user: user, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithTokenHash creates a test client that carries a session token +// hash. Use this when tests need to exercise the periodic session-expiry check. +func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: user.ID, + user: user, + tokenHash: tokenHash, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } } // TouchForTest exposes Client.touch for external tests. @@ -138,9 +224,11 @@ func GetClientVoiceChIDForTest(c *Client) int64 { return c.getVoiceChID() } -// GetClientVoiceJoinTokenForTest exposes Client.getVoiceJoinToken. +// GetClientVoiceJoinTokenForTest reads the join token under voiceMu. func GetClientVoiceJoinTokenForTest(c *Client) string { - return c.getVoiceJoinToken() + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.voiceJoinToken } // ExpireSettingsCacheForTest forces the settings cache to appear stale so that @@ -161,9 +249,11 @@ func BuildJSONForTest(v any) []byte { return buildJSON(v) } -// ParseIdentityForTest exposes parseIdentity for external tests. +// ParseIdentityForTest parses a LiveKit participant identity and discards the +// join token, exercising the production parseParticipantIdentity. func ParseIdentityForTest(identity string) (int64, error) { - return parseIdentity(identity) + userID, _, err := parseParticipantIdentity(identity) + return userID, err } // ParseParticipantIdentityForTest exposes parseParticipantIdentity for tests. @@ -202,11 +292,6 @@ func BuildDMChannelOpenForTest(channelID int64, recipient *db.User) []byte { return buildDMChannelOpen(channelID, recipient) } -// BroadcastVoiceStateUpdateForTest exposes broadcastVoiceStateUpdate for external tests. -func (h *Hub) BroadcastVoiceStateUpdateForTest(c *Client) { - h.broadcastVoiceStateUpdate(c) -} - // HandleWebhookParticipantLeftForTest exposes handleWebhookParticipantLeft for // external tests so they can simulate LiveKit webhook events without HTTP. func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, joinToken string) { diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index ac846a87..51898cf9 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -89,12 +89,6 @@ func parseParticipantIdentity(identity string) (int64, string, error) { return userID, joinToken, nil } -// parseIdentity extracts a user ID from a LiveKit participant identity. -func parseIdentity(identity string) (int64, error) { - userID, _, err := parseParticipantIdentity(identity) - return userID, err -} - // parseRoomChannelID extracts a channel ID from a LiveKit room name // formatted as "channel-{id}". func parseRoomChannelID(roomName string) (int64, error) { diff --git a/Server/ws/voice_broadcast.go b/Server/ws/voice_broadcast.go index 8df6ba27..3bfe4761 100644 --- a/Server/ws/voice_broadcast.go +++ b/Server/ws/voice_broadcast.go @@ -1,7 +1,6 @@ package ws import ( - "log/slog" "time" ) @@ -33,18 +32,3 @@ func qualityBitrate(quality string) int { } return voiceQualities["medium"] } - -// broadcastVoiceStateUpdate fetches the current voice state for the client -// and broadcasts it to all members of the voice channel they are in. -func (h *Hub) broadcastVoiceStateUpdate(c *Client) { - state, err := h.db.GetVoiceState(c.userID) - if err != nil { - slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to broadcast voice state update")) - return - } - if state == nil { - return // user not in a voice channel — nothing to broadcast - } - h.BroadcastToAll(buildVoiceState(*state)) -} diff --git a/docs/api.md b/docs/api.md index 76c811d0..f0a85fd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,7 +19,7 @@ All authenticated endpoints require a session token delivered via the `Authoriza 1. **RequestID** -- assigns a unique `X-Request-Id` response header. 2. **Recoverer** -- catches panics and returns 500. 3. **Request Logger** -- structured logging of method, path, status, duration. -4. **SecurityHeaders** -- sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. +4. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. 5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit). ---