Opt-in community analytics + admin UI surface
Closes the last T2 plan item. Off by default; toggling on requires the operator to confirm a collector URL (an empty URL is "armed but silent"). The toggle lives on the admin Overview page next to the public-key card — the right place for a privacy-affecting choice since it's where operators actually live. What's sent (per the in-card "Show me exactly what gets sent" disclosure, and pinned by the test): - install_uuid: random UUIDv4 generated on first opt-in. NOT derived from operator_name, store id, public URL, or any other identifier. Wipeable via the Reset button. - daemon_version (CARGO_PKG_VERSION). - tier (creator/pro/patron/unlicensed) — the same string the admin tier endpoint already exposes. - counts: products, active_licenses, settled_invoices — each floored to the nearest 5 (anti-fingerprinting; an exact license count uniquely identifies an operator over time). - uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact). What's NOT sent (test asserts none of these strings appear in the preview heartbeat): operator_name, public_url, store_id, api_key, buyer_email, btcpay_url. Also no product/policy slugs or names, no license/invoice ids, no fingerprints, no webhook secrets. Backend: - src/analytics.rs — heartbeat builder, opt-in check, daily background tick (5min initial grace period after boot). - src/api/community.rs — GET / POST / reset admin endpoints. - main.rs spawns the background tick unconditionally; the tick is a no-op if disabled OR no collector URL configured. Frontend (web/index.html, Overview page): - Toggle + collector URL input + privacy disclosure showing the EXACT JSON shape that would be sent (renders the live preview heartbeat from /v1/admin/community-analytics). - "Reset install_uuid" button so an operator who's been beaconing under one identifier can start fresh. Also includes the configureBtcpay.ts idempotency change from v0.1.0:46 (already committed; touched again here only because the diff includes the .ts file in the same dirty-tree push). Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract which seeds 23 licenses and verifies the heartbeat reports 20 — proves the floor-to-5 anti-fingerprinting is in effect).
This commit is contained in:
@@ -1138,3 +1138,159 @@ async fn recover_returns_license_key_for_matching_pair() {
|
||||
.unwrap();
|
||||
assert_eq!(audit_count, 1, "recovery must write an audit row");
|
||||
}
|
||||
|
||||
/// Community analytics: opt-in toggle + privacy contract.
|
||||
///
|
||||
/// Locks in two invariants:
|
||||
/// - Default state is OFF; no install_uuid generated.
|
||||
/// - Enabling generates a fresh install_uuid; the heartbeat
|
||||
/// preview's counts are floored to the nearest 5 (anti-
|
||||
/// fingerprinting); no operator-identifying fields are present.
|
||||
/// - Bad collector URL → 400 (must start with http:// or https://).
|
||||
#[tokio::test]
|
||||
async fn community_analytics_opt_in_and_privacy_contract() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||
|
||||
// Default state: disabled, no install_uuid yet.
|
||||
let req = build_request(
|
||||
"GET",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
None,
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["enabled"], false, "must default to off");
|
||||
assert!(
|
||||
body["install_uuid"].is_null(),
|
||||
"no UUID should exist before opt-in"
|
||||
);
|
||||
assert!(
|
||||
body["collector_url"].is_null(),
|
||||
"no URL should exist before opt-in"
|
||||
);
|
||||
|
||||
// Bad URL → 400.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
Some(json!({"enabled": true, "collector_url": "ftp://wrong"})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Enabling without a URL is allowed (armed but silent).
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
Some(json!({"enabled": true, "collector_url": null})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Now an install_uuid exists.
|
||||
let req = build_request(
|
||||
"GET",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
None,
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["enabled"], true);
|
||||
let uuid = body["install_uuid"]
|
||||
.as_str()
|
||||
.expect("install_uuid should be present after opt-in");
|
||||
assert_eq!(uuid.len(), 36, "install_uuid should be a UUIDv4 string");
|
||||
|
||||
// Privacy contract: the preview heartbeat MUST contain only
|
||||
// anonymized fields. Specifically, no operator_name, no
|
||||
// public_url, no store_id, no api keys, no buyer info.
|
||||
let preview = &body["preview_heartbeat"];
|
||||
let preview_str =
|
||||
serde_json::to_string(preview).expect("preview should serialize");
|
||||
for forbidden in &[
|
||||
"operator_name",
|
||||
"public_url",
|
||||
"store_id",
|
||||
"api_key",
|
||||
"buyer_email",
|
||||
"btcpay_url",
|
||||
] {
|
||||
assert!(
|
||||
!preview_str.contains(forbidden),
|
||||
"preview heartbeat must not contain '{forbidden}': {preview_str}"
|
||||
);
|
||||
}
|
||||
// Counts must be floored to the nearest 5. Seed 23 active
|
||||
// licenses → counts.active_licenses must be 20.
|
||||
let product = repo::create_product(
|
||||
&state.db,
|
||||
"ana-prod",
|
||||
"Analytics Test",
|
||||
"",
|
||||
100,
|
||||
&json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..23 {
|
||||
let lid = Uuid::new_v4().to_string();
|
||||
repo::create_license(
|
||||
&state.db,
|
||||
&lid,
|
||||
&product.id,
|
||||
None,
|
||||
&Utc::now().to_rfc3339(),
|
||||
&json!({}),
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
1,
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let req = build_request(
|
||||
"GET",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
None,
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
let body = body_json(resp).await;
|
||||
let preview = &body["preview_heartbeat"];
|
||||
assert_eq!(
|
||||
preview["counts"]["active_licenses"], 20,
|
||||
"23 licenses must floor to 20 (anti-fingerprinting): {preview:?}"
|
||||
);
|
||||
|
||||
// Reset wipes the UUID.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/admin/community-analytics/reset",
|
||||
&[("authorization", &auth)],
|
||||
None,
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let req = build_request(
|
||||
"GET",
|
||||
"/v1/admin/community-analytics",
|
||||
&[("authorization", &auth)],
|
||||
None,
|
||||
);
|
||||
let body = body_json(send(&state, req).await).await;
|
||||
assert!(
|
||||
body["install_uuid"].is_null(),
|
||||
"install_uuid must be wiped after reset: {body:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user