Files
OwnCord/Server/service/moderation_test.go
T
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)

* fix(db): 1 defect(s) (OC-0096)

* fix(admin): 1 defect(s) (OC-0097)

* fix(auth): 2 defect(s) (OC-0099, OC-0021)

* fix(voice): 1 defect(s) (OC-0018)

* fix(admin): 1 defect(s) (OC-0045)

* fix(api): 1 defect(s) (OC-0103)

* fix(client): 1 defect(s) (OC-0105)

* fix(client): 1 defect(s) (OC-0107)

* fix(api): 1 defect(s) (OC-0109)

* fix(api): 1 defect(s) (OC-0112)

* test(admin): compare restore bytes with bytes.Equal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0095, OC-0014)

OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.

OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.

* fix(profile): 2 defect(s) (OC-0100, OC-0102)

* fix(service): 1 defect(s) (OC-0022)

Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.

* fix(api): 1 defect(s) (OC-0048)

* chore(workflows): correct stale model labels in bughunt-fix phase details

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): 1 defect(s) (OC-0015)

* fix(voice): 1 defect(s) (OC-0002)

* test: fix two CI-only failures in the batch-4 test suite

The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.

The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:49:27 +02:00

269 lines
11 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"testing"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// newTestModerationService seeds a real in-memory DB with a role hierarchy:
// owner (pos 100, Administrator) > mod (pos 80, BanMembers) > member (pos 40).
// Users: 1=owner, 2=mod, 3=member, 4=member, 5=mod (equal rank to 2).
func newTestModerationService(t *testing.T) (*ModerationService, *db.DB) {
t.Helper()
database := newTestDB(t)
seedRole(t, database, &db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100})
seedRole(t, database, &db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80})
seedRole(t, database, &db.Role{ID: 3, Name: "member", Permissions: permissions.SendMessages, Position: 40})
for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 3, 5: 2} {
seedUser(t, database, &db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"})
seedUserRole(t, database, userID, roleID)
}
checker := permissions.NewChecker(database)
return NewModerationService(database, NewPermissionService(database, checker)), database
}
func TestBanUser_RequiresBanPermission(t *testing.T) {
svc, _ := newTestModerationService(t)
// A member without BAN_MEMBERS is refused.
if err := svc.BanUser(context.Background(), 3, 4, "nope", nil); !errors.Is(err, ErrForbidden) {
t.Fatalf("member ban attempt: want ErrForbidden, got %v", err)
}
// And gets Forbidden — not NotFound — for a nonexistent target, so the
// ban path cannot be used to enumerate user ids.
if err := svc.BanUser(context.Background(), 3, 999, "probe", nil); !errors.Is(err, ErrForbidden) {
t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err)
}
}
func TestBanUser_HierarchyEnforced(t *testing.T) {
svc, database := newTestModerationService(t)
// Equal rank: mod cannot ban mod.
if err := svc.BanUser(context.Background(), 2, 5, "peer", nil); !errors.Is(err, ErrForbidden) {
t.Fatalf("equal-rank ban: want ErrForbidden, got %v", err)
}
// Higher rank target: mod cannot ban the owner.
if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) {
t.Fatalf("ban owner: want ErrForbidden, got %v", err)
}
owner, _ := database.GetUserByID(context.Background(), 1)
if owner.Banned {
t.Fatal("owner must not be banned")
}
}
func TestBanUser_AuthorizedSucceeds(t *testing.T) {
svc, database := newTestModerationService(t)
if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil {
t.Fatalf("authorized ban: %v", err)
}
target, _ := database.GetUserByID(context.Background(), 3)
if !target.Banned {
t.Fatal("target should be banned")
}
if target.BanReason == nil || *target.BanReason != "spam" {
t.Fatalf("ban reason not recorded: %v", target.BanReason)
}
// Authorized actor gets a real NotFound for a missing target.
if err := svc.BanUser(context.Background(), 2, 999, "gone", nil); !errors.Is(err, ErrNotFound) {
t.Fatalf("authorized ban of missing id: want ErrNotFound, got %v", err)
}
// Self-ban is a bad request regardless of authority.
if err := svc.BanUser(context.Background(), 2, 2, "self", nil); !errors.Is(err, ErrBadRequest) {
t.Fatalf("self ban: want ErrBadRequest, got %v", err)
}
}
// newTestRoleService seeds a four-rank hierarchy for the role-assignment and
// force-logout paths: owner (pos 100, Administrator) > admin (pos 80,
// Administrator) > mod (pos 60, MANAGE_ROLES+KICK_MEMBERS) > member (pos 40).
// Users: 1=owner, 2=admin, 3=mod, 4=member, 5=member.
func newTestRoleService(t *testing.T) (*ModerationService, *db.DB) {
t.Helper()
database := newTestDB(t)
seedRole(t, database, &db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100})
seedRole(t, database, &db.Role{ID: 2, Name: "admin", Permissions: permissions.Administrator, Position: 80})
seedRole(t, database, &db.Role{ID: 3, Name: "mod",
Permissions: permissions.ManageRoles | permissions.KickMembers, Position: 60})
seedRole(t, database, &db.Role{ID: 4, Name: "member", Permissions: permissions.SendMessages, Position: 40})
for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 4, 5: 4} {
seedUser(t, database, &db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"})
seedUserRole(t, database, userID, roleID)
}
checker := permissions.NewChecker(database)
return NewModerationService(database, NewPermissionService(database, checker)), database
}
func roleIDOf(t *testing.T, database *db.DB, userID int64) int64 {
t.Helper()
user, err := database.GetUserByID(context.Background(), userID)
if err != nil || user == nil {
t.Fatalf("GetUserByID(%d): %v", userID, err)
}
return user.RoleID
}
func TestChangeUserRole_RequiresManageRoles(t *testing.T) {
svc, database := newTestRoleService(t)
// A member without MANAGE_ROLES is refused...
if _, err := svc.ChangeUserRole(context.Background(), 4, 5, 3); !errors.Is(err, ErrForbidden) {
t.Fatalf("member role change: want ErrForbidden, got %v", err)
}
// ...and gets Forbidden, not NotFound, for a missing target.
if _, err := svc.ChangeUserRole(context.Background(), 4, 999, 3); !errors.Is(err, ErrForbidden) {
t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err)
}
if got := roleIDOf(t, database, 5); got != 4 {
t.Fatalf("target role changed to %d despite refusal", got)
}
}
func TestChangeUserRole_CannotAssignAtOrAboveOwnRank(t *testing.T) {
svc, database := newTestRoleService(t)
// The hole this closes: an Administrator could promote anyone to Owner.
if _, err := svc.ChangeUserRole(context.Background(), 2, 4, 1); !errors.Is(err, ErrForbidden) {
t.Fatalf("admin promoting to owner: want ErrForbidden, got %v", err)
}
// Equal rank is refused too — an admin cannot mint another admin.
if _, err := svc.ChangeUserRole(context.Background(), 2, 4, 2); !errors.Is(err, ErrForbidden) {
t.Fatalf("admin assigning own rank: want ErrForbidden, got %v", err)
}
if got := roleIDOf(t, database, 4); got != 4 {
t.Fatalf("member role changed to %d despite refusal", got)
}
// Strictly below own rank is allowed.
if _, err := svc.ChangeUserRole(context.Background(), 2, 4, 3); err != nil {
t.Fatalf("admin promoting to mod: %v", err)
}
if got := roleIDOf(t, database, 4); got != 3 {
t.Fatalf("role after promotion = %d, want 3", got)
}
// The owner outranks the admin role, so the owner may grant it.
if _, err := svc.ChangeUserRole(context.Background(), 1, 5, 2); err != nil {
t.Fatalf("owner promoting to admin: %v", err)
}
}
func TestChangeUserRole_HierarchyAndValidation(t *testing.T) {
svc, database := newTestRoleService(t)
// A moderator holding MANAGE_ROLES still cannot touch a higher-ranked user.
if _, err := svc.ChangeUserRole(context.Background(), 3, 2, 4); !errors.Is(err, ErrForbidden) {
t.Fatalf("mod demoting an admin: want ErrForbidden, got %v", err)
}
if got := roleIDOf(t, database, 2); got != 2 {
t.Fatalf("admin role changed to %d despite refusal", got)
}
// Nor the owner.
if _, err := svc.ChangeUserRole(context.Background(), 3, 1, 4); !errors.Is(err, ErrForbidden) {
t.Fatalf("mod demoting the owner: want ErrForbidden, got %v", err)
}
// Self-service promotion is a bad request regardless of authority.
if _, err := svc.ChangeUserRole(context.Background(), 2, 2, 1); !errors.Is(err, ErrBadRequest) {
t.Fatalf("self role change: want ErrBadRequest, got %v", err)
}
// A nonexistent role is a bad request, not a 500.
if _, err := svc.ChangeUserRole(context.Background(), 1, 4, 9999); !errors.Is(err, ErrBadRequest) {
t.Fatalf("unknown role id: want ErrBadRequest, got %v", err)
}
// Authorized actor gets a real NotFound for a missing target.
if _, err := svc.ChangeUserRole(context.Background(), 1, 999, 4); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing target: want ErrNotFound, got %v", err)
}
}
func TestChangeUserRole_AuditWritten(t *testing.T) {
svc, database := newTestRoleService(t)
if _, err := svc.ChangeUserRole(context.Background(), 1, 4, 3); err != nil {
t.Fatalf("owner role change: %v", err)
}
entries, err := database.GetAuditLog(context.Background(), 10, 0)
if err != nil {
t.Fatalf("GetAuditLog: %v", err)
}
found := false
for _, e := range entries {
if e.Action == "role_change" && e.TargetID == 4 && e.ActorID == 1 {
found = true
}
}
if !found {
t.Fatalf("no role_change audit entry, got %+v", entries)
}
}
func TestForceLogout_AuthorizationMatrix(t *testing.T) {
svc, database := newTestRoleService(t)
ctx := context.Background()
if _, err := database.CreateSession(ctx, 4, "victim-hash", "web", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// No KICK_MEMBERS → Forbidden (member 5 targeting member 4).
if err := svc.ForceLogout(ctx, 5, 4); !errors.Is(err, ErrForbidden) {
t.Fatalf("member force-logout: want ErrForbidden, got %v", err)
}
// Holding KICK_MEMBERS is not enough against a higher rank.
if err := svc.ForceLogout(ctx, 3, 2); !errors.Is(err, ErrForbidden) {
t.Fatalf("mod force-logout of admin: want ErrForbidden, got %v", err)
}
// Self is a bad request.
if err := svc.ForceLogout(ctx, 3, 3); !errors.Is(err, ErrBadRequest) {
t.Fatalf("self force-logout: want ErrBadRequest, got %v", err)
}
sessions, _ := database.GetUserSessions(ctx, 4)
if len(sessions) != 1 {
t.Fatalf("sessions = %d before an authorized call, want 1", len(sessions))
}
// Authorized: mod outranks member and holds KICK_MEMBERS.
if err := svc.ForceLogout(ctx, 3, 4); err != nil {
t.Fatalf("authorized force-logout: %v", err)
}
sessions, _ = database.GetUserSessions(ctx, 4)
if len(sessions) != 0 {
t.Fatalf("sessions = %d after force logout, want 0", len(sessions))
}
// Authorized actor gets a real NotFound for a missing target.
if err := svc.ForceLogout(ctx, 3, 999); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing target: want ErrNotFound, got %v", err)
}
}
func TestUnbanUser_AuthorizationMatrix(t *testing.T) {
svc, database := newTestModerationService(t)
if err := svc.BanUser(context.Background(), 1, 3, "setup", nil); err != nil {
t.Fatalf("setup ban: %v", err)
}
// No BAN_MEMBERS → Forbidden (member 4 trying to unban member 3).
if err := svc.UnbanUser(context.Background(), 4, 3); !errors.Is(err, ErrForbidden) {
t.Fatalf("member unban: want ErrForbidden, got %v", err)
}
// Equal rank → Forbidden.
if err := svc.UnbanUser(context.Background(), 2, 5); !errors.Is(err, ErrForbidden) {
t.Fatalf("equal-rank unban: want ErrForbidden, got %v", err)
}
// Authorized → succeeds.
if err := svc.UnbanUser(context.Background(), 2, 3); err != nil {
t.Fatalf("authorized unban: %v", err)
}
target, _ := database.GetUserByID(context.Background(), 3)
if target.Banned {
t.Fatal("target should be unbanned")
}
}