use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::{
Json, Router,
extract::{ConnectInfo, DefaultBodyLimit},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use dashmap::DashMap;
use serde::Deserialize;
use serde_json::json;
use tokio::time::Instant;
use zeroize::Zeroizing;
use crate::client_events::user_op_rate_limit::UserOpRateLimiter;
use crate::contract::BundleKeyKind;
use crate::server::{AllowedHosts, AllowedSourceCidrs, HostedMode};
use crate::wasm_runtime::UserId;
use super::hosted_export::{
ExportOpManagerHandle, export_rate_limited, export_user_context_or_reject, run_export,
};
use super::hosted_import::{
BUNDLE_KEY_HEADER, BUNDLE_KEY_KIND_HEADER, MAX_IMPORT_BUNDLE_BYTES, import_gate_or_reject,
run_import,
};
use super::{ApiVersion, OriginContractMap};
const PULL_TOKEN_TTL: Duration = Duration::from_secs(600);
const MAX_PULL_TOKENS_PER_USER: usize = 5;
const MAX_PULL_TOKENS_TOTAL: usize = 512;
const MAX_PULL_BYTES_PER_USER: usize = MAX_IMPORT_BUNDLE_BYTES + 64 * 1024 * 1024;
const MAX_PULL_STORE_BYTES: usize = 2 * MAX_PULL_BYTES_PER_USER;
const MAX_PULL_IMPORT_REQUEST_BYTES: usize = 64 * 1024;
const MIGRATION_PULL_TIMEOUT_SECS: u64 = 30;
const ALLOWED_MIGRATION_HOSTS: &[&str] = &["try.freenet.org"];
struct PullEntry {
bundle: Vec<u8>,
key: Zeroizing<String>,
user_id: UserId,
expires_at: Instant,
}
#[derive(Clone, Copy)]
struct StoreLimits {
max_per_user_count: usize,
max_per_user_bytes: usize,
max_total_count: usize,
max_total_bytes: usize,
}
const PROD_STORE_LIMITS: StoreLimits = StoreLimits {
max_per_user_count: MAX_PULL_TOKENS_PER_USER,
max_per_user_bytes: MAX_PULL_BYTES_PER_USER,
max_total_count: MAX_PULL_TOKENS_TOTAL,
max_total_bytes: MAX_PULL_STORE_BYTES,
};
#[derive(Clone, Default)]
pub(crate) struct MigratePullStore(Arc<DashMap<String, PullEntry>>);
impl MigratePullStore {
fn insert(&self, user_id: UserId, bundle: Vec<u8>, key: String) -> Option<String> {
self.insert_with_limits(user_id, bundle, key, PROD_STORE_LIMITS)
}
fn insert_with_limits(
&self,
user_id: UserId,
bundle: Vec<u8>,
key: String,
limits: StoreLimits,
) -> Option<String> {
let now = Instant::now();
let new_len = bundle.len();
if new_len > limits.max_per_user_bytes || new_len > limits.max_total_bytes {
return None;
}
self.0.retain(|_, e| e.expires_at > now);
self.evict_oldest_until(
|v| v.user_id == user_id,
|count, bytes| {
count < limits.max_per_user_count && bytes + new_len <= limits.max_per_user_bytes
},
);
self.evict_oldest_until(
|_| true,
|count, bytes| {
count < limits.max_total_count && bytes + new_len <= limits.max_total_bytes
},
);
let token = random_token();
self.0.insert(
token.clone(),
PullEntry {
bundle,
key: Zeroizing::new(key),
user_id,
expires_at: now + PULL_TOKEN_TTL,
},
);
Some(token)
}
fn evict_oldest_until(
&self,
scope: impl Fn(&PullEntry) -> bool,
fits: impl Fn(usize, usize) -> bool,
) {
loop {
let mut in_scope: Vec<(String, Instant, usize)> = self
.0
.iter()
.filter(|r| scope(r.value()))
.map(|r| {
(
r.key().clone(),
r.value().expires_at,
r.value().bundle.len(),
)
})
.collect();
let count = in_scope.len();
let bytes: usize = in_scope.iter().map(|(_, _, len)| *len).sum();
if in_scope.is_empty() || fits(count, bytes) {
break;
}
in_scope.sort_by_key(|(_, exp, _)| *exp);
let (oldest_id, _, _) = in_scope.remove(0);
self.0.remove(&oldest_id);
}
}
fn take(&self, token: &str) -> Option<PullEntry> {
let (_, entry) = self.0.remove(token)?;
if entry.expires_at <= Instant::now() {
return None;
}
Some(entry)
}
}
fn random_token() -> String {
use chacha20poly1305::aead::OsRng;
use chacha20poly1305::aead::rand_core::RngCore;
let mut buf = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(buf.as_mut_slice());
bs58::encode(buf.as_ref())
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string()
}
pub(super) fn routes(version: ApiVersion) -> Router {
let prefix = version.prefix();
Router::new()
.route(
&format!("/{prefix}/hosted/migrate/mint"),
post(mint_handler),
)
.route(&format!("/{prefix}/hosted/migrate/pull"), get(pull_handler))
.route(
&format!("/{prefix}/hosted/pull-import"),
post(pull_import_handler).layer(DefaultBodyLimit::max(MAX_PULL_IMPORT_REQUEST_BYTES)),
)
}
async fn mint_handler(req: axum::extract::Request) -> Response {
let hosted_mode = req
.extensions()
.get::<HostedMode>()
.map(|hm| hm.0)
.unwrap_or(false);
let source_ip = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
let headers = req.headers();
let (user_context, durable_token) =
match export_user_context_or_reject(headers, source_ip, hosted_mode) {
Ok(v) => v,
Err((status, reason)) => {
tracing::warn!(source_ip = ?source_ip, reason, "Rejected migration mint");
return (status, reason).into_response();
}
};
drop(zeroize::Zeroizing::new(durable_token));
let limiter = req.extensions().get::<UserOpRateLimiter>();
if export_rate_limited(limiter, &user_context) {
tracing::warn!(
user_id = ?user_context.user_id(),
"Rejected migration mint: per-user export rate limit exceeded"
);
return (
StatusCode::TOO_MANY_REQUESTS,
"migration rate limit exceeded; retry shortly",
)
.into_response();
}
let op_manager = req
.extensions()
.get::<ExportOpManagerHandle>()
.and_then(ExportOpManagerHandle::current);
let Some(op_manager) = op_manager else {
tracing::warn!("Migration mint requested but no running node is registered");
return (
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve migrations",
)
.into_response();
};
let Some(store) = req.extensions().get::<MigratePullStore>().cloned() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"migration is not available on this node",
)
.into_response();
};
let ephemeral_key = random_token();
let user_id = *user_context.user_id();
let bundle = match run_export(
&op_manager,
user_context,
ephemeral_key.clone().into_bytes(),
)
.await
{
Ok(b) => b,
Err((status, reason)) => return (status, reason).into_response(),
};
let Some(pull_token) = store.insert(user_id, bundle, ephemeral_key) else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"too many pending migrations; retry shortly",
)
.into_response();
};
(
StatusCode::OK,
Json(json!({
"pull_token": pull_token,
"expires_in_secs": PULL_TOKEN_TTL.as_secs(),
})),
)
.into_response()
}
async fn pull_handler(req: axum::extract::Request) -> Response {
let Some(store) = req.extensions().get::<MigratePullStore>().cloned() else {
return pull_not_found();
};
let Some(pt) = pt_from_query(req.uri().query()) else {
return pull_not_found();
};
let Some(entry) = store.take(&pt) else {
return pull_not_found();
};
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
match HeaderValue::from_str(entry.key.as_str()) {
Ok(v) => {
headers.insert(HeaderName::from_static(BUNDLE_KEY_HEADER), v);
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"migration key encoding error",
)
.into_response();
}
}
headers.insert(
HeaderName::from_static(BUNDLE_KEY_KIND_HEADER),
HeaderValue::from_static("token"),
);
(StatusCode::OK, headers, entry.bundle).into_response()
}
fn pull_not_found() -> Response {
(
StatusCode::NOT_FOUND,
"migration link not found, expired, or already used",
)
.into_response()
}
fn pt_from_query(query: Option<&str>) -> Option<String> {
let q = query?;
for pair in q.split('&') {
if let Some(val) = pair.strip_prefix("pt=")
&& is_valid_pull_token(val)
{
return Some(val.to_owned());
}
}
None
}
fn is_valid_pull_token(pt: &str) -> bool {
!pt.is_empty() && pt.len() <= 64 && pt.chars().all(|c| c.is_ascii_alphanumeric())
}
pub(super) async fn import_page() -> 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"),
];
(
headers,
Html(format!(
include_str!("assets/hosted_import_page.html"),
style = HOSTED_IMPORT_PAGE_CSS,
script = HOSTED_IMPORT_PAGE_JS,
)),
)
}
const HOSTED_IMPORT_PAGE_CSS: &str = include_str!("assets/hosted_import_page.css");
const HOSTED_IMPORT_PAGE_JS: &str = include_str!("assets/hosted_import_page.js");
#[derive(Deserialize)]
struct PullImportRequest {
source: String,
pt: String,
}
async fn pull_import_handler(req: axum::extract::Request) -> Response {
let (parts, body) = req.into_parts();
let headers = &parts.headers;
let source_ip = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
let allowed_hosts = parts.extensions.get::<AllowedHosts>();
let allowed_source_cidrs = parts.extensions.get::<AllowedSourceCidrs>();
let empty_origin_contracts: OriginContractMap = Arc::new(DashMap::new());
let origin_contracts = parts
.extensions
.get::<OriginContractMap>()
.unwrap_or(&empty_origin_contracts);
if let Err((status, reason)) = import_gate_or_reject(
headers,
source_ip,
allowed_hosts,
allowed_source_cidrs,
parts.uri.query(),
origin_contracts,
) {
tracing::warn!(source_ip = ?source_ip, reason, "Rejected migration pull-import");
return (status, reason).into_response();
}
let op_manager = parts
.extensions
.get::<ExportOpManagerHandle>()
.and_then(ExportOpManagerHandle::current);
let Some(op_manager) = op_manager else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve imports",
)
.into_response();
};
let body_bytes = match axum::body::to_bytes(body, MAX_PULL_IMPORT_REQUEST_BYTES).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
}
};
let request: PullImportRequest = match serde_json::from_slice(&body_bytes) {
Ok(v) => v,
Err(_) => {
return (StatusCode::BAD_REQUEST, "invalid pull-import request body").into_response();
}
};
let Some(host) = allowed_migration_host(&request.source) else {
tracing::warn!(source = %request.source, "Rejected migration pull-import: source not allowed");
return (
StatusCode::FORBIDDEN,
"migration source is not an allowed origin",
)
.into_response();
};
if !is_valid_pull_token(&request.pt) {
return (StatusCode::BAD_REQUEST, "invalid migration token").into_response();
}
let (bundle, key, key_kind) = match pull_bundle_from_source(host, &request.pt).await {
Ok(v) => v,
Err((status, reason)) => return (status, reason).into_response(),
};
match run_import(&op_manager, bundle, key, key_kind, false).await {
Ok(report) => (
StatusCode::OK,
Json(json!({
"imported": report.imported,
"skipped": report.skipped,
})),
)
.into_response(),
Err((status, reason)) => (status, reason).into_response(),
}
}
fn allowed_migration_host(source: &str) -> Option<&'static str> {
let url = reqwest::Url::parse(source).ok()?;
if url.scheme() != "https" {
return None;
}
if url.port().is_some() {
return None;
}
let host = url.host_str()?;
ALLOWED_MIGRATION_HOSTS
.iter()
.copied()
.find(|allowed| allowed.eq_ignore_ascii_case(host))
}
async fn pull_bundle_from_source(
host: &str,
pt: &str,
) -> Result<(Vec<u8>, String, BundleKeyKind), (StatusCode, &'static str)> {
let url = format!("https://{host}/v1/hosted/migrate/pull?pt={pt}");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(MIGRATION_PULL_TIMEOUT_SECS))
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"migration HTTP client init failed",
)
})?;
let resp = client.get(&url).send().await.map_err(|_| {
(
StatusCode::BAD_GATEWAY,
"could not reach the migration source",
)
})?;
read_pull_response(resp).await
}
async fn read_pull_response(
mut resp: reqwest::Response,
) -> Result<(Vec<u8>, String, BundleKeyKind), (StatusCode, &'static str)> {
if !resp.status().is_success() {
return Err((
StatusCode::BAD_GATEWAY,
"migration source rejected the link (expired or already used)",
));
}
let key = resp
.headers()
.get(BUNDLE_KEY_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.filter(|k| !k.is_empty());
let Some(key) = key else {
return Err((
StatusCode::BAD_GATEWAY,
"migration source returned no bundle key",
));
};
let key_kind = match resp
.headers()
.get(BUNDLE_KEY_KIND_HEADER)
.and_then(|v| v.to_str().ok())
{
Some(s) if s.eq_ignore_ascii_case("passphrase") => BundleKeyKind::Passphrase,
_ => BundleKeyKind::Token,
};
let mut bundle = Vec::new();
loop {
match resp.chunk().await {
Ok(Some(chunk)) => {
if bundle.len() + chunk.len() > MAX_IMPORT_BUNDLE_BYTES {
return Err((StatusCode::BAD_GATEWAY, "migration bundle too large"));
}
bundle.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(_) => {
return Err((
StatusCode::BAD_GATEWAY,
"error reading the migration bundle",
));
}
}
}
if bundle.is_empty() {
return Err((
StatusCode::BAD_GATEWAY,
"migration source returned an empty bundle",
));
}
Ok((bundle, key, key_kind))
}
#[cfg(test)]
mod tests {
use super::*;
fn user(tag: u8) -> UserId {
UserId::new([tag; 32])
}
#[tokio::test(start_paused = true)]
async fn pull_token_is_single_use() {
let store = MigratePullStore::default();
let token = store
.insert(
user(1),
b"bundle-bytes".to_vec(),
"ephemeral-key".to_owned(),
)
.expect("mint must store the bundle");
let first = store.take(&token).expect("first pull returns the entry");
assert_eq!(first.bundle, b"bundle-bytes");
assert_eq!(first.key.as_str(), "ephemeral-key");
assert!(
store.take(&token).is_none(),
"a second pull of the same token must find nothing (single-use)"
);
}
#[tokio::test(start_paused = true)]
async fn pull_unknown_token_is_none() {
let store = MigratePullStore::default();
assert!(store.take("never-minted").is_none());
}
#[tokio::test(start_paused = true)]
async fn pull_token_expires() {
let store = MigratePullStore::default();
let token = store
.insert(user(1), b"b".to_vec(), "k".to_owned())
.expect("mint");
tokio::time::advance(PULL_TOKEN_TTL + Duration::from_secs(1)).await;
assert!(
store.take(&token).is_none(),
"an expired token must read as absent"
);
}
#[tokio::test(start_paused = true)]
async fn per_user_cap_evicts_oldest() {
let store = MigratePullStore::default();
let mut tokens = Vec::new();
for i in 0..MAX_PULL_TOKENS_PER_USER {
let t = store
.insert(user(1), vec![i as u8], format!("k{i}"))
.expect("mint");
tokens.push(t);
tokio::time::advance(Duration::from_secs(1)).await;
}
let other = store
.insert(user(2), b"other".to_vec(), "ok".to_owned())
.expect("mint");
let newest = store
.insert(user(1), b"newest".to_vec(), "kn".to_owned())
.expect("mint");
assert!(
store.take(&tokens[0]).is_none(),
"user-1's oldest token must have been evicted at the cap"
);
assert!(
store.take(&newest).is_some(),
"the newest user-1 token must be present"
);
assert!(
store.take(&other).is_some(),
"another user's token must be unaffected by user-1's cap"
);
}
fn tiny_limits() -> StoreLimits {
StoreLimits {
max_per_user_count: 10,
max_per_user_bytes: 100,
max_total_count: 10,
max_total_bytes: 1000,
}
}
#[tokio::test(start_paused = true)]
async fn per_user_byte_budget_evicts_own_oldest_not_others() {
let store = MigratePullStore::default();
let limits = tiny_limits();
let t1 = store
.insert_with_limits(user(1), vec![0u8; 60], "k1".to_owned(), limits)
.expect("mint");
tokio::time::advance(Duration::from_secs(1)).await;
let other = store
.insert_with_limits(user(2), vec![0u8; 10], "ok".to_owned(), limits)
.expect("mint");
tokio::time::advance(Duration::from_secs(1)).await;
let t2 = store
.insert_with_limits(user(1), vec![0u8; 60], "k2".to_owned(), limits)
.expect("mint");
assert!(
store.take(&t1).is_none(),
"user-1's oldest must be evicted by the per-user byte budget"
);
assert!(
store.take(&t2).is_some(),
"the newest user-1 bundle remains"
);
assert!(
store.take(&other).is_some(),
"another user's bundle must NOT be evicted by user-1's byte pressure"
);
}
#[tokio::test(start_paused = true)]
async fn oversize_bundle_is_refused() {
let store = MigratePullStore::default();
let limits = tiny_limits(); assert!(
store
.insert_with_limits(user(1), vec![0u8; 101], "k".to_owned(), limits)
.is_none(),
"a bundle over the per-user byte budget must be refused"
);
assert_eq!(store.0.len(), 0, "a refused oversize mint stores nothing");
}
#[tokio::test(start_paused = true)]
async fn global_count_cap_evicts_globally_oldest() {
let store = MigratePullStore::default();
let limits = StoreLimits {
max_per_user_count: 100,
max_per_user_bytes: 10_000,
max_total_count: 3,
max_total_bytes: 10_000,
};
let a = store
.insert_with_limits(user(1), vec![1], "a".to_owned(), limits)
.expect("mint");
tokio::time::advance(Duration::from_secs(1)).await;
let b = store
.insert_with_limits(user(2), vec![2], "b".to_owned(), limits)
.expect("mint");
tokio::time::advance(Duration::from_secs(1)).await;
let c = store
.insert_with_limits(user(3), vec![3], "c".to_owned(), limits)
.expect("mint");
tokio::time::advance(Duration::from_secs(1)).await;
let d = store
.insert_with_limits(user(4), vec![4], "d".to_owned(), limits)
.expect("mint");
assert!(store.take(&a).is_none(), "globally-oldest evicted at cap");
for (name, tok) in [("b", &b), ("c", &c), ("d", &d)] {
assert!(
store.take(tok).is_some(),
"{name} must remain under the cap"
);
}
}
fn synth_response(status: u16, headers: &[(&str, &str)], body: Vec<u8>) -> reqwest::Response {
let mut builder = axum::http::Response::builder().status(status);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
reqwest::Response::from(builder.body(body).expect("valid synthetic response"))
}
#[tokio::test]
async fn read_pull_response_success_extracts_bundle_and_key() {
let resp = synth_response(
200,
&[
("x-freenet-bundle-key", "ephemeral-key-xyz"),
("x-freenet-bundle-key-kind", "token"),
],
b"the-bundle-bytes".to_vec(),
);
let (bundle, key, kind) = read_pull_response(resp).await.expect("must succeed");
assert_eq!(bundle, b"the-bundle-bytes");
assert_eq!(key, "ephemeral-key-xyz");
assert!(matches!(kind, BundleKeyKind::Token));
}
#[tokio::test]
async fn read_pull_response_kind_defaults_token_and_honors_passphrase() {
let resp = synth_response(200, &[("x-freenet-bundle-key", "k")], b"b".to_vec());
let (_, _, kind) = read_pull_response(resp).await.unwrap();
assert!(matches!(kind, BundleKeyKind::Token));
let resp = synth_response(
200,
&[
("x-freenet-bundle-key", "k"),
("x-freenet-bundle-key-kind", "passphrase"),
],
b"b".to_vec(),
);
let (_, _, kind) = read_pull_response(resp).await.unwrap();
assert!(matches!(kind, BundleKeyKind::Passphrase));
}
#[tokio::test]
async fn read_pull_response_rejects_non_success_missing_key_and_empty() {
let resp = synth_response(404, &[("x-freenet-bundle-key", "k")], b"b".to_vec());
assert_eq!(
read_pull_response(resp).await.unwrap_err().0,
StatusCode::BAD_GATEWAY
);
let resp = synth_response(200, &[], b"b".to_vec());
assert_eq!(
read_pull_response(resp).await.unwrap_err().0,
StatusCode::BAD_GATEWAY
);
let resp = synth_response(200, &[("x-freenet-bundle-key", "k")], Vec::new());
assert_eq!(
read_pull_response(resp).await.unwrap_err().0,
StatusCode::BAD_GATEWAY
);
}
#[test]
fn ssrf_allowlist_accepts_only_https_default_port_trusted_host() {
assert_eq!(
allowed_migration_host("https://try.freenet.org"),
Some("try.freenet.org")
);
assert_eq!(
allowed_migration_host("https://try.freenet.org/"),
Some("try.freenet.org")
);
assert_eq!(
allowed_migration_host("https://TRY.freenet.org"),
Some("try.freenet.org")
);
}
#[test]
fn ssrf_allowlist_rejects_non_https_and_untrusted() {
assert!(allowed_migration_host("http://try.freenet.org").is_none());
assert!(allowed_migration_host("https://evil.example.com").is_none());
assert!(allowed_migration_host("https://try.freenet.org.evil.com").is_none());
assert!(allowed_migration_host("https://try.freenet.org:8443").is_none());
assert!(allowed_migration_host("https://try.freenet.org@evil.com").is_none());
assert!(allowed_migration_host("https://127.0.0.1").is_none());
assert!(allowed_migration_host("not a url").is_none());
}
#[test]
fn pull_token_validation() {
assert!(is_valid_pull_token("abcDEF123"));
assert!(!is_valid_pull_token(""));
assert!(!is_valid_pull_token("has-dash"));
assert!(!is_valid_pull_token("has space"));
assert!(!is_valid_pull_token("has/slash"));
assert!(!is_valid_pull_token(&"a".repeat(65)));
}
#[test]
fn pt_extracted_and_validated_from_query() {
assert_eq!(pt_from_query(Some("pt=abc123")).as_deref(), Some("abc123"));
assert_eq!(
pt_from_query(Some("x=1&pt=tok9&y=2")).as_deref(),
Some("tok9")
);
assert!(pt_from_query(Some("pt=bad-token")).is_none());
assert!(pt_from_query(Some("nopt=here")).is_none());
assert!(pt_from_query(None).is_none());
}
#[test]
fn import_page_wiring() {
let html = format!(
include_str!("assets/hosted_import_page.html"),
style = HOSTED_IMPORT_PAGE_CSS,
script = HOSTED_IMPORT_PAGE_JS,
);
assert!(
html.contains("/v1/hosted/pull-import"),
"the page JS must POST to the local pull-import endpoint"
);
assert!(
HOSTED_IMPORT_PAGE_JS.contains("addEventListener"),
"the import must be behind an explicit user gesture, not auto-run"
);
}
#[tokio::test]
async fn import_page_sets_anti_framing_headers() {
let resp = import_page().await.into_response();
let h = resp.headers();
assert_eq!(
h.get("x-frame-options").expect("X-Frame-Options present"),
"DENY"
);
assert_eq!(
h.get("cache-control").expect("Cache-Control present"),
"no-store"
);
assert_eq!(
h.get("cross-origin-opener-policy").expect("COOP present"),
"same-origin"
);
let csp = h
.get("content-security-policy")
.expect("CSP present")
.to_str()
.unwrap();
assert!(
csp.contains("frame-ancestors 'none'"),
"CSP must forbid framing, got: {csp}"
);
}
#[tokio::test]
async fn pull_response_is_no_store_and_carries_key() {
let store = MigratePullStore::default();
let token = store
.insert(
user(1),
b"bundle-bytes".to_vec(),
"ephemeral-key".to_owned(),
)
.expect("mint");
let req = axum::http::Request::builder()
.uri(format!("/v1/hosted/migrate/pull?pt={token}"))
.extension(store)
.body(axum::body::Body::empty())
.unwrap();
let resp = pull_handler(req).await;
assert_eq!(resp.status(), StatusCode::OK);
let h = resp.headers();
assert_eq!(
h.get("cache-control").expect("Cache-Control present"),
"no-store"
);
assert_eq!(
h.get(BUNDLE_KEY_HEADER).expect("bundle key header present"),
"ephemeral-key"
);
assert_eq!(
h.get(BUNDLE_KEY_KIND_HEADER).expect("kind header present"),
"token"
);
}
}