use axum::extract::Path;
use axum::http::HeaderMap;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use serde::Deserialize;
use crate::contract::user_input::{CallerIdentity, PendingPrompts};
pub(super) fn routes() -> Router {
Router::new()
.route("/permission/pending", get(pending_prompts))
.route("/permission/{nonce}", get(permission_page))
.route("/permission/{nonce}/respond", post(permission_respond))
}
const OVERLAY_MESSAGE_MAX: usize = 2048;
const OVERLAY_LABELS_MAX: usize = 8;
const OVERLAY_LABEL_CHARS_MAX: usize = 64;
const OVERLAY_KEY_CHARS_MAX: usize = 256;
const HASH_PREFIX_CHARS: usize = 8;
const HASH_SUFFIX_CHARS: usize = 5;
fn truncate_hash(s: &str) -> String {
let total: usize = s.chars().count();
if total <= HASH_PREFIX_CHARS + HASH_SUFFIX_CHARS + 1 {
return s.to_string();
}
let prefix: String = s.chars().take(HASH_PREFIX_CHARS).collect();
let suffix_rev: String = s.chars().rev().take(HASH_SUFFIX_CHARS).collect();
let suffix: String = suffix_rev.chars().rev().collect();
format!("{prefix}…{suffix}")
}
fn sanitize_display(s: &str, max_chars: usize) -> String {
let mut out = String::with_capacity(s.len().min(max_chars * 4));
for ch in s.chars().take(max_chars) {
let keep = match ch {
'\t' | '\n' | '\r' => true,
c if (c as u32) < 0x20 || ((c as u32) >= 0x7f && (c as u32) <= 0x9f) => false,
'\u{202A}'..='\u{202E}' => false,
'\u{2066}'..='\u{2069}' => false,
'\u{200B}'..='\u{200F}' => false,
'\u{FEFF}' => false,
_ => true,
};
if keep {
out.push(ch);
}
}
out
}
fn caller_to_json(caller: &CallerIdentity) -> serde_json::Value {
match caller {
CallerIdentity::None => serde_json::json!({ "kind": "none", "hash": null }),
CallerIdentity::WebApp(hash) => serde_json::json!({
"kind": "webapp",
"hash": sanitize_display(hash, OVERLAY_KEY_CHARS_MAX),
}),
}
}
async fn pending_prompts(
headers: HeaderMap,
Extension(pending): Extension<PendingPrompts>,
) -> impl IntoResponse {
let trusted = match headers.get("origin") {
Some(value) => value.to_str().map(is_trusted_origin).unwrap_or(false),
None => true,
};
let cors_headers = [("access-control-allow-origin", "*")];
if !trusted {
return (
axum::http::StatusCode::OK,
cors_headers,
Json(serde_json::json!([])),
);
}
let prompts: Vec<serde_json::Value> = pending
.iter()
.map(|entry| {
let prompt = entry.value();
let message = sanitize_display(&prompt.message, OVERLAY_MESSAGE_MAX);
let labels: Vec<String> = prompt
.labels
.iter()
.take(OVERLAY_LABELS_MAX)
.map(|l| sanitize_display(l, OVERLAY_LABEL_CHARS_MAX))
.collect();
serde_json::json!({
"nonce": entry.key(),
"message": message,
"labels": labels,
"delegate_key": sanitize_display(&prompt.delegate_key, OVERLAY_KEY_CHARS_MAX),
"caller": caller_to_json(&prompt.caller),
})
})
.collect();
(
axum::http::StatusCode::OK,
cors_headers,
Json(serde_json::json!(prompts)),
)
}
fn caller_display(caller: &CallerIdentity) -> (String, Option<String>) {
match caller {
CallerIdentity::None => ("No app caller".to_string(), None),
CallerIdentity::WebApp(hash) => {
let sanitized = sanitize_display(hash, OVERLAY_KEY_CHARS_MAX);
let truncated = truncate_hash(&sanitized);
(format!("Freenet app {truncated}"), Some(sanitized))
}
}
}
async fn permission_page(
Path(nonce): Path<String>,
Extension(pending): Extension<PendingPrompts>,
) -> impl IntoResponse {
let headers = [
("X-Frame-Options", "DENY"),
(
"Content-Security-Policy",
"frame-ancestors 'none'; default-src 'self' 'unsafe-inline'",
),
("Cache-Control", "no-store"),
("Cross-Origin-Opener-Policy", "same-origin"),
];
let Some(entry) = pending.get(&nonce) else {
return (headers, Html(expired_html()));
};
let message = html_escape(&entry.message);
let buttons_html: String = entry
.labels
.iter()
.enumerate()
.map(|(i, label)| {
let escaped = html_escape(label);
let class = if i == 0 { "btn primary" } else { "btn" };
let escaped_nonce = html_escape(&nonce);
format!(
r#"<button class="{class}" onclick="respond('{escaped_nonce}', {i})">{escaped}</button>"#
)
})
.collect::<Vec<_>>()
.join("\n ");
let delegate_full = sanitize_display(&entry.delegate_key, OVERLAY_KEY_CHARS_MAX);
let delegate_trunc = truncate_hash(&delegate_full);
let delegate_full_attr = html_escape(&delegate_full);
let delegate_trunc_html = html_escape(&delegate_trunc);
let (caller_display_text, caller_full) = caller_display(&entry.caller);
let caller_title_html = caller_full
.as_deref()
.map(|h| format!(" title=\"{}\"", html_escape(h)))
.unwrap_or_default();
let caller_display_html = html_escape(&caller_display_text);
(
headers,
Html(format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Freenet - Permission Request</title>
<style>
:root {{ --bg: #0f1419; --fg: #e6e8eb; --card: #1a2028; --accent: #3b82f6;
--border: #2d3748; --warn: #f59e0b; --muted: #6b7280; }}
@media (prefers-color-scheme: light) {{
:root {{ --bg: #f5f5f5; --fg: #1a1a1a; --card: #fff; --accent: #2563eb;
--border: #d1d5db; --warn: #d97706; --muted: #9ca3af; }}
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--fg); display: flex; justify-content: center;
align-items: center; min-height: 100vh; padding: 20px; }}
.card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 32px; max-width: 520px; width: 100%; box-shadow: 0 4px 24px rgba(0,0,0,0.2); }}
.header {{ display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }}
.icon {{ font-size: 32px; }}
h1 {{ font-size: 18px; font-weight: 600; }}
.message-label {{ font-size: 12px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase;
letter-spacing: 0.5px; }}
.message {{ font-size: 15px; line-height: 1.5; margin-bottom: 24px; padding: 16px;
background: var(--bg); border-left: 3px solid var(--warn); border-radius: 4px; }}
.buttons {{ display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }}
.btn {{ padding: 10px 24px; border: 1px solid var(--border); border-radius: 8px;
background: var(--card); color: var(--fg); font-size: 14px; cursor: pointer;
transition: all 0.15s; flex: 1; min-width: 100px; font-weight: 500; }}
.btn.primary {{ background: var(--accent); color: white; border-color: var(--accent); }}
.btn:hover {{ opacity: 0.85; transform: translateY(-1px); }}
.btn:disabled {{ opacity: 0.5; cursor: not-allowed; transform: none; }}
.delegate-line {{ font-size: 12px; color: var(--muted); margin-top: 8px;
font-family: monospace; }}
.delegate-line .hash {{ user-select: all; }}
details.tech {{ margin-top: 12px; font-size: 12px; color: var(--muted); }}
details.tech summary {{ cursor: pointer; user-select: none; }}
details.tech dl {{ margin-top: 8px; padding-left: 16px; }}
details.tech dt {{ font-weight: 600; color: var(--fg); margin-top: 6px; }}
details.tech dd {{ font-family: monospace; word-break: break-all; user-select: all; }}
.timer {{ margin-top: 16px; font-size: 13px; color: var(--muted); text-align: center; }}
.result {{ text-align: center; padding: 24px 0; }}
.result .icon {{ font-size: 48px; margin-bottom: 12px; }}
</style>
</head>
<body>
<div class="card" id="prompt">
<div class="header">
<span class="icon">🔒</span>
<h1>Permission Request</h1>
</div>
<div class="message-label">Delegate says:</div>
<p class="message">{message}</p>
<div class="buttons">
{buttons_html}
</div>
<div class="delegate-line">
Delegate: <span class="hash" title="{delegate_full_attr}">{delegate_trunc_html}</span>
</div>
<details class="tech">
<summary>Technical details</summary>
<dl>
<dt>Delegate</dt>
<dd title="{delegate_full_attr}">{delegate_full_attr}</dd>
<dt>Caller</dt>
<dd{caller_title_html}>{caller_display_html}</dd>
</dl>
</details>
<div class="timer">Auto-deny in <span id="countdown">60</span>s</div>
</div>
<div class="card result" id="done" style="display:none">
<span class="icon">✅</span>
<h1>Response sent</h1>
<p>You can close this tab.</p>
</div>
<div class="card result" id="expired" style="display:none">
<span class="icon">⏰</span>
<h1>Timed out</h1>
<p>The request was auto-denied. You can close this tab.</p>
</div>
<script>
var seconds = 60;
var timer = setInterval(function() {{
seconds--;
var el = document.getElementById('countdown');
if (el) el.textContent = seconds;
if (seconds <= 0) {{
clearInterval(timer);
document.getElementById('prompt').style.display = 'none';
document.getElementById('expired').style.display = 'block';
}}
}}, 1000);
function respond(nonce, index) {{
var buttons = document.querySelectorAll('.btn');
buttons.forEach(function(b) {{ b.disabled = true; }});
fetch('/permission/' + nonce + '/respond', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ index: index }})
}}).then(function(r) {{
if (r.ok) {{
document.getElementById('prompt').style.display = 'none';
document.getElementById('done').style.display = 'block';
clearInterval(timer);
}} else {{
buttons.forEach(function(b) {{ b.disabled = false; }});
}}
}}).catch(function() {{
buttons.forEach(function(b) {{ b.disabled = false; }});
}});
}}
</script>
</body>
</html>"##,
)),
)
}
#[derive(Deserialize)]
struct PermissionResponse {
index: usize,
}
async fn permission_respond(
Path(nonce): Path<String>,
headers: HeaderMap,
Extension(pending): Extension<PendingPrompts>,
Json(body): Json<PermissionResponse>,
) -> impl IntoResponse {
if let Some(origin) = headers.get("origin") {
let origin = origin.to_str().unwrap_or("");
if !is_trusted_origin(origin) {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "forbidden"})),
);
}
} else {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "missing origin"})),
);
}
let label_count = pending.get(&nonce).map(|e| e.labels.len());
match label_count {
None => (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "expired or already answered"})),
),
Some(len) if body.index >= len => (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid index"})),
),
Some(_) => {
if let Some((_, prompt)) = pending.remove(&nonce) {
if prompt.response_tx.send(body.index).is_err() {
tracing::debug!(nonce = %nonce, "Permission response channel already closed");
}
(
axum::http::StatusCode::OK,
Json(serde_json::json!({"ok": true})),
)
} else {
(
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "expired or already answered"})),
)
}
}
}
}
fn is_trusted_origin(origin: &str) -> bool {
let Some(host_port) = origin.strip_prefix("http://") else {
return false;
};
if host_port.starts_with('[') {
let host = if let Some((h, _port)) = host_port.split_once(']') {
format!("{h}]")
} else {
return false;
};
return host == "[::1]";
}
let host = if let Some((h, _port)) = host_port.rsplit_once(':') {
h
} else {
host_port
};
matches!(host, "127.0.0.1" | "localhost")
}
fn expired_html() -> String {
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Freenet</title>
<style>
:root { --bg: #0f1419; --fg: #e6e8eb; --card: #1a2028; --border: #2d3748; }
@media (prefers-color-scheme: light) {
:root { --bg: #f5f5f5; --fg: #1a1a1a; --card: #fff; --border: #d1d5db; }
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--fg); display: flex; justify-content: center;
align-items: center; min-height: 100vh; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 40px; text-align: center; max-width: 400px; }
.icon { font-size: 48px; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="card">
<div class="icon">ℹ</div>
<h1>Request expired</h1>
<p>This permission request has already been answered or timed out.</p>
</div>
</body>
</html>"##
.to_string()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trusted_origin_localhost_default_port() {
assert!(is_trusted_origin("http://localhost:7509"));
}
#[test]
fn test_trusted_origin_localhost_custom_port() {
assert!(is_trusted_origin("http://localhost:8080"));
}
#[test]
fn test_trusted_origin_ipv4_loopback() {
assert!(is_trusted_origin("http://127.0.0.1:7509"));
}
#[test]
fn test_trusted_origin_ipv6_loopback() {
assert!(is_trusted_origin("http://[::1]:7509"));
}
#[test]
fn test_trusted_origin_ipv6_no_port() {
assert!(is_trusted_origin("http://[::1]"));
}
#[test]
fn test_untrusted_origin_external() {
assert!(!is_trusted_origin("http://evil.com"));
assert!(!is_trusted_origin("http://evil.com:7509"));
}
#[test]
fn test_untrusted_origin_https() {
assert!(!is_trusted_origin("https://localhost:7509"));
}
#[test]
fn test_untrusted_origin_null() {
assert!(!is_trusted_origin("null"));
}
#[test]
fn test_untrusted_origin_empty() {
assert!(!is_trusted_origin(""));
}
#[test]
fn test_html_escape_script_tag() {
assert_eq!(
html_escape("<script>alert(1)</script>"),
"<script>alert(1)</script>"
);
}
#[test]
fn test_html_escape_quotes() {
assert_eq!(
html_escape(r#"" onclick="evil()""#),
"" onclick="evil()""
);
}
#[test]
fn test_html_escape_ampersand() {
assert_eq!(html_escape("a & b"), "a & b");
}
#[test]
fn test_truncate_hash_long_input() {
let h = "DLog47hEabcdefghijk8vK2";
let t = truncate_hash(h);
assert_eq!(t, "DLog47hE…k8vK2");
}
#[test]
fn test_truncate_hash_short_input_unchanged() {
let h = "abc";
assert_eq!(truncate_hash(h), "abc");
}
#[test]
fn test_truncate_hash_boundary_at_threshold() {
let h = "1234567890ABCD"; assert_eq!(truncate_hash(h), h);
}
#[test]
fn test_truncate_hash_first_truncated_length() {
let h = "1234567890ABCDE"; let t = truncate_hash(h);
assert_eq!(t, "12345678…ABCDE");
assert_ne!(t, h, "15-char input must actually be truncated");
}
#[test]
fn test_truncate_hash_unicode() {
let h = "🔥".repeat(16);
let t = truncate_hash(&h);
assert!(t.contains('…'));
assert!(t.starts_with(&"🔥".repeat(8)));
assert!(t.ends_with(&"🔥".repeat(5)));
}
fn empty_pending() -> PendingPrompts {
use dashmap::DashMap;
use std::sync::Arc;
Arc::new(DashMap::new())
}
fn insert_prompt(
pending: &PendingPrompts,
nonce: &str,
message: &str,
labels: Vec<&str>,
delegate_key: &str,
caller: CallerIdentity,
) -> tokio::sync::oneshot::Receiver<usize> {
use crate::contract::user_input::PendingPrompt;
let (tx, rx) = tokio::sync::oneshot::channel::<usize>();
pending.insert(
nonce.to_string(),
PendingPrompt {
message: message.to_string(),
labels: labels.into_iter().map(String::from).collect(),
delegate_key: delegate_key.to_string(),
caller,
response_tx: tx,
},
);
rx
}
fn webapp_caller(s: &str) -> CallerIdentity {
CallerIdentity::WebApp(s.to_string())
}
fn trusted_header() -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("origin", "http://localhost:7509".parse().unwrap());
h
}
async fn call_pending(
headers: HeaderMap,
pending: PendingPrompts,
) -> (axum::http::StatusCode, serde_json::Value) {
let (status, _hdrs, value) = call_pending_full(headers, pending).await;
(status, value)
}
async fn call_pending_full(
headers: HeaderMap,
pending: PendingPrompts,
) -> (axum::http::StatusCode, HeaderMap, serde_json::Value) {
use axum::body::to_bytes;
use axum::response::IntoResponse;
let resp = pending_prompts(headers, Extension(pending))
.await
.into_response();
let status = resp.status();
let resp_headers = resp.headers().clone();
let body = to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
(status, resp_headers, value)
}
async fn call_permission_page(nonce: &str, pending: PendingPrompts) -> String {
use axum::body::to_bytes;
use axum::response::IntoResponse;
let resp = permission_page(Path(nonce.to_string()), Extension(pending))
.await
.into_response();
let body = to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
String::from_utf8(body.to_vec()).unwrap()
}
#[tokio::test]
async fn test_pending_prompts_includes_overlay_fields() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"nonce123",
"Approve this?",
vec!["Allow Once", "Always Allow", "Deny"],
"dkey",
webapp_caller("cid"),
);
let (status, value) = call_pending(trusted_header(), pending).await;
assert_eq!(status, axum::http::StatusCode::OK);
let arr = value.as_array().expect("array");
assert_eq!(arr.len(), 1);
let entry = &arr[0];
assert_eq!(entry["nonce"], "nonce123");
assert_eq!(entry["message"], "Approve this?");
assert_eq!(
entry["labels"],
serde_json::json!(["Allow Once", "Always Allow", "Deny"])
);
assert_eq!(entry["delegate_key"], "dkey");
assert_eq!(entry["caller"]["kind"], "webapp");
assert_eq!(entry["caller"]["hash"], "cid");
}
#[tokio::test]
async fn test_pending_prompts_none_caller_encoding() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "m", vec!["OK"], "dkey", CallerIdentity::None);
let (_, value) = call_pending(trusted_header(), pending).await;
assert_eq!(value[0]["caller"]["kind"], "none");
assert!(value[0]["caller"]["hash"].is_null());
}
#[tokio::test]
async fn test_pending_prompts_message_capped() {
let pending = empty_pending();
let huge = "a".repeat(OVERLAY_MESSAGE_MAX * 4);
let _rx = insert_prompt(&pending, "n", &huge, vec!["OK"], "d", webapp_caller("c"));
let (_, value) = call_pending(trusted_header(), pending).await;
assert_eq!(
value[0]["message"].as_str().unwrap().chars().count(),
OVERLAY_MESSAGE_MAX
);
}
#[tokio::test]
async fn test_pending_prompts_message_cap_is_char_based() {
let pending = empty_pending();
let emoji = "\u{1F525}".repeat(OVERLAY_MESSAGE_MAX);
let _rx = insert_prompt(&pending, "n", &emoji, vec!["OK"], "d", webapp_caller("c"));
let (_, value) = call_pending(trusted_header(), pending).await;
let got = value[0]["message"].as_str().unwrap();
assert_eq!(got.chars().count(), OVERLAY_MESSAGE_MAX);
assert!(got.chars().all(|c| c == '\u{1F525}'));
}
#[tokio::test]
async fn test_pending_prompts_labels_capped_and_truncated() {
let pending = empty_pending();
let long_label: String = "L".repeat(OVERLAY_LABEL_CHARS_MAX * 4);
let labels: Vec<String> = (0..OVERLAY_LABELS_MAX * 4)
.map(|_| long_label.clone())
.collect();
{
use crate::contract::user_input::PendingPrompt;
let (tx, _rx) = tokio::sync::oneshot::channel::<usize>();
pending.insert(
"n".to_string(),
PendingPrompt {
message: "m".to_string(),
labels,
delegate_key: "d".to_string(),
caller: webapp_caller("c"),
response_tx: tx,
},
);
}
let (_, value) = call_pending(trusted_header(), pending).await;
let out_labels = value[0]["labels"].as_array().unwrap();
assert_eq!(out_labels.len(), OVERLAY_LABELS_MAX);
for l in out_labels {
assert_eq!(l.as_str().unwrap().chars().count(), OVERLAY_LABEL_CHARS_MAX);
}
}
#[tokio::test]
async fn test_pending_prompts_empty_labels_round_trip() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "m", vec![], "d", webapp_caller("c"));
let (_, value) = call_pending(trusted_header(), pending).await;
assert_eq!(value[0]["labels"], serde_json::json!([]));
}
#[tokio::test]
async fn test_pending_prompts_strips_bidi_and_controls() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"n",
"Hello\u{202E}evil\u{202A}!",
vec!["\u{202E}Allow\u{202C}"],
"\u{FEFF}key\u{200B}123",
webapp_caller("c\u{0007}id"),
);
let (_, value) = call_pending(trusted_header(), pending).await;
assert_eq!(value[0]["message"], "Helloevil!");
assert_eq!(value[0]["labels"], serde_json::json!(["Allow"]));
assert_eq!(value[0]["delegate_key"], "key123");
assert_eq!(value[0]["caller"]["hash"], "cid");
}
#[tokio::test]
async fn test_pending_prompts_untrusted_origin_returns_empty_with_cors() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "m", vec!["OK"], "d", webapp_caller("c"));
let mut headers = HeaderMap::new();
headers.insert("origin", "http://evil.com".parse().unwrap());
let (status, resp_headers, value) = call_pending_full(headers, pending).await;
assert_eq!(status, axum::http::StatusCode::OK);
assert_eq!(value, serde_json::json!([]));
assert_eq!(
resp_headers
.get("access-control-allow-origin")
.map(|v| v.to_str().unwrap()),
Some("*"),
"CORS header must be present so the browser can deliver the \
empty-list response instead of logging a CORS error"
);
}
#[tokio::test]
async fn test_pending_prompts_null_origin_returns_empty_with_cors() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "m", vec!["OK"], "d", webapp_caller("c"));
let mut headers = HeaderMap::new();
headers.insert("origin", "null".parse().unwrap());
let (status, resp_headers, value) = call_pending_full(headers, pending).await;
assert_eq!(status, axum::http::StatusCode::OK);
assert_eq!(value, serde_json::json!([]));
assert_eq!(
resp_headers
.get("access-control-allow-origin")
.map(|v| v.to_str().unwrap()),
Some("*"),
);
}
#[tokio::test]
async fn test_pending_prompts_trusted_origin_returns_list_with_cors() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "msg", vec!["OK"], "d", webapp_caller("c"));
let (status, resp_headers, value) = call_pending_full(trusted_header(), pending).await;
assert_eq!(status, axum::http::StatusCode::OK);
let arr = value.as_array().expect("array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["message"], "msg");
assert_eq!(
resp_headers
.get("access-control-allow-origin")
.map(|v| v.to_str().unwrap()),
Some("*"),
);
}
#[tokio::test]
async fn test_pending_prompts_allows_missing_origin() {
let pending = empty_pending();
let _rx = insert_prompt(&pending, "n", "m", vec!["OK"], "d", webapp_caller("c"));
let (status, value) = call_pending(HeaderMap::new(), pending).await;
assert_eq!(status, axum::http::StatusCode::OK);
assert_eq!(value.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_respond_consumes_nonce_and_second_response_404s() {
let pending = empty_pending();
let rx_a = insert_prompt(
&pending,
"a",
"mA",
vec!["Yes", "No"],
"d",
webapp_caller("c"),
);
let _rx_b = insert_prompt(
&pending,
"b",
"mB",
vec!["Yes", "No"],
"d",
webapp_caller("c"),
);
let (_, value) = call_pending(trusted_header(), pending.clone()).await;
let nonces: Vec<&str> = value
.as_array()
.unwrap()
.iter()
.map(|v| v["nonce"].as_str().unwrap())
.collect();
assert_eq!(nonces.len(), 2);
assert!(nonces.contains(&"a") && nonces.contains(&"b"));
let (status, _) = {
let resp = permission_respond(
Path("a".to_string()),
trusted_header(),
Extension(pending.clone()),
Json(PermissionResponse { index: 0 }),
)
.await
.into_response();
let status = resp.status();
use axum::body::to_bytes;
let _ = to_bytes(resp.into_body(), 1024).await.unwrap();
(status, ())
};
assert_eq!(status, axum::http::StatusCode::OK);
assert_eq!(rx_a.await.unwrap(), 0);
let (_, value) = call_pending(trusted_header(), pending.clone()).await;
let remaining: Vec<&str> = value
.as_array()
.unwrap()
.iter()
.map(|v| v["nonce"].as_str().unwrap())
.collect();
assert_eq!(remaining, vec!["b"]);
let resp = permission_respond(
Path("a".to_string()),
trusted_header(),
Extension(pending),
Json(PermissionResponse { index: 0 }),
)
.await
.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_permission_page_renders_webapp_caller() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"abc",
"Approve signing this document.",
vec!["Allow", "Deny"],
"DLog47hEverylongdelegatekeyhashk8vK2",
webapp_caller("CONTRACTabcdefghijklmnopqZ"),
);
let html = call_permission_page("abc", pending).await;
assert!(
html.contains("Delegate says:"),
"authorship label must be present (codex point 2)"
);
assert!(
html.contains("Approve signing this document."),
"delegate message must be rendered"
);
assert!(
html.contains("DLog47hE…k8vK2"),
"truncated delegate hash must appear in body (codex point 3)"
);
assert!(
html.contains(r#"title="DLog47hEverylongdelegatekeyhashk8vK2""#),
"full delegate hash must be present in a title attribute"
);
assert!(
html.contains("Freenet app"),
"caller kind label must be present"
);
assert!(
html.contains("CONTRACT…nopqZ"),
"truncated caller hash must appear in body, got HTML: {html}"
);
}
#[tokio::test]
async fn test_permission_page_renders_none_caller() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"n",
"m",
vec!["OK"],
"DLGKEYabcdefghk8vK2",
CallerIdentity::None,
);
let html = call_permission_page("n", pending).await;
assert!(
html.contains("No app caller"),
"None caller must render as 'No app caller'"
);
assert!(
html.contains("Delegate says:"),
"authorship label must be present even with no app caller"
);
assert!(
!html.contains("Freenet app"),
"None caller must NOT render the 'Freenet app' prefix"
);
}
#[tokio::test]
async fn test_permission_page_escapes_hostile_message() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"n",
r#"<script>alert('xss')</script><img src=x onerror=evil()>"#,
vec!["<b>Allow</b>"],
"dkey",
webapp_caller("cid"),
);
let html = call_permission_page("n", pending).await;
assert!(!html.contains("<script>alert"));
assert!(!html.contains("<img src=x"));
assert!(html.contains("<script>"));
assert!(html.contains("<b>Allow</b>"));
}
#[tokio::test]
async fn test_permission_page_escapes_hostile_hash_fields() {
let pending = empty_pending();
let _rx = insert_prompt(
&pending,
"n",
"ok",
vec!["Allow"],
r#"<script>alert(1)</script>"#,
webapp_caller(r#""onload="evil()"#),
);
let html = call_permission_page("n", pending).await;
assert!(
!html.contains("<script>alert(1)</script>"),
"raw <script> from delegate_key must not appear in HTML"
);
assert!(
!html.contains(r#""onload="evil()"#),
"raw quote-bearing payload from caller hash must not appear unescaped"
);
assert!(
html.contains("<script>alert(1)</script>"),
"escaped delegate_key markup must appear"
);
assert!(
html.contains(""onload="evil()"),
"escaped caller hash quotes must appear"
);
}
}