From ead64cdc20d7a29b2a272e81b352653c999f9ca3 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:22:55 +0200 Subject: [PATCH] chore(lint): errorlint + exhaustive + switch-exhaustiveness-check, and permissions.deny for generated files (#1462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a --------- Co-authored-by: Claude Fable 5 --- .claude/rules/db-change.md | 20 +++++++++++++ .claude/rules/gendocs.md | 19 +++++++++++++ .claude/rules/protocol-change.md | 20 +++++++++++++ .claude/settings.json | 15 +++++++++- .gitignore | 1 + Client/eslint.config.js | 5 ++++ Server/.golangci.yml | 8 ++++++ Server/api/router.go | 3 +- Server/db/coverage_boost_test.go | 3 +- Server/plugin/host_http.go | 6 ++-- Server/plugin/registry.go | 2 +- Server/service/block.go | 6 ++-- Server/service/channel.go | 2 +- Server/service/dm.go | 28 +++++++++---------- Server/service/emoji.go | 14 +++++----- Server/service/invite.go | 8 +++--- Server/service/message_perms.go | 14 +++++----- Server/service/message_query.go | 8 +++--- Server/service/moderation.go | 8 +++--- Server/service/role.go | 28 +++++++++---------- Server/service/user.go | 12 ++++---- Server/storage/storage.go | 2 +- Server/updater/download.go | 3 +- Server/ws/event_test.go | 4 ++- Server/ws/handler_v2_channel_focus_test.go | 4 ++- Server/ws/handler_v2_migration_test.go | 13 ++++++--- Server/ws/handler_v2_voice_e2ee_offer_test.go | 19 +++++++++---- Server/ws/handler_v2_voice_e2ee_test.go | 19 +++++++++---- Server/ws/handler_v2_voice_token_test.go | 16 +++++++---- Server/ws/handlers.go | 4 ++- Server/ws/handlers_command_gate_test.go | 7 +++-- Server/ws/hub_sim_test.go | 5 ++-- Server/ws/oc_0006_video_stream_count_test.go | 4 ++- .../oc_0023_screenshare_video_limit_test.go | 7 +++-- .../ws/oc_0237_service_error_internal_test.go | 5 ++-- Server/ws/registry_test.go | 4 ++- .../ws/voice_moderation_deafen_race_test.go | 7 +++-- Server/ws/voice_rate_limits_test.go | 7 +++-- 38 files changed, 248 insertions(+), 112 deletions(-) create mode 100644 .claude/rules/db-change.md create mode 100644 .claude/rules/gendocs.md create mode 100644 .claude/rules/protocol-change.md diff --git a/.claude/rules/db-change.md b/.claude/rules/db-change.md new file mode 100644 index 00000000..03850aaa --- /dev/null +++ b/.claude/rules/db-change.md @@ -0,0 +1,20 @@ +--- +paths: + - "Server/db/queries/**" + - "Server/migrations/**" + - "Server/sqlc.yaml" +--- + +# Schema and query edits + +These files are the sqlc source of truth. Invoke the `db-change` skill before +changing anything here. + +`Server/db/dbgen/` is generated from them and is denied to Edit/Write in +`.claude/settings.json`. After a change, regenerate and stage the result: + +``` +cd Server && sqlc generate +``` + +The pre-commit hook and CI (`make sqlc-verify`) both fail on drift. diff --git a/.claude/rules/gendocs.md b/.claude/rules/gendocs.md new file mode 100644 index 00000000..8d249224 --- /dev/null +++ b/.claude/rules/gendocs.md @@ -0,0 +1,19 @@ +--- +paths: + - "Server/api/router.go" + - "Server/config/config.go" + - "Server/migrations/**" +--- + +# Generated documentation blocks + +The `gendocs:*` blocks in `docs/api.md`, `docs/schema.md` and +`docs/server-configuration.md` are generated from these files. Never edit +inside a `gendocs:*` block by hand. After changing routes, config fields or +migrations, regenerate and stage the docs: + +``` +cd Server && go run -tags otel,wazero ./cmd/gendocs +``` + +CI (`make docs-verify`) fails on drift. diff --git a/.claude/rules/protocol-change.md b/.claude/rules/protocol-change.md new file mode 100644 index 00000000..1da7bc94 --- /dev/null +++ b/.claude/rules/protocol-change.md @@ -0,0 +1,20 @@ +--- +paths: + - "protocol/schema.json" + - "Server/cmd/genprotocol/**" +--- + +# Protocol message types + +`protocol/schema.json` is the source of truth for the WebSocket message types. +Invoke the `protocol-change` skill before changing it. + +`Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated +from it and are denied to Edit/Write in `.claude/settings.json`. After a change, +regenerate and stage both files: + +``` +cd Server && go run ./cmd/genprotocol +``` + +The pre-commit hook and CI (`make protocol-verify`) both fail on drift. diff --git a/.claude/settings.json b/.claude/settings.json index 0967ef42..c305f9c9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1 +1,14 @@ -{} +{ + "permissions": { + "deny": [ + "Edit(Server/db/dbgen/**)", + "Write(Server/db/dbgen/**)", + "Edit(Server/ws/message_types.go)", + "Write(Server/ws/message_types.go)", + "Edit(Client/src/lib/protocolTypes.ts)", + "Write(Client/src/lib/protocolTypes.ts)", + "Edit(Client/src/generated/**)", + "Write(Client/src/generated/**)" + ] + } +} diff --git a/.gitignore b/.gitignore index a5fcd03a..c9af965a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ Server/.env .claude/* !.claude/skills/ !.claude/workflows/ +!.claude/rules/ !.claude/settings.json CLAUDE.local.md .mcp.json diff --git a/Client/eslint.config.js b/Client/eslint.config.js index d166ec0c..97fe1e7f 100644 --- a/Client/eslint.config.js +++ b/Client/eslint.config.js @@ -15,6 +15,11 @@ export default tseslint.config( rules: { // --- Key rules from T-191 --- "@typescript-eslint/no-floating-promises": "error", + // A switch over a union that misses a member is a silent drop, not a type error. + "@typescript-eslint/switch-exhaustiveness-check": [ + "error", + { considerDefaultExhaustiveForUnions: true }, + ], "@typescript-eslint/no-unused-vars": [ "error", { diff --git a/Server/.golangci.yml b/Server/.golangci.yml index 2aae8b76..279d416b 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -25,7 +25,15 @@ linters: - nestif # deeply nested if-blocks - dupl # verbatim duplicated blocks + # Silent-drop and sentinel-error classes (added 2026-08-30). + - exhaustive # a switch over an enum-like type with no default that misses a member + - errorlint # errors.Is/As instead of == and type assertions on wrapped errors; %w in Errorf + settings: + exhaustive: + # An explicit default branch is a deliberate catch-all; only switches without + # one, which fall through in silence, must list every member. + default-signifies-exhaustive: true staticcheck: checks: - "all" diff --git a/Server/api/router.go b/Server/api/router.go index ddcb417b..b41f8c37 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -653,7 +654,7 @@ func recoverer(next http.Handler) http.Handler { defer func() { if rec := recover(); rec != nil { // Preserve chi's behaviour of not swallowing the abort sentinel. - if rec == http.ErrAbortHandler { + if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) { panic(rec) } attrs := []any{ diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 6c1733ac..c8200ee0 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -2,6 +2,7 @@ package db_test import ( "context" + "errors" "testing" "time" @@ -52,7 +53,7 @@ func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) { if err == nil { t.Fatal("expected ErrChannelFull, got nil") } - if err != db.ErrChannelFull { + if !errors.Is(err, db.ErrChannelFull) { t.Errorf("error = %v, want ErrChannelFull", err) } } diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index 1695a0f7..5c33a62d 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -188,20 +188,20 @@ func GuardedDialContext() func(ctx context.Context, network, addr string) (net.C // IP literal: validate and dial as-is (no resolution happens). if ip := net.ParseIP(h); ip != nil { if err := ipAllowed(ip); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + return nil, fmt.Errorf("%w: %w", ErrHTTPHostDenied, err) } return dialContext(ctx, network, addr) } ips, lookupErr := lookupIPAddr(ctx, h) if lookupErr != nil { - return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) + return nil, fmt.Errorf("%w: dns lookup failed: %w", ErrHTTPHostDenied, lookupErr) } if len(ips) == 0 { return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) } for _, resolved := range ips { if err := ipAllowed(resolved.IP); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + return nil, fmt.Errorf("%w: %w", ErrHTTPHostDenied, err) } } var dialErr error diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index bd203b53..12e1771c 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -409,7 +409,7 @@ func installZipWriteEntry(f *zip.File, destAbs string, remaining int64) (int64, n, copyErr := io.CopyN(out, rc, remaining+1) _ = rc.Close() _ = out.Close() - if copyErr != nil && copyErr != io.EOF { + if copyErr != nil && !errors.Is(copyErr, io.EOF) { return 0, copyErr } if n > remaining { diff --git a/Server/service/block.go b/Server/service/block.go index 39f0ca28..f1a03e5d 100644 --- a/Server/service/block.go +++ b/Server/service/block.go @@ -46,7 +46,7 @@ func (s *BlockService) BlockUser(ctx context.Context, blockerID, targetID int64) } if err := s.st.BlockUser(ctx, blockerID, targetID); err != nil { - return fmt.Errorf("%w: failed to block user: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to block user: %w", ErrInternal, err) } slog.Info("user blocked", "blocker_id", blockerID, "target_id", targetID) @@ -59,7 +59,7 @@ func (s *BlockService) UnblockUser(ctx context.Context, blockerID, targetID int6 return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } if err := s.st.UnblockUser(ctx, blockerID, targetID); err != nil { - return fmt.Errorf("%w: failed to unblock user: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to unblock user: %w", ErrInternal, err) } slog.Info("user unblocked", "blocker_id", blockerID, "target_id", targetID) return nil @@ -69,7 +69,7 @@ func (s *BlockService) UnblockUser(ctx context.Context, blockerID, targetID int6 func (s *BlockService) ListBlocked(ctx context.Context, blockerID int64) ([]int64, error) { ids, err := s.st.ListBlockedUsers(ctx, blockerID) if err != nil { - return nil, fmt.Errorf("%w: failed to list blocked users: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list blocked users: %w", ErrInternal, err) } if ids == nil { ids = []int64{} diff --git a/Server/service/channel.go b/Server/service/channel.go index 1f0d4775..30871dc0 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -253,7 +253,7 @@ func (s *ChannelService) HandleChannelFocus(ctx context.Context, userID, channel return nil, fmt.Errorf("%w: access denied", ErrForbidden) } if err := permissions.CanAdmitSession(sub); err != nil { - return nil, fmt.Errorf("%w: %v", ErrForbidden, err) + return nil, fmt.Errorf("%w: %w", ErrForbidden, err) } // Mark channel as read. latestID == 0 (no undeleted messages) still diff --git a/Server/service/dm.go b/Server/service/dm.go index a8cba51a..2c00b22e 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -129,7 +129,7 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID) if err != nil { - return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to check block status: %w", ErrInternal, err) } if blocked { return nil, fmt.Errorf("%w: cannot create DM — user is blocked", ErrForbidden) @@ -152,7 +152,7 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) { dms, err := s.st.GetUserDMChannels(ctx, userID) if err != nil { - return nil, fmt.Errorf("%w: failed to list DMs: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list DMs: %w", ErrInternal, err) } // GetUserDMChannels only applies db.StatusForViewer (invisible -> // offline); apply the "no live connection" half too, see @@ -198,12 +198,12 @@ func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*Clos isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to read DM kind: %w", ErrInternal, err) } if !isGroup { if err := s.st.CloseDM(ctx, userID, channelID); err != nil { - return nil, fmt.Errorf("%w: failed to close DM: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to close DM: %w", ErrInternal, err) } slog.Debug("DM closed", "user_id", userID, "channel_id", channelID) return &CloseDMResult{}, nil @@ -214,7 +214,7 @@ func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*Clos // never in it" if the delete half-succeeded. remaining, err := s.st.GetDMParticipantIDs(ctx, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to read DM participants: %w", ErrInternal, err) } survivors := make([]int64, 0, len(remaining)) for _, pid := range remaining { @@ -225,7 +225,7 @@ func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*Clos deleted, err := s.st.LeaveGroupDM(ctx, userID, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to leave group DM: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to leave group DM: %w", ErrInternal, err) } slog.Debug("group DM left", "user_id", userID, "channel_id", channelID, "deleted", deleted) @@ -330,7 +330,7 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID for j := i + 1; j < len(participantIDs); j++ { blocked, err := s.st.IsEitherBlocked(ctx, participantIDs[i], participantIDs[j]) if err != nil { - return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to check block status: %w", ErrInternal, err) } if blocked { return nil, fmt.Errorf("%w: cannot add a blocked user to a group DM", ErrForbidden) @@ -389,7 +389,7 @@ func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to read DM kind: %w", ErrInternal, err) } if !isGroup { return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest) @@ -403,7 +403,7 @@ func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, } if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil { - return nil, fmt.Errorf("%w: failed to rename group DM: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to rename group DM: %w", ErrInternal, err) } ch, err := s.st.GetChannel(ctx, channelID) @@ -427,7 +427,7 @@ func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) } participants, err := s.st.GetDMParticipants(ctx, channelID, viewerID) if err != nil { - return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM participants: %w", ErrInternal, err) } ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { @@ -435,7 +435,7 @@ func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) } isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { - return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) + return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %w", ErrInternal, err) } // See presentableDMChannelInfo: this is the single place broadcastDMOpen // (group create/rename/leave refresh) and PATCH /dms/{id}'s response @@ -450,7 +450,7 @@ func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) func (s *DMService) SharedOneToOneDM(ctx context.Context, userA, userB int64) (int64, bool, error) { id, ok, err := s.st.FindDMChannelIDBetween(ctx, userA, userB) if err != nil { - return 0, false, fmt.Errorf("%w: failed to look up shared DM: %v", ErrInternal, err) + return 0, false, fmt.Errorf("%w: failed to look up shared DM: %w", ErrInternal, err) } return id, ok, nil } @@ -469,7 +469,7 @@ func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([ } ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to check DM participation: %w", ErrInternal, err) } if !ok { return nil, fmt.Errorf("%w: not a participant in this DM", ErrForbidden) @@ -484,7 +484,7 @@ func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([ ids, err := s.st.GetDMParticipantIDs(ctx, channelID) if err != nil { - return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to read DM participants: %w", ErrInternal, err) } targets := make([]int64, 0, len(ids)) for _, pid := range ids { diff --git a/Server/service/emoji.go b/Server/service/emoji.go index a18025a6..b4bfba11 100644 --- a/Server/service/emoji.go +++ b/Server/service/emoji.go @@ -102,7 +102,7 @@ func (s *EmojiService) RequireManage(ctx context.Context, actorID int64) error { func (s *EmojiService) List(ctx context.Context) ([]*db.Emoji, error) { list, err := s.st.ListEmoji(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list emoji: %w", ErrInternal, err) } return list, nil } @@ -125,7 +125,7 @@ func (s *EmojiService) Create(ctx context.Context, actorID int64, rawShortcode, existing, err := s.st.GetEmojiByShortcode(ctx, shortcode) if err != nil { - return nil, fmt.Errorf("%w: failed to check shortcode: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to check shortcode: %w", ErrInternal, err) } if existing != nil { return nil, fmt.Errorf("%w: an emoji named :%s: already exists", ErrConflict, shortcode) @@ -133,7 +133,7 @@ func (s *EmojiService) Create(ctx context.Context, actorID int64, rawShortcode, current, err := s.st.ListEmoji(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to count emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to count emoji: %w", ErrInternal, err) } if len(current) >= MaxEmojiCount { return nil, fmt.Errorf("%w: this server already has the maximum of %d emoji", ErrBadRequest, MaxEmojiCount) @@ -147,7 +147,7 @@ func (s *EmojiService) Create(ctx context.Context, actorID int64, rawShortcode, // server fault. return nil, fmt.Errorf("%w: an emoji named :%s: already exists", ErrConflict, shortcode) } - return nil, fmt.Errorf("%w: failed to create emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to create emoji: %w", ErrInternal, err) } db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "emoji_create", "emoji", created.ID, @@ -165,14 +165,14 @@ func (s *EmojiService) Delete(ctx context.Context, actorID, emojiID int64) (*db. } existing, err := s.st.GetEmoji(ctx, emojiID) if err != nil { - return nil, fmt.Errorf("%w: failed to load emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to load emoji: %w", ErrInternal, err) } if existing == nil { return nil, fmt.Errorf("%w: emoji not found", ErrNotFound) } deleted, err := s.st.DeleteEmoji(ctx, emojiID) if err != nil { - return nil, fmt.Errorf("%w: failed to delete emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to delete emoji: %w", ErrInternal, err) } if !deleted { // Lost a race with another delete -- report it as the 404 it now is. @@ -189,7 +189,7 @@ func (s *EmojiService) Delete(ctx context.Context, actorID, emojiID int64) (*db. func (s *EmojiService) Get(ctx context.Context, emojiID int64) (*db.Emoji, error) { e, err := s.st.GetEmoji(ctx, emojiID) if err != nil { - return nil, fmt.Errorf("%w: failed to load emoji: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to load emoji: %w", ErrInternal, err) } if e == nil { return nil, fmt.Errorf("%w: emoji not found", ErrNotFound) diff --git a/Server/service/invite.go b/Server/service/invite.go index 55198f2b..258f2152 100644 --- a/Server/service/invite.go +++ b/Server/service/invite.go @@ -50,7 +50,7 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs code, err := s.st.CreateInvite(ctx, createdBy, maxUses, expiresAt) if err != nil { - return nil, fmt.Errorf("%w: failed to create invite: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to create invite: %w", ErrInternal, err) } // The invite is committed from here on: a request canceled during the @@ -58,7 +58,7 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs tailCtx := context.WithoutCancel(ctx) invite, err := s.st.GetInvite(tailCtx, code) if err != nil || invite == nil { - return nil, fmt.Errorf("%w: failed to retrieve invite: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to retrieve invite: %w", ErrInternal, err) } // S-02: the row names the invite by id, never by code; the code is the // credential. @@ -71,7 +71,7 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs func (s *InviteService) ListInvites(ctx context.Context) ([]*db.Invite, error) { invites, err := s.st.ListInvites(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list invites: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list invites: %w", ErrInternal, err) } return invites, nil } @@ -83,7 +83,7 @@ func (s *InviteService) RevokeInvite(ctx context.Context, actorID int64, code st return fmt.Errorf("%w: invite not found", ErrNotFound) } if err := s.st.RevokeInvite(ctx, code); err != nil { - return fmt.Errorf("%w: failed to revoke invite: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to revoke invite: %w", ErrInternal, err) } db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "invite_revoke", "invite", invite.ID, "") return nil diff --git a/Server/service/message_perms.go b/Server/service/message_perms.go index 92c3efa1..3ab5702d 100644 --- a/Server/service/message_perms.go +++ b/Server/service/message_perms.go @@ -13,12 +13,12 @@ import ( func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) { channels, err := s.st.ListChannels(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list channels: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list channels: %w", ErrInternal, err) } role, err := s.perms.GetRoleForUser(ctx, userID) if err != nil || role == nil { - return nil, fmt.Errorf("%w: failed to get role: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to get role: %w", ErrInternal, err) } var overrides map[int64]db.ChannelOverride @@ -26,7 +26,7 @@ func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int var overrideErr error overrides, overrideErr = s.st.GetChannelOverridesFor(ctx, role.ID, userID) if overrideErr != nil { - return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr) + return nil, fmt.Errorf("%w: failed to fetch channel overrides: %w", ErrInternal, overrideErr) } } @@ -50,7 +50,7 @@ func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int // computeAllowedChannels in ws/serve.go. dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID) if err != nil { - return nil, fmt.Errorf("%w: failed to fetch DM channels: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to fetch DM channels: %w", ErrInternal, err) } ids = append(ids, dmIDs...) @@ -101,7 +101,7 @@ func channelSubject(ctx context.Context, st Store, perms *PermissionService, use } ok, dmErr := st.IsDMParticipant(ctx, userID, ch.ID) if dmErr != nil { - return sub, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, dmErr) + return sub, fmt.Errorf("%w: failed to check DM participation: %w", ErrInternal, dmErr) } sub.DMParticipant = ok if ok && withBlock { @@ -125,7 +125,7 @@ func denial(err error) error { case errors.Is(err, permissions.ErrBlocked): return fmt.Errorf("%w: user is blocked", ErrBlocked) default: - return fmt.Errorf("%w: %v", ErrForbidden, err) + return fmt.Errorf("%w: %w", ErrForbidden, err) } } @@ -199,7 +199,7 @@ func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) } blocked, blkErr := st.IsEitherBlocked(ctx, userID, recipient.ID) if blkErr != nil { - return fmt.Errorf("%w: failed to check block status: %v", ErrInternal, blkErr) + return fmt.Errorf("%w: failed to check block status: %w", ErrInternal, blkErr) } if blocked { return fmt.Errorf("%w: user is blocked", ErrBlocked) diff --git a/Server/service/message_query.go b/Server/service/message_query.go index 2b7e7bf0..b23e7a00 100644 --- a/Server/service/message_query.go +++ b/Server/service/message_query.go @@ -92,7 +92,7 @@ func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query } results, err := s.st.SearchMessages(ctx, query, channelID, limit) if err != nil { - return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: search failed: %w", ErrInternal, err) } return results, nil } @@ -108,7 +108,7 @@ func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit) if err != nil { - return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: search failed: %w", ErrInternal, err) } return results, nil } @@ -191,7 +191,7 @@ func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelI } msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) if err != nil { - return nil, fmt.Errorf("%w: failed to fetch pinned messages: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to fetch pinned messages: %w", ErrInternal, err) } return msgs, nil } @@ -242,7 +242,7 @@ func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: message not found in this channel", ErrNotFound) } - return fmt.Errorf("%w: %v", ErrInternal, err) + return fmt.Errorf("%w: %w", ErrInternal, err) } return nil } diff --git a/Server/service/moderation.go b/Server/service/moderation.go index ef323ff0..b0087a59 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -120,7 +120,7 @@ func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64 } if err := s.st.BanUser(ctx, targetID, reason, expires); err != nil { - return fmt.Errorf("%w: failed to ban user: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to ban user: %w", ErrInternal, err) } // Audit rows must survive a request canceled after the ban committed. @@ -194,7 +194,7 @@ func (s *ModerationService) ChangeUserRole(ctx context.Context, actorID, targetI } if err := s.st.UpdateUserRole(ctx, targetID, newRoleID); err != nil { - return nil, fmt.Errorf("%w: failed to update role: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to update role: %w", ErrInternal, err) } // Drop the target's cached role immediately: without this a demotion keeps // granting the old bits (and the old rank) for up to permCacheTTL. @@ -233,7 +233,7 @@ func (s *ModerationService) ForceLogout(ctx context.Context, actorID, targetID i } if err := s.st.ForceLogoutUser(ctx, targetID); err != nil { - return fmt.Errorf("%w: failed to log out user: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to log out user: %w", ErrInternal, err) } // Audit rows must survive a request canceled after the sessions were cut. @@ -263,7 +263,7 @@ func (s *ModerationService) UnbanUser(ctx context.Context, actorID, targetID int } if err := s.st.UnbanUser(ctx, targetID); err != nil { - return fmt.Errorf("%w: failed to unban user: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to unban user: %w", ErrInternal, err) } // Audit rows must survive a request canceled after the unban committed. diff --git a/Server/service/role.go b/Server/service/role.go index c4f23b0c..c7276a97 100644 --- a/Server/service/role.go +++ b/Server/service/role.go @@ -124,7 +124,7 @@ func (s *RoleService) validateName(ctx context.Context, raw string, excludeID in } existing, err := s.st.GetRoleByName(ctx, name) if err != nil { - return "", fmt.Errorf("%w: failed to check role name: %v", ErrInternal, err) + return "", fmt.Errorf("%w: failed to check role name: %w", ErrInternal, err) } if existing != nil && existing.ID != excludeID { return "", fmt.Errorf("%w: a role named %q already exists", ErrBadRequest, existing.Name) @@ -169,11 +169,11 @@ func (s *RoleService) ListRoles(ctx context.Context, actorID int64) ([]RoleWithM } roles, err := s.st.ListRoles(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list roles: %w", ErrInternal, err) } counts, err := s.st.CountRoleMembers(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to count role members: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to count role members: %w", ErrInternal, err) } out := make([]RoleWithMembers, 0, len(roles)) for _, r := range roles { @@ -216,7 +216,7 @@ func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInpu existing, err := s.st.ListRoles(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list roles: %w", ErrInternal, err) } if len(existing) >= maxRoles { return nil, fmt.Errorf("%w: server already has the maximum of %d roles", ErrBadRequest, maxRoles) @@ -259,7 +259,7 @@ func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInpu role, err := s.st.CreateRole(ctx, name, color, perms, position) if err != nil { - return nil, fmt.Errorf("%w: failed to create role: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to create role: %w", ErrInternal, err) } db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_create", "role", role.ID, @@ -281,7 +281,7 @@ func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in } role, err := s.st.GetRoleByID(ctx, roleID) if err != nil { - return nil, false, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err) + return nil, false, fmt.Errorf("%w: failed to fetch role: %w", ErrInternal, err) } if role == nil { return nil, false, fmt.Errorf("%w: role not found", ErrNotFound) @@ -321,7 +321,7 @@ func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in if position != role.Position { existing, err := s.st.ListRoles(ctx) if err != nil { - return nil, false, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + return nil, false, fmt.Errorf("%w: failed to list roles: %w", ErrInternal, err) } for _, rl := range existing { if rl.ID != role.ID && rl.Position == position { @@ -332,7 +332,7 @@ func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in } if err := s.st.UpdateRole(ctx, role.ID, name, color, perms, position); err != nil { - return nil, false, fmt.Errorf("%w: failed to update role: %v", ErrInternal, err) + return nil, false, fmt.Errorf("%w: failed to update role: %w", ErrInternal, err) } db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_update", "role", role.ID, @@ -361,7 +361,7 @@ func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (de } role, err := s.st.GetRoleByID(ctx, roleID) if err != nil { - return nil, nil, nil, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err) + return nil, nil, nil, fmt.Errorf("%w: failed to fetch role: %w", ErrInternal, err) } if role == nil { return nil, nil, nil, fmt.Errorf("%w: role not found", ErrNotFound) @@ -382,7 +382,7 @@ func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (de fallback, err = s.st.GetDefaultRole(ctx) if err != nil { - return nil, nil, nil, fmt.Errorf("%w: failed to resolve the default role: %v", ErrInternal, err) + return nil, nil, nil, fmt.Errorf("%w: failed to resolve the default role: %w", ErrInternal, err) } if fallback == nil { // Fail closed: without a fallback the members would be orphaned on a @@ -392,7 +392,7 @@ func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (de movedUserIDs, err = s.st.DeleteRoleReassigning(ctx, role.ID, fallback.ID) if err != nil { - return nil, nil, nil, fmt.Errorf("%w: failed to delete role: %v", ErrInternal, err) + return nil, nil, nil, fmt.Errorf("%w: failed to delete role: %w", ErrInternal, err) } // The members' cached masks are the deleted role's until this drops them. if s.perms != nil { @@ -426,7 +426,7 @@ func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedID } roles, err := s.st.ListRoles(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list roles: %w", ErrInternal, err) } manageable := make(map[int64]*db.Role, len(roles)) @@ -459,7 +459,7 @@ func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedID positions[id] = len(orderedIDs) - i } if err := s.st.SetRolePositions(ctx, positions); err != nil { - return nil, fmt.Errorf("%w: failed to reorder roles: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to reorder roles: %w", ErrInternal, err) } db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_reorder", "role", 0, @@ -468,7 +468,7 @@ func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedID updated, err := s.st.ListRoles(ctx) if err != nil { - return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list roles: %w", ErrInternal, err) } return updated, nil } diff --git a/Server/service/user.go b/Server/service/user.go index 346bf797..b15f0f90 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -225,7 +225,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro if db.IsUniqueConstraintError(err) { return nil, fmt.Errorf("%w: username is already taken", ErrConflict) } - return nil, fmt.Errorf("%w: failed to update profile: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to update profile: %w", ErrInternal, err) } // This re-read, like the audit write below, must survive a request ctx // canceled after the write above committed — otherwise a client that @@ -273,7 +273,7 @@ func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text st return err } if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil { - return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to update custom status: %w", ErrInternal, err) } return nil } @@ -283,7 +283,7 @@ func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text st // user signed out states something about them that is no longer true. func (s *UserService) ClearCustomStatus(ctx context.Context, userID int64) error { if err := s.st.UpdateUserCustomStatus(ctx, userID, nil); err != nil { - return fmt.Errorf("%w: failed to clear custom status: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to clear custom status: %w", ErrInternal, err) } return nil } @@ -324,7 +324,7 @@ type ChangePasswordResult struct { // ChangePassword updates the user's password and revokes other sessions. func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { if err := s.st.UpdateUserPassword(ctx, userID, newPasswordHash); err != nil { - return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password: %v", ErrInternal, err) + return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password: %w", ErrInternal, err) } // The password is committed from here on: every path below reports @@ -356,7 +356,7 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPassw func (s *UserService) ListSessions(ctx context.Context, userID int64) ([]db.Session, error) { sessions, err := s.st.ListUserSessions(ctx, userID) if err != nil { - return nil, fmt.Errorf("%w: failed to list sessions: %v", ErrInternal, err) + return nil, fmt.Errorf("%w: failed to list sessions: %w", ErrInternal, err) } return sessions, nil } @@ -367,7 +367,7 @@ func (s *UserService) RevokeSession(ctx context.Context, userID, sessionID int64 if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: session not found", ErrNotFound) } - return fmt.Errorf("%w: failed to revoke session: %v", ErrInternal, err) + return fmt.Errorf("%w: failed to revoke session: %w", ErrInternal, err) } // Audit rows must survive a request canceled after the delete committed. db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "session_revoke", "session", sessionID, "session revoked") diff --git a/Server/storage/storage.go b/Server/storage/storage.go index d6ce0c71..15f75026 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -124,7 +124,7 @@ func (s *Storage) Save(uuid string, r io.Reader) (int64, error) { // Read the first 8 bytes to check magic bytes without consuming the stream. var header [8]byte n, err := io.ReadFull(r, header[:]) - if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { return 0, fmt.Errorf("reading file header: %w", err) } headerSlice := header[:n] diff --git a/Server/updater/download.go b/Server/updater/download.go index 375c6384..ff296fb3 100644 --- a/Server/updater/download.go +++ b/Server/updater/download.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -193,7 +194,7 @@ func extractChatserverFromTarGz(r io.Reader, destPath string) (string, error) { tr := tar.NewReader(gr) for { hdr, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { return "", fmt.Errorf("archive contains no file named chatserver") } if err != nil { diff --git a/Server/ws/event_test.go b/Server/ws/event_test.go index 4b72defe..9b42046b 100644 --- a/Server/ws/event_test.go +++ b/Server/ws/event_test.go @@ -2,6 +2,7 @@ package ws import ( "bytes" + "errors" "testing" ) @@ -35,7 +36,8 @@ func TestResultWithError(t *testing.T) { if r.Error == nil { t.Fatal("expected non-nil Error") } - ce, ok := r.Error.(ClientError) + var ce ClientError + ok := errors.As(r.Error, &ce) if !ok { t.Fatal("expected ClientError type") } diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go index f9399cbb..f9bba33f 100644 --- a/Server/ws/handler_v2_channel_focus_test.go +++ b/Server/ws/handler_v2_channel_focus_test.go @@ -2,6 +2,7 @@ package ws import ( "context" + "errors" "testing" "github.com/J3vb/OwnCord/Server/auth" @@ -122,7 +123,8 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) { if result.Error == nil { t.Fatal("expected FORBIDDEN error for denied permission") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } diff --git a/Server/ws/handler_v2_migration_test.go b/Server/ws/handler_v2_migration_test.go index 551f5344..a71f64a8 100644 --- a/Server/ws/handler_v2_migration_test.go +++ b/Server/ws/handler_v2_migration_test.go @@ -7,6 +7,7 @@ package ws import ( "context" "encoding/json" + "errors" "testing" "github.com/J3vb/OwnCord/Server/auth" @@ -77,7 +78,8 @@ func TestHandleVoiceLeaveV2_RateLimited(t *testing.T) { for range voiceLeaveRateLimit + 1 { res := handleVoiceLeaveV2(context.Background(), cmd, info, deps) if res.Error != nil { - ce, ok := res.Error.(ClientError) + var ce ClientError + ok := errors.As(res.Error, &ce) if !ok || ce.Code != ErrCodeRateLimited { t.Fatalf("expected rate-limit ClientError, got %v", res.Error) } @@ -108,7 +110,8 @@ func TestHandleVoiceLeaveV2_RateLimited_StillSignalsLeave(t *testing.T) { for range voiceLeaveRateLimit + 1 { res := handleVoiceLeaveV2(context.Background(), cmd, info, deps) if res.Error != nil { - ce, ok := res.Error.(ClientError) + var ce ClientError + ok := errors.As(res.Error, &ce) if !ok || ce.Code != ErrCodeRateLimited { t.Fatalf("expected rate-limit ClientError, got %v", res.Error) } @@ -160,7 +163,8 @@ func TestHandleChatCommandV2_NoRegistry(t *testing.T) { result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: 1}, deps) - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -178,7 +182,8 @@ func TestCanPluginBroadcast_NilServiceFailsClosed(t *testing.T) { if gate == nil { t.Fatal("expected a forbidden Result when MessageSvc is nil") } - ce, ok := gate.Error.(ClientError) + var ce ClientError + ok := errors.As(gate.Error, &ce) if !ok || ce.Code != ErrCodeForbidden { t.Errorf("expected FORBIDDEN ClientError, got %v", gate.Error) } diff --git a/Server/ws/handler_v2_voice_e2ee_offer_test.go b/Server/ws/handler_v2_voice_e2ee_offer_test.go index d012ed47..92d8084b 100644 --- a/Server/ws/handler_v2_voice_e2ee_offer_test.go +++ b/Server/ws/handler_v2_voice_e2ee_offer_test.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/base64" + "errors" "strings" "testing" @@ -153,7 +154,8 @@ func TestVoiceE2EEOfferV2_NotInVoiceChannel(t *testing.T) { if result.Error == nil { t.Fatal("expected error for not in voice channel") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -180,7 +182,8 @@ func TestVoiceE2EEOfferV2_EmptyFields(t *testing.T) { if result.Error == nil { t.Fatal("expected error for empty field") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -208,7 +211,8 @@ func TestVoiceE2EEOfferV2_OversizedFields(t *testing.T) { if result.Error == nil { t.Fatal("expected error for oversized field") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -236,7 +240,8 @@ func TestVoiceE2EEOfferV2_InvalidBase64(t *testing.T) { if result.Error == nil { t.Fatal("expected error for invalid base64") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -257,7 +262,8 @@ func TestVoiceE2EEOfferV2_NotKeyHolder(t *testing.T) { if result.Error == nil { t.Fatal("expected error for non-key-holder") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -276,7 +282,8 @@ func TestVoiceE2EEOfferV2_NilKeyHolder_ReturnsInternal(t *testing.T) { if result.Error == nil { t.Fatal("expected error for nil KeyHolder") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } diff --git a/Server/ws/handler_v2_voice_e2ee_test.go b/Server/ws/handler_v2_voice_e2ee_test.go index 73bbc59d..3c66abbf 100644 --- a/Server/ws/handler_v2_voice_e2ee_test.go +++ b/Server/ws/handler_v2_voice_e2ee_test.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/base64" + "errors" "strings" "testing" ) @@ -55,7 +56,8 @@ func TestVoiceE2EEAnnounceV2_NotInVoiceChannel(t *testing.T) { if result.Error == nil { t.Fatal("expected error for not in voice channel") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -74,7 +76,8 @@ func TestVoiceE2EEAnnounceV2_EmptyPublicKey(t *testing.T) { if result.Error == nil { t.Fatal("expected error for empty public_key") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -94,7 +97,8 @@ func TestVoiceE2EEAnnounceV2_PublicKeyTooLarge(t *testing.T) { if result.Error == nil { t.Fatal("expected error for oversized public_key") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -113,7 +117,8 @@ func TestVoiceE2EEAnnounceV2_InvalidBase64(t *testing.T) { if result.Error == nil { t.Fatal("expected error for invalid base64") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -194,7 +199,8 @@ func TestVoiceE2EEAnnounceV2_SignatureInvalidBase64(t *testing.T) { if result.Error == nil { t.Fatal("expected error for invalid signature base64") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -217,7 +223,8 @@ func TestVoiceE2EEAnnounceV2_SignatureTooLarge(t *testing.T) { if result.Error == nil { t.Fatal("expected error for oversized signature") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } diff --git a/Server/ws/handler_v2_voice_token_test.go b/Server/ws/handler_v2_voice_token_test.go index af83f4fc..5a452f0c 100644 --- a/Server/ws/handler_v2_voice_token_test.go +++ b/Server/ws/handler_v2_voice_token_test.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/json" + "errors" "testing" "github.com/J3vb/OwnCord/Server/auth" @@ -130,7 +131,8 @@ func TestVoiceTokenRefreshV2_NotInVoice(t *testing.T) { if result.Error == nil { t.Fatal("expected error for not in voice") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -152,7 +154,8 @@ func TestVoiceTokenRefreshV2_RateLimited(t *testing.T) { if result.Error == nil { t.Fatal("expected rate limit error") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -172,7 +175,8 @@ func TestVoiceTokenRefreshV2_TokenGenNil(t *testing.T) { if result.Error == nil { t.Fatal("expected error for nil TokenGen") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -192,7 +196,8 @@ func TestVoiceTokenRefreshV2_GenerateTokenError(t *testing.T) { if result.Error == nil { t.Fatal("expected error from GenerateToken failure") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } @@ -294,7 +299,8 @@ func TestVoiceTokenRefreshV2_RevokedConnectVoiceRefusedAndEvicts(t *testing.T) { if result.Error == nil { t.Fatal("revoked CONNECT_VOICE must not mint a fresh SFU token") } - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T", result.Error) } diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 4c613bb3..0493d455 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/json" + "errors" "fmt" "log/slog" @@ -96,7 +97,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { return } if result.Error != nil { - if ce, ok := result.Error.(ClientError); ok { + var ce ClientError + if errors.As(result.Error, &ce) { c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID)) } else { slog.Error("ws handler internal error", diff --git a/Server/ws/handlers_command_gate_test.go b/Server/ws/handlers_command_gate_test.go index 9f00c1fb..edc0378e 100644 --- a/Server/ws/handlers_command_gate_test.go +++ b/Server/ws/handlers_command_gate_test.go @@ -9,6 +9,7 @@ package ws import ( "context" "encoding/json" + "errors" "testing" "github.com/J3vb/OwnCord/Server/auth" @@ -156,7 +157,8 @@ func TestHandleChatCommandV2_BroadcastDeniedWithoutPostPermission(t *testing.T) result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: mutedID}, deps) - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error) } @@ -179,7 +181,8 @@ func TestHandleChatCommandV2_BroadcastUnknownChannel(t *testing.T) { result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps) - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error) } diff --git a/Server/ws/hub_sim_test.go b/Server/ws/hub_sim_test.go index 0f484eee..ffbeeef7 100644 --- a/Server/ws/hub_sim_test.go +++ b/Server/ws/hub_sim_test.go @@ -482,12 +482,11 @@ func (s *sim) read(c *simClient, n int) { read := 0 for n < 0 || read < n { frame, st := c.wire.Recv() - if st == ws.FaultOK { + switch st { + case ws.FaultOK: s.observe(c, frame) read++ continue - } - switch st { case ws.FaultEmpty: if len(c.owed) != c.wire.Buffered() { s.failf("I2: c%d conn %d: owed %v but only %d unread frame(s) remain (W=%d)", c.idx, c.conns, c.owed, c.wire.Buffered(), c.w) diff --git a/Server/ws/oc_0006_video_stream_count_test.go b/Server/ws/oc_0006_video_stream_count_test.go index 21df16ff..d68ad8df 100644 --- a/Server/ws/oc_0006_video_stream_count_test.go +++ b/Server/ws/oc_0006_video_stream_count_test.go @@ -11,6 +11,7 @@ package ws import ( "context" + "errors" "testing" "github.com/J3vb/OwnCord/Server/db" @@ -105,7 +106,8 @@ func TestEnableVideoSlot_SameUserDoubleStreamCountsTwoSlots(t *testing.T) { if bobCamRes.Error == nil { t.Fatal("bob's camera enable succeeded as the channel's 3rd live video stream against a cap of 2 -- the same-user double-publish (camera+screenshare on one row) was undercounted as a single slot") } - if ce, ok := bobCamRes.Error.(ClientError); !ok || ce.Code != ErrCodeVideoLimit { + var ce ClientError + if !errors.As(bobCamRes.Error, &ce) || ce.Code != ErrCodeVideoLimit { t.Errorf("error = %+v, want ClientError{Code: %q}", bobCamRes.Error, ErrCodeVideoLimit) } diff --git a/Server/ws/oc_0023_screenshare_video_limit_test.go b/Server/ws/oc_0023_screenshare_video_limit_test.go index 049de3da..4e39fd84 100644 --- a/Server/ws/oc_0023_screenshare_video_limit_test.go +++ b/Server/ws/oc_0023_screenshare_video_limit_test.go @@ -8,6 +8,7 @@ package ws import ( "context" + "errors" "testing" "github.com/J3vb/OwnCord/Server/db" @@ -97,7 +98,8 @@ func TestHandleVoiceScreenshareV2_RefusedWhenCameraSlotFull(t *testing.T) { if ssRes.Error == nil { t.Fatal("voice_screenshare succeeded with the channel's single video slot already held by a camera publisher — VIDEO_LIMIT was never checked") } - if ce, ok := ssRes.Error.(ClientError); !ok || ce.Code != ErrCodeVideoLimit { + var ce ClientError + if !errors.As(ssRes.Error, &ce) || ce.Code != ErrCodeVideoLimit { t.Errorf("error = %+v, want ClientError{Code: %q}", ssRes.Error, ErrCodeVideoLimit) } @@ -140,7 +142,8 @@ func TestHandleVoiceCameraV2_RefusedWhenScreenshareSlotFull(t *testing.T) { if camRes.Error == nil { t.Fatal("voice_camera succeeded with the channel's single video slot already held by a screenshare publisher — the slot-count query ignores screenshare rows") } - if ce, ok := camRes.Error.(ClientError); !ok || ce.Code != ErrCodeVideoLimit { + var ce ClientError + if !errors.As(camRes.Error, &ce) || ce.Code != ErrCodeVideoLimit { t.Errorf("error = %+v, want ClientError{Code: %q}", camRes.Error, ErrCodeVideoLimit) } diff --git a/Server/ws/oc_0237_service_error_internal_test.go b/Server/ws/oc_0237_service_error_internal_test.go index 96a1687f..33237809 100644 --- a/Server/ws/oc_0237_service_error_internal_test.go +++ b/Server/ws/oc_0237_service_error_internal_test.go @@ -32,11 +32,12 @@ func TestServiceErrorToResult_InternalErrorDoesNotLeakAndIsLogged(t *testing.T) // driver error text via %v, exactly what handlers_call.go's RingTargets // call produces when GetDMParticipantIDs fails. driverErr := errors.New("GetDMParticipantIDs: database is locked") - svcErr := fmt.Errorf("%w: failed to read DM participants: %v", service.ErrInternal, driverErr) + svcErr := fmt.Errorf("%w: failed to read DM participants: %w", service.ErrInternal, driverErr) result := serviceErrorToResult(svcErr) - ce, ok := result.Error.(ClientError) + var ce ClientError + ok := errors.As(result.Error, &ce) if !ok { t.Fatalf("serviceErrorToResult(ErrInternal wrapper) did not return a ClientError, got %T", result.Error) } diff --git a/Server/ws/registry_test.go b/Server/ws/registry_test.go index 4f1ec088..33c9fabf 100644 --- a/Server/ws/registry_test.go +++ b/Server/ws/registry_test.go @@ -2,6 +2,7 @@ package ws import ( "context" + "errors" "fmt" "sort" "strings" @@ -62,7 +63,8 @@ func TestDispatchV2_PanicIsRecovered(t *testing.T) { if !ok { t.Fatal("DispatchV2 must return ok=true even after a panic (handler was found)") } - ce, isCE := result.Error.(ClientError) + var ce ClientError + isCE := errors.As(result.Error, &ce) if !isCE { t.Fatalf("expected ClientError after panic recovery, got %T", result.Error) } diff --git a/Server/ws/voice_moderation_deafen_race_test.go b/Server/ws/voice_moderation_deafen_race_test.go index a54d5c83..a9711ed2 100644 --- a/Server/ws/voice_moderation_deafen_race_test.go +++ b/Server/ws/voice_moderation_deafen_race_test.go @@ -20,6 +20,7 @@ package ws import ( "context" + "errors" "testing" "github.com/J3vb/OwnCord/Server/db" @@ -106,7 +107,8 @@ func TestVoiceModDeafen_RollbackFollowsTargetChannelMove(t *testing.T) { if !hookRan { t.Fatal("voiceModDeafenPreMuteRaceHook never fired — test setup is broken, not exercising the race window") } - clientErr, ok := result.Error.(ClientError) + var clientErr ClientError + ok := errors.As(result.Error, &clientErr) if !ok { t.Fatalf("result error = %#v (%T), want a ClientError", result.Error, result.Error) } @@ -189,7 +191,8 @@ func TestVoiceModDeafen_UndeafenRollbackDoesNotApplyOnUnauthorizedChannel(t *tes if !hookRan { t.Fatal("voiceModDeafenPreMuteRaceHook never fired — test setup is broken, not exercising the race window") } - clientErr, ok := result.Error.(ClientError) + var clientErr ClientError + ok := errors.As(result.Error, &clientErr) if !ok { t.Fatalf("result error = %#v (%T), want a ClientError", result.Error, result.Error) } diff --git a/Server/ws/voice_rate_limits_test.go b/Server/ws/voice_rate_limits_test.go index b9f8a24d..f32f578d 100644 --- a/Server/ws/voice_rate_limits_test.go +++ b/Server/ws/voice_rate_limits_test.go @@ -10,6 +10,7 @@ package ws import ( "context" "encoding/json" + "errors" "testing" "github.com/J3vb/OwnCord/Server/auth" @@ -108,7 +109,8 @@ func TestVoiceHandlersV2_RateLimited(t *testing.T) { for i := range tt.limit { res := tt.call(deps) - ce, ok := res.Error.(ClientError) + var ce ClientError + ok := errors.As(res.Error, &ce) if !ok { t.Fatalf("call %d: expected ClientError, got %v", i, res.Error) } @@ -118,7 +120,8 @@ func TestVoiceHandlersV2_RateLimited(t *testing.T) { } res := tt.call(deps) - ce, ok := res.Error.(ClientError) + var ce ClientError + ok := errors.As(res.Error, &ce) if !ok { t.Fatalf("expected ClientError past the budget, got %v", res.Error) }