mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(b2-5): canonical permission predicates One value-taking predicate per security property in Server/permissions: CanViewChannel, CanAdmitSession (= view), CanSendMessage, CanType (= send), CanJoinVoice, CanModerateVoice, all over a Subject the caller resolves (role bits, both override layers, channel flags, DM state). Checker now resolves a Subject and asks it, so HasChannelPerm, HasChannelPermBatch and VisibleChannelIDs are the same rule rather than three copies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * fix(b2-5): send sites delegate to CanSendMessage (S-01) checkSendPermission, HandleTyping, the ready payload's can_send and the composer refresh all ask permissions.CanSendMessage over a resolved Subject (PermissionService.Subject / ws subjectFor). Typing now follows the post policy: a read-only member, an announcement reader without MANAGE_MESSAGES, an archived channel, a blocked or non-participant DM user emit nothing. Parity tables run each site against the predicate over the same fixture, in both the cached-service and bare-hub branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * refactor(b2-5): view sites delegate to CanViewChannel/CanAdmitSession (S-12) HandleChannelFocus and the post-Subscribe revalidation (applySetChannelID) ask permissions.CanAdmitSession; channelReadAudience and RefreshChannelVisibility ask CanViewChannel — all over a Subject resolved by subjectFor in either the cached-service or bare-hub branch, so no ws path mirrors the visibility rule by hand any more. hasPermChecked is gone with its last caller. Parity tables per site, both branches, every override layer plus an archived channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * refactor(b2-5): voice join sites delegate to CanJoinVoice voice_join, voice_token_refresh, the destination of a moderator move and the stale-voice sweep all ask permissions.CanJoinVoice over the subject the new ws channelSubject resolves (role bits, both override layers, channel flags, DM membership and block state); joinDenial maps a refusal to the frame each reason always produced. hasChannelAccess, hasChannelAccessLive and Hub.requireChannelAccess are gone with their last callers. The sweep now re-runs the whole join rule (a deleted or archived channel, a lost DM membership or a new block evict too, not only a lost CONNECT_VOICE bit), and the token refresh refuses a deleted channel. Parity tables cover the shared resolver, the join gate and the sweep in both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * fix(b2-5): voice moderation delegates to CanModerateVoice (SEC-02 server half) voiceModTarget decides with permissions.CanModerateVoice over the actor's subject in the target's channel: effective MUTE_MEMBERS there (a role-layer or user-layer deny now holds), READ_MESSAGES so a hidden room cannot be moderated, and DM membership for a DM call. The base-bit check stays as an early rejection only, keeping FORBIDDEN ahead of the voice-state lookup. Locked by a table over both override layers, a hidden channel and the Administrator bypass, through the real voice_mod_mute path; the deafen-race fixtures gain the Checker the gate now needs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * docs(b2-5): evidence block, inventory and closed rows Record the B2-5 evidence (pre-squash SHAs, before/after inventory, the SEC-02 READ decision, the residue that leaves the authz-chokepoint rule with B3 item 15) in the plan, mark the step done, and flip S-01, S-12 and the server half of SEC-02 to resolved/superseded in the issue register. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * fix(b2-5): CanJoinVoice refuses an archived DM call too The old voice_join gate refused every archived channel regardless of type, and the admin PATCH accepts archived for a DM; the predicate's DM branch returned before consulting the flag, so join, token refresh and the sweep would have let an evicted participant back into an archived call. Archive is now checked after membership and block for both channel kinds (Codex P2 on #1440), pinned in the predicate table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu * docs(b2-5): record the Codex P2 fix in the evidence block Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
420 lines
16 KiB
Go
420 lines
16 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
)
|
|
|
|
// B2-5 parity tables for the ws call sites that decide a security property:
|
|
// each site is run against the canonical permissions predicate over the same
|
|
// fixture, in both the PermissionService-wired and the bare-hub branch, so
|
|
// the two resolution paths and the rule can never disagree (S-12).
|
|
|
|
// parityOverrideCases are the override layers every ws parity table walks:
|
|
// each one flips a bit the predicates consult.
|
|
var parityOverrideCases = []struct {
|
|
name string
|
|
allow, deny int64 // role layer
|
|
uAllow int64 // user layer
|
|
uDeny int64
|
|
}{
|
|
{"no override", 0, 0, 0, 0},
|
|
{"role deny SEND", 0, permissions.SendMessages, 0, 0},
|
|
{"role deny READ", 0, permissions.ReadMessages, 0, 0},
|
|
{"role deny CONNECT", 0, permissions.ConnectVoice, 0, 0},
|
|
{"role deny MUTE", 0, permissions.MuteMembers, 0, 0},
|
|
{"role allow MANAGE", permissions.ManageMessages, 0, 0, 0},
|
|
{"user deny SEND", 0, 0, 0, permissions.SendMessages},
|
|
{"user deny READ", 0, 0, 0, permissions.ReadMessages},
|
|
{"user deny MUTE", 0, 0, 0, permissions.MuteMembers},
|
|
{"user allow MANAGE", 0, 0, permissions.ManageMessages, 0},
|
|
{"user allow beats role deny", 0, permissions.SendMessages, permissions.SendMessages, 0},
|
|
}
|
|
|
|
// setParityOverrides installs one case's layers for (role, user) on the
|
|
// channel and drops the permission cache so the service branch re-reads.
|
|
func setParityOverrides(t *testing.T, database *db.DB, permSvc *service.PermissionService, chID, roleID, userID int64, c struct {
|
|
name string
|
|
allow, deny int64
|
|
uAllow int64
|
|
uDeny int64
|
|
},
|
|
) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if err := database.UpsertChannelOverride(ctx, chID, roleID, c.allow, c.deny); err != nil {
|
|
t.Fatalf("%s: UpsertChannelOverride: %v", c.name, err)
|
|
}
|
|
if err := database.UpsertChannelUserOverride(ctx, chID, userID, c.uAllow, c.uDeny); err != nil {
|
|
t.Fatalf("%s: UpsertChannelUserOverride: %v", c.name, err)
|
|
}
|
|
permSvc.InvalidateAll()
|
|
}
|
|
|
|
// paritySubject resolves the subject the way the test wants it, straight from
|
|
// the Checker, so the site under test is compared against an independent
|
|
// resolution rather than its own.
|
|
func paritySubject(t *testing.T, database *db.DB, userID int64, ch *db.Channel) permissions.Subject {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
role, err := database.GetRoleForUser(ctx, userID)
|
|
if err != nil || role == nil {
|
|
t.Fatalf("GetRoleForUser(%d): %v", userID, err)
|
|
}
|
|
sub, err := permissions.NewChecker(database).Subject(ctx, role.Permissions, role.ID, userID, ch.ID)
|
|
if err != nil {
|
|
t.Fatalf("Checker.Subject: %v", err)
|
|
}
|
|
sub.Channel = channelRef(ch)
|
|
return sub
|
|
}
|
|
|
|
// TestRefreshChannelVisibilityCanSend_Parity: the composer refresh verdict is
|
|
// CanSendMessage in both branches, for text and announcement channels.
|
|
func TestRefreshChannelVisibilityCanSend_Parity(t *testing.T) {
|
|
ctx := context.Background()
|
|
database := newHarvestVoiceDB(t)
|
|
uid := seedHarvestVoiceUser(t, database, "refresh-parity-user")
|
|
textID := mustCreateVoiceChannel(t, database, "refresh-parity-text")
|
|
if _, err := database.ExecContext(ctx, `UPDATE channels SET type = 'text' WHERE id = ?`, textID); err != nil {
|
|
t.Fatalf("retype: %v", err)
|
|
}
|
|
newsID := mustCreateVoiceChannel(t, database, "refresh-parity-news")
|
|
if _, err := database.ExecContext(ctx, `UPDATE channels SET type = 'announcement' WHERE id = ?`, newsID); err != nil {
|
|
t.Fatalf("retype: %v", err)
|
|
}
|
|
|
|
h := NewHub(database, auth.NewRateLimiter(), nil)
|
|
permSvc := service.NewPermissionService(database, h.permChecker)
|
|
|
|
for _, chID := range []int64{textID, newsID} {
|
|
ch, err := database.GetChannel(ctx, chID)
|
|
if err != nil || ch == nil {
|
|
t.Fatalf("GetChannel(%d): %v", chID, err)
|
|
}
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, database, permSvc, chID, harvestVoiceRoleID, uid, c)
|
|
want := permissions.CanSendMessage(paritySubject(t, database, uid, ch)) == nil
|
|
|
|
h.perms = nil
|
|
if got := h.refreshChannelVisibilityCanSend(ctx, ch, uid); got != want {
|
|
t.Errorf("%s/%s bare hub: refreshChannelVisibilityCanSend = %v, CanSendMessage = %v", ch.Type, c.name, got, want)
|
|
}
|
|
h.perms = permSvc
|
|
if got := h.refreshChannelVisibilityCanSend(ctx, ch, uid); got != want {
|
|
t.Errorf("%s/%s service: refreshChannelVisibilityCanSend = %v, CanSendMessage = %v", ch.Type, c.name, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// viewParityFixture is a bare hub with one registered client on a text
|
|
// channel plus a second, archived channel, shared by the view-property
|
|
// parity tables below.
|
|
type viewParityFixture struct {
|
|
h *Hub
|
|
database *db.DB
|
|
permSvc *service.PermissionService
|
|
user *db.User
|
|
textID int64
|
|
oldID int64 // archived
|
|
client *Client
|
|
send chan []byte
|
|
}
|
|
|
|
func newViewParityFixture(t *testing.T) *viewParityFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
database := newHarvestVoiceDB(t)
|
|
uid := seedHarvestVoiceUser(t, database, "view-parity-user")
|
|
textID := mustCreateVoiceChannel(t, database, "view-parity-text")
|
|
oldID := mustCreateVoiceChannel(t, database, "view-parity-old")
|
|
if _, err := database.ExecContext(ctx, `UPDATE channels SET type = 'text' WHERE id IN (?, ?)`, textID, oldID); err != nil {
|
|
t.Fatalf("retype: %v", err)
|
|
}
|
|
if _, err := database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = ?`, oldID); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
h := NewHub(database, auth.NewRateLimiter(), nil)
|
|
user, err := database.GetUserByID(ctx, uid)
|
|
if err != nil || user == nil {
|
|
t.Fatalf("GetUserByID: %v", err)
|
|
}
|
|
send := make(chan []byte, 64)
|
|
c := NewTestClientWithUser(h, user, textID, send)
|
|
h.RegisterNowForTest(c)
|
|
return &viewParityFixture{
|
|
h: h, database: database, permSvc: service.NewPermissionService(database, h.permChecker),
|
|
user: user, textID: textID, oldID: oldID, client: c, send: send,
|
|
}
|
|
}
|
|
|
|
func (f *viewParityFixture) channel(t *testing.T, id int64) *db.Channel {
|
|
t.Helper()
|
|
ch, err := f.database.GetChannel(context.Background(), id)
|
|
if err != nil || ch == nil {
|
|
t.Fatalf("GetChannel(%d): %v", id, err)
|
|
}
|
|
return ch
|
|
}
|
|
|
|
// eachBranch runs fn once with the bare hub and once with the cached
|
|
// PermissionService wired, labelling the branch.
|
|
func (f *viewParityFixture) eachBranch(fn func(branch string)) {
|
|
f.h.perms = nil
|
|
fn("bare")
|
|
f.h.perms = f.permSvc
|
|
fn("service")
|
|
}
|
|
|
|
// TestApplySetChannelID_Parity: the post-Subscribe revalidation keeps the
|
|
// subscription exactly when CanAdmitSession allows it — for every override
|
|
// layer, and for an archived channel.
|
|
func TestApplySetChannelID_Parity(t *testing.T) {
|
|
f := newViewParityFixture(t)
|
|
for _, chID := range []int64{f.textID, f.oldID} {
|
|
ch := f.channel(t, chID)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := permissions.CanAdmitSession(paritySubject(t, f.database, f.user.ID, ch)) == nil
|
|
f.eachBranch(func(branch string) {
|
|
f.h.applySetChannelID(f.client, 0) // a same-channel focus is a no-op; refocus from scratch
|
|
f.h.applySetChannelID(f.client, chID)
|
|
if got := f.h.SubscribedToChannelTopicForTest(f.client, chID); got != want {
|
|
t.Errorf("chan=%d/%s/%s: subscribed = %v, CanAdmitSession = %v", chID, c.name, branch, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChannelReadAudience_Parity: a connected user is in a channel's read
|
|
// audience exactly when CanViewChannel allows it.
|
|
func TestChannelReadAudience_Parity(t *testing.T) {
|
|
f := newViewParityFixture(t)
|
|
ctx := context.Background()
|
|
for _, chID := range []int64{f.textID, f.oldID} {
|
|
ch := f.channel(t, chID)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := permissions.CanViewChannel(paritySubject(t, f.database, f.user.ID, ch)) == nil
|
|
f.eachBranch(func(branch string) {
|
|
got := false
|
|
for _, uid := range f.h.channelReadAudience(ctx, chID) {
|
|
if uid == f.user.ID {
|
|
got = true
|
|
}
|
|
}
|
|
if got != want {
|
|
t.Errorf("chan=%d/%s/%s: in audience = %v, CanViewChannel = %v", chID, c.name, branch, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// voiceParityFixture adds to the view fixture a voice channel, an archived
|
|
// voice channel, and a DM with a second user (optionally blocking).
|
|
type voiceParityFixture struct {
|
|
*viewParityFixture
|
|
voiceID, oldVoiceID, dmID int64
|
|
other int64
|
|
}
|
|
|
|
func newVoiceParityFixture(t *testing.T) *voiceParityFixture {
|
|
t.Helper()
|
|
f := newViewParityFixture(t)
|
|
ctx := context.Background()
|
|
voiceID := mustCreateVoiceChannel(t, f.database, "voice-parity")
|
|
oldVoiceID := mustCreateVoiceChannel(t, f.database, "voice-parity-old")
|
|
if _, err := f.database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = ?`, oldVoiceID); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
other := seedHarvestVoiceUser(t, f.database, "voice-parity-other")
|
|
res, err := f.database.ExecContext(ctx, `INSERT INTO channels (name, type, position) VALUES ('dm-parity', 'dm', 0)`)
|
|
if err != nil {
|
|
t.Fatalf("insert dm: %v", err)
|
|
}
|
|
dmID, _ := res.LastInsertId()
|
|
for _, uid := range []int64{f.user.ID, other} {
|
|
if _, err := f.database.ExecContext(ctx, `INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, dmID, uid); err != nil {
|
|
t.Fatalf("insert dm participant: %v", err)
|
|
}
|
|
}
|
|
return &voiceParityFixture{viewParityFixture: f, voiceID: voiceID, oldVoiceID: oldVoiceID, dmID: dmID, other: other}
|
|
}
|
|
|
|
// setBlocked makes other block the fixture user (or clears the block).
|
|
func (f *voiceParityFixture) setBlocked(t *testing.T, blocked bool) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if _, err := f.database.ExecContext(ctx, `DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?`, f.other, f.user.ID); err != nil {
|
|
t.Fatalf("clear block: %v", err)
|
|
}
|
|
if blocked {
|
|
if _, err := f.database.ExecContext(ctx, `INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`, f.other, f.user.ID); err != nil {
|
|
t.Fatalf("insert block: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// joinWant is the independent CanJoinVoice verdict for the fixture user on
|
|
// ch: Checker-resolved bits plus DM state read straight from the tables.
|
|
func (f *voiceParityFixture) joinWant(t *testing.T, ch *db.Channel, blocked bool) error {
|
|
t.Helper()
|
|
sub := paritySubject(t, f.database, f.user.ID, ch)
|
|
if ch.Type == "dm" {
|
|
sub.DMParticipant = true
|
|
sub.DMBlocked = blocked
|
|
}
|
|
return permissions.CanJoinVoice(sub)
|
|
}
|
|
|
|
// TestChannelSubject_Parity: the shared resolver every voice site feeds the
|
|
// predicates agrees with an independent resolution in both branches,
|
|
// including DM membership and block state.
|
|
func TestChannelSubject_Parity(t *testing.T) {
|
|
f := newVoiceParityFixture(t)
|
|
ctx := context.Background()
|
|
for _, chID := range []int64{f.voiceID, f.oldVoiceID, f.dmID} {
|
|
ch := f.channel(t, chID)
|
|
for _, blocked := range []bool{false, true} {
|
|
f.setBlocked(t, blocked)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := paritySubject(t, f.database, f.user.ID, ch)
|
|
if ch.Type == "dm" {
|
|
want.DMParticipant = true
|
|
want.DMBlocked = blocked
|
|
}
|
|
f.eachBranch(func(branch string) {
|
|
got, err := channelSubject(ctx, f.database, f.h.permChecker, f.h.perms, f.user.ID, ch, true)
|
|
if err != nil {
|
|
t.Fatalf("chan=%d/%s/%s: channelSubject: %v", chID, c.name, branch, err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("chan=%d/blocked=%v/%s/%s: channelSubject = %+v, want %+v", chID, blocked, c.name, branch, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVoiceJoinPrecheck_Parity: the voice_join gate refuses exactly when
|
|
// CanJoinVoice refuses, with the frame joinDenial maps that reason to; when
|
|
// the predicate allows, the only refusal left is the fixture having no
|
|
// LiveKit (VOICE_ERROR), which proves the gate was passed.
|
|
func TestVoiceJoinPrecheck_Parity(t *testing.T) {
|
|
f := newVoiceParityFixture(t)
|
|
f.h.limiter = nil // the table would trip the per-user join limit
|
|
ctx := context.Background()
|
|
for _, chID := range []int64{f.voiceID, f.oldVoiceID, f.textID, f.dmID} {
|
|
ch := f.channel(t, chID)
|
|
for _, blocked := range []bool{false, true} {
|
|
f.setBlocked(t, blocked)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := f.joinWant(t, ch, blocked)
|
|
f.eachBranch(func(branch string) {
|
|
for len(f.send) > 0 {
|
|
<-f.send
|
|
}
|
|
_, _, ok := f.h.voiceJoinPrecheck(ctx, f.client, json.RawMessage(fmt.Sprintf(`{"channel_id":%d}`, chID)))
|
|
if ok {
|
|
t.Fatalf("chan=%d/%s/%s: precheck passed with no LiveKit configured", chID, c.name, branch)
|
|
}
|
|
var env struct {
|
|
Payload struct {
|
|
Code string `json:"code"`
|
|
} `json:"payload"`
|
|
}
|
|
select {
|
|
case raw := <-f.send:
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("chan=%d/%s/%s: no error frame", chID, c.name, branch)
|
|
}
|
|
wantCode := ErrCodeVoiceError // allowed: refused only by the missing LiveKit
|
|
if want != nil {
|
|
wantCode = joinDenial(want).Code
|
|
}
|
|
if env.Payload.Code != wantCode {
|
|
t.Errorf("chan=%d/blocked=%v/%s/%s: frame %s, CanJoinVoice says %v (want %s)", chID, blocked, c.name, branch, env.Payload.Code, want, wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVoiceStillAllowed_Parity: the sweep's re-check is CanJoinVoice over the
|
|
// live subject, so it evicts exactly what the join gate would now refuse.
|
|
func TestVoiceStillAllowed_Parity(t *testing.T) {
|
|
f := newVoiceParityFixture(t)
|
|
ctx := context.Background()
|
|
for _, chID := range []int64{f.voiceID, f.oldVoiceID, f.dmID} {
|
|
ch := f.channel(t, chID)
|
|
for _, blocked := range []bool{false, true} {
|
|
f.setBlocked(t, blocked)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := f.joinWant(t, ch, blocked) == nil
|
|
got, err := f.h.voiceStillAllowed(ctx, f.user.ID, chID)
|
|
if err != nil {
|
|
t.Fatalf("chan=%d/%s: voiceStillAllowed: %v", chID, c.name, err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("chan=%d/blocked=%v/%s: voiceStillAllowed = %v, CanJoinVoice = %v", chID, blocked, c.name, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if got, err := f.h.voiceStillAllowed(ctx, f.user.ID, 999999); err != nil || got {
|
|
t.Errorf("deleted channel: allowed=%v err=%v, want a refusal with no error", got, err)
|
|
}
|
|
}
|
|
|
|
// TestRefreshChannelVisibility_Parity: the fan-out sends channel_create
|
|
// exactly when CanViewChannel allows and channel_delete otherwise.
|
|
func TestRefreshChannelVisibility_Parity(t *testing.T) {
|
|
f := newViewParityFixture(t)
|
|
for _, chID := range []int64{f.textID, f.oldID} {
|
|
ch := f.channel(t, chID)
|
|
for _, c := range parityOverrideCases {
|
|
setParityOverrides(t, f.database, f.permSvc, chID, harvestVoiceRoleID, f.user.ID, c)
|
|
want := MsgTypeChannelDelete
|
|
if permissions.CanViewChannel(paritySubject(t, f.database, f.user.ID, ch)) == nil {
|
|
want = MsgTypeChannelCreate
|
|
}
|
|
f.eachBranch(func(branch string) {
|
|
f.h.RefreshChannelVisibility(ch)
|
|
var env struct {
|
|
Type string `json:"type"`
|
|
}
|
|
select {
|
|
case raw := <-f.send:
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("chan=%d/%s/%s: no frame from RefreshChannelVisibility", chID, c.name, branch)
|
|
}
|
|
if env.Type != want {
|
|
t.Errorf("chan=%d/%s/%s: got %s, CanViewChannel says %s", chID, c.name, branch, env.Type, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|