Skip to main content

babyrite/expand/
discord.rs

1//! Discord message link expansion.
2//!
3//! This module provides functionality for parsing Discord message links
4//! and generating embed previews of the linked messages.
5//!
6//! Migrated from `preview.rs` with support for multiple link expansion.
7
8use regex::Regex;
9use serenity::all::{
10    ChannelId, ChannelType, Context, GuildChannel, GuildId, Message, MessageId,
11    PermissionOverwrite, PermissionOverwriteType, Permissions, RoleId,
12};
13use serenity_builder::model::embed::SerenityEmbed;
14use std::collections::{HashMap, HashSet};
15use std::sync::LazyLock;
16
17use super::{ExpandContext, ExpandError, ExpandedContent, LinkExpander};
18use crate::cache::CacheArgs;
19use crate::config::BabyriteConfig;
20use serenity::futures::future::join_all;
21
22/// Regex pattern for matching Discord message links.
23///
24/// Supports production, PTB, and Canary Discord URLs.
25pub static MESSAGE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r"https://(?:ptb\.|canary\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)").unwrap()
27});
28
29/// Parsed IDs from a Discord message link.
30#[derive(Debug)]
31pub struct MessageLinkIDs {
32    /// The guild ID from the message link.
33    pub guild_id: GuildId,
34    /// The channel ID from the message link.
35    pub channel_id: ChannelId,
36    /// The message ID from the message link.
37    pub message_id: MessageId,
38}
39
40/// A preview containing the message and its channel.
41#[derive(Debug)]
42pub struct Preview {
43    /// The message to preview.
44    pub message: Message,
45    /// The channel containing the message.
46    pub channel: GuildChannel,
47}
48
49/// Discord message link expander.
50pub struct DiscordExpander;
51
52#[serenity::async_trait]
53impl LinkExpander for DiscordExpander {
54    /// Discord link expansion is the bot's core function and has no feature flag.
55    fn enabled(&self, _config: &BabyriteConfig) -> bool {
56        true
57    }
58
59    /// Expands Discord message links into embed previews.
60    ///
61    /// Cross-guild links are skipped. The source channel is resolved once — the
62    /// expanded preview is posted there, so it is needed to verify each link
63    /// target is at least as visible as that channel. If it cannot be resolved,
64    /// Discord expansion is skipped entirely (other expanders are unaffected).
65    #[cfg_attr(coverage_nightly, coverage(off))]
66    async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent> {
67        let links = MessageLinkIDs::parse_all(&cx.message.content);
68        if links.is_empty() {
69            return Vec::new();
70        }
71        tracing::debug!(count = links.len(), "parsed Discord links");
72
73        let links: Vec<_> = links
74            .into_iter()
75            .filter(|ids| {
76                if ids.guild_id != cx.guild_id {
77                    tracing::debug!(
78                        link_guild_id = %ids.guild_id,
79                        "skipping cross-guild Discord link"
80                    );
81                    return false;
82                }
83                true
84            })
85            .collect();
86        if links.is_empty() {
87            return Vec::new();
88        }
89
90        let source_channel = match (CacheArgs {
91            guild_id: cx.guild_id,
92            channel_id: cx.message.channel_id,
93        })
94        .get(cx.ctx)
95        .await
96        {
97            Ok(channel) => channel,
98            Err(e) => {
99                tracing::error!(error = %e, "failed to resolve source channel");
100                return Vec::new();
101            }
102        };
103
104        join_all(links.iter().map(|ids| ids.fetch(cx.ctx, &source_channel)))
105            .await
106            .into_iter()
107            .filter_map(|result| match result {
108                Ok(content) => Some(content),
109                Err(e) => {
110                    tracing::error!(error = %e, "failed to expand Discord link");
111                    None
112                }
113            })
114            .collect()
115    }
116}
117
118/// Errors that can occur when generating a Discord message preview.
119#[derive(thiserror::Error, Debug)]
120pub enum PreviewError {
121    /// Failed to retrieve channel information from cache.
122    #[error("Failed to retrieve from cache.")]
123    Cache,
124    /// The target channel is marked as NSFW.
125    #[error("NSFW content previews are not permitted, but the channel is marked as NSFW.")]
126    Nsfw,
127    /// The target channel is private or a private thread.
128    #[error("The channel is a private channel or private thread.")]
129    Permission,
130    /// An error occurred while communicating with Discord.
131    #[allow(clippy::enum_variant_names)]
132    #[error(transparent)]
133    SerenityError(#[from] serenity::Error),
134}
135
136impl MessageLinkIDs {
137    /// Parses all Discord message links from the given text.
138    ///
139    /// Returns a `Vec<MessageLinkIDs>` containing all valid message links found.
140    /// The shared link policy applies (see [`super::parse_links`]): angle-bracket
141    /// wrapped and duplicate URLs are ignored, and at most 3 links are returned.
142    pub fn parse_all(text: &str) -> Vec<MessageLinkIDs> {
143        super::parse_links(text, &MESSAGE_LINK_REGEX, |captures| {
144            Some(MessageLinkIDs {
145                guild_id: GuildId::new(captures.get(1)?.as_str().parse().ok()?),
146                channel_id: ChannelId::new(captures.get(2)?.as_str().parse().ok()?),
147                message_id: MessageId::new(captures.get(3)?.as_str().parse().ok()?),
148            })
149        })
150    }
151
152    /// Fetches the linked message and returns an embed preview.
153    ///
154    /// `source_channel` is the channel where the request originated. It is used to
155    /// ensure the linked content is not exposed to members who could not otherwise
156    /// view it (see [`Preview::get`]).
157    #[cfg_attr(coverage_nightly, coverage(off))]
158    #[tracing::instrument(
159        skip(self, ctx, source_channel),
160        fields(
161            guild_id = %self.guild_id,
162            channel_id = %self.channel_id,
163            message_id = %self.message_id,
164        )
165    )]
166    pub async fn fetch(
167        &self,
168        ctx: &Context,
169        source_channel: &GuildChannel,
170    ) -> Result<ExpandedContent, ExpandError> {
171        let Preview { message, channel } = Preview::get(self, ctx, source_channel).await?;
172
173        let author_icon_url = message.author.avatar_url().unwrap_or_default();
174        let embed = SerenityEmbed::builder()
175            .description(message.content)
176            .author_name(message.author.name)
177            .author_icon_url(author_icon_url)
178            .footer_text(channel.name)
179            .timestamp(message.timestamp)
180            .color(0x7A4AFFu32)
181            .image_url(
182                message
183                    .attachments
184                    .first()
185                    .map(|a| a.url.clone())
186                    .unwrap_or_default(),
187            )
188            .build();
189
190        Ok(ExpandedContent::Embed(Box::new(embed)))
191    }
192}
193
194/// Returns `true` for thread channel types.
195///
196/// Threads do not carry their own permission overwrites; their visibility
197/// follows the parent channel. This is used to decide whether visibility must be
198/// resolved against the parent (see [`permission_channel`]).
199fn is_thread(kind: ChannelType) -> bool {
200    matches!(
201        kind,
202        ChannelType::NewsThread | ChannelType::PublicThread | ChannelType::PrivateThread
203    )
204}
205
206/// Returns `true` if any per-member overwrite denies `VIEW_CHANNEL`.
207///
208/// Per-member overwrites cannot be captured by the role-set comparison in
209/// [`viewing_roles`], so their presence forces a conservative rejection.
210fn has_member_view_deny(overwrites: &[PermissionOverwrite]) -> bool {
211    overwrites.iter().any(|ow| {
212        matches!(ow.kind, PermissionOverwriteType::Member(_))
213            && ow.deny.contains(Permissions::VIEW_CHANNEL)
214    })
215}
216
217/// Returns `true` when the link's visibility must be validated against the
218/// request's source channel.
219///
220/// A link pointing back into the same channel the request came from is always
221/// safe to expand: the reply lands in that very channel, so it cannot expose
222/// anything its readers cannot already see. Such links need no visibility
223/// checks, while links to any other channel do.
224fn requires_visibility_check(target: ChannelId, source: ChannelId) -> bool {
225    target != source
226}
227
228/// Computes the set of roles that can effectively `VIEW_CHANNEL` a channel.
229///
230/// `@everyone` (role id == guild id) is treated as a normal role and included in
231/// the result when applicable. For each role the effective permission is
232/// `@everyone perms | role perms`; a role with `ADMINISTRATOR` always views the
233/// channel. Otherwise the channel's `@everyone` overwrite is applied first, then
234/// the role's own overwrite, each as deny-then-allow.
235fn viewing_roles(
236    overwrites: &[PermissionOverwrite],
237    role_perms: &HashMap<RoleId, Permissions>,
238    everyone_role_id: RoleId,
239) -> HashSet<RoleId> {
240    let everyone_base = role_perms
241        .get(&everyone_role_id)
242        .copied()
243        .unwrap_or_else(Permissions::empty);
244
245    let overwrite_by_role: HashMap<RoleId, (Permissions, Permissions)> = overwrites
246        .iter()
247        .filter_map(|ow| match ow.kind {
248            PermissionOverwriteType::Role(id) => Some((id, (ow.allow, ow.deny))),
249            _ => None,
250        })
251        .collect();
252
253    let mut set = HashSet::new();
254    for (&role_id, &perms) in role_perms {
255        let base = everyone_base | perms;
256        if base.contains(Permissions::ADMINISTRATOR) {
257            set.insert(role_id);
258            continue;
259        }
260
261        let mut allowed = base.contains(Permissions::VIEW_CHANNEL);
262        for target in [everyone_role_id, role_id] {
263            if let Some(&(allow, deny)) = overwrite_by_role.get(&target) {
264                if deny.contains(Permissions::VIEW_CHANNEL) {
265                    allowed = false;
266                }
267                if allow.contains(Permissions::VIEW_CHANNEL) {
268                    allowed = true;
269                }
270            }
271        }
272
273        if allowed {
274            set.insert(role_id);
275        }
276    }
277    set
278}
279
280/// Resolves the channel whose permission overwrites determine visibility.
281///
282/// Threads inherit visibility from their parent channel, so for any thread the
283/// parent channel is fetched and returned. Non-thread channels are returned
284/// unchanged. A thread without a `parent_id` is treated as an error.
285#[cfg_attr(coverage_nightly, coverage(off))]
286async fn permission_channel(
287    channel: &GuildChannel,
288    ctx: &Context,
289) -> Result<GuildChannel, PreviewError> {
290    if !is_thread(channel.kind) {
291        return Ok(channel.clone());
292    }
293
294    let parent_id = channel.parent_id.ok_or(PreviewError::Permission)?;
295    CacheArgs {
296        guild_id: channel.guild_id,
297        channel_id: parent_id,
298    }
299    .get(ctx)
300    .await
301    .map_err(|_| PreviewError::Cache)
302}
303
304/// Validates that everyone who can view `source_channel` could also view `channel`.
305///
306/// The expanded content is posted as a single message that all members of
307/// `source_channel` can read, so the linked channel must be at least as visible
308/// as the source channel to avoid leaking restricted content.
309#[cfg_attr(coverage_nightly, coverage(off))]
310async fn check_visibility(
311    channel: &GuildChannel,
312    source_channel: &GuildChannel,
313    guild_id: GuildId,
314    ctx: &Context,
315) -> Result<(), PreviewError> {
316    // Private threads cannot be represented by the role-set comparison
317    // (membership is per-user), and DMs are outside the guild context, so
318    // both are rejected. Public/news threads fall through and are judged via
319    // their parent channel.
320    if matches!(
321        channel.kind,
322        ChannelType::PrivateThread | ChannelType::Private
323    ) {
324        tracing::debug!(kind = ?channel.kind, "rejected: private channel or thread");
325        return Err(PreviewError::Permission);
326    }
327
328    // Threads follow their parent channel's permissions, so resolve both the
329    // link target and the request source to the channel that actually
330    // defines visibility before comparing.
331    let (dest_perm, source_perm) = tokio::try_join!(
332        permission_channel(channel, ctx),
333        permission_channel(source_channel, ctx),
334    )?;
335
336    // A per-member deny on the target cannot be represented in the role-set
337    // comparison below, so reject conservatively.
338    if has_member_view_deny(&dest_perm.permission_overwrites) {
339        tracing::debug!("rejected: target has a per-member VIEW_CHANNEL deny");
340        return Err(PreviewError::Permission);
341    }
342
343    let everyone_role_id = RoleId::new(guild_id.get());
344    // Clone the role permission map out of the cache so the non-`Send`
345    // `GuildRef` is dropped immediately — holding it across an `await` would
346    // make the future `!Send` and fail to compile in the event handler.
347    let role_perms: HashMap<RoleId, Permissions> = {
348        let guild = ctx.cache.guild(guild_id).ok_or(PreviewError::Permission)?;
349        guild
350            .roles
351            .iter()
352            .map(|(&id, role)| (id, role.permissions))
353            .collect()
354    };
355
356    let dest_roles = viewing_roles(
357        &dest_perm.permission_overwrites,
358        &role_perms,
359        everyone_role_id,
360    );
361    let source_roles = viewing_roles(
362        &source_perm.permission_overwrites,
363        &role_perms,
364        everyone_role_id,
365    );
366    if !source_roles.is_subset(&dest_roles) {
367        tracing::debug!(
368            source_roles = source_roles.len(),
369            dest_roles = dest_roles.len(),
370            "rejected: source channel is more visible than the target"
371        );
372        return Err(PreviewError::Permission);
373    }
374
375    Ok(())
376}
377
378impl Preview {
379    /// Retrieves a preview for the given message link.
380    ///
381    /// Validates that the linked channel is not NSFW, is not a private thread or
382    /// DM, and that everyone who can view the request's `source_channel` could
383    /// also view the linked channel. The expanded content is posted as a single
384    /// message that all members of `source_channel` can read, so the linked
385    /// channel must be at least as visible as the source channel to avoid leaking
386    /// restricted content. Public and news threads are judged by their parent
387    /// channel's permissions, since threads do not carry their own overwrites.
388    ///
389    /// When the link target is the same channel as `source_channel`, the
390    /// visibility checks are skipped entirely: the reply lands in that same
391    /// channel, so it cannot expose anything its readers cannot already see.
392    #[cfg_attr(coverage_nightly, coverage(off))]
393    #[tracing::instrument(
394        skip(args, ctx, source_channel),
395        fields(
396            guild_id = %args.guild_id,
397            channel_id = %args.channel_id,
398            message_id = %args.message_id,
399        )
400    )]
401    async fn get(
402        args: &MessageLinkIDs,
403        ctx: &Context,
404        source_channel: &GuildChannel,
405    ) -> Result<Preview, PreviewError> {
406        let caches = CacheArgs {
407            guild_id: args.guild_id,
408            channel_id: args.channel_id,
409        };
410
411        let channel = caches.get(ctx).await.map_err(|_| PreviewError::Cache)?;
412        tracing::debug!(kind = ?channel.kind, nsfw = channel.nsfw, "resolved target channel");
413
414        if channel.nsfw {
415            tracing::debug!("rejected: target channel is NSFW");
416            return Err(PreviewError::Nsfw);
417        }
418
419        // When the link points to the same channel the request came from, the
420        // expansion is posted back into that very channel. Every member who can
421        // read the reply can already read the original message, so there is
422        // nothing to leak and the visibility checks can be skipped. This
423        // notably covers quoting within a private channel, which would otherwise
424        // be rejected by the per-member deny guard.
425        if requires_visibility_check(args.channel_id, source_channel.id) {
426            check_visibility(&channel, source_channel, args.guild_id, ctx).await?;
427        }
428
429        let started = std::time::Instant::now();
430        let message = channel.message(&ctx.http, args.message_id).await?;
431        tracing::debug!(
432            elapsed_ms = started.elapsed().as_millis(),
433            "fetched linked message"
434        );
435        Ok(Preview { message, channel })
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn parse_standard_link() {
445        let text = "https://discord.com/channels/123456789/987654321/111111111";
446        let results = MessageLinkIDs::parse_all(text);
447        assert_eq!(results.len(), 1);
448        assert_eq!(results[0].guild_id, GuildId::new(123456789));
449        assert_eq!(results[0].channel_id, ChannelId::new(987654321));
450        assert_eq!(results[0].message_id, MessageId::new(111111111));
451    }
452
453    #[test]
454    fn parse_ptb_link() {
455        let text = "https://ptb.discord.com/channels/123/456/789";
456        let results = MessageLinkIDs::parse_all(text);
457        assert_eq!(results.len(), 1);
458        assert_eq!(results[0].guild_id, GuildId::new(123));
459    }
460
461    #[test]
462    fn parse_canary_link() {
463        let text = "https://canary.discord.com/channels/123/456/789";
464        let results = MessageLinkIDs::parse_all(text);
465        assert_eq!(results.len(), 1);
466        assert_eq!(results[0].guild_id, GuildId::new(123));
467    }
468
469    #[test]
470    fn parse_multiple_links() {
471        let text = "https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6";
472        let results = MessageLinkIDs::parse_all(text);
473        assert_eq!(results.len(), 2);
474        assert_eq!(results[0].guild_id, GuildId::new(1));
475        assert_eq!(results[1].guild_id, GuildId::new(4));
476    }
477
478    #[test]
479    fn parse_deduplicates() {
480        let text = "https://discord.com/channels/1/2/3 https://discord.com/channels/1/2/3";
481        let results = MessageLinkIDs::parse_all(text);
482        assert_eq!(results.len(), 1);
483    }
484
485    #[test]
486    fn parse_limits_to_three() {
487        let text = "\
488            https://discord.com/channels/1/2/3 \
489            https://discord.com/channels/4/5/6 \
490            https://discord.com/channels/7/8/9 \
491            https://discord.com/channels/10/11/12";
492        let results = MessageLinkIDs::parse_all(text);
493        assert_eq!(results.len(), 3);
494    }
495
496    #[test]
497    fn parse_no_match() {
498        let text = "Just some regular text";
499        let results = MessageLinkIDs::parse_all(text);
500        assert!(results.is_empty());
501    }
502
503    #[test]
504    fn parse_ignores_invalid_url() {
505        // Non-discord domain should not match (regex anchors to discord.com)
506        let text = "https://notdiscord.com/channels/1/2/3";
507        let results = MessageLinkIDs::parse_all(text);
508        assert!(results.is_empty());
509    }
510
511    #[test]
512    fn parse_ignores_angle_bracket_link() {
513        let text = "<https://discord.com/channels/123/456/789>";
514        let results = MessageLinkIDs::parse_all(text);
515        assert!(results.is_empty());
516    }
517
518    #[test]
519    fn parse_mixed_with_text() {
520        let text = "Hey check this out https://discord.com/channels/1/2/3 pretty cool right?";
521        let results = MessageLinkIDs::parse_all(text);
522        assert_eq!(results.len(), 1);
523        assert_eq!(results[0].message_id, MessageId::new(3));
524    }
525
526    // --- Privacy / permission resolution ---
527
528    use serenity::all::UserId;
529
530    /// Builds a role VIEW_CHANNEL overwrite.
531    fn role_ow(id: u64, allow_view: bool, deny_view: bool) -> PermissionOverwrite {
532        PermissionOverwrite {
533            allow: if allow_view {
534                Permissions::VIEW_CHANNEL
535            } else {
536                Permissions::empty()
537            },
538            deny: if deny_view {
539                Permissions::VIEW_CHANNEL
540            } else {
541                Permissions::empty()
542            },
543            kind: PermissionOverwriteType::Role(RoleId::new(id)),
544        }
545    }
546
547    /// Builds a per-member overwrite that denies VIEW_CHANNEL when `deny_view`.
548    fn member_ow(id: u64, deny_view: bool) -> PermissionOverwrite {
549        PermissionOverwrite {
550            allow: Permissions::empty(),
551            deny: if deny_view {
552                Permissions::VIEW_CHANNEL
553            } else {
554                Permissions::empty()
555            },
556            kind: PermissionOverwriteType::Member(UserId::new(id)),
557        }
558    }
559
560    const EVERYONE: RoleId = RoleId::new(1);
561    const MEMBER: RoleId = RoleId::new(100);
562    const SPECIAL: RoleId = RoleId::new(200);
563    const ADMIN: RoleId = RoleId::new(300);
564
565    #[test]
566    fn thread_kinds_detected() {
567        assert!(is_thread(ChannelType::PublicThread));
568        assert!(is_thread(ChannelType::NewsThread));
569        assert!(is_thread(ChannelType::PrivateThread));
570        assert!(!is_thread(ChannelType::Text));
571        assert!(!is_thread(ChannelType::Voice));
572    }
573
574    #[test]
575    fn member_view_deny_detected() {
576        assert!(has_member_view_deny(&[member_ow(5, true)]));
577        // role deny is not a member deny
578        assert!(!has_member_view_deny(&[role_ow(1, false, true)]));
579        // member allow (not deny) does not trigger
580        assert!(!has_member_view_deny(&[member_ow(5, false)]));
581        assert!(!has_member_view_deny(&[]));
582    }
583
584    #[test]
585    fn same_channel_skips_visibility_check() {
586        let chan = ChannelId::new(42);
587        // Quoting within the same channel needs no visibility check.
588        assert!(!requires_visibility_check(chan, chan));
589        // A link to a different channel still requires validation.
590        assert!(requires_visibility_check(chan, ChannelId::new(99)));
591    }
592
593    #[test]
594    fn public_channels_are_subsets() {
595        // @everyone has VIEW_CHANNEL by base permission, no overwrites.
596        let mut roles = HashMap::new();
597        roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
598        roles.insert(MEMBER, Permissions::empty());
599
600        let viewing = viewing_roles(&[], &roles, EVERYONE);
601        assert!(viewing.contains(&EVERYONE));
602        assert!(viewing.contains(&MEMBER));
603        // source == dest -> subset holds
604        assert!(viewing.is_subset(&viewing));
605    }
606
607    #[test]
608    fn role_gate_allows_matching_member_role() {
609        // @everyone has no base view; member role is granted via overwrite.
610        let mut roles = HashMap::new();
611        roles.insert(EVERYONE, Permissions::empty());
612        roles.insert(MEMBER, Permissions::empty());
613
614        let ow = [
615            role_ow(EVERYONE.get(), false, true),
616            role_ow(MEMBER.get(), true, false),
617        ];
618        let viewing = viewing_roles(&ow, &roles, EVERYONE);
619
620        assert!(!viewing.contains(&EVERYONE));
621        assert!(viewing.contains(&MEMBER));
622
623        // Both source and dest gated identically -> subset holds (expansion allowed).
624        let source = viewing_roles(&ow, &roles, EVERYONE);
625        assert!(source.is_subset(&viewing));
626    }
627
628    #[test]
629    fn narrower_target_is_rejected() {
630        let mut roles = HashMap::new();
631        roles.insert(EVERYONE, Permissions::empty());
632        roles.insert(MEMBER, Permissions::empty());
633        roles.insert(SPECIAL, Permissions::empty());
634
635        // Source: role-gated, visible to MEMBER.
636        let source_ow = [
637            role_ow(EVERYONE.get(), false, true),
638            role_ow(MEMBER.get(), true, false),
639        ];
640        let source = viewing_roles(&source_ow, &roles, EVERYONE);
641
642        // Dest: visible only to SPECIAL.
643        let dest_ow = [
644            role_ow(EVERYONE.get(), false, true),
645            role_ow(SPECIAL.get(), true, false),
646        ];
647        let dest = viewing_roles(&dest_ow, &roles, EVERYONE);
648
649        assert!(source.contains(&MEMBER));
650        assert!(!dest.contains(&MEMBER));
651        // MEMBER can see source but not dest -> leak -> not a subset.
652        assert!(!source.is_subset(&dest));
653    }
654
655    #[test]
656    fn administrator_always_views() {
657        let mut roles = HashMap::new();
658        roles.insert(EVERYONE, Permissions::empty());
659        roles.insert(ADMIN, Permissions::ADMINISTRATOR);
660
661        // Even with @everyone denied, an ADMINISTRATOR role still views.
662        let ow = [role_ow(EVERYONE.get(), false, true)];
663        let viewing = viewing_roles(&ow, &roles, EVERYONE);
664        assert!(viewing.contains(&ADMIN));
665        assert!(!viewing.contains(&EVERYONE));
666    }
667
668    #[test]
669    fn other_role_overwrite_does_not_affect_role() {
670        let mut roles = HashMap::new();
671        roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
672        roles.insert(MEMBER, Permissions::empty());
673        roles.insert(SPECIAL, Permissions::empty());
674
675        // Only SPECIAL is denied; MEMBER should be unaffected.
676        let ow = [role_ow(SPECIAL.get(), false, true)];
677        let viewing = viewing_roles(&ow, &roles, EVERYONE);
678        assert!(viewing.contains(&MEMBER));
679        assert!(!viewing.contains(&SPECIAL));
680    }
681
682    #[test]
683    fn missing_everyone_role_defaults_to_no_base() {
684        // @everyone absent from the role map -> base falls back to empty,
685        // so a role with no view permission and no overwrite cannot view.
686        let mut roles = HashMap::new();
687        roles.insert(MEMBER, Permissions::empty());
688
689        let viewing = viewing_roles(&[], &roles, EVERYONE);
690        assert!(!viewing.contains(&MEMBER));
691
692        // The same role gains access once an overwrite allows it.
693        let ow = [role_ow(MEMBER.get(), true, false)];
694        let viewing = viewing_roles(&ow, &roles, EVERYONE);
695        assert!(viewing.contains(&MEMBER));
696    }
697}