Files
OwnCord/Server/admin/handlers_users_broadcast_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

153 lines
6.1 KiB
Go

package admin_test
import (
"context"
"net/http"
"testing"
"github.com/owncord/server/admin"
"github.com/owncord/server/db"
)
// unbanMockHub wraps mockHub (admin/api_test.go) and additionally implements
// the optional memberUnbanBroadcaster capability handlePatchUser looks for
// via a type assertion, so these tests can observe whether an unban fired
// the mirror of BroadcastMemberBan.
type unbanMockHub struct {
*mockHub
unbannedIDs []int64
}
func (m *unbanMockHub) BroadcastMemberUnban(userID int64) {
m.unbannedIDs = append(m.unbannedIDs, userID)
}
// An unban must tell every already-connected client the user is back in the
// roster — the mirror of the ban path's BroadcastMemberBan — or they stay
// missing from every connected client's member store until that client
// reconnects (v022).
func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
database := openAdminTestDB(t)
hub := &unbanMockHub{mockHub: &mockHub{}}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "unbanbroadcast", "hash", 3)
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{
"banned": true,
})
if w.Code != http.StatusOK {
t.Fatalf("ban: status = %d, want 200; body: %s", w.Code, w.Body.String())
}
w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{
"banned": false,
})
if w.Code != http.StatusOK {
t.Fatalf("unban: status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if len(hub.unbannedIDs) != 1 || hub.unbannedIDs[0] != targetUID {
t.Fatalf("BroadcastMemberUnban calls = %v, want exactly [%d]", hub.unbannedIDs, targetUID)
}
if len(hub.memberBanIDs) != 1 || hub.memberBanIDs[0] != targetUID {
t.Fatalf("BroadcastMemberBan calls = %v, want exactly [%d] (unaffected by the unban change)", hub.memberBanIDs, targetUID)
}
}
// A role change must re-derive channel visibility for the promoted user, not
// just revoke what they can no longer read — otherwise a channel the new
// role newly gained READ_MESSAGES on never appears in their sidebar until
// they reconnect (v025).
func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "rolerefresh", "hash", 3)
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{
"role_id": 2,
})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if hub.allVisibilityRefreshes != 1 {
t.Fatalf("RefreshAllChannelVisibility calls = %d, want 1", hub.allVisibilityRefreshes)
}
found := false
for _, mu := range hub.memberUpdates {
if mu.userID == targetUID {
found = true
}
}
if !found {
t.Fatalf("expected a member_update for the role change, got %+v", hub.memberUpdates)
}
}
// roleDeletingInvalidator simulates a second admin deleting the just-assigned
// role in the window between ModerationService.ChangeUserRole committing and
// handlePatchUser's own re-read of that role (used only for its name in the
// member_update payload). It hooks PermissionInvalidator.InvalidateUser
// because handlePatchUser calls that exactly once, synchronously, right
// after ChangeUserRole succeeds and right before the vulnerable re-read.
type roleDeletingInvalidator struct {
database *db.DB
deleteRoleID, fallbackRoleID int64
}
func (r *roleDeletingInvalidator) InvalidateUser(int64) {
if _, err := r.database.DeleteRoleReassigning(context.Background(), r.deleteRoleID, r.fallbackRoleID); err != nil {
panic(err) // test setup bug, not the behavior under test
}
}
func (r *roleDeletingInvalidator) InvalidateAll() {}
// A role demotion's live-subscription revocation (BroadcastMemberUpdate ->
// revokeUnreadableChannels) and visibility re-derivation
// (RefreshAllChannelVisibility) must run whenever ChangeUserRole actually
// committed the role change, not only when a second, purely-cosmetic re-read
// of the role (done only to get its name) happens to still succeed. If the
// role is deleted out from under that re-read — a real admin racing a role
// deletion against this handler, or a transient read error — the demoted
// user's socket must not be left subscribed to channels it can no longer
// read (OC-0045).
func TestAdminAPI_PatchUser_RoleChangeBroadcastsEvenIfRoleReReadFails(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
invalidator := &roleDeletingInvalidator{database: database, deleteRoleID: 2, fallbackRoleID: 3}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "roleracetarget", "hash", 3)
// Promote the target to role 2 ("Admin"). The handler's own
// InvalidateUser hook fires mid-request and deletes role 2 (reassigning
// the target back to role 3 first, exactly like a real admin's DELETE
// /admin/api/roles/2 would), so by the time handlePatchUser re-reads
// role 2 for its name, GetRoleByID returns (nil, nil).
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{
"role_id": 2,
})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if hub.allVisibilityRefreshes != 1 {
t.Fatalf("RefreshAllChannelVisibility calls = %d, want 1 (role change committed regardless of the re-read)", hub.allVisibilityRefreshes)
}
found := false
for _, mu := range hub.memberUpdates {
if mu.userID == targetUID {
found = true
}
}
if !found {
t.Fatalf("expected a member_update for the role change despite the concurrent role deletion, got %+v", hub.memberUpdates)
}
}