Files
Test User 5152086668
CI / Docker build (push) Has been cancelled
CI / Frontend test and build (push) Has been cancelled
CI / Rust test (push) Has been cancelled
Release / Build and publish release (push) Has been cancelled
Initial browser fingerprint app with Gitea CI
2026-07-04 03:15:26 +08:00

653 lines
23 KiB
Rust

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"),
"<main id=\"root\">shell</main>",
)
.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);
}