Skip to content

Commit 3745970

Browse files
committed
test(coverage): channels incremental push (71→73%)
- presentation.rs: split_sentences, group_sentences, merge_short, segment_delay monotonic/bounded, is_structured_content detection, segment_for_delivery edge cases. - runtime/dispatch.rs: contains_any, starts_with_any, full coverage of select_acknowledgment_reaction across all 7 categories + deterministic + empty/single-char inputs. - commands.rs: doctor_channels with telegram/discord/slack/imessage/ multiple-config branches. - discord/api: check_channel_permissions mock-server tests — admin bypass, all-missing, everyone-allow, channel overwrite deny, member lookup failure. Added check_channel_permissions_at_base seam. - lark: should_refresh_last_recv, LarkChannel::new, is_user_allowed wildcard/empty allowlist, parse_event_payload edge cases (unsupported type / empty sender / missing event / post type). - email_channel: is_sender_allowed full matrix (empty/wildcard/exact/ @-prefix/bare-domain/subdomain-confusion), strip_html empty/tags-only/ unclosed/whitespace collapse. - voice/schemas: tolerate event interleaving in broadcast-channel test (schema bus is process-global).
1 parent 02b4c6f commit 3745970

7 files changed

Lines changed: 690 additions & 6 deletions

File tree

src/openhuman/channels/commands.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,4 +308,89 @@ mod tests {
308308
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
309309
doctor_channels(config).await.unwrap();
310310
}
311+
312+
#[tokio::test]
313+
async fn doctor_channels_runs_with_telegram_config() {
314+
use crate::openhuman::config::{StreamMode, TelegramConfig};
315+
let mut config = Config::default();
316+
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
317+
config.channels_config.telegram = Some(TelegramConfig {
318+
bot_token: "fake:token".into(),
319+
allowed_users: vec!["user1".into()],
320+
stream_mode: StreamMode::default(),
321+
draft_update_interval_ms: 2000,
322+
mention_only: false,
323+
});
324+
let _ = doctor_channels(config).await;
325+
}
326+
327+
#[tokio::test]
328+
async fn doctor_channels_runs_with_discord_config() {
329+
use crate::openhuman::config::DiscordConfig;
330+
let mut config = Config::default();
331+
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
332+
config.channels_config.discord = Some(DiscordConfig {
333+
bot_token: "fake".into(),
334+
guild_id: Some("123".into()),
335+
channel_id: Some("456".into()),
336+
allowed_users: vec![],
337+
listen_to_bots: false,
338+
mention_only: true,
339+
});
340+
let _ = doctor_channels(config).await;
341+
}
342+
343+
#[tokio::test]
344+
async fn doctor_channels_runs_with_slack_config() {
345+
use crate::openhuman::config::SlackConfig;
346+
let mut config = Config::default();
347+
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
348+
config.channels_config.slack = Some(SlackConfig {
349+
bot_token: "fake".into(),
350+
app_token: None,
351+
channel_id: Some("C123".into()),
352+
allowed_users: vec![],
353+
});
354+
let _ = doctor_channels(config).await;
355+
}
356+
357+
#[tokio::test]
358+
async fn doctor_channels_runs_with_imessage_config() {
359+
use crate::openhuman::config::IMessageConfig;
360+
let mut config = Config::default();
361+
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
362+
config.channels_config.imessage = Some(IMessageConfig {
363+
allowed_contacts: vec!["a@b.com".into()],
364+
});
365+
let _ = doctor_channels(config).await;
366+
}
367+
368+
#[tokio::test]
369+
async fn doctor_channels_runs_with_multiple_channels() {
370+
use crate::openhuman::config::{DiscordConfig, SlackConfig, StreamMode, TelegramConfig};
371+
let mut config = Config::default();
372+
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
373+
config.channels_config.telegram = Some(TelegramConfig {
374+
bot_token: "fake".into(),
375+
allowed_users: vec![],
376+
stream_mode: StreamMode::default(),
377+
draft_update_interval_ms: 2000,
378+
mention_only: false,
379+
});
380+
config.channels_config.discord = Some(DiscordConfig {
381+
bot_token: "fake".into(),
382+
guild_id: Some("123".into()),
383+
channel_id: Some("456".into()),
384+
allowed_users: vec![],
385+
listen_to_bots: false,
386+
mention_only: false,
387+
});
388+
config.channels_config.slack = Some(SlackConfig {
389+
bot_token: "fake".into(),
390+
app_token: None,
391+
channel_id: Some("C123".into()),
392+
allowed_users: vec![],
393+
});
394+
let _ = doctor_channels(config).await;
395+
}
311396
}

src/openhuman/channels/providers/discord/api.rs

Lines changed: 155 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,19 @@ pub async fn check_channel_permissions(
130130
token: &str,
131131
guild_id: &str,
132132
channel_id: &str,
133+
) -> anyhow::Result<BotPermissionCheck> {
134+
check_channel_permissions_at_base(DISCORD_API_BASE, token, guild_id, channel_id).await
135+
}
136+
137+
/// Test seam: see [`check_channel_permissions`].
138+
async fn check_channel_permissions_at_base(
139+
base: &str,
140+
token: &str,
141+
guild_id: &str,
142+
channel_id: &str,
133143
) -> anyhow::Result<BotPermissionCheck> {
134144
// Fetch the bot's guild member info which includes computed permissions
135-
let url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/members/@me");
145+
let url = format!("{base}/guilds/{guild_id}/members/@me");
136146
tracing::debug!(
137147
"[discord-api] checking permissions in channel {channel_id} (guild {guild_id})"
138148
);
@@ -152,7 +162,7 @@ pub async fn check_channel_permissions(
152162
let member: serde_json::Value = resp.json().await?;
153163

154164
// Fetch guild roles to compute permissions
155-
let roles_url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/roles");
165+
let roles_url = format!("{base}/guilds/{guild_id}/roles");
156166
let roles_resp = build_client()
157167
.get(&roles_url)
158168
.header("Authorization", auth_header(token))
@@ -200,7 +210,7 @@ pub async fn check_channel_permissions(
200210
}
201211

202212
// Now check channel-level permission overwrites
203-
let channel_url = format!("{DISCORD_API_BASE}/channels/{channel_id}");
213+
let channel_url = format!("{base}/channels/{channel_id}");
204214
let ch_resp = build_client()
205215
.get(&channel_url)
206216
.header("Authorization", auth_header(token))
@@ -515,4 +525,146 @@ mod tests {
515525
let channels = list_guild_channels_at_base(&base, "t", "g").await.unwrap();
516526
assert!(channels.is_empty());
517527
}
528+
529+
// ── check_channel_permissions ─────────────────────────────────
530+
531+
/// Build a mock Discord that answers all three endpoints the permissions
532+
/// check touches: `/guilds/<id>/members/@me`, `/guilds/<id>/roles`, and
533+
/// `/channels/<id>`.
534+
fn permissions_mock(
535+
member: serde_json::Value,
536+
roles: serde_json::Value,
537+
channel: serde_json::Value,
538+
) -> Router {
539+
use axum::extract::Path;
540+
Router::new()
541+
.route(
542+
"/guilds/{guild_id}/members/@me",
543+
get(move |Path(_g): Path<String>| {
544+
let m = member.clone();
545+
async move { Json(m) }
546+
}),
547+
)
548+
.route(
549+
"/guilds/{guild_id}/roles",
550+
get(move |Path(_g): Path<String>| {
551+
let r = roles.clone();
552+
async move { Json(r) }
553+
}),
554+
)
555+
.route(
556+
"/channels/{channel_id}",
557+
get(move |Path(_c): Path<String>| {
558+
let c = channel.clone();
559+
async move { Json(c) }
560+
}),
561+
)
562+
}
563+
564+
#[tokio::test]
565+
async fn check_channel_permissions_administrator_bypasses_everything() {
566+
let member = json!({ "roles": ["role-admin"], "user": { "id": "bot-1" } });
567+
// Role with Administrator bit (1<<3 = 8) — overrides all other checks.
568+
let roles = json!([
569+
{ "id": "role-admin", "permissions": "8" }
570+
]);
571+
let channel = json!({ "permission_overwrites": [] });
572+
let base = spawn_mock(permissions_mock(member, roles, channel)).await;
573+
let out = check_channel_permissions_at_base(&base, "token", "guild-1", "channel-1")
574+
.await
575+
.unwrap();
576+
assert!(out.can_view_channel);
577+
assert!(out.can_send_messages);
578+
assert!(out.can_read_message_history);
579+
assert!(out.missing_permissions.is_empty());
580+
}
581+
582+
#[tokio::test]
583+
async fn check_channel_permissions_flags_missing_bits_when_role_lacks_them() {
584+
// No roles grant any of the 3 permissions → all missing.
585+
let member = json!({ "roles": ["role-nobody"], "user": { "id": "bot-1" } });
586+
let roles = json!([
587+
{ "id": "role-nobody", "permissions": "0" }
588+
]);
589+
let channel = json!({ "permission_overwrites": [] });
590+
let base = spawn_mock(permissions_mock(member, roles, channel)).await;
591+
let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
592+
.await
593+
.unwrap();
594+
assert!(!out.can_view_channel);
595+
assert!(!out.can_send_messages);
596+
assert!(!out.can_read_message_history);
597+
assert!(out
598+
.missing_permissions
599+
.contains(&"VIEW_CHANNEL".to_string()));
600+
assert!(out
601+
.missing_permissions
602+
.contains(&"SEND_MESSAGES".to_string()));
603+
assert!(out
604+
.missing_permissions
605+
.contains(&"READ_MESSAGE_HISTORY".to_string()));
606+
}
607+
608+
#[tokio::test]
609+
async fn check_channel_permissions_grants_everything_when_everyone_role_allows() {
610+
// @everyone role (id == guild_id) grants VIEW|SEND|HISTORY
611+
// = 1024 | 2048 | 65536 = 68608
612+
let member = json!({ "roles": [], "user": { "id": "bot-1" } });
613+
let roles = json!([
614+
{ "id": "guild-1", "permissions": "68608" }
615+
]);
616+
let channel = json!({ "permission_overwrites": [] });
617+
let base = spawn_mock(permissions_mock(member, roles, channel)).await;
618+
let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
619+
.await
620+
.unwrap();
621+
assert!(out.can_view_channel);
622+
assert!(out.can_send_messages);
623+
assert!(out.can_read_message_history);
624+
assert!(out.missing_permissions.is_empty());
625+
}
626+
627+
#[tokio::test]
628+
async fn check_channel_permissions_channel_overwrite_can_deny_permission() {
629+
// @everyone role grants everything, but the channel's @everyone
630+
// overwrite denies VIEW_CHANNEL — expect VIEW missing.
631+
let member = json!({ "roles": [], "user": { "id": "bot-1" } });
632+
let roles = json!([
633+
{ "id": "guild-1", "permissions": "68608" }
634+
]);
635+
let channel = json!({
636+
"permission_overwrites": [
637+
{
638+
"id": "guild-1",
639+
"type": 0,
640+
"allow": "0",
641+
"deny": "1024" // VIEW_CHANNEL
642+
}
643+
]
644+
});
645+
let base = spawn_mock(permissions_mock(member, roles, channel)).await;
646+
let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
647+
.await
648+
.unwrap();
649+
assert!(!out.can_view_channel);
650+
assert!(out
651+
.missing_permissions
652+
.contains(&"VIEW_CHANNEL".to_string()));
653+
}
654+
655+
#[tokio::test]
656+
async fn check_channel_permissions_errors_on_member_lookup_failure() {
657+
use axum::http::StatusCode;
658+
let app = Router::new().route(
659+
"/guilds/{guild_id}/members/@me",
660+
get(|| async { (StatusCode::UNAUTHORIZED, "bad token") }),
661+
);
662+
let base = spawn_mock(app).await;
663+
let err = check_channel_permissions_at_base(&base, "t", "g", "c")
664+
.await
665+
.unwrap_err()
666+
.to_string();
667+
assert!(err.contains("member info failed"));
668+
assert!(err.contains("401"));
669+
}
518670
}

src/openhuman/channels/providers/email_channel.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,4 +965,95 @@ mod tests {
965965
let debug_str = format!("{:?}", config);
966966
assert!(debug_str.contains("imap.debug.com"));
967967
}
968+
969+
// ── is_sender_allowed comprehensive matrix ─────────────────────
970+
971+
fn channel_with_allowlist(allowlist: Vec<String>) -> EmailChannel {
972+
let cfg = EmailConfig {
973+
imap_host: "imap.x".into(),
974+
imap_port: 993,
975+
imap_folder: "INBOX".into(),
976+
smtp_host: "smtp.x".into(),
977+
smtp_port: 465,
978+
smtp_tls: true,
979+
username: "u".into(),
980+
password: "p".into(),
981+
from_address: "me@x".into(),
982+
idle_timeout_secs: 300,
983+
allowed_senders: allowlist,
984+
};
985+
EmailChannel::new(cfg)
986+
}
987+
988+
#[test]
989+
fn is_sender_allowed_empty_denies_all() {
990+
let ch = channel_with_allowlist(vec![]);
991+
assert!(!ch.is_sender_allowed("anyone@any.com"));
992+
}
993+
994+
#[test]
995+
fn is_sender_allowed_wildcard_allows_everyone() {
996+
let ch = channel_with_allowlist(vec!["*".into()]);
997+
assert!(ch.is_sender_allowed("anyone@any.com"));
998+
assert!(ch.is_sender_allowed("other@different.com"));
999+
}
1000+
1001+
#[test]
1002+
fn is_sender_allowed_full_email_exact_match_case_insensitive() {
1003+
let ch = channel_with_allowlist(vec!["alice@example.com".into()]);
1004+
assert!(ch.is_sender_allowed("alice@example.com"));
1005+
assert!(ch.is_sender_allowed("ALICE@EXAMPLE.COM"));
1006+
assert!(!ch.is_sender_allowed("bob@example.com"));
1007+
}
1008+
1009+
#[test]
1010+
fn is_sender_allowed_at_prefix_domain_match() {
1011+
let ch = channel_with_allowlist(vec!["@trusted.com".into()]);
1012+
assert!(ch.is_sender_allowed("user@trusted.com"));
1013+
assert!(ch.is_sender_allowed("other@Trusted.com"));
1014+
assert!(!ch.is_sender_allowed("user@untrusted.com"));
1015+
}
1016+
1017+
#[test]
1018+
fn is_sender_allowed_bare_domain_match_is_case_insensitive() {
1019+
let ch = channel_with_allowlist(vec!["trusted.com".into()]);
1020+
assert!(ch.is_sender_allowed("user@trusted.com"));
1021+
assert!(ch.is_sender_allowed("USER@TRUSTED.COM"));
1022+
assert!(!ch.is_sender_allowed("user@other.com"));
1023+
}
1024+
1025+
#[test]
1026+
fn is_sender_allowed_prevents_subdomain_confusion() {
1027+
// "trusted.com" must NOT match "user@malicioustrusted.com"
1028+
let ch = channel_with_allowlist(vec!["trusted.com".into()]);
1029+
assert!(!ch.is_sender_allowed("user@notmytrusted.com"));
1030+
assert!(!ch.is_sender_allowed("user@trusted.com.evil.com"));
1031+
}
1032+
1033+
// ── strip_html edge cases ──────────────────────────────────────
1034+
1035+
#[test]
1036+
fn strip_html_empty_string() {
1037+
assert_eq!(EmailChannel::strip_html(""), "");
1038+
}
1039+
1040+
#[test]
1041+
fn strip_html_only_tags() {
1042+
assert_eq!(EmailChannel::strip_html("<p></p><br/>"), "");
1043+
}
1044+
1045+
#[test]
1046+
fn strip_html_unclosed_tag_eats_rest_until_gt() {
1047+
// A '<' without '>' enters tag mode; anything after until a '>' is
1048+
// discarded. This is the implementation's behaviour — lock it in.
1049+
assert_eq!(EmailChannel::strip_html("before<never closed"), "before");
1050+
}
1051+
1052+
#[test]
1053+
fn strip_html_collapses_whitespace_runs() {
1054+
assert_eq!(
1055+
EmailChannel::strip_html("<p>hello</p>\n\n\n <p>world</p>"),
1056+
"hello world"
1057+
);
1058+
}
9681059
}

0 commit comments

Comments
 (0)