Files
OwnCord/Server/ws/livekit_webhook_joined_test.go
T
Claude 0918f859a0 test: close measured test-coverage gaps across server, client and Rust
Audits what actually has tests, then closes the gaps it found. Full write-up
with before/after numbers in docs/audit-test-coverage-2026-07-25.md.

Measurement first: `go test ./... -coverprofile` (what CI runs) instruments
each package only for itself, so code exercised through another package's
tests reads as uncovered — `service` reported 36.7% against a real 85%. All
analysis here uses -coverpkg=./..., and both views now have Makefile targets.

Features that had zero coverage at every layer:
- user blocking (db + service + the /api/v1/blocks routes)
- auth lockout persistence — the DB round-trip that survives a restart
- plugin install/enable/disable/uninstall and the plugin KV namespace
- event replay bounds (GetMaxEventSeq, PruneEventsOlderThan)
- LiveKit participant_joined webhook (replayed-token guard), the room-service
  client, and proxyWebSocket/copyWS
- ws_proxy.rs and livekit_proxy.rs — pure helpers extracted, matching the
  existing tofu.rs pattern, so cert-pin and header-injection checks are testable

Gaps that were hidden rather than absent:
- Server/admin reported 0.3% coverage with 307 tests passing. TestSpawnDetached_*
  re-execs the test binary; the child inherited GOCOVERDIR and the parent's
  stdout, clobbering the profile and printing "[no tests to run]". Now 71.4%,
  and CI's uploaded artifact is correct.
- vitest.config.ts excluded 2.2k LOC unexplained, including two files that
  already had tests. Trimmed to three entries, each justified inline.
- api.HandleLiveKitHealthForTest re-implemented the handler it claimed to
  expose, so eight call sites tested a copy. Added a hook to the real one.

Two bugs found and pinned rather than silently patched: logctx.WithGroup nests
req_id under the group, and drag-reorder.ts takes one listener ref per channel
but releases one per sidebar, so the count never reaches zero.

Coverage: client 92.93% -> 94.87% statements (3371 -> 3572 tests) even after
un-excluding hidden files; Rust 47 -> 74 tests; Go zero-coverage functions
~70 -> 21, with plugin 61->77%, admin 67->86%, db 76->84%, service 85->91%.

Verified: go vet, all four build-tag variants, go test -race, -tags deadlock,
vitest --coverage, cargo test --lib, cargo clippy --all-targets, playwright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEETs3Vh6sAHHb1jMBL75g
2026-07-25 14:47:21 +00:00

197 lines
6.3 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)
}
}
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)
}