mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,3 +127,41 @@ func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testin
|
||||
t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes))
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0158, create side: handleCreateChannel commits AdminCreateChannel and
|
||||
// only afterwards re-reads the row to broadcast it. A caller cancellation
|
||||
// landing in that window (tab close, network blip) failed the re-read and
|
||||
// 500ed the request, leaving a durably created channel no connected client
|
||||
// was ever told about — the same shape already fixed in the PATCH and DELETE
|
||||
// siblings. The hook fires synchronously right after the commit so the window
|
||||
// is hit deterministically instead of by wall-clock timing.
|
||||
func TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
restore := admin.SetCreateChannelPostCommitHook(func() {
|
||||
cancel()
|
||||
})
|
||||
defer restore()
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"name": "create-cancel-race", "type": "text"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (create must survive a caller cancellation that arrives after the row already committed); body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(hub.channelCreates) != 1 {
|
||||
t.Fatalf("BroadcastChannelCreate called %d times, want 1 — the row committed, so connected clients must be told", len(hub.channelCreates))
|
||||
}
|
||||
if hub.channelCreates[0].Name != "create-cancel-race" {
|
||||
t.Errorf("broadcast channel name = %q, want create-cancel-race", hub.channelCreates[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ func SetPatchChannelPostCommitHook(h func()) (restore func()) {
|
||||
return func() { patchChannelPostCommitHook = prev }
|
||||
}
|
||||
|
||||
// SetCreateChannelPostCommitHook installs h to run synchronously right after
|
||||
// handleCreateChannel's AdminCreateChannel commit, before the post-commit
|
||||
// re-read and hub fan-out — the create-side twin of
|
||||
// SetPatchChannelPostCommitHook (OC-0158).
|
||||
func SetCreateChannelPostCommitHook(h func()) (restore func()) {
|
||||
prev := createChannelPostCommitHook
|
||||
createChannelPostCommitHook = h
|
||||
return func() { createChannelPostCommitHook = prev }
|
||||
}
|
||||
|
||||
// StubCopyBackup swaps the restore path's file-copy hook so tests can inject
|
||||
// mid-copy failures that pass the pre-copy integrity gate. CopyBackupForTest
|
||||
// is the real implementation, for stubs that only want to fail once.
|
||||
|
||||
@@ -93,6 +93,13 @@ type createChannelRequest struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// createChannelPostCommitHook, when non-nil, runs synchronously right after
|
||||
// handleCreateChannel's AdminCreateChannel commit, before the post-commit
|
||||
// re-read and hub fan-out — the create-side twin of
|
||||
// patchChannelPostCommitHook, so tests can land a caller cancellation in that
|
||||
// exact window (OC-0158) instead of relying on wall-clock timing.
|
||||
var createChannelPostCommitHook func()
|
||||
|
||||
func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req createChannelRequest
|
||||
@@ -120,14 +127,28 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := database.GetChannel(r.Context(), id)
|
||||
// From here on the row has already committed. If the admin's browser
|
||||
// goes away in this window (tab close, navigation, network blip),
|
||||
// r.Context() cancels, and a GetChannel re-read that still used it
|
||||
// would fail with context.Canceled — 500ing while leaving a durably
|
||||
// created channel unbroadcast, so no connected client learns about it
|
||||
// until it reconnects (OC-0158). Run the rest of the handler on an
|
||||
// uncancellable tail, matching handlePatchChannel and
|
||||
// handleDeleteChannel.
|
||||
tail := context.WithoutCancel(r.Context())
|
||||
|
||||
if createChannelPostCommitHook != nil {
|
||||
createChannelPostCommitHook()
|
||||
}
|
||||
|
||||
ch, err := database.GetChannel(tail, id)
|
||||
if err != nil || ch == nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
|
||||
return
|
||||
}
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_create", "channel", id,
|
||||
db.WriteAudit(tail, database, actor, "channel_create", "channel", id,
|
||||
fmt.Sprintf("created #%s (%s)", req.Name, req.Type))
|
||||
if hub != nil {
|
||||
hub.BroadcastChannelCreate(ch)
|
||||
|
||||
@@ -190,6 +190,39 @@ func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIP_XForwardedFor_SkipsMalformedEntries pins the skip guard in the
|
||||
// right-to-left walk: a garbage or empty XFF entry must be stepped over, never
|
||||
// used as a rate-limit/lockout key. The garbage sits to the RIGHT of the real
|
||||
// client so the walk actually reaches it. Dropping the guard would return
|
||||
// "not-an-ip" / "garbage" as the key.
|
||||
func TestClientIP_XForwardedFor_SkipsMalformedEntries(t *testing.T) {
|
||||
trusted := parseCIDRList([]string{"10.0.0.0/8"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{"garbage rightmost", "203.0.113.10, not-an-ip", "203.0.113.10"},
|
||||
{"empty entry", "203.0.113.10, , 10.0.0.1", "203.0.113.10"},
|
||||
{"garbage between hops", "203.0.113.10, ::gg::, 10.0.0.1", "203.0.113.10"},
|
||||
// Nothing parseable at all: fall back to RemoteAddr, never a garbage key.
|
||||
{"all malformed falls back to RemoteAddr", "garbage, , not-an-ip", "10.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:9999" // the trusted proxy
|
||||
req.Header.Set("X-Forwarded-For", tt.xff)
|
||||
|
||||
if ip := clientIPWithProxies(req, trusted); ip != tt.want {
|
||||
t.Errorf("clientIP XFF %q = %q, want %q", tt.xff, ip, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_RemoteAddrWithoutPort(t *testing.T) {
|
||||
// RemoteAddr sometimes has no port (e.g. Unix sockets in tests).
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// I-7: loginRateLimitPerMinute must be 5 (not 60).
|
||||
func TestLoginRateLimit_Value(t *testing.T) {
|
||||
@@ -21,3 +25,83 @@ func TestRateLimiterCleanupHorizon_CoversMaxSlowMode(t *testing.T) {
|
||||
rateLimiterCleanupMaxWindow, maxSlowMode)
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthRateScale/scaledAuthLimit gate every per-IP auth limit
|
||||
// (auth_handler.go:107-136) and the per-IP login failure threshold that arms
|
||||
// the lockout (auth_handler.go:514,537). The multiplier is operator-supplied
|
||||
// via security.auth_rate_limit_multiplier and config validates nothing, so
|
||||
// this clamp is all that stands between a typo and brute-force protection
|
||||
// disappearing.
|
||||
func TestSetAuthRateScale_ClampsMultiplier(t *testing.T) {
|
||||
t.Cleanup(func() { setAuthRateScale(1.0) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mult float64
|
||||
limit int
|
||||
want int
|
||||
}{
|
||||
{"unset config means 1x", 0, loginRateLimitPerMinute, 5},
|
||||
{"negative means 1x", -3.5, loginRateLimitPerMinute, 5},
|
||||
{"1x leaves the limit alone", 1, registerRateLimitPerMinute, 3},
|
||||
{"above the cap clamps to 100x", 1e9, loginRateLimitPerMinute, 500},
|
||||
{"at the cap is 100x", 100, loginRateLimitPerMinute, 500},
|
||||
{"below the floor clamps to 0.1x", 1e-9, verifyTOTPRateLimitPerMinute, 1},
|
||||
{"at the floor is 0.1x", 0.1, verifyTOTPRateLimitPerMinute, 1},
|
||||
{"in range scales and rounds", 0.5, loginRateLimitPerMinute, 3},
|
||||
{"in range scales the failure threshold", 2, loginFailureThreshold, 18},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setAuthRateScale(tt.mult)
|
||||
if got := scaledAuthLimit(tt.limit); got != tt.want {
|
||||
t.Errorf("setAuthRateScale(%v); scaledAuthLimit(%d) = %d, want %d",
|
||||
tt.mult, tt.limit, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A limit of 0 lets nothing through: on the login failure threshold
|
||||
// (auth_handler.go:514) that locks every IP out on its first attempt. The
|
||||
// smallest allowed multiplier must still leave every scaled limit usable.
|
||||
func TestScaledAuthLimit_NeverBelowOne(t *testing.T) {
|
||||
t.Cleanup(func() { setAuthRateScale(1.0) })
|
||||
setAuthRateScale(0.1)
|
||||
|
||||
for _, n := range []int{
|
||||
1,
|
||||
registerRateLimitPerMinute,
|
||||
loginRateLimitPerMinute,
|
||||
verifyTOTPRateLimitPerMinute,
|
||||
sensitiveEndpointRateLimitPerMinute,
|
||||
loginFailureThreshold,
|
||||
} {
|
||||
if got := scaledAuthLimit(n); got < 1 {
|
||||
t.Errorf("scaledAuthLimit(%d) = %d at the 0.1x floor, want >= 1", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The multiplier exists for shared-NAT *per-IP* limits. The per-user caps are
|
||||
// the only cross-IP brute-force defence, so scaling them would hand a
|
||||
// distributed attacker up to 100x the guesses (totp_handler.go:76-80). Those
|
||||
// caps are only observable through a limiter key inside the handler, so this
|
||||
// pins the call site instead.
|
||||
func TestPerUserFailureCapsStayUnscaled(t *testing.T) {
|
||||
for file, constants := range map[string][]string{
|
||||
"totp_handler.go": {"totpFailureRateLimit"},
|
||||
"auth_handler.go": {"loginUserFailureThreshold"},
|
||||
} {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, c := range constants {
|
||||
if strings.Contains(string(src), "scaledAuthLimit("+c) {
|
||||
t.Errorf("%s scales %s with the per-IP auth multiplier; per-user caps must stay unscaled",
|
||||
file, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +391,52 @@ func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequirePermission_NoRoleInContext pins the fail-closed branch: the
|
||||
// server-wide authz chokepoint must deny when the request context carries no
|
||||
// usable *db.Role. Every other RequirePermission test composes AuthMiddleware,
|
||||
// which always installs a non-nil role, so without this the guard could be
|
||||
// rewritten to `if !ok { next.ServeHTTP(w, r); return }` and stay green.
|
||||
func TestRequirePermission_NoRoleInContext(t *testing.T) {
|
||||
var nilRole *db.Role
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx func(context.Context) context.Context
|
||||
}{
|
||||
{"missing key", func(ctx context.Context) context.Context { return ctx }},
|
||||
{"typed nil role", func(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, api.RoleKey, nilRole)
|
||||
}},
|
||||
{"wrong type", func(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, api.RoleKey, "administrator")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
downstream := false
|
||||
h := api.RequirePermission(permissions.ManageServer)(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
downstream = true
|
||||
ok(w, r)
|
||||
}),
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(tt.ctx(req.Context()))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("RequirePermission without role status = %d, want 403", rr.Code)
|
||||
}
|
||||
if downstream {
|
||||
t.Error("RequirePermission without role ran the downstream handler")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RateLimitMiddleware tests ────────────────────────────────────────────────
|
||||
|
||||
func TestRateLimitMiddleware_UnderLimit(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -89,6 +90,67 @@ func TestWAFMiddleware_BlocksScannerUserAgent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The inline engine's four phase-2 request-body rules must actually block a
|
||||
// body-borne attack payload, not just log it: SQLi (942100), XSS (941100),
|
||||
// path traversal (930100) and command injection (932100) are all
|
||||
// deny,status:403 under an always-on SecRuleEngine. Every other blocking test
|
||||
// in the suite fires the phase-1 User-Agent rule on a bodyless GET, so this is
|
||||
// what pins wafInspectRequestBody's interruption path.
|
||||
//
|
||||
// CRS mode off is deliberate: with no CRS engine attached the inline engine is
|
||||
// the only thing that can block, so the asserted rule id proves the inline
|
||||
// rule fired. Detect mode is included because it is the production default and
|
||||
// its CRS engine never interrupts (DetectionOnly), so the block must still
|
||||
// come from the inline engine — block mode is left out precisely because there
|
||||
// the CRS layer could be the one blocking.
|
||||
//
|
||||
// Payloads are form-urlencoded: that is the body form coraza parses into
|
||||
// ARGS/REQUEST_BODY for this engine (it loads no coraza.conf-recommended, so
|
||||
// no JSON body processor is selected — see waf_crs_test.go for the CRS layer,
|
||||
// which does inspect JSON bodies).
|
||||
func TestWAFMiddleware_BlocksAttackPayloadInRequestBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
ruleID int
|
||||
}{
|
||||
{"sqli", `q=1%27%20OR%20%271%27%3D%271%20--%20`, 942100},
|
||||
{"xss", `q=%3Cscript%3Ealert%281%29%3C%2Fscript%3E`, 941100},
|
||||
{"path_traversal", `q=..%2F..%2Fetc%2Fpasswd`, 930100},
|
||||
{"command_injection", `q=hello%20%7C%20id`, 932100},
|
||||
}
|
||||
|
||||
for _, mode := range []string{CRSModeOff, CRSModeDetect} {
|
||||
middleware := NewWAFMiddlewareCRS(2, mode)
|
||||
for _, tc := range cases {
|
||||
t.Run(mode+"/"+tc.name, func(t *testing.T) {
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("downstream handler must not be called for a %s request body", tc.name)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "OwnCordClient/1.0")
|
||||
req.RemoteAddr = "127.0.0.1:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
out := captureSlog(t, func() { handler.ServeHTTP(rr, req) })
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(rr.Body.String()) != `{"error":"request blocked by security rules"}` {
|
||||
t.Fatalf("body = %q, want blocked JSON", rr.Body.String())
|
||||
}
|
||||
if want := fmt.Sprintf("rule_id=%d", tc.ruleID); !strings.Contains(out, want) {
|
||||
t.Fatalf("blocked by the wrong rule: want %s in\n%s", want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routes exempted from the app's global 1 MiB body cap (bodyCapExemptPrefixes
|
||||
// in constants.go) must also be exempted from the inline WAF engine's own
|
||||
// SecRequestBodyLimit, or coraza's default SecRequestBodyLimitAction (Reject)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
// testKey returns a deterministic 32-byte AES-256 key.
|
||||
func testKey(fill byte) []byte {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = fill
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// TestEncryptDecryptTOTPSecret_RoundTrip pins the real AES-GCM path: a secret
|
||||
// encrypted with a key must decrypt back to itself byte-for-byte, and the
|
||||
// stored form must not be the plaintext.
|
||||
func TestEncryptDecryptTOTPSecret_RoundTrip(t *testing.T) {
|
||||
key := testKey(0x2a)
|
||||
secrets := []string{
|
||||
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", // 32-char base32 TOTP secret
|
||||
"JBSWY3DPEHPK3PXP", // 16-char legacy-length secret
|
||||
"", // empty plaintext still round-trips
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
encrypted, err := auth.EncryptTOTPSecret(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptTOTPSecret(%q): %v", secret, err)
|
||||
}
|
||||
if encrypted == secret {
|
||||
t.Fatalf("EncryptTOTPSecret(%q) returned the plaintext", secret)
|
||||
}
|
||||
|
||||
got, err := auth.DecryptTOTPSecret(key, encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptTOTPSecret(%q): %v", secret, err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("round-trip = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptTOTPSecret_NonceIsRandom pins that two encryptions of the same
|
||||
// secret differ, so a stored ciphertext cannot be used as a secret fingerprint.
|
||||
func TestEncryptTOTPSecret_NonceIsRandom(t *testing.T) {
|
||||
key := testKey(0x11)
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
|
||||
first, err := auth.EncryptTOTPSecret(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptTOTPSecret: %v", err)
|
||||
}
|
||||
second, err := auth.EncryptTOTPSecret(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptTOTPSecret: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("two encryptions of the same secret produced identical ciphertext (nonce reuse)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptTOTPSecret_FailsClosed pins the documented invariant at
|
||||
// totp_encrypt.go: a value that has the full encrypted shape (valid hex, long
|
||||
// enough for nonce+tag) but fails GCM authentication must return an error and
|
||||
// an EMPTY string. Returning the ciphertext would silently mask a wrong
|
||||
// TOTP_ENCRYPTION_KEY and hand the caller a bogus "secret".
|
||||
func TestDecryptTOTPSecret_FailsClosed(t *testing.T) {
|
||||
key := testKey(0x01)
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
|
||||
encrypted, err := auth.EncryptTOTPSecret(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptTOTPSecret: %v", err)
|
||||
}
|
||||
|
||||
// Flip the last ciphertext byte to simulate tampering/corruption.
|
||||
raw, err := hex.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("hex.DecodeString: %v", err)
|
||||
}
|
||||
raw[len(raw)-1] ^= 0xff
|
||||
tampered := hex.EncodeToString(raw)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key []byte
|
||||
ciphertext string
|
||||
}{
|
||||
{name: "wrong key", key: testKey(0x02), ciphertext: encrypted},
|
||||
{name: "tampered ciphertext", key: key, ciphertext: tampered},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := auth.DecryptTOTPSecret(tc.key, tc.ciphertext)
|
||||
if err == nil {
|
||||
t.Fatalf("DecryptTOTPSecret returned nil error (got %q); must fail closed", got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("DecryptTOTPSecret returned %q on auth failure; must return the empty string, never the ciphertext", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptTOTPSecret_LegacyPlaintextPassthrough pins the backwards-compat
|
||||
// branches: values that cannot be encrypted data are handed back unchanged
|
||||
// with no error, which is what makes the fail-closed branch above safe.
|
||||
func TestDecryptTOTPSecret_LegacyPlaintextPassthrough(t *testing.T) {
|
||||
key := testKey(0x03)
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "short base32 secret", value: "JBSWY3DPEHPK3PXP"},
|
||||
// Long enough for the encrypted format but not valid hex.
|
||||
{name: "long non-hex value", value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBV"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := auth.DecryptTOTPSecret(key, tc.value)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptTOTPSecret(%q): %v", tc.value, err)
|
||||
}
|
||||
if got != tc.value {
|
||||
t.Fatalf("DecryptTOTPSecret(%q) = %q, want the value unchanged", tc.value, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrGenerateTOTPKey_StableAcrossRestarts pins that the second boot
|
||||
// reads totp.key back off disk instead of generating a fresh key. A regression
|
||||
// here makes every stored (encrypted) TOTP secret undecryptable after a
|
||||
// restart, locking every 2FA account out.
|
||||
func TestLoadOrGenerateTOTPKey_StableAcrossRestarts(t *testing.T) {
|
||||
t.Setenv("OWNCORD_TOTP_KEY", "")
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
first, err := auth.LoadOrGenerateTOTPKey(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("first LoadOrGenerateTOTPKey: %v", err)
|
||||
}
|
||||
if len(first) != 32 {
|
||||
t.Fatalf("key length = %d, want 32", len(first))
|
||||
}
|
||||
|
||||
second, err := auth.LoadOrGenerateTOTPKey(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("second LoadOrGenerateTOTPKey: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatalf("key changed across restarts: %x then %x", first, second)
|
||||
}
|
||||
|
||||
// The persisted key must be the one returned, so an operator copying
|
||||
// totp.key to another host gets the same decryption key.
|
||||
onDisk, err := os.ReadFile(filepath.Join(dataDir, "totp.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading totp.key: %v", err)
|
||||
}
|
||||
if string(onDisk) != hex.EncodeToString(first) {
|
||||
t.Fatalf("totp.key = %q, want %q", onDisk, hex.EncodeToString(first))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrGenerateTOTPKey_EnvVar pins that OWNCORD_TOTP_KEY wins over a
|
||||
// totp.key on disk (so an operator can rotate without touching the file) and
|
||||
// that a wrong-length env key is a hard error rather than a silent fallback to
|
||||
// auto-generation.
|
||||
func TestLoadOrGenerateTOTPKey_EnvVar(t *testing.T) {
|
||||
t.Run("valid hex wins over the key file", func(t *testing.T) {
|
||||
envKey := testKey(0x7e)
|
||||
t.Setenv("OWNCORD_TOTP_KEY", hex.EncodeToString(envKey))
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "totp.key"),
|
||||
[]byte(hex.EncodeToString(testKey(0x01))), 0o600); err != nil {
|
||||
t.Fatalf("writing totp.key: %v", err)
|
||||
}
|
||||
|
||||
got, err := auth.LoadOrGenerateTOTPKey(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerateTOTPKey: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, envKey) {
|
||||
t.Fatalf("key = %x, want the env key %x", got, envKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong length is a hard error", func(t *testing.T) {
|
||||
t.Setenv("OWNCORD_TOTP_KEY", hex.EncodeToString(make([]byte, 16)))
|
||||
key, err := auth.LoadOrGenerateTOTPKey(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatalf("LoadOrGenerateTOTPKey accepted a 16-byte OWNCORD_TOTP_KEY (returned %x)", key)
|
||||
}
|
||||
if key != nil {
|
||||
t.Fatalf("LoadOrGenerateTOTPKey returned key %x alongside an error; want nil", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadOrGenerateTOTPKey_RejectsBadKeyFile pins that a corrupt totp.key is a
|
||||
// hard error rather than a silent regeneration (which would orphan every
|
||||
// stored secret).
|
||||
func TestLoadOrGenerateTOTPKey_RejectsBadKeyFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contents string
|
||||
}{
|
||||
{name: "invalid hex", contents: "not-hex-at-all"},
|
||||
{name: "wrong length", contents: hex.EncodeToString(make([]byte, 16))},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("OWNCORD_TOTP_KEY", "")
|
||||
dataDir := t.TempDir()
|
||||
keyPath := filepath.Join(dataDir, "totp.key")
|
||||
if err := os.WriteFile(keyPath, []byte(tc.contents), 0o600); err != nil {
|
||||
t.Fatalf("writing totp.key: %v", err)
|
||||
}
|
||||
|
||||
key, err := auth.LoadOrGenerateTOTPKey(dataDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadOrGenerateTOTPKey accepted a corrupt totp.key (returned %x)", key)
|
||||
}
|
||||
if key != nil {
|
||||
t.Fatalf("LoadOrGenerateTOTPKey returned key %x alongside an error; want nil", key)
|
||||
}
|
||||
|
||||
// The corrupt file must be left alone, not overwritten with a
|
||||
// freshly generated key.
|
||||
after, readErr := os.ReadFile(keyPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("reading totp.key after failure: %v", readErr)
|
||||
}
|
||||
if string(after) != tc.contents {
|
||||
t.Fatalf("totp.key was rewritten to %q, want %q", after, tc.contents)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ package db_test
|
||||
// 2. A database that was created at an older schema point (migrations
|
||||
// 001..019 only, before any of the phase 2-6 additions) and already has
|
||||
// data in it can be upgraded by applying the remaining migrations
|
||||
// (020..028) without error, and every pre-existing row survives with sane
|
||||
// defaults for the newly added columns.
|
||||
// (020..head) without error, and every pre-existing row survives with sane
|
||||
// defaults for the newly added columns — including the attachments rows
|
||||
// that migration 030 copies through a DROP/RENAME table rebuild.
|
||||
//
|
||||
// TestMigrate_022SeedsMentionEveryone and TestMigrate_022CreatesMentionSchema
|
||||
// in migrate_test.go already lock the mention-specific pieces in isolation;
|
||||
@@ -145,14 +146,19 @@ func TestMigrate_FullChainSchemaIsCoherent(t *testing.T) {
|
||||
// TestMigrate_UpgradeFromMigration019PreservesData simulates upgrading a
|
||||
// database that was last migrated at 019_perf_indexes.sql: it builds that
|
||||
// schema via a filtered view of the real embedded migrations, inserts a row
|
||||
// each into users/roles/channels/messages/voice_states/emoji (the tables the
|
||||
// 020..028 migrations touch), then applies the full chain and asserts:
|
||||
// each into users/roles/channels/messages/voice_states/emoji/attachments (the
|
||||
// tables the 020..head migrations touch), then applies the full chain and
|
||||
// asserts:
|
||||
//
|
||||
// - the upgrade completes without error,
|
||||
// - the pre-existing rows are all still present (by primary key), and
|
||||
// - the pre-existing rows are all still present (by primary key),
|
||||
// - the new columns those rows gained have the migration's stated defaults
|
||||
// (0/NULL), not some other value — i.e. old data is not silently
|
||||
// backfilled with something other than the documented default.
|
||||
// backfilled with something other than the documented default, and
|
||||
// - the attachments row survives migration 030's INSERT…SELECT + DROP +
|
||||
// RENAME rebuild with every column value intact. 030 is the only
|
||||
// migration that destroys and recreates a table holding user data, so it
|
||||
// is the only one whose data copy can silently lose or reorder columns.
|
||||
func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
ctx := context.Background()
|
||||
@@ -202,6 +208,16 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) {
|
||||
`INSERT INTO emoji (id, shortcode, filename, uploaded_by) VALUES (1, 'partyparrot', 'stored-uuid', 1)`); err != nil {
|
||||
t.Fatalf("seed emoji: %v", err)
|
||||
}
|
||||
// Every attachments column populated (no NULLs, no defaults) so migration
|
||||
// 030's rebuild has something to lose in each of the ten positions it
|
||||
// copies.
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`INSERT INTO attachments (id, message_id, filename, stored_as, mime_type,
|
||||
size, uploaded_at, width, height, uploader_id)
|
||||
VALUES ('att-1', 1, 'cat.png', 'stored-cat-uuid', 'image/png',
|
||||
4242, '2024-01-02 03:04:05', 640, 480, 1)`); err != nil {
|
||||
t.Fatalf("seed attachment: %v", err)
|
||||
}
|
||||
|
||||
// Apply the remaining migrations (020..028) via the real production path.
|
||||
if err := db.Migrate(database); err != nil {
|
||||
@@ -220,6 +236,7 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) {
|
||||
{"SELECT 1 FROM messages WHERE id = ?", []any{1}, "message"},
|
||||
{"SELECT 1 FROM voice_states WHERE user_id = ?", []any{1}, "voice_states"},
|
||||
{"SELECT 1 FROM emoji WHERE id = ?", []any{1}, "emoji"},
|
||||
{"SELECT 1 FROM attachments WHERE id = ?", []any{"att-1"}, "attachment"},
|
||||
} {
|
||||
var one int
|
||||
if err := database.QueryRowContext(ctx, tc.query, tc.args...).Scan(&one); err != nil {
|
||||
@@ -269,6 +286,41 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) {
|
||||
t.Errorf("emoji.mime_type = %q for pre-existing row, want the migration's documented default %q", mimeType, "image/png")
|
||||
}
|
||||
|
||||
// Migration 030 rebuilds attachments (INSERT…SELECT into attachments_v030,
|
||||
// DROP, RENAME) to swap message_id's FK action to ON DELETE SET NULL. The
|
||||
// copy lists ten columns twice, so a dropped, added or reordered column
|
||||
// silently corrupts every pre-existing row — assert all ten came through
|
||||
// unchanged, message_id still bound to the seeded message.
|
||||
var (
|
||||
attMessageID, attSize, attWidth, attHeight, attUploaderID int64
|
||||
attFilename, attStoredAs, attMimeType, attUploadedAt string
|
||||
)
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT message_id, filename, stored_as, mime_type, size, uploaded_at, width, height, uploader_id
|
||||
FROM attachments WHERE id = 'att-1'`,
|
||||
).Scan(&attMessageID, &attFilename, &attStoredAs, &attMimeType, &attSize,
|
||||
&attUploadedAt, &attWidth, &attHeight, &attUploaderID); err != nil {
|
||||
t.Fatalf("reading upgraded attachment: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
column string
|
||||
got, want any
|
||||
}{
|
||||
{"message_id", attMessageID, int64(1)},
|
||||
{"filename", attFilename, "cat.png"},
|
||||
{"stored_as", attStoredAs, "stored-cat-uuid"},
|
||||
{"mime_type", attMimeType, "image/png"},
|
||||
{"size", attSize, int64(4242)},
|
||||
{"uploaded_at", attUploadedAt, "2024-01-02 03:04:05"},
|
||||
{"width", attWidth, int64(640)},
|
||||
{"height", attHeight, int64(480)},
|
||||
{"uploader_id", attUploaderID, int64(1)},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("attachments.%s = %v after migration 030's rebuild, want %v", tc.column, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
var mentionsEveryone int
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT mentions_everyone FROM messages WHERE id = 1`).Scan(&mentionsEveryone); err != nil {
|
||||
@@ -298,7 +350,7 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) {
|
||||
|
||||
// Every migration file, old and new, must be recorded — this is the
|
||||
// upgrade path's real contract: 001..019 came from the seed/normal path
|
||||
// during the first MigrateFS call, 020..028 from the second.
|
||||
// during the first MigrateFS call, 020..head from the second.
|
||||
all, err := fs.ReadDir(migrations.FS, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("reading embedded migrations dir: %v", err)
|
||||
|
||||
@@ -79,42 +79,55 @@ func TestDeleteExpiredSessions_SargableFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration031_NormalizesLegacyFormats verifies the one-time UPDATE pass:
|
||||
// space-separated and Z-less rows become the RFC3339-Z layout.
|
||||
// TestMigration031_NormalizesLegacyFormats drives the real migration file:
|
||||
// it builds the pre-031 schema with migrationCutoffFS, seeds legacy
|
||||
// space-separated and Z-less expires_at rows on it, then applies the full
|
||||
// chain so migration 031's one-time UPDATE pass is what normalizes them.
|
||||
func TestMigration031_NormalizesLegacyFormats(t *testing.T) {
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := db.MigrateFS(database, migrations.FS); err != nil {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
database := openMemory(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.MigrateFS(database, migrationCutoffFS{underlying: migrations.FS, cutoff: "031_"}); err != nil {
|
||||
t.Fatalf("MigrateFS building pre-031 schema: %v", err)
|
||||
}
|
||||
var idx int
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_sessions_expires_at'`).Scan(&idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx != 0 {
|
||||
t.Fatal("idx_sessions_expires_at exists before 031 ran — cutoff FS leaked the migration")
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, password, role_id) VALUES (1, 'u', 'x', 1)`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// Simulate pre-031 rows, then re-run the normalization statements the
|
||||
// migration contains (the migration itself already ran on the empty DB).
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`INSERT INTO sessions (user_id, token, expires_at) VALUES (1, 'legacy', '2030-05-01 10:00:00')`); err != nil {
|
||||
t.Fatal(err)
|
||||
cases := []struct{ token, stored, want string }{
|
||||
{"legacy_space", "2030-05-01 10:00:00", "2030-05-01T10:00:00Z"},
|
||||
{"legacy_no_z", "2030-06-02T11:22:33", "2030-06-02T11:22:33Z"},
|
||||
{"already_normalized", "2030-07-03T12:34:56Z", "2030-07-03T12:34:56Z"},
|
||||
}
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`UPDATE sessions SET expires_at = replace(expires_at, ' ', 'T') WHERE instr(expires_at, ' ') > 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
for _, tc := range cases {
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`INSERT INTO sessions (user_id, token, expires_at) VALUES (1, ?, ?)`, tc.token, tc.stored); err != nil {
|
||||
t.Fatalf("seed session %s: %v", tc.token, err)
|
||||
}
|
||||
}
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`UPDATE sessions SET expires_at = expires_at || 'Z' WHERE length(expires_at) = 19`); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
// Only 031 is left to apply, so any change below is its doing.
|
||||
if err := db.MigrateFS(database, migrations.FS); err != nil {
|
||||
t.Fatalf("MigrateFS applying 031: %v", err)
|
||||
}
|
||||
var got string
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM sessions WHERE token = 'legacy'`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2030-05-01T10:00:00Z" {
|
||||
t.Fatalf("normalized expires_at = %q, want 2030-05-01T10:00:00Z", got)
|
||||
|
||||
for _, tc := range cases {
|
||||
var got string
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM sessions WHERE token = ?`, tc.token).Scan(&got); err != nil {
|
||||
t.Fatalf("read %s: %v", tc.token, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: expires_at = %q, want %q", tc.token, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -501,6 +503,120 @@ func TestExtractChatserverFromTarGz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// tarEntry is one member of a test archive; body is empty for header-only
|
||||
// entries such as symlinks.
|
||||
type tarEntry struct {
|
||||
hdr tar.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func buildTarGz(t *testing.T, entries ...tarEntry) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
for i := range entries {
|
||||
hdr := entries[i].hdr
|
||||
hdr.Mode = 0o755
|
||||
hdr.Size = int64(len(entries[i].body))
|
||||
if err := tw.WriteHeader(&hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(entries[i].body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractChatserverFromTarGzEntryFilters(t *testing.T) {
|
||||
want := []byte("#!/bin/real\n")
|
||||
wantSum := sha256.Sum256(want)
|
||||
regular := tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: want}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []tarEntry
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
// A "chatserver" shipped as a symlink must not be followed.
|
||||
name: "non-regular entry is skipped",
|
||||
entries: []tarEntry{{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}, regular},
|
||||
},
|
||||
{
|
||||
name: "path-traversal name is skipped",
|
||||
entries: []tarEntry{{hdr: tar.Header{Name: "../../chatserver", Typeflag: tar.TypeReg}, body: []byte("planted")}, regular},
|
||||
},
|
||||
{
|
||||
name: "other basename is skipped",
|
||||
entries: []tarEntry{{hdr: tar.Header{Name: "chatserver.sig", Typeflag: tar.TypeReg}, body: []byte("sig")}, regular},
|
||||
},
|
||||
{
|
||||
name: "no chatserver member at all",
|
||||
entries: []tarEntry{{hdr: tar.Header{Name: "README", Typeflag: tar.TypeReg}, body: []byte("hi")}, {hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/bin/sh"}}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "chatserver")
|
||||
gotHash, err := extractChatserverFromTarGz(bytes.NewReader(buildTarGz(t, tc.entries...)), dest)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("extractChatserverFromTarGz = %q, want error", gotHash)
|
||||
}
|
||||
if _, statErr := os.Stat(dest); !errors.Is(statErr, fs.ErrNotExist) {
|
||||
t.Errorf("destPath exists after a failed extraction (stat err %v)", statErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("extractChatserverFromTarGz: %v", err)
|
||||
}
|
||||
if gotHash != hex.EncodeToString(wantSum[:]) {
|
||||
t.Errorf("hash = %q, want hash of the regular chatserver entry", gotHash)
|
||||
}
|
||||
got, readErr := os.ReadFile(dest)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("extracted %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-existing staging path is an attacker-planted file: staging is O_EXCL,
|
||||
// so extraction must fail rather than write through it.
|
||||
func TestExtractChatserverFromTarGzRefusesExistingDest(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "chatserver")
|
||||
planted := []byte("planted-by-attacker")
|
||||
if err := os.WriteFile(dest, planted, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
archive := buildTarGz(t, tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: []byte("#!/bin/real\n")})
|
||||
if _, err := extractChatserverFromTarGz(bytes.NewReader(archive), dest); !errors.Is(err, fs.ErrExist) {
|
||||
t.Fatalf("extractChatserverFromTarGz err = %v, want fs.ErrExist", err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, planted) {
|
||||
t.Errorf("pre-existing file was overwritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetFilenameFromURL(t *testing.T) {
|
||||
got, err := assetFilenameFromURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe")
|
||||
if err != nil {
|
||||
|
||||
+10
-1
@@ -84,11 +84,20 @@ type KeyHolderChecker interface {
|
||||
// it live at dispatch time picks up the late wiring. MessageSvc gates channel
|
||||
// broadcasts through the same posting policy as a real message send.
|
||||
type PluginDeps struct {
|
||||
Registry func() *plugin.Registry
|
||||
Registry func() CommandDispatcher
|
||||
MessageSvc *service.MessageService
|
||||
Limiter *auth.RateLimiter
|
||||
}
|
||||
|
||||
// CommandDispatcher is the one method the chat_command handler needs from the
|
||||
// plugin registry; *plugin.Registry satisfies it. Taking the interface rather
|
||||
// than the concrete type is what makes the broadcast path testable: without
|
||||
// the wazero build tag a real registry has no runtime and can only ever answer
|
||||
// with a Reply, so the CanPost gate would otherwise be unreachable from a test.
|
||||
type CommandDispatcher interface {
|
||||
DispatchCommand(ctx context.Context, userID, channelID int64, cmd string, args []string) (*plugin.CommandResult, bool)
|
||||
}
|
||||
|
||||
// VoiceDeps holds dependencies for voice handlers.
|
||||
type VoiceDeps struct {
|
||||
DB *db.DB
|
||||
|
||||
@@ -407,14 +407,28 @@ func (h *Hub) HandleWebhookParticipantLeftWithContextForTest(ctx context.Context
|
||||
// for external tests. identity and roomName are passed raw so a test can feed
|
||||
// malformed values through the same parse path a hostile webhook would.
|
||||
func (h *Hub) HandleWebhookParticipantJoinedForTest(identity, roomName string) {
|
||||
h.HandleWebhookParticipantJoinedWithContextForTest(context.Background(), identity, roomName)
|
||||
}
|
||||
|
||||
// HandleWebhookParticipantJoinedWithContextForTest is
|
||||
// HandleWebhookParticipantJoinedForTest with a caller-supplied context, so
|
||||
// external tests can simulate the webhook HTTP handler's request context
|
||||
// (e.g. already-cancelled, as it would be after the webhook sender hangs up)
|
||||
// instead of always running with context.Background(). Mirrors
|
||||
// HandleWebhookParticipantLeftWithContextForTest.
|
||||
func (h *Hub) HandleWebhookParticipantJoinedWithContextForTest(ctx context.Context, identity, roomName string) {
|
||||
event := &livekit.WebhookEvent{
|
||||
Event: "participant_joined",
|
||||
Participant: &livekit.ParticipantInfo{Identity: identity},
|
||||
Room: &livekit.Room{Name: roomName},
|
||||
}
|
||||
h.handleWebhookParticipantJoined(context.Background(), event)
|
||||
h.handleWebhookParticipantJoined(ctx, event)
|
||||
}
|
||||
|
||||
// WebhookMaxBodyBytesForTest exposes the webhook body cap so external tests can
|
||||
// build a body that is over it without hardcoding the constant twice.
|
||||
const WebhookMaxBodyBytesForTest = webhookMaxBodyBytes
|
||||
|
||||
// HandleWebhookParticipantJoinedEventForTest exposes
|
||||
// handleWebhookParticipantJoined with a caller-built event so tests can cover
|
||||
// the nil-participant and nil-room guards.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/plugin"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
@@ -50,7 +49,7 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an
|
||||
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many commands"}}
|
||||
}
|
||||
|
||||
var reg *plugin.Registry
|
||||
var reg CommandDispatcher
|
||||
if d.Registry != nil {
|
||||
reg = d.Registry()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package ws
|
||||
|
||||
// handlers_command_gate_test.go — success path of the chat_command handler:
|
||||
// the ephemeral reply, the MessageService.CanPost broadcast gate, and the
|
||||
// plugin_broadcast fan-out. handlers_command_test.go (package ws_test) covers
|
||||
// only the refusals, which it can reach through the hub; these need PluginDeps
|
||||
// and the unexported command struct, so they live in-package.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/plugin"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// stubDispatcher stands in for *plugin.Registry. A real registry is useless
|
||||
// here: without the wazero build tag it has no runtime, so DispatchCommand can
|
||||
// only ever return the "runtime is not built" Reply — never a Broadcast, and
|
||||
// therefore never the CanPost gate below.
|
||||
type stubDispatcher struct {
|
||||
result *plugin.CommandResult
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (s stubDispatcher) DispatchCommand(_ context.Context, _, _ int64, _ string, _ []string) (*plugin.CommandResult, bool) {
|
||||
return s.result, s.handled
|
||||
}
|
||||
|
||||
// newCommandTestDeps builds PluginDeps whose MessageSvc is the real service
|
||||
// (the same CanPost a message send runs) over an in-memory DB, plus a
|
||||
// dispatcher stub returning res. Returns the owner (all permissions), a user
|
||||
// whose role carries none, and a text channel.
|
||||
func newCommandTestDeps(t *testing.T, res *plugin.CommandResult) (deps PluginDeps, ownerID, mutedID, chID int64) {
|
||||
t.Helper()
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
if ownerID, err = database.CreateUser(ctx, "cmd-owner", "hash", 1); err != nil { // Owner role
|
||||
t.Fatalf("CreateUser owner: %v", err)
|
||||
}
|
||||
role, err := database.CreateRole(ctx, "cmd-muted", nil, 0, 0) // no permission bits
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRole: %v", err)
|
||||
}
|
||||
if mutedID, err = database.CreateUser(ctx, "cmd-muted-user", "hash", int(role.ID)); err != nil {
|
||||
t.Fatalf("CreateUser muted: %v", err)
|
||||
}
|
||||
if chID, err = database.CreateChannel(ctx, "cmd-chan", "text", "", "", 0); err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
svc := service.New(database, auth.NewRateLimiter())
|
||||
deps = PluginDeps{
|
||||
Registry: func() CommandDispatcher { return stubDispatcher{result: res, handled: true} },
|
||||
MessageSvc: svc.Messages,
|
||||
}
|
||||
return deps, ownerID, mutedID, chID
|
||||
}
|
||||
|
||||
// A plugin Reply becomes an ephemeral command_reply envelope carrying the
|
||||
// request's req_id, and reaches nobody else.
|
||||
func TestHandleChatCommandV2_ReplyIsEphemeral(t *testing.T) {
|
||||
deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "pong"})
|
||||
cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/ping", reqID: "req-7"}
|
||||
|
||||
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
|
||||
|
||||
if result.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Error)
|
||||
}
|
||||
if len(result.Events) != 0 {
|
||||
t.Fatalf("a reply-only command must not broadcast, got %d events", len(result.Events))
|
||||
}
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
ReqID string `json:"req_id"`
|
||||
Payload struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Reply, &env); err != nil {
|
||||
t.Fatalf("unmarshal reply %s: %v", result.Reply, err)
|
||||
}
|
||||
if env.Type != MsgTypeCommandReply {
|
||||
t.Errorf("type = %q, want %q", env.Type, MsgTypeCommandReply)
|
||||
}
|
||||
if env.ReqID != "req-7" {
|
||||
t.Errorf("req_id = %q, want req-7", env.ReqID)
|
||||
}
|
||||
if env.Payload.Text != "pong" {
|
||||
t.Errorf("text = %q, want pong", env.Payload.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// An authorized user's Broadcast fans out as a plugin_broadcast event on the
|
||||
// invoking channel.
|
||||
func TestHandleChatCommandV2_BroadcastFansOutWhenAllowed(t *testing.T) {
|
||||
deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "rolled a 6"})
|
||||
cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/roll", reqID: "req-8"}
|
||||
|
||||
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
|
||||
|
||||
if result.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Error)
|
||||
}
|
||||
if len(result.Events) != 1 {
|
||||
t.Fatalf("expected 1 broadcast event, got %d", len(result.Events))
|
||||
}
|
||||
ev, ok := result.Events[0].(PluginBroadcastEvent)
|
||||
if !ok {
|
||||
t.Fatalf("expected PluginBroadcastEvent, got %T", result.Events[0])
|
||||
}
|
||||
if ev.ChannelID() != chID {
|
||||
t.Errorf("event channel = %d, want %d", ev.ChannelID(), chID)
|
||||
}
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Command string `json:"command"`
|
||||
Text string `json:"text"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(ev.Payload(), &env); err != nil {
|
||||
t.Fatalf("unmarshal payload %s: %v", ev.Payload(), err)
|
||||
}
|
||||
if env.Type != MsgTypePluginBroadcast {
|
||||
t.Errorf("type = %q, want %q", env.Type, MsgTypePluginBroadcast)
|
||||
}
|
||||
if env.Payload.ChannelID != chID || env.Payload.UserID != ownerID {
|
||||
t.Errorf("payload ids = (%d,%d), want (%d,%d)", env.Payload.ChannelID, env.Payload.UserID, chID, ownerID)
|
||||
}
|
||||
if env.Payload.Command != "/roll" || env.Payload.Text != "rolled a 6" {
|
||||
t.Errorf("payload = %+v, want command=/roll text=rolled a 6", env.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// The CanPost gate: a user whose role cannot post gets FORBIDDEN and nothing
|
||||
// reaches the channel — even though the plugin returned a broadcast. The reply
|
||||
// is dropped with it (the denial is the security signal; see handlers_command.go).
|
||||
func TestHandleChatCommandV2_BroadcastDeniedWithoutPostPermission(t *testing.T) {
|
||||
deps, _, mutedID, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "ok", Broadcast: "rolled a 6"})
|
||||
cmd := ChatCommandCmd{userID: mutedID, channelID: chID, command: "/roll", reqID: "req-9"}
|
||||
|
||||
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: mutedID}, deps)
|
||||
|
||||
ce, ok := result.Error.(ClientError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error)
|
||||
}
|
||||
if ce.Code != ErrCodeForbidden {
|
||||
t.Errorf("code = %q, want %q", ce.Code, ErrCodeForbidden)
|
||||
}
|
||||
if len(result.Events) != 0 {
|
||||
t.Errorf("denied command must not broadcast, got %d events", len(result.Events))
|
||||
}
|
||||
if result.Reply != nil {
|
||||
t.Errorf("denied command must not also reply, got %s", result.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A broadcast aimed at a channel that does not exist is NOT_FOUND, not
|
||||
// FORBIDDEN — CanPost's missing-channel branch.
|
||||
func TestHandleChatCommandV2_BroadcastUnknownChannel(t *testing.T) {
|
||||
deps, ownerID, _, _ := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "hi"})
|
||||
cmd := ChatCommandCmd{userID: ownerID, channelID: 424242, command: "/roll"}
|
||||
|
||||
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
|
||||
|
||||
ce, ok := result.Error.(ClientError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error)
|
||||
}
|
||||
if ce.Code != ErrCodeNotFound {
|
||||
t.Errorf("code = %q, want %q", ce.Code, ErrCodeNotFound)
|
||||
}
|
||||
if len(result.Events) != 0 {
|
||||
t.Errorf("expected no events, got %d", len(result.Events))
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -188,7 +188,15 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *
|
||||
// Phase C Step 9 — plugin slash commands. Registry is read live because
|
||||
// SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts.
|
||||
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
|
||||
Registry: func() *plugin.Registry { return h.pluginRegistry },
|
||||
// A nil registry must yield a nil interface, not a typed-nil
|
||||
// *plugin.Registry — the handler's "no plugins loaded" check is an
|
||||
// interface comparison.
|
||||
Registry: func() CommandDispatcher {
|
||||
if h.pluginRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
return h.pluginRegistry
|
||||
},
|
||||
MessageSvc: h.messageSvc,
|
||||
Limiter: h.limiter,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
@@ -15,22 +16,29 @@ import (
|
||||
// every connected client — an identity key that fails to propagate silently
|
||||
// breaks E2EE key agreement for everyone already online.
|
||||
|
||||
// awaitMessage reads one message from ch, failing if none arrives.
|
||||
func awaitMessage(t *testing.T, ch chan []byte) map[string]any {
|
||||
// awaitRawMessage reads one raw frame from ch, failing if none arrives.
|
||||
func awaitRawMessage(t *testing.T, ch chan []byte) []byte {
|
||||
t.Helper()
|
||||
select {
|
||||
case raw := <-ch:
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", raw, err)
|
||||
}
|
||||
return msg
|
||||
return raw
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no message received")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// awaitMessage reads one message from ch, failing if none arrives.
|
||||
func awaitMessage(t *testing.T, ch chan []byte) map[string]any {
|
||||
t.Helper()
|
||||
raw := awaitRawMessage(t, ch)
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", raw, err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func TestHub_BroadcastUserUpdate(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
go hub.Run()
|
||||
@@ -186,14 +194,123 @@ func TestHub_ChannelReadAudience_ExcludesArchivedChannel(t *testing.T) {
|
||||
assertNotReceived(t, send, "member with base READ_MESSAGES on an archived channel")
|
||||
}
|
||||
|
||||
// stubDMEvent is a minimal ws.SequencedDMEvent so EmitEvents routes through
|
||||
// sendSequencedToUsers — persistEvent's second call site, the one that stamps
|
||||
// a non-zero channel_id without going through the broadcast queue.
|
||||
type stubDMEvent struct {
|
||||
channelID int64
|
||||
participantIDs []int64
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (e stubDMEvent) EventType() string { return "chat_message" }
|
||||
func (e stubDMEvent) ChannelID() int64 { return e.channelID }
|
||||
func (e stubDMEvent) ParticipantIDs() []int64 { return e.participantIDs }
|
||||
func (e stubDMEvent) Payload() []byte { return e.payload }
|
||||
|
||||
// TestHub_SetEventPersister pins the invariant persistEvent exists for: the
|
||||
// row written to the EventStore carries the same seq (and type, and channel)
|
||||
// as the wrapped payload the client received — from both call sites,
|
||||
// deliverBroadcast and sendSequencedToUsers. Cold-tier reconnect replay
|
||||
// selects rows by row-seq against the payload-seq the client acked, so a
|
||||
// mismatch silently replays the wrong window.
|
||||
func TestHub_SetEventPersister(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
|
||||
persister := ws.NewEventPersister(database, 16, 4, 10*time.Millisecond)
|
||||
// The persister needs the real events table; the hub's own test schema has
|
||||
// none, so the store is a separately migrated DB.
|
||||
store := openEventStoreDB(t)
|
||||
persister := ws.NewEventPersister(store, 64, 1, 5*time.Millisecond)
|
||||
persister.Start(context.Background())
|
||||
|
||||
// Setting and clearing must both be safe — SetEventPersister is called at
|
||||
// startup and again on shutdown/reconfiguration.
|
||||
hub.SetEventPersister(persister)
|
||||
hub.SetEventPersister(nil)
|
||||
hub.SetEventPersister(persister)
|
||||
|
||||
go hub.Run()
|
||||
t.Cleanup(func() {
|
||||
hub.Stop()
|
||||
persister.Stop(context.Background())
|
||||
})
|
||||
|
||||
user := seedMemberUser(t, database, "persisted-event-member")
|
||||
chID := seedTestChannel(t, database, "persisted-event-dm")
|
||||
|
||||
send := make(chan []byte, 8)
|
||||
hub.RegisterNowForTest(ws.NewTestClient(hub, user.ID, send))
|
||||
|
||||
// Global broadcast → deliverBroadcast → persistEvent(seq, 0, wrapped).
|
||||
hub.BroadcastToAll([]byte(`{"type":"user_update","payload":{"user_id":7}}`))
|
||||
globalFrame := awaitRawMessage(t, send)
|
||||
|
||||
// Sequenced DM → sendSequencedToUsers → persistEvent(seq, chID, wrapped).
|
||||
hub.EmitEvents(context.Background(), []ws.Event{stubDMEvent{
|
||||
channelID: chID,
|
||||
participantIDs: []int64{user.ID},
|
||||
payload: []byte(`{"type":"chat_message","payload":{"id":1}}`),
|
||||
}})
|
||||
dmFrame := awaitRawMessage(t, send)
|
||||
|
||||
// Stop drains the queue and waits for the flusher to exit, so everything
|
||||
// enqueued above is on disk once it returns. The cleanup's second Stop is
|
||||
// a no-op.
|
||||
persister.Stop(context.Background())
|
||||
|
||||
stored, err := store.GetEventsSince(context.Background(), 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEventsSince: %v", err)
|
||||
}
|
||||
bySeq := make(map[int64]db.PersistedEvent, len(stored))
|
||||
seen := make([]int64, 0, len(stored))
|
||||
for _, row := range stored {
|
||||
bySeq[row.Seq] = row
|
||||
seen = append(seen, row.Seq)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
label string
|
||||
frame []byte
|
||||
eventType string
|
||||
channelID int64
|
||||
}{
|
||||
{"global broadcast", globalFrame, "user_update", 0},
|
||||
{"sequenced DM", dmFrame, "chat_message", chID},
|
||||
}
|
||||
seqs := make([]int64, 0, len(cases))
|
||||
for _, tc := range cases {
|
||||
var wire struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(tc.frame, &wire); err != nil {
|
||||
t.Fatalf("%s: unmarshal %q: %v", tc.label, tc.frame, err)
|
||||
}
|
||||
if wire.Seq == 0 {
|
||||
t.Fatalf("%s: delivered frame carries no seq: %s", tc.label, tc.frame)
|
||||
}
|
||||
if wire.Type != tc.eventType {
|
||||
t.Fatalf("%s: frame type = %q, want %q", tc.label, wire.Type, tc.eventType)
|
||||
}
|
||||
seqs = append(seqs, wire.Seq)
|
||||
|
||||
row, ok := bySeq[wire.Seq]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no persisted row at the delivered seq %d (stored seqs %v)",
|
||||
tc.label, wire.Seq, seen)
|
||||
}
|
||||
if row.EventType != tc.eventType {
|
||||
t.Errorf("%s: row event_type = %q, want %q", tc.label, row.EventType, tc.eventType)
|
||||
}
|
||||
if row.ChannelID != tc.channelID {
|
||||
t.Errorf("%s: row channel_id = %d, want %d", tc.label, row.ChannelID, tc.channelID)
|
||||
}
|
||||
if !bytes.Equal(row.Payload, tc.frame) {
|
||||
t.Errorf("%s: row payload = %s, want the delivered frame %s", tc.label, row.Payload, tc.frame)
|
||||
}
|
||||
}
|
||||
if seqs[1] <= seqs[0] {
|
||||
t.Errorf("seqs not monotonic across the two persist call sites: %v", seqs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package ws_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -1268,6 +1271,121 @@ func TestWebhookHandler_EmptyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// signedWebhookRequest builds the request LiveKit itself would send: the body
|
||||
// hashed with sha256, that hash carried as the token's sha256 claim, and the
|
||||
// token signed with the shared secret. bodyToSign is what the token commits
|
||||
// to; bodySent is what actually travels — passing different values simulates a
|
||||
// captured token replayed against a forged payload.
|
||||
func signedWebhookRequest(t *testing.T, apiKey, apiSecret, bodyToSign, bodySent string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
sum := sha256.Sum256([]byte(bodyToSign))
|
||||
token, err := auth.NewAccessToken(apiKey, apiSecret).
|
||||
SetValidFor(5 * time.Minute).
|
||||
SetSha256(base64.StdEncoding.EncodeToString(sum[:])).
|
||||
ToJWT()
|
||||
if err != nil {
|
||||
t.Fatalf("minting webhook token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/livekit/webhook", strings.NewReader(bodySent))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/webhook+json")
|
||||
return req
|
||||
}
|
||||
|
||||
// webhookBody renders the protojson payload LiveKit posts for a participant
|
||||
// event. pad is an unknown field the parser discards, used only to push the
|
||||
// body past the size cap.
|
||||
func webhookBody(event string, userID, channelID int64, joinToken, pad string) string {
|
||||
return fmt.Sprintf(
|
||||
`{"event":%q,"room":{"name":%q},"participant":{"identity":%q},"pad":%q}`,
|
||||
event, ws.RoomName(channelID), participantIdentityFor(userID, joinToken), pad)
|
||||
}
|
||||
|
||||
// TestWebhookHandler_SignedParticipantLeftDispatches is the only webhook test
|
||||
// that gets past ReceiveWebhookEvent: it mints a real LiveKit webhook token
|
||||
// over the real body and asserts the handler both dispatches the event (the
|
||||
// voice_states row is cleared) and answers 200. Without it, the 401 tests above
|
||||
// would all still pass if verification were changed to reject everything.
|
||||
func TestWebhookHandler_SignedParticipantLeftDispatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const apiKey, apiSecret = "webhook-signed-key", "webhook-signed-secret-0123456789"
|
||||
|
||||
hub, database := newVoiceHub(t)
|
||||
user := seedVoiceOwner(t, database, "webhook-signed-user")
|
||||
chanID := seedVoiceChan(t, database, "webhook-signed-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)
|
||||
}
|
||||
|
||||
body := webhookBody("participant_left", user.ID, chanID, state.JoinedAt, "")
|
||||
rec := httptest.NewRecorder()
|
||||
hub.NewLiveKitWebhookHandler(apiKey, apiSecret)(rec,
|
||||
signedWebhookRequest(t, apiKey, apiSecret, body, body))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for a correctly signed webhook, got %d (%s)",
|
||||
rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
|
||||
after, err := database.GetVoiceState(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVoiceState after webhook: %v", err)
|
||||
}
|
||||
if after != nil {
|
||||
t.Errorf("participant_left verified but never dispatched: voice state still present (channel %d)",
|
||||
after.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookHandler_SignedRequestRejections covers the two ways a request
|
||||
// carrying a genuinely signed token must still be refused: the token's
|
||||
// body-hash claim not matching the body it arrived with (a captured token
|
||||
// replayed against a forged payload), and a body past webhookMaxBodyBytes.
|
||||
func TestWebhookHandler_SignedRequestRejections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const apiKey, apiSecret = "webhook-reject-key", "webhook-reject-secret-0123456789"
|
||||
|
||||
signed := webhookBody("participant_left", 7, 42, "tok", "")
|
||||
// Same token, different body: only the sha256 claim binding catches this.
|
||||
mutated := webhookBody("participant_left", 8, 42, "tok", "")
|
||||
// Correctly signed, but larger than webhookMaxBodyBytes — only the
|
||||
// MaxBytesReader cap catches this one.
|
||||
oversize := webhookBody("participant_left", 7, 42, "tok", strings.Repeat("a", ws.WebhookMaxBodyBytesForTest))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bodyToSign string
|
||||
bodySent string
|
||||
}{
|
||||
{"token replayed against a mutated body", signed, mutated},
|
||||
{"body over webhookMaxBodyBytes", oversize, oversize},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hub := ws.NewHubForTest()
|
||||
rec := httptest.NewRecorder()
|
||||
hub.NewLiveKitWebhookHandler(apiKey, apiSecret)(rec,
|
||||
signedWebhookRequest(t, apiKey, apiSecret, tt.bodyToSign, tt.bodySent))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// livekit_webhook.go – MountWebhookRoute tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,43 @@ func TestWebhook_ParticipantJoined_TransientReadErrorDoesNotEvict(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_ParticipantJoined_SurvivesCancelledRequestContext locks the
|
||||
// participant_joined half of OC-0018 (the participant_left half is locked by
|
||||
// TestWebhook_ParticipantLeft_SurvivesCancelledRequestContext in
|
||||
// livekit_test.go). Without the context.WithoutCancel detach, a webhook sender
|
||||
// (LiveKit) that hangs up mid-request cancels r.Context(); GetVoiceState then
|
||||
// fails, the handler takes the "transient read failure" branch and skips the
|
||||
// rogue-participant check entirely — so a participant presenting a replayed
|
||||
// join token is never removed from the SFU.
|
||||
func TestWebhook_ParticipantJoined_SurvivesCancelledRequestContext(t *testing.T) {
|
||||
hub, database := newVoiceHub(t)
|
||||
user := seedVoiceOwner(t, database, "joined-ctxcancel-user")
|
||||
chanID := seedVoiceChan(t, database, "joined-ctxcancel-ch")
|
||||
|
||||
// Simulate net/http cancelling the request context because the webhook
|
||||
// sender hung up before the handler finished.
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
logs := captureLogs(t)
|
||||
|
||||
// No voice_states row exists for this user — the join is unauthorized and
|
||||
// must still be flagged and evicted on a dead request context.
|
||||
hub.HandleWebhookParticipantJoinedWithContextForTest(
|
||||
cancelledCtx,
|
||||
participantIdentityFor(user.ID, "replayed-token"),
|
||||
roomNameFor(chanID),
|
||||
)
|
||||
|
||||
out := logs()
|
||||
if strings.Contains(out, "skipping rogue-participant check") {
|
||||
t.Errorf("the cancelled request context was mistaken for a transient DB failure, so the rogue participant was never evicted; log:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "rogue participant_joined") {
|
||||
t.Errorf("no rogue-participant warning logged on a cancelled request context; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_ParticipantJoined_WrongChannelFlagged(t *testing.T) {
|
||||
hub, database := newVoiceHub(t)
|
||||
user := seedVoiceOwner(t, database, "joined-wrongch-user")
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
// Voice membership is gated on CONNECT_VOICE alone (voice_join), but the
|
||||
@@ -108,3 +110,72 @@ func TestFinishVoiceLeave_EvictedUserAlwaysInAudience(t *testing.T) {
|
||||
t.Fatal("finishVoiceLeave enqueued nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Both DB-error branches of the READ-audience resolver must deny. The role
|
||||
// scan underneath them treats whatever channel it is handed as a readable
|
||||
// non-DM channel, so an unreadable channels row — or an unreadable DM
|
||||
// participant list — that fell through would resolve to every connected user
|
||||
// holding base READ_MESSAGES, fanning a private room's voice_state /
|
||||
// voice_leave out server-wide.
|
||||
func TestChannelReadAudience_GetChannelErrorDeniesEveryone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := newHarvestVoiceDB(t)
|
||||
uid := seedHarvestVoiceUser(t, database, "audience-chan-err")
|
||||
chID := mustCreateVoiceChannel(t, database, "audience-room")
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h.clients[uid] = NewTestClient(h, uid, make(chan []byte, 8))
|
||||
|
||||
// Precondition: the role scan really does grant this user READ on this
|
||||
// channel, so an empty audience after the fault can only be the deny.
|
||||
if got := h.channelReadAudience(ctx, chID); !slices.Contains(got, uid) {
|
||||
t.Fatalf("precondition: user %d must be in the READ audience, got %v", uid, got)
|
||||
}
|
||||
|
||||
// Make exactly GetChannel fail; roles and channel_overrides keep
|
||||
// resolving, so the role scan would still return this user.
|
||||
if _, err := database.ExecContext(ctx, `ALTER TABLE channels RENAME TO channels_offline`); err != nil {
|
||||
t.Fatalf("rename channels: %v", err)
|
||||
}
|
||||
|
||||
if got := h.channelReadAudience(ctx, chID); len(got) != 0 {
|
||||
t.Errorf("channelReadAudience resolved %v for an unreadable channel row — an unresolvable channel must deny, not fall through to the role scan", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The DM half of the same rule: a DM carries no channel_overrides rows, so its
|
||||
// participant list is the only membership evidence there is. When that read
|
||||
// fails there is nothing left to filter on and the audience must be empty.
|
||||
func TestChannelReadAudience_DMParticipantsErrorDeniesEveryone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := newHarvestVoiceDB(t)
|
||||
alice := seedHarvestVoiceUser(t, database, "audience-dm-alice")
|
||||
bob := seedHarvestVoiceUser(t, database, "audience-dm-bob")
|
||||
mallory := seedHarvestVoiceUser(t, database, "audience-dm-mallory")
|
||||
dm, _, err := database.GetOrCreateDMChannel(ctx, alice, bob)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateDMChannel: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
for _, uid := range []int64{alice, bob, mallory} {
|
||||
h.clients[uid] = NewTestClient(h, uid, make(chan []byte, 8))
|
||||
}
|
||||
|
||||
// Precondition: the DM resolves to its participants only — mallory is
|
||||
// connected and holds base READ_MESSAGES, but is not in this DM.
|
||||
if got := h.channelReadAudience(ctx, dm.ID); !slices.Contains(got, alice) ||
|
||||
!slices.Contains(got, bob) || slices.Contains(got, mallory) {
|
||||
t.Fatalf("precondition: DM audience must be exactly participants %d and %d, got %v", alice, bob, got)
|
||||
}
|
||||
|
||||
// Make exactly GetDMParticipantIDs fail; the channels row still resolves
|
||||
// as type "dm", so the resolver reaches the DM branch and nothing else.
|
||||
if _, err := database.ExecContext(ctx, `ALTER TABLE dm_participants RENAME TO dm_participants_offline`); err != nil {
|
||||
t.Fatalf("rename dm_participants: %v", err)
|
||||
}
|
||||
|
||||
if got := h.channelReadAudience(ctx, dm.ID); len(got) != 0 {
|
||||
t.Errorf("channelReadAudience resolved %v for a DM whose participant list could not be read — an unresolvable DM must deny", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ws_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -647,27 +648,47 @@ func TestVoice_Camera_DisableAllowedAfterPermissionRevoked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoice_Camera_NoPermission: Member without USE_VIDEO gets FORBIDDEN.
|
||||
func TestVoice_Camera_NoPermission(t *testing.T) {
|
||||
hub, _ := newVoiceHub(t)
|
||||
// assertStreamPermissionRefused fails unless msgs carry a FORBIDDEN error
|
||||
// naming perm. A bare "an error arrived" check is not enough here: the stream
|
||||
// handlers refuse an un-joined client with VOICE_ERROR "not in a voice channel"
|
||||
// long before the permission gate runs, which is exactly how the camera and
|
||||
// screenshare permission tests used to pass without ever reaching it.
|
||||
func assertStreamPermissionRefused(t *testing.T, msgs [][]byte, perm string) {
|
||||
t.Helper()
|
||||
for _, m := range msgs {
|
||||
if extractCode(t, m) == ws.ErrCodeForbidden &&
|
||||
strings.Contains(extractMessage(t, m), perm) {
|
||||
return
|
||||
}
|
||||
}
|
||||
got := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
got = append(got, extractType(t, m)+"/"+extractCode(t, m)+"/"+extractMessage(t, m))
|
||||
}
|
||||
t.Errorf("expected FORBIDDEN error naming %s, got %v", perm, got)
|
||||
}
|
||||
|
||||
// Client with no user set → hasChannelPerm returns false.
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClient(hub, 7001, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
// TestVoice_Camera_NoPermission: a member already in voice whose role lacks
|
||||
// USE_VIDEO is refused with FORBIDDEN and stays off camera.
|
||||
func TestVoice_Camera_NoPermission(t *testing.T) {
|
||||
hub, database := newVoiceHub(t)
|
||||
|
||||
// Role 4 (Member) carries CONNECT_VOICE but neither USE_VIDEO nor
|
||||
// SHARE_SCREEN, so voice_join succeeds and only the toggle is denied.
|
||||
user := seedVoiceUserWithRole(t, database, "cam-noperm", 4)
|
||||
chanID := seedVoiceChan(t, database, "vc-cam-noperm")
|
||||
c, send := joinVoice(t, hub, user, chanID)
|
||||
|
||||
hub.HandleMessageForTest(c, voiceCameraMsg(true))
|
||||
|
||||
msgs := drainChanTimeout(send, 30*time.Millisecond)
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if extractType(t, m) == "error" {
|
||||
found = true
|
||||
}
|
||||
assertStreamPermissionRefused(t, drainChanTimeout(send, 30*time.Millisecond), "USE_VIDEO")
|
||||
|
||||
state, err := database.GetVoiceState(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected FORBIDDEN error for camera toggle without USE_VIDEO permission")
|
||||
if state == nil || state.Camera {
|
||||
t.Error("camera enabled despite the missing USE_VIDEO permission")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,27 +855,25 @@ func TestVoice_Screenshare_DisableAllowedAfterPermissionRevoked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoice_Screenshare_NoPermission: client without SHARE_SCREEN gets FORBIDDEN.
|
||||
// TestVoice_Screenshare_NoPermission: a member already in voice whose role
|
||||
// lacks SHARE_SCREEN is refused with FORBIDDEN and publishes nothing.
|
||||
func TestVoice_Screenshare_NoPermission(t *testing.T) {
|
||||
hub, _ := newVoiceHub(t)
|
||||
hub, database := newVoiceHub(t)
|
||||
|
||||
// Client with no user set → hasChannelPerm returns false.
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClient(hub, 7002, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
user := seedVoiceUserWithRole(t, database, "ss-noperm", 4) // Member: no SHARE_SCREEN
|
||||
chanID := seedVoiceChan(t, database, "vc-ss-noperm")
|
||||
c, send := joinVoice(t, hub, user, chanID)
|
||||
|
||||
hub.HandleMessageForTest(c, voiceScreenshareMsg(true))
|
||||
|
||||
msgs := drainChanTimeout(send, 30*time.Millisecond)
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if extractType(t, m) == "error" {
|
||||
found = true
|
||||
}
|
||||
assertStreamPermissionRefused(t, drainChanTimeout(send, 30*time.Millisecond), "SHARE_SCREEN")
|
||||
|
||||
state, err := database.GetVoiceState(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected FORBIDDEN error for screenshare toggle without SHARE_SCREEN permission")
|
||||
if state == nil || state.Screenshare {
|
||||
t.Error("screenshare enabled despite the missing SHARE_SCREEN permission")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
@@ -573,6 +574,54 @@ func TestVoiceMod_Move_ArchivedDestination_BadRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden locks the
|
||||
// destination gate that is evaluated against the TARGET's access, not the
|
||||
// moderator's: a move must not become a way to place someone in a channel they
|
||||
// could not join themselves. The actor keeps MUTE_MEMBERS and still outranks
|
||||
// the target, so only the target's missing CONNECT_VOICE can refuse this.
|
||||
func TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden(t *testing.T) {
|
||||
hub, database := newVoiceModHub(t)
|
||||
fromID := seedVoiceChan(t, database, "vc-move-noconnect-from")
|
||||
toID := seedVoiceChan(t, database, "vc-move-noconnect-to")
|
||||
actor := seedVoiceUserWithRole(t, database, "admin-move-noconnect", 2)
|
||||
target := seedVoiceUserWithRole(t, database, "member-move-noconnect", 4)
|
||||
|
||||
// Destination denies CONNECT_VOICE to the target's role (Member, id 4).
|
||||
if err := database.UpsertChannelOverride(
|
||||
context.Background(), toID, 4, 0, permissions.ConnectVoice,
|
||||
); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride: %v", err)
|
||||
}
|
||||
|
||||
_, targetSend := joinVoice(t, hub, target, fromID)
|
||||
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, actor, fromID, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
|
||||
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID))
|
||||
|
||||
if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" {
|
||||
t.Fatalf("error code = %q, want FORBIDDEN", code)
|
||||
}
|
||||
if payload := receiveMsgOfType(targetSend, "voice_moved", 100*time.Millisecond); payload != nil {
|
||||
t.Errorf("target must not receive voice_moved for a refused move, got %v", payload)
|
||||
}
|
||||
state, err := database.GetVoiceState(context.Background(), target.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
t.Fatal("a refused move must leave the target in voice")
|
||||
} else if state.ChannelID != fromID {
|
||||
t.Errorf("target channel = %d, want %d (unchanged)", state.ChannelID, fromID)
|
||||
}
|
||||
if slices.Contains(auditActions(t, database), "voice_mod_move") {
|
||||
t.Error("a refused move must not write a voice_mod_move audit entry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel locks the fix for
|
||||
// v024: voiceModTarget authorizes against a DB snapshot, but the eviction ran
|
||||
// through the unscoped VoiceModerator.DisconnectFromVoice, which drops the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package ws
|
||||
|
||||
// voice_rate_limits_test.go — the refusal branch of the voice limiters that
|
||||
// had no coverage: voice_join's precheck (voice_join.go), the shared
|
||||
// voice_mute/voice_deafen self-toggle (voice_controls.go) and
|
||||
// voice_e2ee_announce (voice_e2ee.go). Their siblings (voice_leave,
|
||||
// camera/screenshare, the e2ee offer budgets, plugin_cmd) are all already
|
||||
// pinned; these three were the gap.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
// decodeErrorFrame extracts the code from a server->client error envelope.
|
||||
func decodeErrorFrame(t *testing.T, frame []byte) string {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(frame, &env); err != nil {
|
||||
t.Fatalf("unmarshal frame %s: %v", frame, err)
|
||||
}
|
||||
if env.Type != MsgTypeError {
|
||||
t.Fatalf("expected an error frame, got type %q (%s)", env.Type, frame)
|
||||
}
|
||||
return env.Payload.Code
|
||||
}
|
||||
|
||||
// voice_join fans a voice_state broadcast out to every connected client, so
|
||||
// the limiter is consulted first — before the payload is even parsed. A
|
||||
// payload that could never join therefore still burns a token, and once the
|
||||
// budget is gone the refusal is RATE_LIMITED rather than the parse error.
|
||||
func TestVoiceJoinPrecheck_RateLimited(t *testing.T) {
|
||||
h := &Hub{limiter: auth.NewRateLimiter()}
|
||||
send := make(chan []byte, voiceJoinRateLimit+2)
|
||||
c := &Client{userID: 1, send: send, sendHigh: send, sendLow: send}
|
||||
payload := json.RawMessage(`{"channel_id":"not-an-int"}`)
|
||||
|
||||
for i := range voiceJoinRateLimit + 1 {
|
||||
if _, _, ok := h.voiceJoinPrecheck(context.Background(), c, payload); ok {
|
||||
t.Fatalf("call %d: precheck passed on a malformed payload", i)
|
||||
}
|
||||
}
|
||||
|
||||
if got := len(send); got != voiceJoinRateLimit+1 {
|
||||
t.Fatalf("queued %d error frames, want %d", got, voiceJoinRateLimit+1)
|
||||
}
|
||||
for i := range voiceJoinRateLimit {
|
||||
if code := decodeErrorFrame(t, <-send); code != ErrCodeBadRequest {
|
||||
t.Errorf("call %d: code = %q, want %q (limit not yet reached)", i, code, ErrCodeBadRequest)
|
||||
}
|
||||
}
|
||||
if code := decodeErrorFrame(t, <-send); code != ErrCodeRateLimited {
|
||||
t.Errorf("call past the budget: code = %q, want %q", code, ErrCodeRateLimited)
|
||||
}
|
||||
}
|
||||
|
||||
// The V2 voice handlers whose rate-limit refusal was unexercised. Each case
|
||||
// runs with VoiceChannelID=0 so the calls that pass the limiter stop at the
|
||||
// next gate (VOICE_ERROR) instead of reaching the DB — which also pins the
|
||||
// ordering: the limiter runs before the in-voice check.
|
||||
func TestVoiceHandlersV2_RateLimited(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 0}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
limit int
|
||||
wantMsg string
|
||||
call func(VoiceDeps) Result
|
||||
}{
|
||||
{
|
||||
name: "voice_mute",
|
||||
limit: voiceMuteRateLimit,
|
||||
wantMsg: "too many mute toggles",
|
||||
call: func(d VoiceDeps) Result {
|
||||
return handleVoiceMuteV2(ctx, VoiceMuteCmd{userID: 1, muted: true}, info, d)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "voice_deafen",
|
||||
limit: voiceDeafenRateLimit,
|
||||
wantMsg: "too many deafen toggles",
|
||||
call: func(d VoiceDeps) Result {
|
||||
return handleVoiceDeafenV2(ctx, VoiceDeafenCmd{userID: 1, deafened: true}, info, d)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "voice_e2ee_announce",
|
||||
limit: voiceE2EERateLimit,
|
||||
wantMsg: "too many e2ee announcements",
|
||||
call: func(d VoiceDeps) Result {
|
||||
return handleVoiceE2EEAnnounceV2(ctx, VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key}, info, d)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
deps := VoiceDeps{Limiter: auth.NewRateLimiter()}
|
||||
|
||||
for i := range tt.limit {
|
||||
res := tt.call(deps)
|
||||
ce, ok := res.Error.(ClientError)
|
||||
if !ok {
|
||||
t.Fatalf("call %d: expected ClientError, got %v", i, res.Error)
|
||||
}
|
||||
if ce.Code != ErrCodeVoiceError {
|
||||
t.Fatalf("call %d: code = %q, want %q (limit not yet reached)", i, ce.Code, ErrCodeVoiceError)
|
||||
}
|
||||
}
|
||||
|
||||
res := tt.call(deps)
|
||||
ce, ok := res.Error.(ClientError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ClientError past the budget, got %v", res.Error)
|
||||
}
|
||||
if ce.Code != ErrCodeRateLimited {
|
||||
t.Errorf("code = %q, want %q", ce.Code, ErrCodeRateLimited)
|
||||
}
|
||||
if ce.Message != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", ce.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user