mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
197 lines
6.3 KiB
Go
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)
|
|
}
|