mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(server,admin): private channels via per-role permission overrides
The read side of channel visibility was already complete — channel_overrides
rows deny ReadMessages and every list/focus/send/voice path enforces them —
but nothing could write those rows. Add the missing write path and UI:
- db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides
(roles LEFT JOIN overrides so the UI gets everything in one call)
- admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}]
with unknown permission bits masked via the new permissions.AllPerms,
audit logging, and immediate permission-cache invalidation
- ws: Hub.RefreshChannelVisibility sends targeted channel_create /
channel_delete to connected clients after an override change, unsubscribes
hidden clients from the channel topic, and clears their focus. Sent outside
the sequenced replay path on purpose: a replayed channel_delete would be
filtered by the post-change allowed-channel set, inverting its audience.
- admin panel: per-channel Access modal (lock icon) with per-role
"Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice
Known limits (follow-ups): users offline during a revoke keep a stale
sidebar entry until their next fresh connect (server still denies access),
and users already in a voice channel are not kicked when it goes private.
Closes #93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
This commit is contained in:
@@ -83,9 +83,7 @@ describe("screen share FPS", () => {
|
||||
|
||||
it("scales bitrate up for 60 and 120 fps", () => {
|
||||
expect(getScreenShareMaxBitrate("high", 60)).toBe(SCREENSHARE_PUBLISH_BITRATES.high * 1.5);
|
||||
expect(getScreenShareMaxBitrate("source", 120)).toBe(
|
||||
SCREENSHARE_PUBLISH_BITRATES.source * 2,
|
||||
);
|
||||
expect(getScreenShareMaxBitrate("source", 120)).toBe(SCREENSHARE_PUBLISH_BITRATES.source * 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
|
||||
r.Post("/channels", handleCreateChannel(database, hub))
|
||||
r.Patch("/channels/{id}", handlePatchChannel(database, hub))
|
||||
r.Delete("/channels/{id}", handleDeleteChannel(database, hub))
|
||||
r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database))
|
||||
r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator))
|
||||
r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator))
|
||||
r.Get("/audit-log", handleGetAuditLog(database))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
|
||||
@@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
voice_max_video INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
allow INTEGER NOT NULL DEFAULT 0,
|
||||
deny INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(channel_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
@@ -1107,13 +1116,14 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) {
|
||||
|
||||
// mockHub records which broadcast methods were called and with what arguments.
|
||||
type mockHub struct {
|
||||
restartCalls []restartCall
|
||||
channelCreates []*db.Channel
|
||||
channelUpdates []*db.Channel
|
||||
channelDeleteIDs []int64
|
||||
memberBanIDs []int64
|
||||
memberUpdates []memberUpdateCall
|
||||
clientCount int
|
||||
restartCalls []restartCall
|
||||
channelCreates []*db.Channel
|
||||
channelUpdates []*db.Channel
|
||||
channelDeleteIDs []int64
|
||||
memberBanIDs []int64
|
||||
memberUpdates []memberUpdateCall
|
||||
visibilityRefreshes []*db.Channel
|
||||
clientCount int
|
||||
}
|
||||
|
||||
type memberUpdateCall struct {
|
||||
@@ -1150,6 +1160,10 @@ func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) {
|
||||
m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName})
|
||||
}
|
||||
|
||||
func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) {
|
||||
m.visibilityRefreshes = append(m.visibilityRefreshes, ch)
|
||||
}
|
||||
|
||||
func (m *mockHub) ClientCount() int {
|
||||
return m.clientCount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// ─── Channel Permission Override Handlers ────────────────────────────────────
|
||||
//
|
||||
// These endpoints manage per-role allow/deny permission overrides on a
|
||||
// channel (the channel_overrides table). Denying ReadMessages hides the
|
||||
// channel from a role entirely ("private channel"); the read side is already
|
||||
// enforced by ListVisibleChannels, the WS ready payload, and the per-message
|
||||
// permission checks.
|
||||
|
||||
// getPermChannel loads the channel for an override request and writes the
|
||||
// appropriate error response when it is missing or a DM. Returns nil when a
|
||||
// response has already been written.
|
||||
func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel {
|
||||
id, err := pathInt64(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
|
||||
return nil
|
||||
}
|
||||
ch, err := database.GetChannel(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
|
||||
return nil
|
||||
}
|
||||
if ch == nil {
|
||||
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
|
||||
return nil
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides")
|
||||
return nil
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// channelPermissionsResponse is the JSON shape for GET .../permissions.
|
||||
type channelPermissionsResponse struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Roles []db.ChannelRoleOverride `json:"roles"`
|
||||
}
|
||||
|
||||
func handleGetChannelPermissions(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ch := getPermChannel(database, w, r)
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
overrides, err := database.ListChannelRoleOverrides(ch.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, channelPermissionsResponse{ChannelID: ch.ID, Roles: overrides})
|
||||
}
|
||||
}
|
||||
|
||||
// putChannelPermissionRequest is the JSON body for PUT .../permissions/{roleId}.
|
||||
type putChannelPermissionRequest struct {
|
||||
Allow int64 `json:"allow"`
|
||||
Deny int64 `json:"deny"`
|
||||
}
|
||||
|
||||
func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ch := getPermChannel(database, w, r)
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
roleID, err := pathInt64(r, "roleId")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id")
|
||||
return
|
||||
}
|
||||
role, err := database.GetRoleByID(roleID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role")
|
||||
return
|
||||
}
|
||||
if role == nil {
|
||||
writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req putChannelPermissionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
|
||||
return
|
||||
}
|
||||
// Drop unknown bits so garbage input cannot persist undefined perms.
|
||||
allow := req.Allow & permissions.AllPerms
|
||||
deny := req.Deny & permissions.AllPerms
|
||||
|
||||
if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission")
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID,
|
||||
"role_id", roleID, "allow", allow, "deny", deny)
|
||||
_ = database.LogAudit(actor, "channel_perms_update", "channel", ch.ID,
|
||||
fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny))
|
||||
|
||||
if permInvalidator != nil {
|
||||
permInvalidator.InvalidateAll()
|
||||
}
|
||||
if hub != nil {
|
||||
hub.RefreshChannelVisibility(ch)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, db.ChannelRoleOverride{
|
||||
RoleID: role.ID,
|
||||
RoleName: role.Name,
|
||||
Position: role.Position,
|
||||
Permissions: role.Permissions,
|
||||
Allow: allow,
|
||||
Deny: deny,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ch := getPermChannel(database, w, r)
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
roleID, err := pathInt64(r, "roleId")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission")
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID)
|
||||
_ = database.LogAudit(actor, "channel_perms_clear", "channel", ch.ID,
|
||||
fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name))
|
||||
|
||||
if permInvalidator != nil {
|
||||
permInvalidator.InvalidateAll()
|
||||
}
|
||||
if hub != nil {
|
||||
hub.RefreshChannelVisibility(ch)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/admin"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// mockPermInvalidator records permission-cache invalidation calls.
|
||||
type mockPermInvalidator struct {
|
||||
invalidateUserIDs []int64
|
||||
invalidateAllN int
|
||||
}
|
||||
|
||||
func (m *mockPermInvalidator) InvalidateUser(userID int64) {
|
||||
m.invalidateUserIDs = append(m.invalidateUserIDs, userID)
|
||||
}
|
||||
|
||||
func (m *mockPermInvalidator) InvalidateAll() {
|
||||
m.invalidateAllN++
|
||||
}
|
||||
|
||||
// ─── GET /channels/{id}/permissions ──────────────────────────────────────────
|
||||
|
||||
func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/channels/1/permissions", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Roles []db.ChannelRoleOverride `json:"roles"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.ChannelID != chID && resp.ChannelID != 1 {
|
||||
t.Errorf("channel_id = %d", resp.ChannelID)
|
||||
}
|
||||
if len(resp.Roles) != 3 {
|
||||
t.Fatalf("expected 3 roles, got %d", len(resp.Roles))
|
||||
}
|
||||
if resp.Roles[0].RoleName != "Owner" {
|
||||
t.Errorf("first role = %q, want Owner (position desc)", resp.Roles[0].RoleName)
|
||||
}
|
||||
for _, role := range resp.Roles {
|
||||
if role.Allow != 0 || role.Deny != 0 {
|
||||
t.Errorf("role %d: expected zero overrides, got (%#x, %#x)", role.RoleID, role.Allow, role.Deny)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelPermissions_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelPermissions_DMRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel dm: %v", err)
|
||||
}
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet,
|
||||
"/channels/"+itoa(chID)+"/permissions", token, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT /channels/{id}/permissions/{roleId} ─────────────────────────────────
|
||||
|
||||
func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
denyPrivate := permissions.ReadMessages | permissions.ConnectVoice
|
||||
body := map[string]any{"allow": 0, "deny": denyPrivate}
|
||||
w := doRequest(t, handler, http.MethodPut,
|
||||
"/channels/"+itoa(chID)+"/permissions/3", token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
allow, deny, err := database.GetChannelPermissions(chID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != 0 || deny != denyPrivate {
|
||||
t.Errorf("persisted override = (%#x, %#x), want (0, %#x)", allow, deny, denyPrivate)
|
||||
}
|
||||
|
||||
if inv.invalidateAllN != 1 {
|
||||
t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN)
|
||||
}
|
||||
if len(hub.visibilityRefreshes) != 1 || hub.visibilityRefreshes[0].ID != chID {
|
||||
t.Errorf("RefreshChannelVisibility not called for channel %d", chID)
|
||||
}
|
||||
|
||||
entries, err := database.GetAuditLog(10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuditLog: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Action == "channel_perms_update" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected channel_perms_update audit entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret2", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
// 0x4 and 0x8 are undefined bits — they must be dropped.
|
||||
body := map[string]any{"allow": 0x4 | int64(permissions.SendMessages), "deny": 0x8}
|
||||
w := doRequest(t, handler, http.MethodPut,
|
||||
"/channels/"+itoa(chID)+"/permissions/3", token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
allow, deny, err := database.GetChannelPermissions(chID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != permissions.SendMessages {
|
||||
t.Errorf("allow = %#x, want %#x (unknown bits dropped)", allow, permissions.SendMessages)
|
||||
}
|
||||
if deny != 0 {
|
||||
t.Errorf("deny = %#x, want 0 (unknown bits dropped)", deny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutChannelPermission_UnknownRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret3", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
w := doRequest(t, handler, http.MethodPut,
|
||||
"/channels/"+itoa(chID)+"/permissions/999", token, map[string]any{"allow": 0, "deny": 2})
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
|
||||
_ = createAdminUser(t, database)
|
||||
memberToken := createMemberUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret4", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
|
||||
w := doRequest(t, handler, http.MethodPut,
|
||||
"/channels/"+itoa(chID)+"/permissions/3", memberToken, map[string]any{"allow": 0, "deny": 2})
|
||||
if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 403/401; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE /channels/{id}/permissions/{roleId} ──────────────────────────────
|
||||
|
||||
func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel("secret5", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride: %v", err)
|
||||
}
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete,
|
||||
"/channels/"+itoa(chID)+"/permissions/3", token, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
allow, deny, err := database.GetChannelPermissions(chID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != 0 || deny != 0 {
|
||||
t.Errorf("override still present: (%#x, %#x)", allow, deny)
|
||||
}
|
||||
if inv.invalidateAllN != 1 {
|
||||
t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN)
|
||||
}
|
||||
if len(hub.visibilityRefreshes) != 1 {
|
||||
t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes))
|
||||
}
|
||||
|
||||
// Deleting again is idempotent.
|
||||
w = doRequest(t, handler, http.MethodDelete,
|
||||
"/channels/"+itoa(chID)+"/permissions/3", token, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("second delete status = %d, want 204", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {}
|
||||
func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {}
|
||||
func (m *mockHubWB) BroadcastMemberBan(userID int64) {}
|
||||
func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {}
|
||||
func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {}
|
||||
func (m *mockHubWB) ClientCount() int { return 0 }
|
||||
|
||||
// TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a
|
||||
|
||||
@@ -277,6 +277,7 @@ const I={
|
||||
voice:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>',
|
||||
megaphone:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
logs:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
|
||||
lock:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
|
||||
};
|
||||
|
||||
/* ═══ State ═══ */
|
||||
@@ -527,7 +528,8 @@ async function renderChannels(){
|
||||
html+='<td><span class="badge '+(type==='voice'?'badge-yellow':type==='announcement'?'badge-accent':'badge-muted')+'">'+esc(type)+'</span></td>';
|
||||
html+='<td style="font-size:12px;color:var(--text-faint)">'+esc(cat)+'</td>';
|
||||
html+='<td>'+(archived?'<span class="badge badge-muted">Yes</span>':'<span class="badge badge-green">No</span>')+'</td>';
|
||||
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+esc(name)+'\')">'+I.edit+'</button><button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+esc(name)+'\')">'+I.trash+'</button></div></td></tr>';
|
||||
const lockBtn=type==='dm'?'':'<button class="act-btn" title="Access (private channel)" onclick="openChannelPermsModal('+id+',\''+esc(name)+'\')">'+I.lock+'</button>';
|
||||
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+esc(name)+'\')">'+I.edit+'</button>'+lockBtn+'<button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+esc(name)+'\')">'+I.trash+'</button></div></td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div></div>';
|
||||
return html;
|
||||
@@ -560,6 +562,47 @@ async function confirmDeleteChannel(id){
|
||||
try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')}
|
||||
}
|
||||
|
||||
/* ═══ Channel Access (private channels) ═══ */
|
||||
const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */
|
||||
const ADMIN_BIT=0x40000000;
|
||||
|
||||
async function openChannelPermsModal(id,name){
|
||||
let data;
|
||||
try{data=await api('GET','/channels/'+id+'/permissions')}catch(e){showToast(e.message,'error');return}
|
||||
const roles=data.roles||[];
|
||||
state.permChannelRoles=roles;
|
||||
let rows='';
|
||||
roles.forEach(role=>{
|
||||
const isAdmin=(role.permissions&ADMIN_BIT)!==0;
|
||||
const canAccess=isAdmin||((role.deny&0x2)===0);
|
||||
rows+='<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--bg-active)">'
|
||||
+'<span style="color:'+roleColor(role.role_id)+';font-weight:600">'+esc(role.role_name)+'</span>'
|
||||
+(isAdmin
|
||||
?'<span style="font-size:12px;color:var(--text-faint)">always has access</span>'
|
||||
:'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-muted);cursor:pointer"><input type="checkbox" id="permRole'+role.role_id+'" '+(canAccess?'checked':'')+'> Can access</label>')
|
||||
+'</div>';
|
||||
});
|
||||
openModal('<div class="modal-header"><h3>Channel Access — #'+esc(name)+'</h3><button class="modal-close" onclick="closeModal()">×</button></div>'
|
||||
+'<div class="modal-body"><p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">Uncheck a role to hide this channel from it (private channel). Changes apply to connected users immediately; users already in the voice channel are not disconnected.</p>'
|
||||
+rows+'</div>'
|
||||
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelPerms('+id+')">Save</button></div>');
|
||||
}
|
||||
|
||||
async function saveChannelPerms(id){
|
||||
const roles=state.permChannelRoles||[];
|
||||
try{
|
||||
for(const role of roles){
|
||||
if((role.permissions&ADMIN_BIT)!==0)continue;
|
||||
const box=document.getElementById('permRole'+role.role_id);
|
||||
if(!box)continue;
|
||||
const wasHidden=(role.deny&0x2)!==0;
|
||||
if(!box.checked)await api('PUT','/channels/'+id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});
|
||||
else if(wasHidden)await api('DELETE','/channels/'+id+'/permissions/'+role.role_id);
|
||||
}
|
||||
closeModal();showToast('Channel access updated');renderContent();
|
||||
}catch(e){showToast(e.message,'error')}
|
||||
}
|
||||
|
||||
/* ═══ Audit Log ═══ */
|
||||
async function renderAudit(){
|
||||
const offset=(state.auditPage-1)*PAGE_SIZE;
|
||||
|
||||
@@ -40,6 +40,10 @@ type HubBroadcaster interface {
|
||||
BroadcastChannelDelete(channelID int64)
|
||||
BroadcastMemberBan(userID int64)
|
||||
BroadcastMemberUpdate(userID int64, roleName string)
|
||||
// RefreshChannelVisibility sends targeted channel_create/channel_delete
|
||||
// messages after a channel permission override change so each connected
|
||||
// client's sidebar reflects its new visibility without a reconnect.
|
||||
RefreshChannelVisibility(ch *db.Channel)
|
||||
ClientCount() int
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,81 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpsertChannelOverride inserts or updates the allow/deny permission override
|
||||
// for a role on a channel.
|
||||
func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error {
|
||||
_, err := d.sqlDB.Exec(
|
||||
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(channel_id, role_id)
|
||||
DO UPDATE SET allow = excluded.allow, deny = excluded.deny`,
|
||||
channelID, roleID, allow, deny,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpsertChannelOverride: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChannelOverride removes the permission override for a role on a
|
||||
// channel. Deleting a non-existent override is a no-op.
|
||||
func (d *DB) DeleteChannelOverride(channelID, roleID int64) error {
|
||||
_, err := d.sqlDB.Exec(
|
||||
`DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = ?`,
|
||||
channelID, roleID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteChannelOverride: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelRoleOverride pairs a role with its (possibly zero) permission
|
||||
// override on a specific channel. Permissions carries the role's base bits so
|
||||
// callers can tell which roles bypass overrides via Administrator.
|
||||
type ChannelRoleOverride struct {
|
||||
RoleID int64 `json:"role_id"`
|
||||
RoleName string `json:"role_name"`
|
||||
Position int `json:"position"`
|
||||
Permissions int64 `json:"permissions"`
|
||||
Allow int64 `json:"allow"`
|
||||
Deny int64 `json:"deny"`
|
||||
}
|
||||
|
||||
// ListChannelRoleOverrides returns every role together with its override bits
|
||||
// on the given channel (zero allow/deny when no override row exists), ordered
|
||||
// by role position descending.
|
||||
func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) {
|
||||
rows, err := d.sqlDB.Query(
|
||||
`SELECT r.id, r.name, r.position, r.permissions,
|
||||
COALESCE(o.allow, 0), COALESCE(o.deny, 0)
|
||||
FROM roles r
|
||||
LEFT JOIN channel_overrides o ON o.role_id = r.id AND o.channel_id = ?
|
||||
ORDER BY r.position DESC, r.id ASC`,
|
||||
channelID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListChannelRoleOverrides: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
|
||||
var result []ChannelRoleOverride
|
||||
for rows.Next() {
|
||||
var o ChannelRoleOverride
|
||||
if scanErr := rows.Scan(&o.RoleID, &o.RoleName, &o.Position, &o.Permissions, &o.Allow, &o.Deny); scanErr != nil {
|
||||
return nil, fmt.Errorf("ListChannelRoleOverrides scan: %w", scanErr)
|
||||
}
|
||||
result = append(result, o)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, fmt.Errorf("ListChannelRoleOverrides rows: %w", rows.Err())
|
||||
}
|
||||
if result == nil {
|
||||
result = []ChannelRoleOverride{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// scanChannel scans a single channel row from *sql.Rows.
|
||||
|
||||
@@ -234,6 +234,96 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Channel override write path ─────────────────────────────────────────────
|
||||
|
||||
func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
chID, _ := database.CreateChannel("private", "text", "", "", 0)
|
||||
|
||||
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride insert: %v", err)
|
||||
}
|
||||
allow, deny, err := database.GetChannelPermissions(chID, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != 0 || deny != 0x202 {
|
||||
t.Errorf("after insert: got (%#x, %#x), want (0, 0x202)", allow, deny)
|
||||
}
|
||||
|
||||
// Upsert again with different bits — must update, not duplicate.
|
||||
if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride update: %v", err)
|
||||
}
|
||||
allow, deny, err = database.GetChannelPermissions(chID, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != 0x2 || deny != 0x200 {
|
||||
t.Errorf("after update: got (%#x, %#x), want (0x2, 0x200)", allow, deny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteChannelOverride(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
chID, _ := database.CreateChannel("private2", "text", "", "", 0)
|
||||
|
||||
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride: %v", err)
|
||||
}
|
||||
if err := database.DeleteChannelOverride(chID, 4); err != nil {
|
||||
t.Fatalf("DeleteChannelOverride: %v", err)
|
||||
}
|
||||
allow, deny, err := database.GetChannelPermissions(chID, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelPermissions: %v", err)
|
||||
}
|
||||
if allow != 0 || deny != 0 {
|
||||
t.Errorf("after delete: got (%#x, %#x), want (0, 0)", allow, deny)
|
||||
}
|
||||
|
||||
// Deleting again is a no-op.
|
||||
if err := database.DeleteChannelOverride(chID, 4); err != nil {
|
||||
t.Errorf("DeleteChannelOverride non-existent should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannelRoleOverrides(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
chID, _ := database.CreateChannel("private3", "text", "", "", 0)
|
||||
|
||||
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
|
||||
t.Fatalf("UpsertChannelOverride: %v", err)
|
||||
}
|
||||
|
||||
overrides, err := database.ListChannelRoleOverrides(chID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChannelRoleOverrides: %v", err)
|
||||
}
|
||||
// All four seeded roles must be present, position descending (Owner first).
|
||||
if len(overrides) != 4 {
|
||||
t.Fatalf("expected 4 roles, got %d", len(overrides))
|
||||
}
|
||||
if overrides[0].RoleID != 1 || overrides[0].RoleName != "Owner" {
|
||||
t.Errorf("first role = (%d, %q), want (1, Owner)", overrides[0].RoleID, overrides[0].RoleName)
|
||||
}
|
||||
for _, o := range overrides {
|
||||
switch o.RoleID {
|
||||
case 4:
|
||||
if o.Deny != 0x202 || o.Allow != 0 {
|
||||
t.Errorf("member override = (%#x, %#x), want (0, 0x202)", o.Allow, o.Deny)
|
||||
}
|
||||
default:
|
||||
if o.Allow != 0 || o.Deny != 0 {
|
||||
t.Errorf("role %d override = (%#x, %#x), want zeros", o.RoleID, o.Allow, o.Deny)
|
||||
}
|
||||
}
|
||||
if o.Permissions == 0 {
|
||||
t.Errorf("role %d permissions should be non-zero", o.RoleID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SetChannelSlowMode ─────────────────────────────────────────────────────
|
||||
|
||||
func TestSetChannelSlowMode(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,13 @@ const (
|
||||
Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks
|
||||
)
|
||||
|
||||
// AllPerms is the union of every defined permission bit. Use it to mask
|
||||
// externally supplied permission values so unknown bits are dropped.
|
||||
const AllPerms = SendMessages | ReadMessages | AttachFiles | AddReactions |
|
||||
ConnectVoice | SpeakVoice | UseVideo | ShareScreen |
|
||||
ManageMessages | ManageChannels | KickMembers | BanMembers | MuteMembers |
|
||||
ManageRoles | ManageServer | ManageInvites | ViewAuditLog | Administrator
|
||||
|
||||
// ─── Role ID constants (default roles inserted on first run) ─────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -509,6 +509,69 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
|
||||
h.BroadcastToAll(buildChannelDelete(channelID))
|
||||
}
|
||||
|
||||
// RefreshChannelVisibility re-evaluates which connected clients may see ch
|
||||
// after a channel_overrides change and sends targeted channel_create /
|
||||
// channel_delete messages so sidebars converge without a reconnect. Clients
|
||||
// that lose visibility are also unsubscribed from the channel topic and have
|
||||
// their focused channel cleared so live messages stop flowing.
|
||||
//
|
||||
// The sends deliberately bypass the sequenced broadcast/replay path: a
|
||||
// replayed channel_delete would be filtered by the allowed-channel set
|
||||
// computed at replay time, which after an override change is exactly the
|
||||
// inverse of the intended audience. Clients tolerate seq-less messages.
|
||||
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
clients := make([]*Client, 0, len(h.clients))
|
||||
for _, c := range h.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Visibility is a function of the role, so resolve each role once.
|
||||
visibleByRole := make(map[int64]bool)
|
||||
roleVisible := func(roleID int64) bool {
|
||||
if v, ok := visibleByRole[roleID]; ok {
|
||||
return v
|
||||
}
|
||||
visible := false
|
||||
role, err := h.db.GetRoleByID(roleID)
|
||||
if err == nil && role != nil {
|
||||
if permissions.HasAdmin(role.Permissions) {
|
||||
visible = true
|
||||
} else {
|
||||
allow, deny, permErr := h.db.GetChannelPermissions(ch.ID, roleID)
|
||||
// Fail closed: an error hides the channel rather than leaking it.
|
||||
visible = permErr == nil &&
|
||||
permissions.EffectivePerms(role.Permissions, allow, deny)&permissions.ReadMessages != 0
|
||||
}
|
||||
}
|
||||
visibleByRole[roleID] = visible
|
||||
return visible
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
if c.user == nil {
|
||||
continue
|
||||
}
|
||||
if roleVisible(c.user.RoleID) {
|
||||
// Idempotent add on the client; also refreshes channel metadata.
|
||||
c.sendMsg(buildChannelCreate(ch))
|
||||
continue
|
||||
}
|
||||
c.sendMsg(buildChannelDelete(ch.ID))
|
||||
h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))
|
||||
c.mu.Lock()
|
||||
if c.channelID == ch.ID {
|
||||
c.channelID = 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastMemberBan sends a member_ban message to all connected clients
|
||||
// and immediately disconnects the banned user's WebSocket connection (BUG-113).
|
||||
func (h *Hub) BroadcastMemberBan(userID int64) {
|
||||
|
||||
@@ -860,6 +860,100 @@ func TestHub_VoiceSessionCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RefreshChannelVisibility ─────────────────────────────────────────────────
|
||||
|
||||
// drainForMsgType reads messages from send until one with the given type
|
||||
// arrives or the timeout expires. Returns the decoded payload-bearing message.
|
||||
func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case raw := <-send:
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
if msg["type"] == msgType {
|
||||
return msg
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for %q message", msgType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoMsgType asserts that no message of the given type is buffered.
|
||||
func assertNoMsgType(t *testing.T, send chan []byte, msgType string) {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case raw := <-send:
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
if msg["type"] == msgType {
|
||||
t.Fatalf("unexpected %q message", msgType)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshChannelVisibility_TargetedSends(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
chID := seedTestChannel(t, database, "secret-room")
|
||||
ch, err := database.GetChannel(chID)
|
||||
if err != nil || ch == nil {
|
||||
t.Fatalf("GetChannel: %v", err)
|
||||
}
|
||||
|
||||
owner := seedOwnerUser(t, database, "vis-owner")
|
||||
memberID := seedTestUser(t, database, "vis-member")
|
||||
member, err := database.GetUserByID(memberID)
|
||||
if err != nil || member == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
ownerSend := make(chan []byte, 16)
|
||||
memberSend := make(chan []byte, 16)
|
||||
ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend)
|
||||
memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend)
|
||||
hub.Register(ownerClient)
|
||||
hub.Register(memberClient)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
// Hide the channel from the Member role (deny ReadMessages).
|
||||
if _, err := database.Exec(
|
||||
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`,
|
||||
chID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert override: %v", err)
|
||||
}
|
||||
|
||||
hub.RefreshChannelVisibility(ch)
|
||||
|
||||
// Member loses the channel; owner (admin bit) keeps it.
|
||||
drainForMsgType(t, memberSend, "channel_delete")
|
||||
drainForMsgType(t, ownerSend, "channel_create")
|
||||
|
||||
// Restore visibility — the member gets the channel back.
|
||||
if _, err := database.Exec(
|
||||
`DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID,
|
||||
); err != nil {
|
||||
t.Fatalf("delete override: %v", err)
|
||||
}
|
||||
hub.RefreshChannelVisibility(ch)
|
||||
drainForMsgType(t, memberSend, "channel_create")
|
||||
assertNoMsgType(t, memberSend, "channel_delete")
|
||||
}
|
||||
|
||||
// hubTestSchema is the minimal schema needed for hub tests.
|
||||
var hubTestSchema = []byte(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
|
||||
Reference in New Issue
Block a user