use super::privacy; use serde_json::json; #[test] fn malicious_reports_configurations_and_errors_are_allowlisted() { let malicious = json!({ "username": "private-apple-id", "password": "private-password", "token": "private-token", "cookie": "private-cookie", "download_dir": "/private/path", "albums": ["private-album"], "library": "private-zone", "checksum": "private-checksum", "resolution": "private-provider-response", "inventory_drop_library": "private-library", "sync_token_blocked_zone": "private-zone", "sync_token_blocked_explanation": "private-URL", "sync_token_blocked_reason": "private-error", "bytes_downloaded": 123, "disk_bytes_written": 456, "threads": 2, "primary_layout": {"bound_families": 4, "private": "private-id"}, }); let stats = privacy::stats(&malicious); let configuration = privacy::configuration(&malicious); assert_eq!(stats["bytes_downloaded"], 123); assert_eq!(stats["disk_bytes_written"], 456); assert_eq!(configuration["threads"], 2); assert!(!format!("{stats}{configuration}").contains("private")); let error = anyhow::anyhow!( "https://private-url /private-path password=private-token AppleID=private-apple-id" ); let fields = super::classify_support_error("download", &error); assert_eq!(fields["class"], "other"); assert!(!fields.to_string().contains("private")); } #[test] fn historical_support_questions_have_safe_structured_answers() { // Each fixture states the actual historical question and concrete safe // answer fields, rather than equating a counter collection with diagnosis. let cases = [ ( "765 external restart", "support_startup_v1", json!({"phase":"shutdown","outcome":"interrupted","interrupted":true}), ), ( "853 config provenance", "support_startup_v1", json!({"phase":"configuration","outcome":"ready"}), ), ( "770 query completion", "support_pass_completion_v1", json!({"completion":"proven_eof","selected_scope":"selected","selection_comparable":false}), ), ( "765 receiver mismatch", "support_pass_completion_v1", json!({"expected_fetchers":4,"completed_fetchers":4,"token_count":4,"unique_token_count":2}), ), ( "924 bridge successor", "support_checkpoint_v1", json!({"bridge":true,"successor_provenance":"provider","token_present":true,"reason":"veto"}), ), ( "925 inventory comparability", "support_pass_completion_v1", json!({"api_total":604,"assets_seen":17,"inventory_complete":false,"selection_comparable":false}), ), ("765 sparse debt", "sparse_identity_state_failed", json!({})), ( "845 owner morphology", "exact_lookup_rejection_v1", json!({"target":"paired","stage":"master_reference_scope","reason":"owner_unqualified","expected_owner":"absent","lookup_zone":"primary","rejected_requests":604,"supplied_owner":"other_string","supplied_owner_type":"string","owner_provenance":"lookup_request"}), ), ( "853 ambiguity", "metadata_capture_ambiguity_counts_v1", json!({"stored_renditions":2,"matching_children":3,"full_evidence_matching_children":1}), ), ( "845 inventory unrelated scope", "legacy_inventory_failure_v2", json!({"reason":"invalid_inventory_evidence","phase":"family_scan","subreason":"child_reference_scope_mismatch","eof_observed":true,"family_context":"unrelated","scope_mismatch_component":"owner","expected_owner":"absent","expected_owner_type":"absent","supplied_owner":"private_default","supplied_owner_type":"string","pages":5,"records":604,"transferred_bytes":40000000,"retained_bytes":25000000,"retained_byte_budget":268435456,"elapsed_secs":1020.0}), ), ( "845 refresh omissions", "exact_refresh_outcomes_v1", json!({"requested_task_keys":604,"refreshed_task_keys":17,"remaining_task_keys":587,"phase_elapsed_secs":4.0,"oldest_refreshed_url_observed_age_secs":8.0}), ), ( "845 receipt reuse", "support_publication_v1", json!({"operation":"receipt_recovery","receipt_match":true,"content_equal":true,"size_equal":true,"publication_time_retained":true,"actual_publication":false,"source_status":"pending","destination_status":"downloaded"}), ), ( "858 filesystem error", "support_task_error_v1", json!({"operation":"publication","class":"io","errno":95}), ), ( "914 first attempt before eventual success", "support_transfer_pass_v1", json!({"phase":"first","failed":17,"downloaded":604,"concurrency":2,"state_write_failures":0}), ), ( "913 strict import", "support_import_v1", json!({"phase":"adoption","reason":"strict_refusal","dry_run":true}), ), ( "929 precision", "support_metadata_v1", json!({"operation":"embed","backend":"native_exif","source_fractional":true,"planned_fractional":true,"written_fractional":false,"verified":true}), ), ( "926 provider readiness", "support_startup_v1", json!({"phase":"startup","class":"provider_not_ready","http_status":null}), ), ]; for (question, kind, fields) in cases { let clean = privacy::diagnostic(kind, fields.as_object().unwrap()).unwrap(); assert_eq!( serde_json::Value::Object(clean), fields, "missing actionable evidence for {question}" ); } } #[test] fn tim_paired_counts_remain_overlapping_observations() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("history.json"); // Export never sums 604/17/604 into a unique asset count. let mut cycle = json!({"id":uuid::Uuid::new_v4().to_string(),"started_at":"2026-10-10T16:00:00Z","completed_at":null, "outcome":"running","build_version":"0.25.1-dev","configuration":{},"stats":{},"diagnostics":[]}); cycle["diagnostics"] = json!([604,17,604].into_iter().map(|n| json!({"kind":"exact_lookup_rejection_v1", "fields":{"target":"paired","stage":"master_reference_scope","reason":"owner_unqualified","rejected_requests":n}, "observations":1,"first_at":"2026-10-10T16:01:00Z","last_at":"2026-10-10T16:01:00Z"})).collect::>()); std::fs::write(&path, serde_json::to_vec(&json!({"schema_version":1,"cycles_evicted":0,"queue_dropped":0,"groups_omitted":0,"previous_history_unavailable":false,"cycles":[cycle]})).unwrap()).unwrap(); let (h, status) = super::history::read(&path); assert_eq!(status, "available"); assert_eq!(h.cycles[0].diagnostics.len(), 3); assert_eq!(h.cycles[0].diagnostics[1].fields["rejected_requests"], 17); } #[test] fn owning_checkpoint_decisions_survive_support_allowlist_without_tokens() { use crate::download::RecoveryAction; use crate::sync_cycle::{ CheckpointBasis, CheckpointHoldReason, SourceCheckpointDecision, checkpoint_support_fields, }; for reason in [ CheckpointHoldReason::DryRun, CheckpointHoldReason::StalePassPlan, CheckpointHoldReason::Interrupted, CheckpointHoldReason::SessionExpired, CheckpointHoldReason::EnumerationIncomplete, CheckpointHoldReason::StateNotDurable, CheckpointHoldReason::TokenProofIncomplete, CheckpointHoldReason::LegacyPreservationIncomplete, ] { let decision = SourceCheckpointDecision::Preserve { reason, recovery: RecoveryAction::ReplayFromPriorToken, }; let fields = checkpoint_support_fields(&decision); let projected = super::privacy::diagnostic("support_checkpoint_v1", fields.as_object().unwrap()) .unwrap(); assert_eq!(serde_json::Value::Object(projected), fields); assert_eq!(fields["decision"], "preserved"); } for basis in [ CheckpointBasis::IncrementalDelta, CheckpointBasis::CompleteInventory, CheckpointBasis::InventoryWithDeltaBridge, ] { let fields = checkpoint_support_fields(&SourceCheckpointDecision::Advance { token: "PRIVATE_PROVIDER_TOKEN".into(), basis, }); let projected = super::privacy::diagnostic("support_checkpoint_v1", fields.as_object().unwrap()) .unwrap(); assert_eq!(serde_json::Value::Object(projected), fields); assert!(!fields.to_string().contains("PRIVATE")); } for persistence in [ "failed", "stored", "stored_hold", "staged_reconciliation", "no_state_db", ] { let fields = serde_json::json!({"persistence": persistence}); assert_eq!( serde_json::Value::Object( super::privacy::diagnostic("support_checkpoint_v1", fields.as_object().unwrap()) .unwrap() ), fields ); } assert_eq!( super::privacy::stats( &serde_json::json!({"sync_token_blocked_reason":"inventory_delta_bridge_failed"}) )["sync_token_blocked_reason"], "inventory_delta_bridge_failed" ); } #[test] fn existing_refresh_hold_and_hardlink_fixed_fields_survive_production_layer() { use tracing_subscriber::layer::SubscriberExt; let (layer, drain) = super::history::test_layer(); tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), || { tracing::warn!( diagnostic = "expired_url_refresh_failed", failed_records = 17u64, authentication_failures = 2u64, rate_limit_observations = 3u64, error = "PRIVATE_TOKEN" ); tracing::warn!( diagnostic = "retained_checkpoint_expired", retry_deferred = true, state_write_failed = false, retry_exhausted = true, zone = "PRIVATE_ZONE" ); tracing::warn!( diagnostic = "legacy_preservation_hardlink_trust", affected_paths = 4u64 ); }); let evidence = drain(); assert_eq!(evidence.len(), 3); assert_eq!( evidence[0]["fields"], json!({"failed_records":17,"authentication_failures":2,"rate_limit_observations":3}) ); assert_eq!( evidence[1]["fields"], json!({"retry_deferred":true,"state_write_failed":false,"retry_exhausted":true}) ); assert_eq!(evidence[2]["fields"], json!({"affected_paths":4})); assert!( !serde_json::to_string(&evidence) .unwrap() .contains("PRIVATE") ); } #[test] fn typed_effective_configuration_preserves_every_file_raw_and_live_mode() { use crate::types::{FileMatchPolicy, LivePhotoMode, RawPolicy}; let dir = tempfile::tempdir().unwrap(); let globals = crate::config::GlobalArgs { username: Some("PRIVATE_USER".into()), domain: None, data_dir: Some(dir.path().to_string_lossy().into_owned()), }; let toml: crate::config::TomlConfig = toml::from_str(&format!( "[download]\ndirectory={:?}", dir.path().to_string_lossy() )) .unwrap(); let mut config = crate::config::Config::build( &globals, &crate::cli::PasswordArgs::default(), crate::cli::SyncArgs::default(), Some(&toml), ) .unwrap(); for (variant, label) in [ ( FileMatchPolicy::NameSizeDedupWithSuffix, "name-size-dedup-with-suffix", ), (FileMatchPolicy::NameId7, "name-id7"), ] { config.photos.file_match_policy = variant; assert_eq!( super::runtime_configuration(&config)["file_match_policy"], label ); } for (variant, label) in [ (RawPolicy::AsIs, "as-is"), (RawPolicy::PreferRaw, "prefer-raw"), (RawPolicy::PreferJpeg, "prefer-jpeg"), ] { config.photos.raw_policy = variant; assert_eq!(super::runtime_configuration(&config)["raw_policy"], label); } for (variant, label) in [ (LivePhotoMode::Both, "both"), (LivePhotoMode::ImageOnly, "image-only"), (LivePhotoMode::VideoOnly, "video-only"), (LivePhotoMode::Skip, "skip"), ] { config.photos.live_photo_mode = variant; assert_eq!( super::runtime_configuration(&config)["live_photo_mode"], label ); let input: crate::config::TomlConfig = toml::from_str(&format!("[photos]\nlive_photo_mode={label:?}")).unwrap(); assert_eq!( super::configuration(Some(&input), true)["live_photo_mode"], label ); } assert!( !super::runtime_configuration(&config) .to_string() .contains("PRIVATE") ); } #[test] fn hydration_wait_contract_retains_only_fixed_phases_outcomes_and_unsigned_time() { for phase in ["asset_identity", "paired_asset", "album_relation"] { for outcome in ["started", "waiting", "returned", "cancelled"] { let fields = json!({"phase": phase, "outcome": outcome, "elapsed_secs": 31, "token": "private-token", "library": "private-zone", "message": "private-body"}); let safe = privacy::diagnostic("support_hydration_wait_v1", fields.as_object().unwrap()) .unwrap(); assert_eq!( serde_json::Value::Object(safe), json!({"phase": phase, "outcome": outcome, "elapsed_secs": 31}) ); } } let fields = json!({"phase": "private-zone", "outcome": "private-response", "elapsed_secs": -1}); assert!( privacy::diagnostic("support_hydration_wait_v1", fields.as_object().unwrap()) .unwrap() .is_empty() ); } #[test] fn missing_reference_contract_rejects_private_and_numeric_categories() { let expected = json!({"record_history":"replacement", "prior_reference":"present", "sparse_marker":"true", "linkage":"all_fields", "lookup_outcome":"live_reference", "lookup_family":"candidate", "lookup_scope":"matched", "lookup_shape":"live_reference"}); assert_eq!( serde_json::Value::Object( privacy::diagnostic( "support_missing_reference_v1", expected.as_object().unwrap() ) .unwrap() ), expected ); for value in [ json!(42), json!("private-provider-value"), json!({"nested":"private"}), json!(null), ] { let fields = expected .as_object() .unwrap() .keys() .map(|key| (key.clone(), value.clone())) .collect(); assert!( privacy::diagnostic("support_missing_reference_v1", &fields) .unwrap() .is_empty() ); } } #[test] fn issue956_lookup_record_status_contract_is_closed_and_optional() { for label in [ "not_requested", "omitted", "returned", "tombstone", "not_found", "access_denied", "authentication_failed", "zone_not_found", "retry_later", "throttled", "server_error", "other_error", "malformed_error", ] { let fields = json!({"master_status":label,"child_status":label}); assert_eq!( privacy::diagnostic("exact_lookup_rejection_v1", fields.as_object().unwrap()).unwrap(), fields.as_object().unwrap().clone() ); } for invalid in [ json!("ACCESS_DENIED"), json!("PRIVATE_ERROR_CANARY"), json!(401), json!(null), json!({"secret":"PRIVATE"}), ] { let fields = json!({"master_status":invalid,"child_status":invalid,"reason":"record_provider_error"}); let clean = privacy::diagnostic("exact_lookup_rejection_v1", fields.as_object().unwrap()).unwrap(); assert_eq!( clean, json!({"reason":"record_provider_error"}) .as_object() .unwrap() .clone() ); } // Older retained history carries no newly invented response evidence. let fields = json!({"target":"paired","reason":"record_provider_error"}); assert_eq!( privacy::diagnostic("exact_lookup_rejection_v1", fields.as_object().unwrap()).unwrap(), fields.as_object().unwrap().clone() ); } #[test] fn issue956_child_reference_status_is_a_field_specific_allowlist() { for label in [ "not_requested", "omitted", "unavailable", "invalid_child", "missing", "malformed", "sparse", "matches_requested", "different", ] { let fields = json!({"child_reference_status":label}); assert_eq!( privacy::diagnostic("exact_lookup_rejection_v1", fields.as_object().unwrap()).unwrap(), *fields.as_object().unwrap() ); } for invalid in [ json!("PRIVATE_REFERENCE_CANARY"), json!("returned"), json!(42), json!(null), json!(true), json!({"recordName":"PRIVATE"}), ] { let fields = json!({"child_reference_status":invalid,"reason":"record_provider_error"}); let clean = privacy::diagnostic("exact_lookup_rejection_v1", fields.as_object().unwrap()).unwrap(); assert_eq!( clean, *json!({"reason":"record_provider_error"}) .as_object() .unwrap() ); } let old = json!({"target":"paired","reason":"record_provider_error"}); let clean = privacy::diagnostic("exact_lookup_rejection_v1", old.as_object().unwrap()).unwrap(); assert_eq!( clean, *old.as_object().unwrap(), "old history cannot invent reference evidence" ); } #[test] fn recovery_decisions_preserve_only_fixed_evidence_outcomes() { for (decision, reason) in [ ("partial", "historical_claim_prepared"), ("unsupported", "historical_claim_unverified"), ] { let fields = json!({ "mechanism": "historical_path", "decision": decision, "reason": reason, "owner": "private-owner", "path": "/private/media", "record": "private-record", "token": "private-token", "url": "https://private-provider" }); let projected = privacy::diagnostic("recovery_decision_v1", fields.as_object().unwrap()).unwrap(); assert_eq!( json!(projected), json!({"mechanism":"historical_path","decision":decision,"reason":reason}) ); } let fields = json!({"mechanism":"private-owner","decision":"private-token","reason":"private-path"}); let projected = privacy::diagnostic("recovery_decision_v1", fields.as_object().unwrap()).unwrap(); assert!( projected.is_empty(), "saved history must not authorize arbitrary labels" ); }