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:
J3vb
2026-08-20 19:29:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 03fcb7d518
commit d880b64d64
61 changed files with 206893 additions and 181134 deletions
@@ -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)
}
}
+10
View File
@@ -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.
+23 -2
View File
@@ -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)
+33
View File
@@ -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)
+85 -1
View File
@@ -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)
}
}
}
}
+46
View File
@@ -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) {
+62
View File
@@ -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)
+252
View File
@@ -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)
}
})
}
}
+59 -7
View File
@@ -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)
+41 -28
View File
@@ -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)
}
}
}
+116
View File
@@ -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
View File
@@ -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
+15 -1
View File
@@ -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.
+1 -2
View File
@@ -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()
}
+192
View File
@@ -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
View File
@@ -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,
})
+125 -8
View File
@@ -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)
}
}
+118
View File
@@ -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
// ---------------------------------------------------------------------------
+37
View File
@@ -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")
+71
View File
@@ -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)
}
}
+50 -31
View File
@@ -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")
}
}
+49
View File
@@ -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
+133
View File
@@ -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)
}
})
}
}