mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(service): settings family — SettingsService over the Store seam The B3-8 settings/audit family's service: List, Patch (whitelist, boolean normalization, the require_2fa preconditions incl. the TOTP census and the unrelated-key guard, atomic apply, one audit row per changed key) and Setting (the read the hub and the backup scheduler consume; wraps db.ErrNotFound as the store reports it). db gains ApplySettings — the handler's raw upsert loop as one hand-written transactional wrapper where raw SQL belongs — and Store carries it. parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue with the admin surface's own pinned error wording; both messages are test-pinned, so the twins stay separate. Service-level characterization in settings_test.go mirrors the admin/api_test.go PATCH rows and adds the service-only contracts (ErrNotFound wrap, audit rows, multi-key apply). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(admin): settings handlers thin over SettingsService; scheduler reads via it handleGetSettings/handlePatchSettings become adapters (decode, delegate, map ErrBadRequest to 400 with the service's prefix-free message); the whitelist and every precondition now live only in the service, so admin/types.go's copy is gone. MaintainBackups reads backup_schedule and backup_retention through the service — its backup mechanics keep the handle — and the maintenance chain threads Settings from the runtime the hub stage built. NewHandler/NewAdminAPI gain the settings parameter; all 207 construction sites wired via the newTestSettingsService helper. Behavior parity pinned by the existing TestAdminAPI_*Settings* rows (all green); the only unpinned change is the PATCH 500 path collapsing its four stage-specific internal messages into one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(ws): hub settings cache reads through a SettingsReader The hub's server_name/motd cache consumes a consumer-side SettingsReader interface (service.SettingsService satisfies it; HubOptions.Settings is required and validated like DB and Limiter — the RequiredCollaborators pin gains the refusal case). hub_settings.go no longer touches db at all, so the import pin from the B3-5 finisher goes, and its allowlist row goes with it; the thinned admin settings handler's row is deleted too — two allowlist rows down, the settings family's persistence now lives only in db/ and service/. Test helpers (both ws package namespaces) default the reader over the test database; newBareHub wires it explicitly; production passes Services.Settings from StartRuntime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): settings/audit family re-measure and evidence The backup pair takes its forecast boundary disposition; the family's two deleted rows and the disposition counts (28/18/15 -> 24/18/17) re-derived from the tool. Family evidence block appended to the B3-8 section; README B3 row records B3-5 complete and the family opened. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies The %.0w rework was meant to ride the service commit but was left unstaged: with the plain %w wrap the PATCH error bodies carry a 'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(app): lifecycle hub fixtures wire the required Settings reader The two direct ws.NewHub sites in lifecycle_test predate Settings becoming required; race across internal/app is green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(db): cover ApplySettings — the db coverage floor caught the gap CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was exercised only from service tests, which do not count toward db's own figure. Four db-side rows cover the apply, the empty no-op, the in-transaction failure rollback and the begin failure, using the package's full-migration opener. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * chore(coverage): raise the service floor to the branch's measured 69.2 The settings family's tested service code raised the Linux figure from the 67.8 floor to 69.2; the ratchet raises the floor in the same PR (service is not in the run-varying set). db stays at 79.3 — this PR restores its figure (79.5 with the ApplySettings tests), it did not set out to raise it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com>
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/livekit/protocol/livekit"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/J3vb/OwnCord/Server/api"
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/config"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
"github.com/J3vb/OwnCord/Server/ws"
|
|
)
|
|
|
|
// proxyWebSocket and copyWS carry every LiveKit signaling frame between the
|
|
// client and the media server, and neither had any coverage — the existing
|
|
// livekit_proxy_test.go stops at the path allowlist and Origin check, before
|
|
// the upgrade. handleLiveKitHealth was in the same position: its only "test"
|
|
// hook re-implemented the handler rather than calling it.
|
|
|
|
// echoWSBackend starts a WebSocket server that echoes every message it
|
|
// receives, and returns its ws:// URL.
|
|
func echoWSBackend(t *testing.T) string {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
|
|
|
|
for {
|
|
typ, data, err := conn.Read(r.Context())
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := conn.Write(r.Context(), typ, data); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return "ws://" + srv.Listener.Addr().String()
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_RoundTrip(t *testing.T) {
|
|
backend := echoWSBackend(t)
|
|
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
conn, dialResp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
|
|
if dialResp != nil && dialResp.Body != nil {
|
|
defer dialResp.Body.Close() //nolint:errcheck // best-effort close in test
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("dial through proxy: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
|
|
|
|
// Frontend → backend → frontend, through both copyWS goroutines.
|
|
if err := conn.Write(ctx, websocket.MessageText, []byte("signal")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
typ, got, err := conn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if typ != websocket.MessageText || string(got) != "signal" {
|
|
t.Errorf("echo = (%v, %q), want (text, \"signal\")", typ, got)
|
|
}
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_ForwardsBinary(t *testing.T) {
|
|
backend := echoWSBackend(t)
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
conn, dialResp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
|
|
if dialResp != nil && dialResp.Body != nil {
|
|
defer dialResp.Body.Close() //nolint:errcheck // best-effort close in test
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("dial through proxy: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
|
|
|
|
// LiveKit signaling is protobuf, so the binary opcode must survive the hop.
|
|
payload := []byte{0x00, 0x01, 0x02, 0xff}
|
|
if err := conn.Write(ctx, websocket.MessageBinary, payload); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
typ, got, err := conn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if typ != websocket.MessageBinary || !bytes.Equal(got, payload) {
|
|
t.Errorf("echo = (%v, %v), want (binary, %v)", typ, got, payload)
|
|
}
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_PreservesQueryString(t *testing.T) {
|
|
var gotQuery string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.RawQuery
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = conn.Write(r.Context(), websocket.MessageText, []byte("ok"))
|
|
conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy("ws://"+srv.Listener.Addr().String(), []string{"*"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
conn, dialResp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc?access_token=abc", nil)
|
|
if dialResp != nil && dialResp.Body != nil {
|
|
defer dialResp.Body.Close() //nolint:errcheck // best-effort close in test
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("dial through proxy: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
|
|
|
|
if _, _, err := conn.Read(ctx); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
// LiveKit carries the join token in the query string; dropping it would
|
|
// turn every voice join into an auth failure.
|
|
if gotQuery != "access_token=abc" {
|
|
t.Errorf("backend saw query %q, want %q", gotQuery, "access_token=abc")
|
|
}
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_BackendUnavailable(t *testing.T) {
|
|
// Nothing is listening on this port, so the backend dial must fail.
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy("ws://127.0.0.1:1", []string{"*"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
|
|
if err == nil {
|
|
t.Fatal("dial succeeded despite an unreachable backend")
|
|
}
|
|
if resp == nil {
|
|
t.Fatal("no HTTP response returned for the failed upgrade")
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck // best-effort
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_BlockedPathNotUpgraded(t *testing.T) {
|
|
backend := echoWSBackend(t)
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// The allowlist runs before the upgrade branch, so an Upgrade header must
|
|
// not be a way around it.
|
|
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/twirp/whatever", nil)
|
|
if err == nil {
|
|
t.Fatal("upgrade to a blocked path succeeded")
|
|
}
|
|
if resp == nil {
|
|
t.Fatal("no HTTP response returned")
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck // best-effort
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLiveKitProxy_WebSocket_CrossOriginRejected(t *testing.T) {
|
|
backend := echoWSBackend(t)
|
|
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"https://allowed.example"}))
|
|
t.Cleanup(proxy.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", &websocket.DialOptions{
|
|
HTTPHeader: http.Header{"Origin": []string{"https://evil.example"}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("upgrade from a disallowed origin succeeded")
|
|
}
|
|
if resp == nil {
|
|
t.Fatal("no HTTP response returned")
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck // best-effort
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ─── handleLiveKitHealth (the real handler) ─────────────────────────────────
|
|
|
|
// hubWithLiveKit returns a Hub whose LiveKit client points at a stub room
|
|
// service replying with the supplied status.
|
|
func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if status != http.StatusOK {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(`{"code":"internal","msg":"boom"}`))
|
|
return
|
|
}
|
|
body, _ := proto.Marshal(&livekit.ListRoomsResponse{})
|
|
w.Header().Set("Content-Type", "application/protobuf")
|
|
_, _ = w.Write(body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
|
|
LiveKitAPIKey: "testkeytestkeytest",
|
|
LiveKitAPISecret: "testsecrettestsecrettestsecret",
|
|
LiveKitURL: "ws://" + srv.Listener.Addr().String(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewLiveKitClient: %v", err)
|
|
}
|
|
hub := newBareHub(t, lk)
|
|
return hub
|
|
}
|
|
|
|
// newBareHub builds the smallest hub NewHub now accepts: B3-4 made DB and
|
|
// Limiter required, so the pre-B3-4 ws.NewHub(nil, nil, nil) fixture — the
|
|
// poster child of construction succeeding with nothing wired — is illegal by
|
|
// design. An unmigrated in-memory database is enough: construction only
|
|
// best-effort-reads the settings cache.
|
|
func newBareHub(t *testing.T, lk *ws.LiveKitClient) *ws.Hub {
|
|
t.Helper()
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
hub, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), LiveKit: lk, Settings: service.NewSettingsService(database)})
|
|
if err != nil {
|
|
t.Fatalf("ws.NewHub: %v", err)
|
|
}
|
|
return hub
|
|
}
|
|
|
|
func TestHandleLiveKitHealth_Healthy(t *testing.T) {
|
|
handler := api.LiveKitHealthHandlerForTest(hubWithLiveKit(t, http.StatusOK))
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
|
}
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
LiveKitReachable bool `json:"livekit_reachable"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Status != "ok" || !body.LiveKitReachable {
|
|
t.Errorf("body = %+v, want status ok and reachable", body)
|
|
}
|
|
if body.Error != "" {
|
|
t.Errorf("error = %q on the healthy path, want it omitted", body.Error)
|
|
}
|
|
}
|
|
|
|
func TestHandleLiveKitHealth_Degraded(t *testing.T) {
|
|
handler := api.LiveKitHealthHandlerForTest(hubWithLiveKit(t, http.StatusInternalServerError))
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503 (body %q)", rr.Code, rr.Body.String())
|
|
}
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
LiveKitReachable bool `json:"livekit_reachable"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Status != "degraded" || body.LiveKitReachable {
|
|
t.Errorf("body = %+v, want status degraded and unreachable", body)
|
|
}
|
|
if body.Error == "" {
|
|
t.Error("error is empty on the degraded path; the reason should be surfaced")
|
|
}
|
|
}
|
|
|
|
func TestHandleLiveKitHealth_NotConfigured(t *testing.T) {
|
|
// A hub with no LiveKit client at all — the common case when voice is off.
|
|
handler := api.LiveKitHealthHandlerForTest(newBareHub(t, nil))
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "not configured") {
|
|
t.Errorf("body = %q, want it to name the missing configuration", rr.Body.String())
|
|
}
|
|
}
|