Files
OwnCord/Server/admin/perm_gates_test.go
T
Claude 7a4e5dc357 refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)
`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.

The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.

Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.

350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).

The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.

Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.

Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.

Refs RL-13, L-12
2026-08-26 20:23:49 +00:00

403 lines
18 KiB
Go

package admin_test
// Route-level permission gates. The /admin/api perimeter admits any role
// holding a moderation bit; each group then re-checks the specific bit, so a
// Moderator can manage channels without being able to read settings, the audit
// log, or the owner-only routes.
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// moderatorMask is migration 001's seeded Moderator role: MANAGE_MESSAGES,
// MANAGE_CHANNELS, KICK_MEMBERS, BAN_MEMBERS (bits 16-19) and everything below.
const moderatorMask = int64(0x000FFFFF)
// createRoleUser upserts a role and a user holding it, returning the user id
// and a bearer token for that user's session.
func createRoleUser(t *testing.T, database *db.DB, roleID int64, name string, perms int64, position int, username string) (int64, string) {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`INSERT INTO roles (id, name, color, permissions, position, is_default)
VALUES (?, ?, NULL, ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, permissions=excluded.permissions, position=excluded.position`,
roleID, name, perms, position,
); err != nil {
t.Fatalf("seed role %s: %v", name, err)
}
uid, err := database.CreateUser(context.Background(), username, "$2a$12$placeholder", int(roleID))
if err != nil {
t.Fatalf("CreateUser %s: %v", username, err)
}
token := username + "-token-" + t.Name()
if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession %s: %v", username, err)
}
return uid, token
}
// newModeratorHandler builds the admin API with a Moderator-role principal.
func newModeratorHandler(t *testing.T) (http.Handler, *db.DB, string) {
t.Helper()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
return handler, database, token
}
// ─── Perimeter ────────────────────────────────────────────────────────────────
func TestPerimeter_ModeratorAdmitted(t *testing.T) {
handler, _, token := newModeratorHandler(t)
// Perimeter-level routes: reachable with any moderation bit.
for _, path := range []string{"/stats", "/users", "/me"} {
if w := doRequest(t, handler, http.MethodGet, path, token, nil); w.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200; body: %s", path, w.Code, w.Body.String())
}
}
}
func TestPerimeter_NoModerationBitsRejected(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// MANAGE_MESSAGES alone is not a perimeter bit — it has no admin route.
_, token := createRoleUser(t, database, 11, "Helper", permissions.ManageMessages, 50, "helperuser")
if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
}
// ─── MANAGE_CHANNELS ──────────────────────────────────────────────────────────
func TestChannelRoutes_ModeratorAllowed(t *testing.T) {
handler, _, token := newModeratorHandler(t)
if w := doRequest(t, handler, http.MethodGet, "/channels", token, nil); w.Code != http.StatusOK {
t.Fatalf("GET /channels = %d, want 200; body: %s", w.Code, w.Body.String())
}
w := doRequest(t, handler, http.MethodPost, "/channels", token, map[string]any{"name": "mod-made", "type": "text"})
if w.Code != http.StatusCreated {
t.Fatalf("POST /channels = %d, want 201; body: %s", w.Code, w.Body.String())
}
var created db.Channel
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal channel: %v", err)
}
if w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(created.ID), token,
map[string]any{"name": "mod-renamed"}); w.Code != http.StatusOK {
t.Errorf("PATCH /channels = %d, want 200; body: %s", w.Code, w.Body.String())
}
// Channel-permission overrides ride the same gate.
if w := doRequest(t, handler, http.MethodGet, "/channels/"+itoa(created.ID)+"/permissions", token, nil); w.Code != http.StatusOK {
t.Errorf("GET channel permissions = %d, want 200; body: %s", w.Code, w.Body.String())
}
if w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(created.ID), token, nil); w.Code != http.StatusNoContent {
t.Errorf("DELETE /channels = %d, want 204; body: %s", w.Code, w.Body.String())
}
}
// ─── BAN_MEMBERS reaches PATCH /users/{id} ───────────────────────────────────
// The ban path is authorized inside ModerationService, so the route must stay
// perimeter-level: gating it on ADMINISTRATOR (or on MANAGE_ROLES) would put
// banning out of a Moderator's reach entirely.
func TestPatchUserBan_ModeratorAllowed(t *testing.T) {
handler, database, token := newModeratorHandler(t)
targetUID, _ := database.CreateUser(context.Background(), "spammer", "hash", 3)
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token,
map[string]any{"banned": true, "ban_reason": "spam"})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
user, _ := database.GetUserByID(context.Background(), targetUID)
if !user.Banned {
t.Error("target should be banned")
}
}
func TestChannelRoutes_WithoutManageChannelsForbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/channels"},
{http.MethodPost, "/channels"},
{http.MethodPatch, "/channels/1"},
{http.MethodDelete, "/channels/1"},
{http.MethodGet, "/channels/1/permissions"},
} {
if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden {
t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String())
}
}
}
// ─── VIEW_AUDIT_LOG / MANAGE_SERVER ──────────────────────────────────────────
func TestAuditAndSettings_ModeratorForbidden(t *testing.T) {
handler, _, token := newModeratorHandler(t)
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/audit-log"},
{http.MethodGet, "/settings"},
{http.MethodPatch, "/settings"},
{http.MethodPost, "/logs/ticket"},
} {
if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden {
t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String())
}
}
}
func TestAuditAndSettings_BitHoldersAllowed(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, auditToken := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
_, cfgToken := createRoleUser(t, database, 13, "Configurator", permissions.ManageServer, 50, "cfguser")
if w := doRequest(t, handler, http.MethodGet, "/audit-log", auditToken, nil); w.Code != http.StatusOK {
t.Errorf("GET /audit-log = %d, want 200; body: %s", w.Code, w.Body.String())
}
if w := doRequest(t, handler, http.MethodGet, "/settings", cfgToken, nil); w.Code != http.StatusOK {
t.Errorf("GET /settings = %d, want 200; body: %s", w.Code, w.Body.String())
}
// Each bit gates only its own group.
if w := doRequest(t, handler, http.MethodGet, "/settings", auditToken, nil); w.Code != http.StatusForbidden {
t.Errorf("auditor GET /settings = %d, want 403", w.Code)
}
if w := doRequest(t, handler, http.MethodGet, "/audit-log", cfgToken, nil); w.Code != http.StatusForbidden {
t.Errorf("configurator GET /audit-log = %d, want 403", w.Code)
}
}
// ─── Owner-only routes stay owner-only ───────────────────────────────────────
func TestOwnerOnlyRoutes_ModeratorForbidden(t *testing.T) {
handler, _, token := newModeratorHandler(t)
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/tokens"},
{http.MethodPost, "/tokens"},
{http.MethodGet, "/backups"},
{http.MethodPost, "/backup"},
{http.MethodGet, "/updates"},
{http.MethodPost, "/updates/apply"},
} {
if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden {
t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String())
}
}
}
// ─── KICK_MEMBERS (force logout) ─────────────────────────────────────────────
func TestForceLogout_RequiresKickMembers(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 14, "ChannelMod", permissions.ManageChannels, 60, "chanmoduser")
targetUID, _ := database.CreateUser(context.Background(), "victim", "hash", 3)
if _, err := database.CreateSession(context.Background(), targetUID, "victim-hash-perm", "web", "1.2.3.4"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
sessions, _ := database.GetUserSessions(context.Background(), targetUID)
if len(sessions) != 1 {
t.Errorf("sessions = %d, want 1 (refused call must not cut sessions)", len(sessions))
}
}
func TestForceLogout_HierarchyEnforced(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
// Owner (role 1, position 100) outranks the moderator.
ownerUID, err := database.CreateUser(context.Background(), "theowner", "hash", 1)
if err != nil {
t.Fatalf("CreateUser owner: %v", err)
}
if _, err := database.CreateSession(context.Background(), ownerUID, "owner-hash-hier", "web", "1.2.3.4"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(ownerUID)+"/sessions", token, nil); w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
sessions, _ := database.GetUserSessions(context.Background(), ownerUID)
if len(sessions) != 1 {
t.Errorf("owner sessions = %d, want 1", len(sessions))
}
// A lower-ranked member is fair game.
memberUID, _ := database.CreateUser(context.Background(), "amember", "hash", 3)
if _, err := database.CreateSession(context.Background(), memberUID, "member-hash-hier", "web", "1.2.3.4"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(memberUID)+"/sessions", token, nil); w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
}
}
// ─── MANAGE_ROLES (role assignment) ──────────────────────────────────────────
func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// The seeded Moderator mask stops at bit 19 — no MANAGE_ROLES (bit 24).
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
targetUID, _ := database.CreateUser(context.Background(), "promoteme", "hash", 3)
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{"role_id": 2})
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
user, _ := database.GetUserByID(context.Background(), targetUID)
if user.RoleID != 3 {
t.Errorf("role_id = %d, want 3 (unchanged)", user.RoleID)
}
}
func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Role 2 "Admin" (position 80) holds MANAGE_ROLES but is below Owner.
_, token := createRoleUser(t, database, 2, "Admin", 0x3FFFFFFF, 80, "adminuser2")
targetUID, _ := database.CreateUser(context.Background(), "wannabeowner", "hash", 3)
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token,
map[string]any{"role_id": permissions.OwnerRoleID})
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
user, _ := database.GetUserByID(context.Background(), targetUID)
if user.RoleID != 3 {
t.Errorf("role_id = %d, want 3 (unchanged)", user.RoleID)
}
}
func TestPatchUserRole_ModeratorCannotDemoteAdmin(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// A moderator that does hold MANAGE_ROLES still cannot touch a higher rank.
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask|permissions.ManageRoles, 60, "moduser")
adminUID, err := database.CreateUser(context.Background(), "sitting-admin", "hash", 2)
if err != nil {
t.Fatalf("CreateUser admin: %v", err)
}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(adminUID), token, map[string]any{"role_id": 3})
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
user, _ := database.GetUserByID(context.Background(), adminUID)
if user.RoleID != 2 {
t.Errorf("role_id = %d, want 2 (unchanged)", user.RoleID)
}
}
// ─── RequireAdminAuth (plugin admin routes) ──────────────────────────────────
// The exported gate wraps surfaces outside this package (api/router.go mounts
// the plugin admin handler behind it). Widening the panel perimeter must not
// widen those: they stay ADMINISTRATOR-only.
func TestRequireAdminAuth_StaysAdministratorOnly(t *testing.T) {
database := openAdminTestDB(t)
reached := false
guarded := admin.RequireAdminAuth(database)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
ownerToken := createAdminUser(t, database)
if w := doRequest(t, guarded, http.MethodGet, "/plugins", modToken, nil); w.Code != http.StatusForbidden {
t.Errorf("moderator = %d, want 403; body: %s", w.Code, w.Body.String())
}
if reached {
t.Error("handler reached by a non-administrator")
}
if w := doRequest(t, guarded, http.MethodGet, "/plugins", ownerToken, nil); w.Code != http.StatusOK {
t.Errorf("owner = %d, want 200; body: %s", w.Code, w.Body.String())
}
if !reached {
t.Error("handler not reached by the owner")
}
}
// ─── GET /me ─────────────────────────────────────────────────────────────────
func TestGetMe_ReportsCallerPermissions(t *testing.T) {
handler, _, token := newModeratorHandler(t)
w := doRequest(t, handler, http.MethodGet, "/me", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var me struct {
Username string `json:"username"`
RoleName string `json:"role_name"`
RolePosition int `json:"role_position"`
Permissions int64 `json:"permissions"`
IsOwner bool `json:"is_owner"`
}
if err := json.Unmarshal(w.Body.Bytes(), &me); err != nil {
t.Fatalf("unmarshal me: %v", err)
}
if me.Username != "moduser" || me.RoleName != "Moderator" {
t.Errorf("me = %+v, want moduser/Moderator", me)
}
if me.Permissions != moderatorMask {
t.Errorf("permissions = %#x, want %#x", me.Permissions, moderatorMask)
}
if me.RolePosition != 60 || me.IsOwner {
t.Errorf("role_position = %d, is_owner = %v; want 60/false", me.RolePosition, me.IsOwner)
}
}
func TestGetMe_OwnerFlagged(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/me", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var me struct {
IsOwner bool `json:"is_owner"`
}
if err := json.Unmarshal(w.Body.Bytes(), &me); err != nil {
t.Fatalf("unmarshal me: %v", err)
}
if !me.IsOwner {
t.Error("is_owner = false for the Owner role")
}
}