use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use freenet_stdlib::prelude::{ClientResponse, UserInputRequest};
use tokio::sync::{broadcast, oneshot};
pub(crate) const USER_INPUT_TIMEOUT: Duration = Duration::from_secs(60);
const NO_TAB_OPEN_COOLDOWN: Duration = Duration::from_secs(5);
const MAX_MESSAGE_LEN: usize = 2048;
const MAX_LABEL_LEN: usize = 64;
const MAX_LABELS: usize = 10;
pub(crate) const MAX_IDENTITY_HASH_CHARS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallerIdentity {
None,
WebApp(String),
}
pub trait UserInputPrompter: Send + Sync {
fn prompt(
&self,
request: &UserInputRequest<'static>,
delegate_key: &str,
caller: CallerIdentity,
) -> impl std::future::Future<Output = Option<(usize, ClientResponse<'static>)>> + Send;
}
pub(crate) struct PendingPrompt {
pub message: String,
pub labels: Vec<String>,
pub delegate_key: String,
pub caller: CallerIdentity,
pub response_tx: oneshot::Sender<usize>,
}
pub(crate) type PendingPrompts = Arc<DashMap<String, PendingPrompt>>;
static PENDING_PROMPTS: std::sync::OnceLock<PendingPrompts> = std::sync::OnceLock::new();
pub(crate) fn pending_prompts() -> PendingPrompts {
PENDING_PROMPTS
.get_or_init(|| Arc::new(DashMap::new()))
.clone()
}
const MAX_PENDING_PROMPTS: usize = 32;
#[derive(Clone, Debug)]
pub(crate) struct PromptSnapshot {
pub nonce: String,
pub message: String,
pub labels: Vec<String>,
pub delegate_key: String,
pub caller: CallerIdentity,
}
#[derive(Clone, Debug)]
pub(crate) enum PromptEvent {
Added(PromptSnapshot),
Removed { nonce: String },
}
const PROMPT_EVENT_CAPACITY: usize = 128;
static PROMPT_EVENTS: std::sync::OnceLock<broadcast::Sender<PromptEvent>> =
std::sync::OnceLock::new();
pub(crate) fn prompt_events() -> broadcast::Sender<PromptEvent> {
PROMPT_EVENTS
.get_or_init(|| broadcast::channel(PROMPT_EVENT_CAPACITY).0)
.clone()
}
pub(crate) fn emit_prompt_event(event: PromptEvent) {
let event = match event {
PromptEvent::Removed { nonce } => PromptEvent::Removed {
nonce: cap_identity_chars(&nonce),
},
other => other,
};
drop(prompt_events().send(event));
}
pub(crate) type NoTabNotifier = Arc<dyn Fn(&str) + Send + Sync>;
pub struct DashboardPrompter {
pending: PendingPrompts,
dashboard_authority: String,
notify_no_tab: NoTabNotifier,
last_no_tab_open: std::sync::Mutex<Option<tokio::time::Instant>>,
}
impl DashboardPrompter {
pub fn new(pending: PendingPrompts, ws_api_addr: SocketAddr) -> Self {
Self {
pending,
dashboard_authority: dashboard_authority(ws_api_addr),
notify_no_tab: Arc::new(open_permission_page_in_browser),
last_no_tab_open: std::sync::Mutex::new(None),
}
}
fn maybe_alert_no_tab(&self, nonce: &str, subscriber_count: usize) {
if subscriber_count != 0 || !self.no_tab_cooldown_elapsed() {
return;
}
let url = format!("http://{}/permission/{nonce}", self.dashboard_authority);
(self.notify_no_tab)(&url);
}
fn no_tab_cooldown_elapsed(&self) -> bool {
let now = tokio::time::Instant::now();
let mut last = self
.last_no_tab_open
.lock()
.unwrap_or_else(|e| e.into_inner());
match *last {
Some(prev) if now.duration_since(prev) < NO_TAB_OPEN_COOLDOWN => false,
_ => {
*last = Some(now);
true
}
}
}
}
fn dashboard_authority(bind: SocketAddr) -> String {
let port = bind.port();
match bind.ip() {
IpAddr::V4(v4) => {
let host = if v4.is_unspecified() {
Ipv4Addr::LOCALHOST
} else {
v4
};
format!("{host}:{port}")
}
IpAddr::V6(v6) => {
let host = if v6.is_unspecified() {
Ipv6Addr::LOCALHOST
} else {
v6
};
format!("[{host}]:{port}")
}
}
}
fn open_permission_page_in_browser(url: &str) {
#[cfg(target_os = "windows")]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let operation: Vec<u16> = OsStr::new("open").encode_wide().chain(Some(0)).collect();
let url_wide: Vec<u16> = OsStr::new(url).encode_wide().chain(Some(0)).collect();
unsafe {
winapi::um::shellapi::ShellExecuteW(
std::ptr::null_mut(),
operation.as_ptr(),
url_wide.as_ptr(),
std::ptr::null(),
std::ptr::null(),
winapi::um::winuser::SW_SHOWNORMAL,
);
}
}
#[cfg(not(target_os = "windows"))]
{
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("open");
#[cfg(not(target_os = "macos"))]
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
drop(cmd.spawn());
}
}
#[cfg(test)]
impl DashboardPrompter {
fn with_notifier(
pending: PendingPrompts,
ws_api_addr: SocketAddr,
notify_no_tab: NoTabNotifier,
) -> Self {
Self {
pending,
dashboard_authority: dashboard_authority(ws_api_addr),
notify_no_tab,
last_no_tab_open: std::sync::Mutex::new(None),
}
}
}
impl UserInputPrompter for DashboardPrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
delegate_key: &str,
caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
if request.responses.is_empty() {
tracing::warn!("RequestUserInput has no response options");
return None;
}
if self.pending.len() >= MAX_PENDING_PROMPTS {
tracing::warn!(
max = MAX_PENDING_PROMPTS,
"Too many pending permission prompts, auto-denying"
);
return None;
}
let message = parse_message(request);
let labels = parse_button_labels(request);
let nonce = generate_nonce();
let (tx, rx) = oneshot::channel();
let stored_delegate_key = cap_identity_chars(delegate_key);
let stored_caller = match caller {
CallerIdentity::None => CallerIdentity::None,
CallerIdentity::WebApp(hash) => CallerIdentity::WebApp(cap_identity_chars(&hash)),
};
self.pending.insert(
nonce.clone(),
PendingPrompt {
message: message.clone(),
labels: labels.clone(),
delegate_key: stored_delegate_key.clone(),
caller: stored_caller.clone(),
response_tx: tx,
},
);
emit_prompt_event(PromptEvent::Added(PromptSnapshot {
nonce: nonce.clone(),
message,
labels,
delegate_key: stored_delegate_key,
caller: stored_caller,
}));
self.maybe_alert_no_tab(&nonce, prompt_events().receiver_count());
tracing::debug!(
request_id = request.request_id,
"Permission prompt created, waiting for user response via dashboard"
);
let result = tokio::time::timeout(USER_INPUT_TIMEOUT, rx).await;
let was_present = self.pending.remove(&nonce).is_some();
if was_present {
emit_prompt_event(PromptEvent::Removed {
nonce: nonce.clone(),
});
}
match result {
Ok(Ok(idx)) if idx < request.responses.len() => {
let response = request.responses[idx].clone().into_owned();
Some((idx, response))
}
Ok(Ok(_)) => {
tracing::warn!(nonce = %nonce, "Invalid response index from dashboard");
None
}
Ok(Err(_)) => {
tracing::debug!(nonce = %nonce, "Permission prompt channel closed");
None
}
Err(_) => {
tracing::warn!(nonce = %nonce, "Permission prompt timed out after 60s");
None
}
}
}
}
fn cap_identity_chars(s: &str) -> String {
s.chars().take(MAX_IDENTITY_HASH_CHARS).collect()
}
fn generate_nonce() -> String {
use crate::config::GlobalRng;
let a = GlobalRng::random_u64();
let b = GlobalRng::random_u64();
format!("{a:016x}{b:016x}")
}
pub(crate) fn parse_message(request: &UserInputRequest<'_>) -> String {
let bytes = request.message.bytes();
let raw = if let Ok(json_str) = serde_json::from_slice::<String>(bytes) {
json_str
} else {
String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| "A delegate is requesting permission.".to_string())
};
raw.chars()
.take(MAX_MESSAGE_LEN)
.filter(|c| !c.is_control() || *c == '\n')
.collect()
}
pub(crate) fn parse_button_labels(request: &UserInputRequest<'_>) -> Vec<String> {
request
.responses
.iter()
.take(MAX_LABELS)
.enumerate()
.map(|(i, r)| {
let label =
String::from_utf8((**r).to_vec()).unwrap_or_else(|_| format!("Option {}", i + 1));
label
.chars()
.take(MAX_LABEL_LEN)
.filter(|c| !c.is_control())
.collect()
})
.collect()
}
pub struct AutoApprovePrompter;
impl UserInputPrompter for AutoApprovePrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
_delegate_key: &str,
_caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
request
.responses
.first()
.map(|r| (0, r.clone().into_owned()))
}
}
#[allow(dead_code)]
pub struct AutoDenyPrompter;
impl UserInputPrompter for AutoDenyPrompter {
async fn prompt(
&self,
_request: &UserInputRequest<'static>,
_delegate_key: &str,
_caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
None
}
}
#[cfg(test)]
pub(crate) fn make_test_request(message: &str, responses: Vec<&str>) -> UserInputRequest<'static> {
use freenet_stdlib::prelude::NotificationMessage;
let msg = NotificationMessage::try_from(&serde_json::Value::String(message.to_string()))
.expect("valid JSON");
UserInputRequest {
request_id: 1,
message: msg,
responses: responses
.into_iter()
.map(|r| ClientResponse::new(r.as_bytes().to_vec()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn webapp(s: &str) -> CallerIdentity {
CallerIdentity::WebApp(s.to_string())
}
fn noop_prompter(pending: PendingPrompts) -> DashboardPrompter {
DashboardPrompter::with_notifier(pending, test_ws_addr(), Arc::new(|_| {}))
}
fn test_ws_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7509)
}
#[tokio::test]
async fn test_auto_approve_returns_first_response() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow");
}
#[tokio::test]
async fn test_auto_approve_empty_responses() {
let req = make_test_request("Allow this?", vec![]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_auto_deny_always_returns_none() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[test]
fn test_parse_button_labels() {
let req = make_test_request("msg", vec!["Allow Once", "Always Allow", "Deny"]);
let labels = parse_button_labels(&req);
assert_eq!(labels, vec!["Allow Once", "Always Allow", "Deny"]);
}
#[test]
fn test_parse_message_json_encoded() {
let req = make_test_request("Hello world", vec![]);
let msg = parse_message(&req);
assert_eq!(msg, "Hello world");
}
#[test]
fn test_parse_message_json_with_quotes() {
use freenet_stdlib::prelude::NotificationMessage;
let json_val = serde_json::Value::String("Test with \"quotes\"".to_string());
let msg = NotificationMessage::try_from(&json_val).unwrap();
let req = UserInputRequest {
request_id: 1,
message: msg,
responses: vec![],
};
let parsed = parse_message(&req);
assert_eq!(parsed, "Test with \"quotes\"");
}
#[tokio::test]
async fn test_dashboard_prompter_max_pending() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
for i in 0..MAX_PENDING_PROMPTS {
let (tx, _rx) = oneshot::channel();
pending.insert(
format!("nonce_{i}"),
PendingPrompt {
message: "test".to_string(),
labels: vec!["OK".to_string()],
delegate_key: String::new(),
caller: CallerIdentity::None,
response_tx: tx,
},
);
}
let req = make_test_request("Over limit", vec!["Allow"]);
let result = prompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[test]
fn test_nonce_is_32_hex_chars() {
let nonce = generate_nonce();
assert_eq!(nonce.len(), 32);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_parse_message_strips_control_chars() {
use freenet_stdlib::prelude::NotificationMessage;
let json_val = serde_json::Value::String("Hello\x00\x07world".to_string());
let msg = NotificationMessage::try_from(&json_val).unwrap();
let req = UserInputRequest {
request_id: 1,
message: msg,
responses: vec![],
};
let parsed = parse_message(&req);
assert_eq!(parsed, "Helloworld");
}
#[test]
fn test_parse_button_labels_invalid_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let req = UserInputRequest {
request_id: 1,
message: NotificationMessage::try_from(&serde_json::Value::String("msg".to_string()))
.unwrap(),
responses: vec![
ClientResponse::new(b"Valid".to_vec()),
ClientResponse::new(vec![0xFF, 0xFE]),
],
};
let labels = parse_button_labels(&req);
assert_eq!(labels, vec!["Valid", "Option 2"]);
}
#[test]
fn test_parse_message_at_max_len_unchanged() {
let msg = "a".repeat(MAX_MESSAGE_LEN);
let req = make_test_request(&msg, vec![]);
let parsed = parse_message(&req);
assert_eq!(parsed.chars().count(), MAX_MESSAGE_LEN);
assert_eq!(parsed, msg);
}
#[test]
fn test_parse_message_over_max_len_truncated() {
let msg = "a".repeat(MAX_MESSAGE_LEN + 1);
let req = make_test_request(&msg, vec![]);
let parsed = parse_message(&req);
assert_eq!(parsed.chars().count(), MAX_MESSAGE_LEN);
}
#[test]
fn test_parse_message_over_max_len_char_based() {
let msg = "\u{1F525}".repeat(MAX_MESSAGE_LEN + 5);
let req = make_test_request(&msg, vec![]);
let parsed = parse_message(&req);
assert_eq!(parsed.chars().count(), MAX_MESSAGE_LEN);
assert!(parsed.chars().all(|c| c == '\u{1F525}'));
}
#[test]
fn test_parse_button_labels_at_max_label_len_unchanged() {
let label = "x".repeat(MAX_LABEL_LEN);
let req = make_test_request("msg", vec![label.as_str()]);
let labels = parse_button_labels(&req);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].chars().count(), MAX_LABEL_LEN);
assert_eq!(labels[0], label);
}
#[test]
fn test_parse_button_labels_over_max_label_len_truncated() {
let label = "x".repeat(MAX_LABEL_LEN + 1);
let req = make_test_request("msg", vec![label.as_str()]);
let labels = parse_button_labels(&req);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].chars().count(), MAX_LABEL_LEN);
}
#[test]
fn test_parse_button_labels_at_max_labels_kept() {
let resp: Vec<String> = (0..MAX_LABELS).map(|i| format!("Option {i}")).collect();
let resp_refs: Vec<&str> = resp.iter().map(String::as_str).collect();
let req = make_test_request("msg", resp_refs);
let labels = parse_button_labels(&req);
assert_eq!(labels.len(), MAX_LABELS);
}
#[test]
fn test_parse_button_labels_over_max_labels_capped() {
let resp: Vec<String> = (0..MAX_LABELS + 1).map(|i| format!("Option {i}")).collect();
let resp_refs: Vec<&str> = resp.iter().map(String::as_str).collect();
let req = make_test_request("msg", resp_refs);
let labels = parse_button_labels(&req);
assert_eq!(labels.len(), MAX_LABELS);
}
#[test]
fn test_parse_message_raw_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let raw_msg =
NotificationMessage::try_from(&serde_json::Value::String("Raw message".to_string()))
.unwrap();
let req = UserInputRequest {
request_id: 1,
message: raw_msg,
responses: vec![],
};
let msg = parse_message(&req);
assert_eq!(msg, "Raw message");
}
#[tokio::test]
async fn test_auto_approve_with_three_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow Once");
}
#[tokio::test]
async fn test_auto_deny_with_multiple_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_dashboard_prompter_populates_webapp_caller() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
let req = make_test_request("Approve?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle = tokio::spawn(async move {
prompter
.prompt(&req, "DLGKEY123", webapp("CONTRACT456"))
.await
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let entry = pending_clone
.iter()
.next()
.expect("prompt should be registered");
assert_eq!(entry.value().delegate_key, "DLGKEY123");
assert_eq!(
entry.value().caller,
CallerIdentity::WebApp("CONTRACT456".to_string())
);
let nonce = entry.key().clone();
drop(entry);
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(1).unwrap();
let _ = handle.await.unwrap();
}
#[tokio::test]
async fn test_dashboard_prompter_records_none_caller() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
let req = make_test_request("Approve?", vec!["Allow"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(
async move { prompter.prompt(&req, "DLGKEY", CallerIdentity::None).await },
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let entry = pending_clone
.iter()
.next()
.expect("prompt should be registered");
assert_eq!(entry.value().delegate_key, "DLGKEY");
assert_eq!(entry.value().caller, CallerIdentity::None);
let nonce = entry.key().clone();
drop(entry);
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let _ = handle.await.unwrap();
}
#[tokio::test]
async fn test_dashboard_prompter_happy_path() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
let req = make_test_request("Allow signing?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(async move { prompter.prompt(&req, "dkey", webapp("cid")).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let nonce = pending_clone
.iter()
.next()
.expect("should have a pending prompt")
.key()
.clone();
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let result = handle.await.unwrap();
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow");
}
#[tokio::test]
async fn test_prompt_added_emitted_and_external_remove_skips_cleanup_removed() {
let mut rx = prompt_events().subscribe();
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
let req = make_test_request("lifecycle?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(async move { prompter.prompt(&req, "dkey-lc", webapp("cid-lc")).await });
let mut added_nonce: Option<String> = None;
for _ in 0..50 {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(PromptEvent::Added(snap))) if snap.delegate_key == "dkey-lc" => {
added_nonce = Some(snap.nonce);
break;
}
Ok(_) => continue, Err(_) => break, }
}
let nonce = added_nonce.expect("PromptEvent::Added with our delegate_key");
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let _ = handle.await.unwrap();
let mut saw_removed = false;
for _ in 0..50 {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(PromptEvent::Removed { nonce: n })) if n == nonce => {
saw_removed = true;
break;
}
Ok(_) => continue,
Err(_) => break,
}
}
assert!(
!saw_removed,
"this test exercises the manual-remove path; \
Removed should fire only from the prompter's own cleanup \
(timeout) or the HTTP respond handler (covered elsewhere)"
);
}
#[tokio::test(start_paused = true)]
async fn test_prompt_timeout_emits_removed() {
let mut rx = prompt_events().subscribe();
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = noop_prompter(pending.clone());
let req = make_test_request("timeout?", vec!["Allow"]);
let handle = tokio::spawn(async move {
prompter
.prompt(&req, "dkey-timeout", webapp("cid-timeout"))
.await
});
let mut our_nonce: Option<String> = None;
for _ in 0..50 {
tokio::task::yield_now().await;
match rx.try_recv() {
Ok(PromptEvent::Added(snap)) if snap.delegate_key == "dkey-timeout" => {
our_nonce = Some(snap.nonce);
break;
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(_) => break,
}
}
let nonce = our_nonce.expect("Added event for the timeout test");
tokio::time::advance(USER_INPUT_TIMEOUT + Duration::from_secs(1)).await;
let _ = handle.await.unwrap();
let mut saw_removed = false;
for _ in 0..50 {
match rx.try_recv() {
Ok(PromptEvent::Removed { nonce: n }) if n == nonce => {
saw_removed = true;
break;
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(_) => break,
}
}
assert!(
saw_removed,
"prompter timeout must emit PromptEvent::Removed"
);
}
#[test]
fn maybe_alert_no_tab_opens_browser_only_when_no_subscribers() {
use std::sync::Mutex;
let opened: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder: NoTabNotifier = {
let opened = opened.clone();
Arc::new(move |url: &str| opened.lock().unwrap().push(url.to_string()))
};
let prompter =
DashboardPrompter::with_notifier(Arc::new(DashMap::new()), test_ws_addr(), recorder);
prompter.maybe_alert_no_tab("nonce-with-tab", 1);
assert!(
opened.lock().unwrap().is_empty(),
"browser must not open when a dashboard tab is connected"
);
prompter.maybe_alert_no_tab("nonce-no-tab", 0);
assert_eq!(
opened.lock().unwrap().as_slice(),
["http://127.0.0.1:7509/permission/nonce-no-tab"],
"browser must open exactly the standalone permission page for the prompt"
);
}
#[test]
fn maybe_alert_no_tab_url_uses_configured_port() {
use std::sync::Mutex;
let opened: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder: NoTabNotifier = {
let opened = opened.clone();
Arc::new(move |url: &str| opened.lock().unwrap().push(url.to_string()))
};
let prompter = DashboardPrompter::with_notifier(
Arc::new(DashMap::new()),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 41234),
recorder,
);
prompter.maybe_alert_no_tab("abc", 0);
assert_eq!(
opened.lock().unwrap().as_slice(),
["http://127.0.0.1:41234/permission/abc"]
);
}
#[tokio::test(start_paused = true)]
async fn maybe_alert_no_tab_cooldown_suppresses_burst() {
use std::sync::Mutex;
let opened: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder: NoTabNotifier = {
let opened = opened.clone();
Arc::new(move |url: &str| opened.lock().unwrap().push(url.to_string()))
};
let prompter =
DashboardPrompter::with_notifier(Arc::new(DashMap::new()), test_ws_addr(), recorder);
prompter.maybe_alert_no_tab("a", 0);
prompter.maybe_alert_no_tab("b", 0);
prompter.maybe_alert_no_tab("c", 0);
assert_eq!(
opened.lock().unwrap().len(),
1,
"a burst within the cooldown window must open only one tab"
);
tokio::time::advance(NO_TAB_OPEN_COOLDOWN + Duration::from_millis(1)).await;
prompter.maybe_alert_no_tab("d", 0);
assert_eq!(
opened.lock().unwrap().len(),
2,
"a no-tab prompt after the cooldown window must open a tab"
);
}
#[test]
fn dashboard_authority_maps_bind_address_to_reachable_url_host() {
assert_eq!(
dashboard_authority(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 7509)),
"[::1]:7509"
);
assert_eq!(
dashboard_authority(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7509)),
"127.0.0.1:7509"
);
assert_eq!(
dashboard_authority(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7509)),
"127.0.0.1:7509"
);
assert_eq!(
dashboard_authority(SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 7509)),
"[::1]:7509"
);
assert_eq!(
dashboard_authority(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)),
8000
)),
"192.168.1.5:8000"
);
assert_eq!(
dashboard_authority(SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
8000
)),
"[2001:db8::1]:8000"
);
}
#[test]
fn open_permission_page_spawn_must_null_all_three_standard_handles() {
let src = include_str!("user_input.rs");
let (_, after_fn_start) = src
.split_once("fn open_permission_page_in_browser(url: &str) {")
.expect("open_permission_page_in_browser definition not found");
let body = after_fn_start
.split_once("\n#[cfg(test)]")
.map(|(b, _)| b)
.unwrap_or(after_fn_start);
for handle in ["stdin", "stdout", "stderr"] {
let pattern = format!(".{handle}(std::process::Stdio::null())");
assert!(
body.contains(&pattern),
"open_permission_page_in_browser must call `{pattern}` — without \
it, a Windows service/autostart node fails `Command::spawn()` \
with os error 6 (handle invalid after FreeConsole) and the \
permission page silently never opens. See #3933 / #3820."
);
}
}
}