Files
OwnCord/Server/api/channel_authz_test.go
T
J3vbandClaude Fable 5 6afa9e974c refactor(server): thread context.Context through the db layer and all callers
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>
2026-07-23 17:03:52 +02:00

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))
}
}