use axum::{ body::Body, http::{Request, StatusCode, header}, }; use fingerprint_receiver::{app, db, geoip::GeoIpService}; use http_body_util::BodyExt; use serde_json::json; use tempfile::tempdir; use tower::ServiceExt; async fn response_json(response: axum::response::Response) -> serde_json::Value { let bytes = response .into_body() .collect() .await .expect("body should be readable") .to_bytes(); serde_json::from_slice(&bytes).expect("response should be JSON") } #[tokio::test] async fn network_probe_reports_observed_connection_without_storing_stats() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/network/probe") .header("x-forwarded-for", "203.0.113.42") .header("cf-connecting-ip", "198.51.100.24") .header("cf-ipcountry", "US") .header("cf-ray", "abc123-LAX") .header("x-forwarded-proto", "https") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; assert_eq!(body["observedIp"], serde_json::Value::Null); assert_eq!(body["observedPort"], serde_json::Value::Null); assert_eq!(body["forwardedFor"], "203.0.113.42"); assert_eq!(body["cfConnectingIp"], "198.51.100.24"); assert_eq!(body["cfIpCountry"], "US"); assert_eq!(body["cfRay"], "abc123-LAX"); assert_eq!(body["forwardedProto"], "https"); assert_eq!(body["geoIp"]["city"]["available"], false); assert_eq!(body["geoIp"]["asn"]["available"], false); assert!(body["geoIp"]["sourceIp"].is_string()); assert_eq!(body["stored"], false); let stats_response = app .oneshot( Request::builder() .method("GET") .uri("/api/stats") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let stats = response_json(stats_response).await; assert_eq!(stats["totalScans"], 0); } #[tokio::test] async fn post_scan_accepts_expanded_profile_signal_set() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let signals: Vec<_> = (0..32) .map(|index| { json!({ "id": format!("profile-signal-{index}"), "weight": 0, "score": 0.0, "contribution": 0, "raw": format!("stage=passive category=summary index={index}") }) }) .collect(); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from( json!({ "score": 44, "band": "medium", "locale": "zh-CN", "timezone": "Asia/Shanghai", "signals": signals }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); let stats_response = app .oneshot( Request::builder() .method("GET") .uri("/api/stats") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let stats = response_json(stats_response).await; assert_eq!(stats["totalScans"], 1); assert_eq!(stats["bandCounts"]["medium"], 1); } #[tokio::test] async fn analytics_returns_public_profile_distributions() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); for payload in [ json!({ "score": 72, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "timezoneOffsetMinutes": -480, "userAgentFamily": "Chrome", "signals": [ {"id": "timezone", "weight": 30, "score": 1.0, "contribution": 30, "raw": "Asia/Shanghai"}, {"id": "language", "weight": 24, "score": 1.0, "contribution": 24, "raw": "zh-CN"}, {"id": "profile-local-summary", "weight": 0, "score": 1.0, "contribution": 0, "raw": "stage=local facts=12 low=4 medium=5 high=2 critical=1"}, {"id": "profile-local-categories", "weight": 0, "score": 0.0, "contribution": 0, "raw": "stage=local categories=screen=2 locale=3 hardware=4"}, {"id": "network-webrtc", "weight": 0, "score": 1.0, "contribution": 0, "raw": "webrtc total=3 public=1 private=1 mdns=1 host=2 srflx=1 relay=0"}, {"id": "network-server", "weight": 0, "score": 1.0, "contribution": 0, "raw": "server observed=1 forwarded=1 cf=0 real=0"}, {"id": "network-connection", "weight": 0, "score": 0.0, "contribution": 0, "raw": "connection api=1 type=4g saveData=0 rtt=40"} ] }), json!({ "score": 38, "band": "medium", "locale": "en-US", "timezone": "America/Los_Angeles", "timezoneOffsetMinutes": 420, "userAgentFamily": "Safari", "signals": [ {"id": "language", "weight": 24, "score": 0.0, "contribution": 0, "raw": "en-US"}, {"id": "profile-request-summary", "weight": 0, "score": 0.75, "contribution": 0, "raw": "stage=request facts=6 low=2 medium=2 high=2 critical=0"}, {"id": "profile-request-categories", "weight": 0, "score": 0.0, "contribution": 0, "raw": "stage=request categories=request=2 network=4"}, {"id": "network-webrtc", "weight": 0, "score": 1.0, "contribution": 0, "raw": "webrtc total=1 public=0 private=1 mdns=0 host=1 srflx=0 relay=0"}, {"id": "network-server", "weight": 0, "score": 1.0, "contribution": 0, "raw": "server observed=0 forwarded=0 cf=1 real=1"}, {"id": "network-connection", "weight": 0, "score": 0.0, "contribution": 0, "raw": "connection api=1 type=3g saveData=1 rtt=250"} ] }), ] { let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); } let response = app .oneshot( Request::builder() .method("GET") .uri("/api/analytics") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; assert_eq!(body["totalScans"], 2); assert_eq!(body["bandCounts"]["high"], 1); assert_eq!(body["localeCounts"][0]["label"], "en-US"); assert_eq!(body["timezoneCounts"][0]["count"], 1); assert_eq!(body["userAgentCounts"][0]["label"], "Chrome"); assert_eq!( body["scoreBuckets"] .as_array() .unwrap() .iter() .find(|bucket| bucket["label"] == "61-80") .unwrap()["count"], 1 ); assert_eq!( body["timezoneOffsetCounts"] .as_array() .unwrap() .iter() .find(|bucket| bucket["label"] == "UTC+08:00") .unwrap()["count"], 1 ); assert_eq!( body["stageCounts"] .as_array() .unwrap() .iter() .find(|bucket| bucket["label"] == "request") .unwrap()["count"], 1 ); assert_eq!( body["categoryCounts"] .as_array() .unwrap() .iter() .find(|bucket| bucket["label"] == "network") .unwrap()["count"], 4 ); assert_eq!(body["sensitivityTotals"]["low"], 6); assert_eq!(body["sensitivityTotals"]["medium"], 7); assert_eq!(body["sensitivityTotals"]["high"], 4); assert_eq!(body["sensitivityTotals"]["critical"], 1); assert_eq!(body["networkExposure"]["webrtcSamples"], 2); assert_eq!(body["networkExposure"]["webrtcCandidates"], 4); assert_eq!(body["networkExposure"]["webrtcPublic"], 1); assert_eq!(body["networkExposure"]["webrtcPrivate"], 2); assert_eq!(body["networkExposure"]["webrtcMdns"], 1); assert_eq!(body["networkExposure"]["serverSamples"], 2); assert_eq!(body["networkExposure"]["serverObserved"], 1); assert_eq!(body["networkExposure"]["forwardedFor"], 1); assert_eq!(body["networkExposure"]["cfConnectingIp"], 1); assert_eq!(body["networkExposure"]["realIpHeader"], 1); assert_eq!(body["networkExposure"]["connectionSamples"], 2); assert_eq!(body["networkExposure"]["saveData"], 1); assert_eq!(body["networkExposure"]["averageRttMs"], 145.0); assert_eq!( body["connectionTypeCounts"] .as_array() .unwrap() .iter() .find(|bucket| bucket["label"] == "4g") .unwrap()["count"], 1 ); assert!(body["updatedAt"].as_str().is_some()); } #[tokio::test] async fn post_scan_persists_event_and_updates_stats() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let payload = json!({ "score": 62, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "timezoneOffsetMinutes": -480, "userAgentFamily": "Apple", "signals": [ { "id": "timezone", "weight": 30, "score": 1.0, "contribution": 30, "raw": "Asia/Shanghai" }, { "id": "language", "weight": 24, "score": 1.0, "contribution": 24, "raw": "zh-CN, en-US" } ] }); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); let body = response_json(response).await; assert_eq!(body["stored"], true); assert!(body["id"].as_str().is_some_and(|id| id.len() > 20)); let stats_response = app .oneshot( Request::builder() .method("GET") .uri("/api/stats") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(stats_response.status(), StatusCode::OK); let stats = response_json(stats_response).await; assert_eq!(stats["totalScans"], 1); assert_eq!(stats["bandCounts"]["high"], 1); assert_eq!(stats["averageScore"], 62.0); assert_eq!(stats["topSignals"][0]["id"], "timezone"); assert_eq!(stats["topSignals"][0]["hits"], 1); } #[tokio::test] async fn post_scan_returns_tracking_history_for_repeated_fingerprint() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let payload = json!({ "score": 62, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "signals": [], "tracking": { "visitorKey": "visitor-alpha", "fingerprintHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "components": [ {"id": "timezone", "hash": "1111111111111111111111111111111111111111111111111111111111111111"}, {"id": "canvas-hash", "hash": "2222222222222222222222222222222222222222222222222222222222222222"} ] } }); let first = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(first.status(), StatusCode::CREATED); let first_body = response_json(first).await; assert_eq!(first_body["tracking"]["visitNumber"], 1); assert_eq!(first_body["tracking"]["exactMatchCount"], 0); assert_eq!(first_body["tracking"]["verdict"], "first_visit"); let second = app .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(second.status(), StatusCode::CREATED); let second_body = response_json(second).await; assert_eq!(second_body["tracking"]["visitNumber"], 2); assert_eq!(second_body["tracking"]["exactMatchCount"], 1); assert_eq!(second_body["tracking"]["fingerprintChanged"], false); assert_eq!(second_body["tracking"]["verdict"], "exact_repeat"); } #[tokio::test] async fn post_scan_estimates_masking_effectiveness_for_changed_components() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); for payload in [ json!({ "score": 62, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "signals": [], "tracking": { "visitorKey": "visitor-mask", "fingerprintHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "components": [ {"id": "timezone", "hash": "1111111111111111111111111111111111111111111111111111111111111111"}, {"id": "platform", "hash": "2222222222222222222222222222222222222222222222222222222222222222"}, {"id": "canvas-hash", "hash": "3333333333333333333333333333333333333333333333333333333333333333"} ] } }), json!({ "score": 62, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "signals": [], "tracking": { "visitorKey": "visitor-mask", "fingerprintHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "components": [ {"id": "timezone", "hash": "9999999999999999999999999999999999999999999999999999999999999999"}, {"id": "platform", "hash": "8888888888888888888888888888888888888888888888888888888888888888"}, {"id": "canvas-hash", "hash": "7777777777777777777777777777777777777777777777777777777777777777"} ] } }), ] { let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); } let third = app .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from( json!({ "score": 62, "band": "high", "locale": "zh-CN", "timezone": "Asia/Shanghai", "signals": [], "tracking": { "visitorKey": "visitor-mask", "fingerprintHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "components": [ {"id": "timezone", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"id": "platform", "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, {"id": "canvas-hash", "hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"} ] } }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(third.status(), StatusCode::CREATED); let body = response_json(third).await; assert_eq!(body["tracking"]["visitNumber"], 3); assert_eq!(body["tracking"]["fingerprintChanged"], true); assert_eq!(body["tracking"]["stableComponents"], 0); assert_eq!(body["tracking"]["changedComponents"], 3); assert_eq!(body["tracking"]["verdict"], "mask_likely_effective"); } #[tokio::test] async fn post_scan_rejects_invalid_tracking_hash() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from( json!({ "score": 62, "band": "high", "signals": [], "tracking": { "visitorKey": "visitor-alpha", "fingerprintHash": "raw ip 127.0.0.1", "components": [] } }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = response_json(response).await; assert_eq!(body["error"], "validation_failed"); } #[tokio::test] async fn post_scan_rejects_invalid_score() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let app = app::router_with_geoip(pool, GeoIpService::disabled()); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/scan") .header("content-type", "application/json") .body(Body::from( json!({ "score": 130, "band": "high", "signals": [] }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = response_json(response).await; assert_eq!(body["error"], "validation_failed"); } #[tokio::test] async fn rust_router_serves_frontend_dist_with_spa_fallback() { let pool = db::connect("sqlite::memory:").await.expect("db opens"); db::migrate(&pool).await.expect("schema migrates"); let dist = tempdir().expect("temp dist exists"); std::fs::create_dir_all(dist.path().join("assets")).expect("assets dir can be created"); std::fs::write( dist.path().join("index.html"), "
shell
", ) .expect("index can be written"); std::fs::write( dist.path().join("assets").join("bundle.js"), "console.log('bundle');", ) .expect("asset can be written"); let app = app::router_with_assets(pool, dist.path()); let index_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(index_response.status(), StatusCode::OK); assert!( index_response .headers() .get(header::CONTENT_TYPE) .unwrap() .to_str() .unwrap() .starts_with("text/html") ); let index_body = index_response .into_body() .collect() .await .unwrap() .to_bytes(); assert!(String::from_utf8_lossy(&index_body).contains("shell")); let deep_link_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/scan/history") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(deep_link_response.status(), StatusCode::OK); let deep_link_body = deep_link_response .into_body() .collect() .await .unwrap() .to_bytes(); assert!(String::from_utf8_lossy(&deep_link_body).contains("shell")); let asset_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/assets/bundle.js") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(asset_response.status(), StatusCode::OK); let asset_body = asset_response .into_body() .collect() .await .unwrap() .to_bytes(); assert_eq!(&asset_body[..], b"console.log('bundle');"); let api_404_response = app .oneshot( Request::builder() .method("GET") .uri("/api/missing") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(api_404_response.status(), StatusCode::NOT_FOUND); }