mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(claude): deny hand-edits to generated files via permissions.deny CLAUDE.md already says the sqlc, protocol and tauri-typegen outputs are never hand-edited; this turns the sentence into a permission rule so the Edit/Write tools refuse those paths outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(lint): switch-exhaustiveness-check on the client, default branch counts as exhaustive A switch over a string union that misses a member is a silent drop, not a type error. Every existing default-less switch already covers its union, so this adds no exceptions; the four switches with a default keep it as the deliberate catch-all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(lint): enable errorlint and exhaustive in golangci and fix the 110 hits errorlint: 68 fmt.Errorf sites wrapped the inner error with %v, which hid it from errors.Is/As upstream — now %w; 6 == / != comparisons on sentinel errors become errors.Is (the recover() branch in the router asserts the recovered value is an error first); 36 ClientError type assertions become errors.As, so a wrapped ClientError still reaches the client with its code. Three test assertions the autofixer inverted (!ok || code mismatch) are restored by hand. exhaustive (default-signifies-exhaustive): one hit, the hub simulation's FaultStatus switch — FaultOK moves from an if-guard into the switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(claude): path-scoped rules for the three generated-code workflows .claude/rules/{db-change,protocol-change,gendocs}.md load only when Claude reads a matching source-of-truth file, so the db-change / protocol-change skills and the gendocs regeneration step surface at the moment they apply instead of relying on the CLAUDE.md table being remembered. .gitignore whitelists .claude/rules/ next to skills/, workflows/ and settings.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.3 KiB
Go
137 lines
4.3 KiB
Go
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"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/J3vb/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)
|
|
var ce ClientError
|
|
ok := errors.As(res.Error, &ce)
|
|
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)
|
|
var ce ClientError
|
|
ok := errors.As(res.Error, &ce)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|