mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
9.1 KiB
Go
251 lines
9.1 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// ─── Authorization tests for channel read access (REST) ─────────────────────
|
|
// These tests verify that permission checks (READ_MESSAGES) are enforced on
|
|
// GET /api/v1/channels, GET /api/v1/channels/{id}/messages, and GET /api/v1/search.
|
|
|
|
// denyReadMessages inserts a channel_override that denies READ_MESSAGES for the
|
|
// given role on the given channel.
|
|
func denyReadMessages(t *testing.T, database *db.DB, channelID, roleID int64) {
|
|
t.Helper()
|
|
_, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`,
|
|
channelID, roleID, permissions.ReadMessages,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("denyReadMessages: %v", err)
|
|
}
|
|
}
|
|
|
|
// ─── GET /api/v1/channels: permission filtering ─────────────────────────────
|
|
|
|
func TestChannelList_FiltersOutDeniedChannels(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
// Create member user (roleID=4, has READ_MESSAGES by default).
|
|
token := chTestCreateToken(t, database, "authz-member1", 4)
|
|
|
|
chVisible, _ := database.CreateChannel(context.Background(), "visible", "text", "", "", 0)
|
|
chHidden, _ := database.CreateChannel(context.Background(), "hidden", "text", "", "", 1)
|
|
_ = chVisible // used implicitly in response
|
|
|
|
// Deny READ_MESSAGES on the hidden channel for the Member role.
|
|
denyReadMessages(t, database, chHidden, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var channels []map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(channels) != 1 {
|
|
t.Errorf("expected 1 visible channel, got %d", len(channels))
|
|
}
|
|
if len(channels) > 0 {
|
|
name, _ := channels[0]["name"].(string)
|
|
if name != "visible" {
|
|
t.Errorf("visible channel name = %q, want %q", name, "visible")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChannelList_AdminSeesAllChannels(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
// Owner (roleID=1) has Administrator bit — bypasses all checks.
|
|
token := chTestCreateToken(t, database, "authz-owner1", 1)
|
|
|
|
chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0)
|
|
chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1)
|
|
|
|
// Deny READ_MESSAGES on both channels for all roles.
|
|
denyReadMessages(t, database, chA, permissions.MemberRoleID)
|
|
denyReadMessages(t, database, chB, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var channels []any
|
|
_ = json.NewDecoder(rr.Body).Decode(&channels)
|
|
if len(channels) != 2 {
|
|
t.Errorf("admin should see all 2 channels, got %d", len(channels))
|
|
}
|
|
}
|
|
|
|
// ─── GET /api/v1/channels/{id}/messages: permission check ───────────────────
|
|
|
|
func TestChannelMessages_DeniedByPermission(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
token := chTestCreateToken(t, database, "authz-member2", 4)
|
|
chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0)
|
|
|
|
// Deny READ_MESSAGES for Member role on this channel.
|
|
denyReadMessages(t, database, chID, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChannelMessages_AdminBypassesDeny(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
token := chTestCreateToken(t, database, "authz-owner2", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0)
|
|
|
|
// Deny READ_MESSAGES for Member role — should not affect Owner.
|
|
denyReadMessages(t, database, chID, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200; admin should bypass deny", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── GET /api/v1/search: permission filtering ───────────────────────────────
|
|
|
|
func TestSearch_FiltersResultsByPermission(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
// Create an owner to insert messages (owner can write anywhere).
|
|
_ = chTestCreateToken(t, database, "authz-owner3", 1)
|
|
owner, _ := database.GetUserByUsername(context.Background(), "authz-owner3")
|
|
|
|
// Member user for search.
|
|
memberToken := chTestCreateToken(t, database, "authz-member3", 4)
|
|
|
|
chVisible, _ := database.CreateChannel(context.Background(), "pub", "text", "", "", 0)
|
|
chHidden, _ := database.CreateChannel(context.Background(), "priv", "text", "", "", 1)
|
|
|
|
// Insert messages in both channels with a common keyword.
|
|
_, _ = database.CreateMessage(context.Background(), chVisible, owner.ID, "searchable keyword public", nil)
|
|
_, _ = database.CreateMessage(context.Background(), chHidden, owner.ID, "searchable keyword private", nil)
|
|
|
|
// Deny READ_MESSAGES on the hidden channel for members.
|
|
denyReadMessages(t, database, chHidden, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=searchable", memberToken)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
results, ok := resp["results"].([]any)
|
|
if !ok {
|
|
t.Fatalf("results is not an array: %v", resp)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("expected 1 search result (public only), got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearch_AdminSeesAllResults(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
token := chTestCreateToken(t, database, "authz-owner4", 1)
|
|
owner, _ := database.GetUserByUsername(context.Background(), "authz-owner4")
|
|
|
|
chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0)
|
|
chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1)
|
|
|
|
_, _ = database.CreateMessage(context.Background(), chA, owner.ID, "findme alpha", nil)
|
|
_, _ = database.CreateMessage(context.Background(), chB, owner.ID, "findme beta", nil)
|
|
|
|
// Deny READ_MESSAGES on both for member role — admin bypasses.
|
|
denyReadMessages(t, database, chA, permissions.MemberRoleID)
|
|
denyReadMessages(t, database, chB, permissions.MemberRoleID)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=findme", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
results := resp["results"].([]any)
|
|
if len(results) != 2 {
|
|
t.Errorf("admin should see all 2 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
// ─── DM exclusion from channel list (BUG-093) ─────────────────────────────
|
|
|
|
func TestChannelList_ExcludesDMChannels_Member(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "dm-excl-member", 4)
|
|
|
|
// Create a normal text channel and a DM channel.
|
|
database.CreateChannel(context.Background(), "general", "text", "", "", 0)
|
|
database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var channels []map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
for _, ch := range channels {
|
|
if ch["type"] == "dm" {
|
|
t.Errorf("DM channel should not appear in channel list, got: %v", ch["name"])
|
|
}
|
|
}
|
|
if len(channels) != 1 {
|
|
t.Errorf("expected 1 channel (text only), got %d", len(channels))
|
|
}
|
|
}
|
|
|
|
func TestChannelList_ExcludesDMChannels_Admin(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "dm-excl-admin", 1) // Owner
|
|
|
|
database.CreateChannel(context.Background(), "general", "text", "", "", 0)
|
|
database.CreateChannel(context.Background(), "voice", "voice", "", "", 1)
|
|
database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var channels []map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&channels)
|
|
for _, ch := range channels {
|
|
if ch["type"] == "dm" {
|
|
t.Errorf("DM channel should not appear even for admin, got: %v", ch["name"])
|
|
}
|
|
}
|
|
if len(channels) != 2 {
|
|
t.Errorf("expected 2 channels (text+voice), got %d", len(channels))
|
|
}
|
|
}
|