Skip to main content

max / makenotwork

17.8 KB · 580 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use std::sync::Mutex;
5
6 /// Mutex to serialize tests that call Config::from_env(), since env vars are
7 /// process-global and concurrent mutation causes flaky failures.
8 static ENV_LOCK: Mutex<()> = Mutex::new(());
9
10 /// All env var keys that Config::from_env() reads. Used by the guard to
11 /// snapshot and restore state so tests don't leak into each other.
12 const CONFIG_ENV_VARS: &[&str] = &[
13 "HOST",
14 "PORT",
15 "DATABASE_URL",
16 "HOST_URL",
17 "SIGNING_SECRET",
18 "S3_ENDPOINT",
19 "S3_BUCKET",
20 "S3_ACCESS_KEY",
21 "S3_SECRET_KEY",
22 "S3_REGION",
23 "S3_PUBLIC_BUCKET",
24 "S3_ARTIFACT_BUCKET",
25 "ARTIFACT_S3_ENDPOINT",
26 "ARTIFACT_S3_BUCKET",
27 "ARTIFACT_S3_ACCESS_KEY",
28 "ARTIFACT_S3_SECRET_KEY",
29 "ARTIFACT_S3_REGION",
30 "ARTIFACT_BASE_URL",
31 "SYNCKIT_S3_ENDPOINT",
32 "SYNCKIT_S3_BUCKET",
33 "SYNCKIT_S3_ACCESS_KEY",
34 "SYNCKIT_S3_SECRET_KEY",
35 "SYNCKIT_S3_REGION",
36 "STRIPE_SECRET_KEY",
37 "STRIPE_WEBHOOK_SECRET",
38 "STRIPE_WEBHOOK_SECRET_V2",
39 "ADMIN_USER_ID",
40 "SYNCKIT_JWT_SECRET",
41 "SCAN_ENABLED",
42 "CLAMAV_SOCKET",
43 "YARA_RULES_DIR",
44 "MALWAREBAZAAR_ENABLED",
45 "URLHAUS_ENABLED",
46 "ABUSE_CH_AUTH_KEY",
47 "METADEFENDER_API_KEY",
48 "GIT_REPOS_PATH",
49 "POSTMARK_WEBHOOK_TOKEN",
50 "POSTMARK_BROADCAST_WEBHOOK_TOKEN",
51 "GIT_SSH_HOST",
52 "MT_BASE_URL",
53 "FAN_PLUS_STRIPE_PRICE_ID",
54 "CREATOR_TIER_BASIC_PRICE_ID",
55 "CREATOR_TIER_SMALL_FILES_PRICE_ID",
56 "CREATOR_TIER_BIG_FILES_PRICE_ID",
57 "CREATOR_TIER_EVERYTHING_PRICE_ID",
58 "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID",
59 "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID",
60 "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID",
61 "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID",
62 "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID",
63 "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID",
64 "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID",
65 "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID",
66 "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID",
67 "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID",
68 "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID",
69 "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID",
70 "CREATOR_FOUNDER_WINDOW_OPEN",
71 "BUILD_TRIGGER_TOKEN",
72 "BUILD_HOST_LINUX",
73 "BUILD_HOST_DARWIN",
74 "CDN_BASE_URL",
75 "POSTMARK_INBOUND_WEBHOOK_TOKEN",
76 "INTERNAL_SHARED_SECRET",
77 "CLI_SERVICE_TOKEN",
78 "WAM_URL",
79 "WAM_TOKEN",
80 "ACCESS_GATE",
81 "SSO_PROVIDER_URL",
82 "SSO_CLIENT_ID",
83 "SSO_KEY",
84 ];
85
86 /// RAII guard that snapshots config-related env vars on creation and restores
87 /// them when dropped. Also holds the ENV_LOCK so tests run serially.
88 struct EnvGuard {
89 _lock: std::sync::MutexGuard<'static, ()>,
90 snapshot: Vec<(&'static str, Option<String>)>,
91 }
92
93 impl EnvGuard {
94 fn new() -> Self {
95 let lock = ENV_LOCK
96 .lock()
97 .unwrap_or_else(std::sync::PoisonError::into_inner);
98 let snapshot = CONFIG_ENV_VARS
99 .iter()
100 .map(|&key| (key, std::env::var(key).ok()))
101 .collect();
102 Self {
103 _lock: lock,
104 snapshot,
105 }
106 }
107
108 /// Remove all config env vars so from_env() sees a clean slate.
109 fn clear_all() {
110 for &key in CONFIG_ENV_VARS {
111 // SAFETY: test-only, serialized by mutex
112 unsafe {
113 std::env::remove_var(key);
114 }
115 }
116 }
117 }
118
119 impl Drop for EnvGuard {
120 fn drop(&mut self) {
121 for (key, val) in &self.snapshot {
122 match val {
123 // SAFETY: test-only, serialized by mutex
124 Some(v) => unsafe { std::env::set_var(key, v) },
125 None => unsafe { std::env::remove_var(key) },
126 }
127 }
128 }
129 }
130
131 // ---- tests ----
132
133 #[test]
134 fn socket_addr_combines_host_and_port() {
135 let config = Config {
136 host: "127.0.0.1".parse().unwrap(),
137 port: 8080,
138 database_url: "postgres://test".to_string(),
139 host_url: Arc::from("http://localhost:8080"),
140 signing_secret: "secret".to_string(),
141 storage: None,
142 synckit_storage: None,
143 public_storage: None,
144 artifact_storage: None,
145 artifact_base_url: None,
146 stripe: None,
147 admin_user_id: None,
148 synckit_jwt_secret: None,
149 scan: None,
150 cdn_base_url: "https://cdn.localhost".to_string(),
151 user_pages_host: Arc::from("u.localhost"),
152 access_gate: AccessGate::Open,
153 sso: None,
154 rate_limits: crate::constants::RateLimits::production(),
155 build: BuildConfig {
156 trigger_token: None,
157 host_linux: None,
158 host_darwin: None,
159 git_repos_path: None,
160 git_ssh_host: None,
161 },
162 email_webhooks: EmailWebhookConfig {
163 webhook_token: None,
164 broadcast_webhook_token: None,
165 inbound_webhook_token: None,
166 enforce_sender_auth: true,
167 },
168 creator_pricing: CreatorTierPricing {
169 fan_plus_price_id: None,
170 tier_prices: HashMap::new(),
171 tier_annual_prices: HashMap::new(),
172 tier_founder_prices: HashMap::new(),
173 tier_founder_annual_prices: HashMap::new(),
174 founder_window_open: false,
175 },
176 integrations: IntegrationsConfig {
177 mt_base_url: None,
178 wam_url: None,
179 internal_shared_secret: None,
180 cli_service_token: None,
181 alerts_ingest_token: None,
182 },
183 };
184 let addr = config.socket_addr();
185 assert_eq!(addr.port(), 8080);
186 assert_eq!(addr.ip().to_string(), "127.0.0.1");
187 }
188
189 #[test]
190 fn config_error_display() {
191 assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address");
192 assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number");
193 assert!(
194 ConfigError::MissingDatabaseUrl
195 .to_string()
196 .contains("DATABASE_URL")
197 );
198 }
199
200 // ---- from_env validation tests ----
201
202 #[test]
203 fn from_env_succeeds_with_required_vars() {
204 let guard = EnvGuard::new();
205 EnvGuard::clear_all();
206
207 // SAFETY: test-only, serialized by EnvGuard mutex
208 unsafe {
209 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
210 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
211 }
212
213 let config = Config::from_env().expect("should succeed with DATABASE_URL set");
214 assert_eq!(config.database_url, "postgres://localhost/test_db");
215 // Defaults: host=127.0.0.1, port=3000
216 assert_eq!(config.host.to_string(), "127.0.0.1");
217 assert_eq!(config.port, 3000);
218 // Signing secret should be a random 64-char hex string in dev mode
219 assert!(!config.signing_secret.is_empty());
220 drop(guard);
221 }
222
223 #[test]
224 fn from_env_fails_without_database_url() {
225 let guard = EnvGuard::new();
226 EnvGuard::clear_all();
227
228 let err = Config::from_env().unwrap_err();
229 assert!(
230 matches!(err, ConfigError::MissingDatabaseUrl),
231 "expected MissingDatabaseUrl, got: {err}"
232 );
233 drop(guard);
234 }
235
236 #[test]
237 fn from_env_fails_in_production_without_signing_secret() {
238 let guard = EnvGuard::new();
239 EnvGuard::clear_all();
240
241 // SAFETY: test-only, serialized by EnvGuard mutex
242 unsafe {
243 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
244 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
245 std::env::set_var("HOST", "0.0.0.0"); // production indicator
246 }
247
248 let err = Config::from_env().unwrap_err();
249 assert!(
250 matches!(err, ConfigError::MissingSigningSecret),
251 "expected MissingSigningSecret, got: {err}"
252 );
253 drop(guard);
254 }
255
256 #[test]
257 fn from_env_fails_without_cdn_base_url_even_outside_production() {
258 let guard = EnvGuard::new();
259 EnvGuard::clear_all();
260
261 // SAFETY: test-only, serialized by EnvGuard mutex
262 unsafe {
263 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
264 std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate
265 // No production indicator: HOST stays unset, so this is a dev config.
266 // It must STILL fail. The requirement is unconditional precisely so
267 // no environment can reach the old presigned fallback, which minted
268 // a 24-hour URL into the durable `projects.cover_image_url` column.
269 // CDN_BASE_URL deliberately unset.
270 }
271
272 let err = Config::from_env().unwrap_err();
273 assert!(
274 matches!(err, ConfigError::MissingCdnBaseUrl),
275 "expected MissingCdnBaseUrl, got: {err}"
276 );
277 drop(guard);
278 }
279
280 #[test]
281 fn from_env_accepts_production_with_cdn_base_url() {
282 let guard = EnvGuard::new();
283 EnvGuard::clear_all();
284
285 // SAFETY: test-only, serialized by EnvGuard mutex
286 unsafe {
287 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
288 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
289 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
290 std::env::set_var("HOST", "0.0.0.0");
291 std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work");
292 }
293
294 let config = Config::from_env().expect("production config with CDN should succeed");
295 assert_eq!(config.cdn_base_url, "https://cdn.makenot.work");
296 drop(guard);
297 }
298
299 #[test]
300 fn from_env_fails_with_https_host_url_without_signing_secret() {
301 let guard = EnvGuard::new();
302 EnvGuard::clear_all();
303
304 // SAFETY: test-only, serialized by EnvGuard mutex
305 unsafe {
306 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
307 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
308 std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
309 }
310
311 let err = Config::from_env().unwrap_err();
312 assert!(
313 matches!(err, ConfigError::MissingSigningSecret),
314 "expected MissingSigningSecret, got: {err}"
315 );
316 drop(guard);
317 }
318
319 #[test]
320 fn from_env_fails_with_short_synckit_jwt_secret() {
321 let guard = EnvGuard::new();
322 EnvGuard::clear_all();
323
324 // SAFETY: test-only, serialized by EnvGuard mutex
325 unsafe {
326 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
327 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
328 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
329 // 31 chars, one under the floor.
330 std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31));
331 }
332
333 let err = Config::from_env().unwrap_err();
334 assert!(
335 matches!(err, ConfigError::WeakSynckitJwtSecret),
336 "expected WeakSynckitJwtSecret, got: {err}"
337 );
338 drop(guard);
339 }
340
341 #[test]
342 fn from_env_accepts_strong_synckit_jwt_secret() {
343 let guard = EnvGuard::new();
344 EnvGuard::clear_all();
345
346 // SAFETY: test-only, serialized by EnvGuard mutex
347 unsafe {
348 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
349 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
350 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
351 std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32));
352 }
353
354 let config = Config::from_env().expect("32-char JWT secret should be accepted");
355 assert_eq!(
356 config.synckit_jwt_secret.as_deref(),
357 Some("y".repeat(32).as_str())
358 );
359 drop(guard);
360 }
361
362 #[test]
363 fn from_env_uses_random_dev_secret_when_not_production() {
364 let guard = EnvGuard::new();
365 EnvGuard::clear_all();
366
367 // SAFETY: test-only, serialized by EnvGuard mutex
368 unsafe {
369 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
370 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
371 // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
372 }
373
374 let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET");
375 // Should be a 64-char hex string (256-bit random)
376 assert_eq!(
377 config.signing_secret.len(),
378 64,
379 "expected 64-char hex signing secret, got length {}",
380 config.signing_secret.len()
381 );
382 assert!(
383 config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()),
384 "expected hex signing secret, got: {}",
385 config.signing_secret
386 );
387 drop(guard);
388 }
389
390 #[test]
391 fn from_env_storage_none_when_partially_set() {
392 let guard = EnvGuard::new();
393 EnvGuard::clear_all();
394
395 // SAFETY: test-only, serialized by EnvGuard mutex
396 unsafe {
397 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
398 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
399 // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY
400 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
401 std::env::set_var("S3_BUCKET", "test-bucket");
402 }
403
404 let config = Config::from_env().expect("should succeed");
405 assert!(
406 config.storage.is_none(),
407 "storage should be None when S3 vars are only partially set"
408 );
409 drop(guard);
410 }
411
412 #[test]
413 fn from_env_storage_some_when_fully_set() {
414 let guard = EnvGuard::new();
415 EnvGuard::clear_all();
416
417 // SAFETY: test-only, serialized by EnvGuard mutex
418 unsafe {
419 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
420 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
421 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
422 std::env::set_var("S3_BUCKET", "test-bucket");
423 std::env::set_var("S3_ACCESS_KEY", "ak");
424 std::env::set_var("S3_SECRET_KEY", "sk");
425 }
426
427 let config = Config::from_env().expect("should succeed");
428 let storage = config
429 .storage
430 .expect("storage should be Some when all S3 vars set");
431 assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com");
432 assert_eq!(storage.bucket, "test-bucket");
433 assert_eq!(storage.region, "us-east-1"); // default region
434 drop(guard);
435 }
436
437 #[test]
438 fn from_env_stripe_none_when_secret_key_missing() {
439 let guard = EnvGuard::new();
440 EnvGuard::clear_all();
441
442 // SAFETY: test-only, serialized by EnvGuard mutex
443 unsafe {
444 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
445 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
446 // Set webhook secret but not secret key
447 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
448 }
449
450 let config = Config::from_env().expect("should succeed");
451 assert!(
452 config.stripe.is_none(),
453 "stripe should be None when STRIPE_SECRET_KEY is missing"
454 );
455 drop(guard);
456 }
457
458 #[test]
459 fn from_env_stripe_none_when_webhook_secret_missing() {
460 let guard = EnvGuard::new();
461 EnvGuard::clear_all();
462
463 // SAFETY: test-only, serialized by EnvGuard mutex
464 unsafe {
465 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
466 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
467 // Set secret key but not webhook secret
468 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
469 }
470
471 let config = Config::from_env().expect("should succeed");
472 assert!(
473 config.stripe.is_none(),
474 "stripe should be None when STRIPE_WEBHOOK_SECRET is missing"
475 );
476 drop(guard);
477 }
478
479 #[test]
480 fn from_env_stripe_some_when_fully_set() {
481 let guard = EnvGuard::new();
482 EnvGuard::clear_all();
483
484 // SAFETY: test-only, serialized by EnvGuard mutex
485 unsafe {
486 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
487 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
488 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
489 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
490 }
491
492 let config = Config::from_env().expect("should succeed");
493 let stripe = config
494 .stripe
495 .expect("stripe should be Some when fully configured");
496 assert_eq!(stripe.secret_key, "sk_test_abc");
497 assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]);
498 assert!(stripe.webhook_secret_v2.is_none());
499 drop(guard);
500 }
501
502 #[test]
503 fn from_env_invalid_host_rejected() {
504 let guard = EnvGuard::new();
505 EnvGuard::clear_all();
506
507 // SAFETY: test-only, serialized by EnvGuard mutex
508 unsafe {
509 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
510 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
511 std::env::set_var("HOST", "not-an-ip");
512 }
513
514 let err = Config::from_env().unwrap_err();
515 assert!(
516 matches!(err, ConfigError::InvalidHost),
517 "expected InvalidHost, got: {err}"
518 );
519 drop(guard);
520 }
521
522 #[test]
523 fn from_env_invalid_port_rejected() {
524 let guard = EnvGuard::new();
525 EnvGuard::clear_all();
526
527 // SAFETY: test-only, serialized by EnvGuard mutex
528 unsafe {
529 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
530 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
531 std::env::set_var("PORT", "not-a-number");
532 }
533
534 let err = Config::from_env().unwrap_err();
535 assert!(
536 matches!(err, ConfigError::InvalidPort),
537 "expected InvalidPort, got: {err}"
538 );
539 drop(guard);
540 }
541
542 #[test]
543 fn from_env_scan_disabled_when_explicitly_off() {
544 let guard = EnvGuard::new();
545 EnvGuard::clear_all();
546
547 // SAFETY: test-only, serialized by EnvGuard mutex
548 unsafe {
549 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
550 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
551 std::env::set_var("SCAN_ENABLED", "false");
552 }
553
554 let config = Config::from_env().expect("should succeed");
555 assert!(
556 config.scan.is_none(),
557 "scan should be None when SCAN_ENABLED=false"
558 );
559 drop(guard);
560 }
561
562 #[test]
563 fn from_env_scan_enabled_by_default() {
564 let guard = EnvGuard::new();
565 EnvGuard::clear_all();
566
567 // SAFETY: test-only, serialized by EnvGuard mutex
568 unsafe {
569 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
570 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
571 }
572
573 let config = Config::from_env().expect("should succeed");
574 assert!(
575 config.scan.is_some(),
576 "scan should be Some by default (enabled unless explicitly disabled)"
577 );
578 drop(guard);
579 }
580