mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080) Guard LiveKit session state against supersession: bump the camera/screen generation in leaveVoice and teardownForReconnect so an in-flight enable discards its track, bail out of restoreLocalVoiceState when a newer room claimed _room mid-await, and recheck isStateConnected in the auto-reconnect tail. * fix(ws): 1 defect(s) (OC-0019) * fix(db): 1 defect(s) (OC-0023) * fix(ws): 1 defect(s) (OC-0029) * fix(ws): 1 defect(s) (OC-0032) * fix(voice): 1 defect(s) (OC-0034) * fix(admin): 1 defect(s) (OC-0035) * fix(service): 2 defect(s) (OC-0036, OC-0128) * fix(voice): 2 defect(s) (OC-0038, OC-0065) OC-0038: the LiveKit participant_left webhook cleared the leaver's own client voice state before broadcasting voice_leave, so the broadcast audience (READ_MESSAGES holders union still-in-the-room participants) could no longer see them. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES never learned the server had torn down their call. Extracted finishVoiceLeave's audience logic into broadcastVoiceEventWithLeaver and used it on the webhook path. OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error into the same branch as "no matching row", so a transient DB failure ejected a legitimate participant from the SFU mid-call. Now the read error is logged and the check skipped, matching sweepStaleVoiceStates. * fix(client): 1 defect(s) (OC-0041) * fix(client): 1 defect(s) (OC-0043) * fix(client): 1 defect(s) (OC-0046) * fix(client): 1 defect(s) (OC-0047) * fix(client): 1 defect(s) (OC-0049) * fix(client): 1 defect(s) (OC-0108) * fix(client): 2 defect(s) (OC-0111, OC-0143) OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the window reopens, so auto-idle's return-to-online does not leave the server and every other client stuck on idle. OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes are scoped per server, matching channel mutes, the NSFW gate and volume. * test(ws): align aborted-switch test with OC-0034 no-resurrect behavior The fix agent rewrote this pre-existing test (it locked the buggy restore path) but the prove agent left it out of c67d25ed; committed state alone failed go test ./ws/ without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
234 lines
7.9 KiB
Go
234 lines
7.9 KiB
Go
package ws_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/livekit/protocol/livekit"
|
|
|
|
"github.com/owncord/server/ws"
|
|
)
|
|
|
|
// handleWebhookParticipantJoined had no coverage. It is the server's guard
|
|
// against a replayed LiveKit join token: LiveKit reports who joined a room, and
|
|
// the hub cross-checks that against its own voice_states row, evicting anyone
|
|
// who has no matching state or presents a stale token. If that check silently
|
|
// stops firing, a leaked token grants voice access to a channel the holder was
|
|
// removed from.
|
|
//
|
|
// The handler's only side effects are a slog warning and a RemoveParticipant
|
|
// call, so these tests assert on captured log output.
|
|
|
|
// captureLogs swaps the default slog logger for one writing into a buffer and
|
|
// returns an accessor for what was written.
|
|
func captureLogs(t *testing.T) func() string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
return buf.String
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_RogueParticipantFlagged(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
user := seedVoiceOwner(t, database, "joined-rogue-user")
|
|
chanID := seedVoiceChan(t, database, "joined-rogue-ch")
|
|
|
|
logs := captureLogs(t)
|
|
|
|
// No voice_states row exists for this user — the join is unauthorized.
|
|
hub.HandleWebhookParticipantJoinedForTest(
|
|
participantIdentityFor(user.ID, "sometoken"),
|
|
roomNameFor(chanID),
|
|
)
|
|
|
|
if !strings.Contains(logs(), "rogue participant_joined") {
|
|
t.Errorf("no rogue-participant warning logged; got:\n%s", logs())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_StaleTokenFlagged(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
user := seedVoiceOwner(t, database, "joined-stale-user")
|
|
chanID := seedVoiceChan(t, database, "joined-stale-ch")
|
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
|
|
logs := captureLogs(t)
|
|
|
|
// A matching row exists, but the webhook presents a token from an older
|
|
// session. This is exactly the replay case the check exists for.
|
|
hub.HandleWebhookParticipantJoinedForTest(
|
|
participantIdentityFor(user.ID, "an-old-token"),
|
|
roomNameFor(chanID),
|
|
)
|
|
|
|
if !strings.Contains(logs(), "stale join token") {
|
|
t.Errorf("no stale-token warning logged; got:\n%s", logs())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_ValidJoinAccepted(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
user := seedVoiceOwner(t, database, "joined-valid-user")
|
|
chanID := seedVoiceChan(t, database, "joined-valid-ch")
|
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
state, err := database.GetVoiceState(context.Background(), user.ID)
|
|
if err != nil || state == nil {
|
|
t.Fatalf("GetVoiceState: %v (nil=%v)", err, state == nil)
|
|
}
|
|
|
|
logs := captureLogs(t)
|
|
|
|
hub.HandleWebhookParticipantJoinedForTest(
|
|
participantIdentityFor(user.ID, state.JoinedAt),
|
|
roomNameFor(chanID),
|
|
)
|
|
|
|
out := logs()
|
|
if strings.Contains(out, "rogue participant_joined") || strings.Contains(out, "stale join token") {
|
|
t.Errorf("a legitimate join was flagged; log:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "participant joined") {
|
|
t.Errorf("legitimate join was not logged at all; log:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestWebhook_ParticipantJoined_TransientReadErrorDoesNotEvict locks OC-0065:
|
|
// a GetVoiceState read failure must not be treated as proof of a rogue
|
|
// participant. sweepStaleVoiceStates already draws this distinction via
|
|
// hasChannelPermChecked ("a transient read failure ... is not a revocation");
|
|
// the webhook path OR'd stateErr into the same branch as "no matching row",
|
|
// so a transient DB error (SQLITE_BUSY, an I/O blip) ejected a legitimate
|
|
// participant from the SFU mid-call.
|
|
func TestWebhook_ParticipantJoined_TransientReadErrorDoesNotEvict(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
user := seedVoiceOwner(t, database, "joined-dberr-user")
|
|
chanID := seedVoiceChan(t, database, "joined-dberr-ch")
|
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
|
|
// Fault-inject exactly the GetVoiceState read: renaming the table out from
|
|
// under the query makes it return a genuine DB error instead of the
|
|
// sql.ErrNoRows GetVoiceState collapses to (nil, nil) for a real "no
|
|
// membership" case.
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`ALTER TABLE voice_states RENAME TO voice_states_offline`); err != nil {
|
|
t.Fatalf("rename voice_states: %v", err)
|
|
}
|
|
|
|
logs := captureLogs(t)
|
|
|
|
hub.HandleWebhookParticipantJoinedForTest(
|
|
participantIdentityFor(user.ID, "some-token"),
|
|
roomNameFor(chanID),
|
|
)
|
|
|
|
if out := logs(); strings.Contains(out, "rogue participant_joined") {
|
|
t.Errorf("a transient GetVoiceState error was treated as a rogue participant and evicted; log:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_WrongChannelFlagged(t *testing.T) {
|
|
hub, database := newVoiceHub(t)
|
|
user := seedVoiceOwner(t, database, "joined-wrongch-user")
|
|
joined := seedVoiceChan(t, database, "joined-wrongch-a")
|
|
other := seedVoiceChan(t, database, "joined-wrongch-b")
|
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user.ID, joined); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
state, err := database.GetVoiceState(context.Background(), user.ID)
|
|
if err != nil || state == nil {
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
}
|
|
|
|
logs := captureLogs(t)
|
|
|
|
// Correct token, wrong room — the state's channel must match too.
|
|
hub.HandleWebhookParticipantJoinedForTest(
|
|
participantIdentityFor(user.ID, state.JoinedAt),
|
|
roomNameFor(other),
|
|
)
|
|
|
|
if !strings.Contains(logs(), "rogue participant_joined") {
|
|
t.Errorf("a join into a channel the user is not in was not flagged; got:\n%s", logs())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_MalformedInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
identity string
|
|
room string
|
|
wantLog string
|
|
}{
|
|
{"identity without user- prefix", "bogus", "channel-1", "bad identity"},
|
|
{"identity with non-numeric id", "user-abc:tok", "channel-1", "bad identity"},
|
|
{"empty identity", "", "channel-1", "bad identity"},
|
|
{"room without channel- prefix", "user-1:tok", "lobby", "bad room"},
|
|
{"room with non-numeric id", "user-1:tok", "channel-xyz", "bad room"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hub, _ := newVoiceHub(t)
|
|
logs := captureLogs(t)
|
|
|
|
// A webhook body is attacker-influenced input; the handler must
|
|
// reject malformed values rather than panic or act on them.
|
|
hub.HandleWebhookParticipantJoinedForTest(tt.identity, tt.room)
|
|
|
|
if !strings.Contains(logs(), tt.wantLog) {
|
|
t.Errorf("log does not mention %q; got:\n%s", tt.wantLog, logs())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ParticipantJoined_NilFieldsIgnored(t *testing.T) {
|
|
hub, _ := newVoiceHub(t)
|
|
logs := captureLogs(t)
|
|
|
|
// GetParticipant/GetRoom return nil for a partial event; the guard must
|
|
// bail out before dereferencing either.
|
|
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{Event: "participant_joined"})
|
|
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{
|
|
Event: "participant_joined",
|
|
Room: &livekit.Room{Name: "channel-1"},
|
|
})
|
|
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{
|
|
Event: "participant_joined",
|
|
Participant: &livekit.ParticipantInfo{Identity: "user-1:tok"},
|
|
})
|
|
|
|
if out := logs(); strings.Contains(out, "participant joined") {
|
|
t.Errorf("an event with nil participant/room was processed; log:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// participantIdentityFor mirrors the identity format LiveKit sends back.
|
|
func participantIdentityFor(userID int64, joinToken string) string {
|
|
id := "user-" + strconv.FormatInt(userID, 10)
|
|
if joinToken == "" {
|
|
return id
|
|
}
|
|
return id + ":" + joinToken
|
|
}
|
|
|
|
func roomNameFor(channelID int64) string {
|
|
return ws.RoomName(channelID)
|
|
}
|