Files
OwnCord/Server/ws/livekit_test.go
T
J3vb c3a8aa477c fix: resolve 20 code review bugs across Rust, TypeScript, and Go
Critical/High Rust (Tauri client):
- BUG-140: replace .run() with .build() + RunEvent::Exit handler; native error dialog on startup failure
- BUG-141: eliminate PTT thread TOCTOU race with Mutex critical section; add AtomicBool shutdown and catch_unwind
- BUG-144: fix TOFU cert store corruption — read-before-write rollback restores previous fingerprint on save failure (all 3 write sites)
- BUG-145: add VK code range guard (1..=254) in is_key_down; fix cast to (state as i16) < 0
- BUG-147: replace bare spawns with JoinSet; abort_all + drain on exit; unconditional closed event
- BUG-150: add CRLF guard in handle_connection before header rewriting
- BUG-151: wrap header read loop in tokio::time::timeout(10s)
- BUG-158: extract CERTS_STORE/SETTINGS_STORE to constants.rs (eliminate 3 duplicates)
- HIGH-2: PTT thread self-cleanup uses unwrap_or_else defensive pattern
- HIGH-4: ws_send distinguishes Full vs Closed errors; warn log on backpressure

Critical/High TypeScript (Tauri client):
- BUG-142: join-generation counter prevents stale connectAndSetup completions
- BUG-143: replace 8 mutable LiveKit session fields with discriminated union SessionState
- BUG-146: 60s token refresh deadline; cleared on reply or voice leave
- BUG-148: ResizeObserver hoisted to outer scope; disconnect() in destroy() before ac.abort()
- BUG-152: dismissSignal.aborted guard already present (no change needed)
- BUG-153: measureRendered split into two-pass read-then-write; eliminates per-message reflow
- BUG-154: WS dedup cache batch-evicts to 80% on overflow (amortised O(1))
- BUG-157: pendingUpdates replaced with coalesced function-composition slot (O(1) queue depth)

Go server:
- BUG-149: safe two-value type assertion in getOutboundIP with localhost fallback
- BUG-155: broadcast buffer 256→1024; broadcastDrops atomic counter exposed in /api/v1/metrics
- BUG-156: LiveKitHealthCheck and implementations accept ctx context.Context; all call sites pass r.Context() (12 files)
- BUG-159: MaxMessageBytes constant in config/constants.go; replaces 1<<20 literals in serve.go and updater.go
- HIGH-1: cert store rollback reads old value before write; restores previous cert on save failure

All validation passes: go build, go vet, cargo check, npm typecheck
2026-04-03 23:18:06 +02:00

865 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ws_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/config"
"github.com/owncord/server/ws"
)
// ---------------------------------------------------------------------------
// livekit.go tests
// ---------------------------------------------------------------------------
func TestWsToHTTP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{"ws to http", "ws://localhost:7880", "http://localhost:7880"},
{"wss to https", "wss://livekit.example.com", "https://livekit.example.com"},
{"http passthrough", "http://localhost:7880", "http://localhost:7880"},
{"https passthrough", "https://livekit.example.com", "https://livekit.example.com"},
{"empty string", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ws.WsToHTTPForTest(tt.in)
if got != tt.want {
t.Errorf("wsToHTTP(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestRoomName(t *testing.T) {
t.Parallel()
tests := []struct {
channelID int64
want string
}{
{1, "channel-1"},
{42, "channel-42"},
{0, "channel-0"},
{999999, "channel-999999"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
got := ws.RoomName(tt.channelID)
if got != tt.want {
t.Errorf("RoomName(%d) = %q, want %q", tt.channelID, got, tt.want)
}
})
}
}
func TestNewLiveKitClient_MissingConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg config.VoiceConfig
}{
{
"empty api key",
config.VoiceConfig{
LiveKitAPIKey: "",
LiveKitAPISecret: "some-secret",
LiveKitURL: "ws://localhost:7880",
},
},
{
"empty api secret",
config.VoiceConfig{
LiveKitAPIKey: "some-key",
LiveKitAPISecret: "",
LiveKitURL: "ws://localhost:7880",
},
},
{
"empty url",
config.VoiceConfig{
LiveKitAPIKey: "some-key",
LiveKitAPISecret: "some-secret",
LiveKitURL: "",
},
},
{
"all empty",
config.VoiceConfig{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, err := ws.NewLiveKitClient(&tt.cfg)
if err == nil {
t.Fatal("expected error for missing config, got nil")
}
if client != nil {
t.Fatal("expected nil client on error")
}
})
}
}
func TestGenerateToken_ValidToken(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "test-key",
LiveKitAPISecret: "test-secret-that-is-long-enough-for-hmac",
LiveKitURL: "ws://localhost:7880",
}
client, err := ws.NewLiveKitClient(cfg)
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
token, err := client.GenerateToken(123, "testuser", 456, "join-token-1", true, true, true, true)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if token == "" {
t.Fatal("expected non-empty JWT token")
}
// JWT tokens have three dot-separated parts.
parts := 0
for _, b := range token {
if b == '.' {
parts++
}
}
if parts != 2 {
t.Errorf("expected JWT with 2 dots (3 parts), got %d dots in %q", parts, token)
}
}
func TestGenerateToken_DifferentPermissions(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "test-key",
LiveKitAPISecret: "test-secret-that-is-long-enough-for-hmac",
LiveKitURL: "ws://localhost:7880",
}
client, err := ws.NewLiveKitClient(cfg)
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
// Subscribe-only token (canPublish=false).
token, err := client.GenerateToken(1, "listener", 10, "join-token-2", false, true, false, false)
if err != nil {
t.Fatalf("GenerateToken(subscribe-only): %v", err)
}
if token == "" {
t.Fatal("expected non-empty token for subscribe-only")
}
}
// ---------------------------------------------------------------------------
// livekit_process.go tests
// ---------------------------------------------------------------------------
func TestNewLiveKitProcess(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
if proc == nil {
t.Fatal("expected non-nil LiveKitProcess")
}
}
func TestLiveKitProcess_Start_NoBinary(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
LiveKitBinaryPath: "", // empty → no-op
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
err := proc.Start()
if err != nil {
t.Fatalf("Start() with empty binary should return nil, got: %v", err)
}
}
func TestLiveKitProcess_IsRunning_Default(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
if proc.IsRunning() {
t.Fatal("expected IsRunning() = false before Start()")
}
}
func TestLiveKitProcess_Stop_BeforeStart(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
// Stop() before Start() should not panic.
proc.Stop()
// After Stop(), IsRunning should still be false.
if proc.IsRunning() {
t.Fatal("expected IsRunning() = false after Stop() without Start()")
}
}
// ---------------------------------------------------------------------------
// livekit_webhook.go tests
// ---------------------------------------------------------------------------
func TestParseIdentity_Valid(t *testing.T) {
t.Parallel()
id, err := ws.ParseIdentityForTest("user-123")
if err != nil {
t.Fatalf("parseIdentity(\"user-123\"): unexpected error: %v", err)
}
if id != 123 {
t.Errorf("parseIdentity(\"user-123\") = %d, want 123", id)
}
}
func TestParseParticipantIdentity_WithJoinToken(t *testing.T) {
t.Parallel()
userID, joinToken, err := ws.ParseParticipantIdentityForTest("user-123:join-token-42")
if err != nil {
t.Fatalf("parseParticipantIdentity: unexpected error: %v", err)
}
if userID != 123 {
t.Fatalf("userID = %d, want 123", userID)
}
if joinToken != "join-token-42" {
t.Fatalf("joinToken = %q, want join-token-42", joinToken)
}
}
func TestParseIdentity_Invalid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
}{
{"no prefix", "invalid"},
{"empty id", "user-"},
{"non-numeric", "user-abc"},
{"wrong prefix", "admin-123"},
{"empty string", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ws.ParseIdentityForTest(tt.input)
if err == nil {
t.Errorf("parseIdentity(%q): expected error, got nil", tt.input)
}
})
}
}
func TestParseRoomChannelID_Valid(t *testing.T) {
t.Parallel()
id, err := ws.ParseRoomChannelIDForTest("channel-456")
if err != nil {
t.Fatalf("parseRoomChannelID(\"channel-456\"): unexpected error: %v", err)
}
if id != 456 {
t.Errorf("parseRoomChannelID(\"channel-456\") = %d, want 456", id)
}
}
func TestParseRoomChannelID_Invalid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
}{
{"no prefix", "invalid"},
{"non-numeric", "channel-abc"},
{"wrong prefix", "room-123"},
{"empty string", ""},
{"empty id", "channel-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ws.ParseRoomChannelIDForTest(tt.input)
if err == nil {
t.Errorf("parseRoomChannelID(%q): expected error, got nil", tt.input)
}
})
}
}
// ---------------------------------------------------------------------------
// Webhook idempotency regression tests
// ---------------------------------------------------------------------------
// countVoiceLeaves drains the send channel and returns how many voice_leave
// messages it contained within the timeout.
func countVoiceLeaves(ch <-chan []byte, timeout time.Duration) int {
count := 0
deadline := time.After(timeout)
for {
select {
case msg := <-ch:
var parsed struct {
Type string `json:"type"`
}
if json.Unmarshal(msg, &parsed) == nil && parsed.Type == "voice_leave" {
count++
}
case <-deadline:
return count
}
}
}
// TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup proves that
// after serve.go's fresh-reconnect cleanup clears the old client's voice state,
// a subsequent participant_left webhook with the same join token does NOT
// broadcast a second voice_leave.
func TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup(t *testing.T) {
t.Parallel()
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "webhook-idem-user")
chanID := seedVoiceChannel(t, database, "webhook-idem-ch")
// Observer client to capture broadcasts.
observerSend := make(chan []byte, 64)
observer := ws.NewTestClient(hub, 99999, observerSend)
hub.RegisterNowForTest(observer)
// Insert the matching DB row first so the simulated client carries the
// same join token production would have persisted and handed to LiveKit.
if err := database.JoinVoiceChannel(user.ID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
vs, err := database.GetVoiceState(user.ID)
if err != nil || vs == nil {
t.Fatalf("GetVoiceState: %v (nil=%v)", err, vs == nil)
}
// Simulate the old client being in voice with the persisted join token.
oldSend := make(chan []byte, 64)
oldClient := ws.NewTestClient(hub, user.ID, oldSend)
ws.SetClientVoiceStateForTest(oldClient, chanID, vs.JoinedAt)
hub.RegisterNowForTest(oldClient)
// --- Simulate what serve.go fresh-cleanup does (lines 150-172) ---
// 1. Delete the DB row.
deleted, err := database.LeaveVoiceChannelIfMatch(user.ID, chanID, vs.JoinedAt)
if err != nil || !deleted {
t.Fatalf("LeaveVoiceChannelIfMatch: err=%v deleted=%v", err, deleted)
}
// 2. Clear old client's in-memory voice state (the fix in serve.go).
oldClient.ClearVoiceStateForTest()
// 3. Broadcast voice_leave (serve.go does this).
hub.BroadcastToAll(ws.BuildJSONForTest(map[string]any{
"type": "voice_leave",
"payload": map[string]any{"channel_id": chanID, "user_id": user.ID},
}))
// Give broadcast a moment to propagate.
time.Sleep(20 * time.Millisecond)
// Drain the first voice_leave from the observer.
first := countVoiceLeaves(observerSend, 50*time.Millisecond)
if first != 1 {
t.Fatalf("expected 1 initial voice_leave broadcast, got %d", first)
}
// --- Now simulate the webhook arriving for the same join token ---
hub.HandleWebhookParticipantLeftForTest(user.ID, chanID, vs.JoinedAt)
// The webhook should NOT produce a second voice_leave because:
// - The old client's in-memory voice state was cleared (token-match branch is a no-op).
// - The DB row was already deleted (else branch's LeaveVoiceChannelIfMatch returns deleted=false).
second := countVoiceLeaves(observerSend, 100*time.Millisecond)
if second != 0 {
t.Errorf("expected 0 additional voice_leave broadcasts after webhook, got %d", second)
}
}
// TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement proves that
// a participant_left webhook carrying an old join token does NOT tear down a
// replacement voice session that has a different join token.
func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing.T) {
t.Parallel()
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "webhook-old-token-user")
chanID := seedVoiceChannel(t, database, "webhook-old-token-ch")
// Observer to capture broadcasts.
observerSend := make(chan []byte, 64)
observer := ws.NewTestClient(hub, 88888, observerSend)
hub.RegisterNowForTest(observer)
// Create an old same-channel voice session, then rejoin the same channel so
// the DB carries a replacement join token like production would.
if err := database.JoinVoiceChannel(user.ID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel(old): %v", err)
}
oldState, err := database.GetVoiceState(user.ID)
if err != nil || oldState == nil {
t.Fatalf("GetVoiceState(old): %v (nil=%v)", err, oldState == nil)
}
if err := database.JoinVoiceChannel(user.ID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel(new): %v", err)
}
newState, err := database.GetVoiceState(user.ID)
if err != nil || newState == nil {
t.Fatalf("GetVoiceState(new): %v (nil=%v)", err, newState == nil)
}
if newState.JoinedAt == oldState.JoinedAt {
t.Fatalf("same-channel rejoin reused join token %q", newState.JoinedAt)
}
// The replacement client carries the current persisted join token.
newSend := make(chan []byte, 64)
newClient := ws.NewTestClient(hub, user.ID, newSend)
ws.SetClientVoiceStateForTest(newClient, chanID, newState.JoinedAt)
hub.RegisterNowForTest(newClient)
// --- Webhook arrives with the OLD join token ---
hub.HandleWebhookParticipantLeftForTest(user.ID, chanID, oldState.JoinedAt)
// The webhook should NOT broadcast voice_leave because:
// - Token-match branch: currentJoinToken != old join token -> skipped.
// - Else branch: LeaveVoiceChannelIfMatch with the old token won't match the new DB row → deleted=false.
leaves := countVoiceLeaves(observerSend, 100*time.Millisecond)
if leaves != 0 {
t.Errorf("expected 0 voice_leave broadcasts for old-token webhook, got %d", leaves)
}
// The new client's voice state should be untouched.
if got := ws.GetClientVoiceChIDForTest(newClient); got != chanID {
t.Errorf("new client voiceChID = %d, want %d (should be untouched)", got, chanID)
}
if got := ws.GetClientVoiceJoinTokenForTest(newClient); got != newState.JoinedAt {
t.Errorf("new client voiceJoinToken = %q, want %q", got, newState.JoinedAt)
}
// DB row should still exist.
vs, err := database.GetVoiceState(user.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if vs == nil {
t.Fatal("replacement voice state was deleted by old-token webhook — should have been preserved")
}
if vs.JoinedAt != newState.JoinedAt {
t.Fatalf("replacement join token = %q, want %q", vs.JoinedAt, newState.JoinedAt)
}
}
// ---------------------------------------------------------------------------
// livekit_process.go generateConfig tests
// ---------------------------------------------------------------------------
func TestGenerateConfig_WritesYAML(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "testkey",
LiveKitAPISecret: "testsecret",
LiveKitURL: "ws://localhost:7880",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, dataDir)
cfgPath, err := proc.GenerateConfigForTest()
if err != nil {
t.Fatalf("generateConfig: %v", err)
}
content, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
got := string(content)
for _, want := range []string{
"port: 7880",
`"testkey": "testsecret"`,
"port_range_start: 50000",
"port_range_end: 60000",
"use_external_ip: true",
"level: info",
} {
if !strings.Contains(got, want) {
t.Errorf("config missing %q.\nGot:\n%s", want, got)
}
}
if strings.Contains(got, "node_ip") {
t.Error("config should not contain node_ip when NodeIP is empty")
}
}
func TestGenerateConfig_WithNodeIP(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key1",
LiveKitAPISecret: "secret1",
LiveKitURL: "ws://localhost:7880",
NodeIP: "203.0.113.10",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, dataDir)
cfgPath, err := proc.GenerateConfigForTest()
if err != nil {
t.Fatalf("generateConfig: %v", err)
}
content, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
got := string(content)
if !strings.Contains(got, `node_ip: "203.0.113.10"`) {
t.Errorf("expected node_ip in config.\nGot:\n%s", got)
}
}
func TestGenerateConfig_UnsafeCredentialChars(t *testing.T) {
t.Parallel()
tests := []struct {
name string
key string
secret string
}{
{"colon in key", "bad:key", "secret"},
{"newline in secret", "key", "bad\nsecret"},
{"hash in key", "bad#key", "secret"},
{"brace in secret", "key", "bad{secret"},
{"backslash in key", `bad\key`, "secret"},
{"quote in secret", "key", `bad"secret`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: tt.key,
LiveKitAPISecret: tt.secret,
LiveKitURL: "ws://localhost:7880",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
_, err := proc.GenerateConfigForTest()
if err == nil {
t.Error("expected error for unsafe YAML character, got nil")
}
})
}
}
func TestGenerateConfig_UnsafeNodeIPChars(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "safekey",
LiveKitAPISecret: "safesecret",
LiveKitURL: "ws://localhost:7880",
NodeIP: "192.168.1.1\n evil: true",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
_, err := proc.GenerateConfigForTest()
if err == nil {
t.Error("expected error for unsafe node_ip character, got nil")
}
}
// ---------------------------------------------------------------------------
// livekit_process.go Start guard tests
// ---------------------------------------------------------------------------
func TestStart_AlreadyRunningGuard(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
LiveKitBinaryPath: "/nonexistent/livekit-server",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
proc.SetProcessCmdForTest()
err := proc.Start()
if err == nil {
t.Fatal("expected error when process already running, got nil")
}
if !strings.Contains(err.Error(), "already running") {
t.Errorf("expected 'already running' error, got: %v", err)
}
}
func TestStart_StoppedGuard(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://localhost:7880",
LiveKitBinaryPath: "/nonexistent/livekit-server",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
proc.SetProcessStoppedForTest()
err := proc.Start()
if err != nil {
t.Fatalf("Start() on stopped process returned error: %v", err)
}
proc.Stop()
if proc.IsRunning() {
t.Error("expected IsRunning() = false after Stop()")
}
}
// ---------------------------------------------------------------------------
// livekit_process.go HealthCheck tests
// ---------------------------------------------------------------------------
func TestHealthCheck_Success(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
wsURL := "ws://" + srv.Listener.Addr().String()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: wsURL,
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
ok, err := proc.HealthCheck(context.Background())
if err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if !ok {
t.Error("expected HealthCheck to return true")
}
}
func TestHealthCheck_ServerDown(t *testing.T) {
t.Parallel()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: "ws://127.0.0.1:1",
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
ok, err := proc.HealthCheck(context.Background())
if err == nil {
t.Fatal("expected error for unreachable server, got nil")
}
if ok {
t.Error("expected HealthCheck to return false on error")
}
}
func TestHealthCheck_NonOKStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
wsURL := "ws://" + srv.Listener.Addr().String()
cfg := &config.VoiceConfig{
LiveKitAPIKey: "key",
LiveKitAPISecret: "secret",
LiveKitURL: wsURL,
}
tlsCfg := &config.TLSConfig{}
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
ok, err := proc.HealthCheck(context.Background())
if err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if !ok {
t.Error("expected HealthCheck to return true even for 500 status")
}
}
// ---------------------------------------------------------------------------
// livekit_webhook.go NewLiveKitWebhookHandler tests
// ---------------------------------------------------------------------------
func TestWebhookHandler_MissingAuthHeader(t *testing.T) {
t.Parallel()
hub := ws.NewHubForTest()
handler := hub.NewLiveKitWebhookHandler("api-key", "api-secret")
req := httptest.NewRequest(http.MethodPost, "/livekit/webhook", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestWebhookHandler_InvalidToken(t *testing.T) {
t.Parallel()
hub := ws.NewHubForTest()
handler := hub.NewLiveKitWebhookHandler("api-key", "api-secret")
req := httptest.NewRequest(http.MethodPost, "/livekit/webhook",
strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer not-a-valid-jwt-token")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for invalid token, got %d", rec.Code)
}
}
func TestWebhookHandler_EmptyBody(t *testing.T) {
t.Parallel()
hub := ws.NewHubForTest()
handler := hub.NewLiveKitWebhookHandler("api-key", "api-secret")
req := httptest.NewRequest(http.MethodPost, "/livekit/webhook",
strings.NewReader(""))
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for missing auth, got %d", rec.Code)
}
}
// ---------------------------------------------------------------------------
// livekit_webhook.go MountWebhookRoute tests
// ---------------------------------------------------------------------------
func TestMountWebhookRoute_RegistersRoute(t *testing.T) {
t.Parallel()
hub := ws.NewHubForTest()
handler := ws.MountWebhookRoute(hub, "key", "secret")
if handler == nil {
t.Fatal("MountWebhookRoute returned nil handler")
}
r := chi.NewRouter()
r.Post("/livekit/webhook", handler)
req := httptest.NewRequest(http.MethodPost, "/livekit/webhook",
strings.NewReader(`{}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Error("expected route to be registered, got 404")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401 from mounted webhook handler, got %d", rec.Code)
}
}