1use std::net::SocketAddr;
4
5use codoseo_core::plan::Plan;
6use url::Url;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Mode {
11 SelfHost,
12 Cloud,
13}
14
15#[derive(Clone)]
17pub struct GithubConfig {
18 pub client_id: String,
19 pub client_secret: String,
20 pub authorize_url: Url,
21 pub token_url: Url,
22 pub api_url: Url,
23}
24
25impl std::fmt::Debug for GithubConfig {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("GithubConfig")
28 .field("client_id", &self.client_id)
29 .field("client_secret", &"<redacted>")
30 .field("authorize_url", &self.authorize_url.as_str())
31 .field("token_url", &self.token_url.as_str())
32 .field("api_url", &self.api_url.as_str())
33 .finish()
34 }
35}
36
37#[derive(Clone)]
39pub struct TurnstileConfig {
40 pub site_key: String,
41 pub secret: String,
42 pub verify_url: Url,
43}
44
45impl std::fmt::Debug for TurnstileConfig {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("TurnstileConfig")
48 .field("site_key", &self.site_key)
49 .field("secret", &"<redacted>")
50 .field("verify_url", &self.verify_url.as_str())
51 .finish()
52 }
53}
54
55#[derive(Clone)]
58pub struct DodoConfig {
59 pub api_key: String,
61 pub webhook_secret: String,
63 pub product_pro: String,
64 pub product_agency: String,
65 pub api_url: Url,
68}
69
70impl std::fmt::Debug for DodoConfig {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("DodoConfig")
75 .field("api_key", &"<redacted>")
76 .field("webhook_secret", &"<redacted>")
77 .field("product_pro", &self.product_pro)
78 .field("product_agency", &self.product_agency)
79 .field("api_url", &self.api_url.as_str())
80 .finish()
81 }
82}
83
84impl DodoConfig {
85 pub fn plan_for_product(&self, product_id: &str) -> Option<Plan> {
87 if product_id == self.product_pro {
88 Some(Plan::Pro)
89 } else if product_id == self.product_agency {
90 Some(Plan::Agency)
91 } else {
92 None
93 }
94 }
95
96 pub fn product_for(&self, plan: Plan) -> Option<&str> {
98 match plan {
99 Plan::Pro => Some(&self.product_pro),
100 Plan::Agency => Some(&self.product_agency),
101 Plan::Free | Plan::SelfHosted => None,
102 }
103 }
104}
105
106#[derive(Clone)]
107pub struct Config {
108 pub mode: Mode,
109 pub base_url: Url,
112 pub bind: SocketAddr,
113 pub secret_key: String,
115 pub smtp_url: Option<String>,
116 pub mail_from: String,
118 pub github: Option<GithubConfig>,
119 pub bot_ip: Option<String>,
121 pub turnstile: Option<TurnstileConfig>,
123 pub client_ip_header: String,
126 pub admin_emails: Vec<String>,
128 pub rankorg_url: Url,
130 pub billing: Option<DodoConfig>,
132 pub mcp: McpAnonConfig,
134}
135
136pub const DEFAULT_SHARED_CLIENTS: &str = "claude-user,chatgpt,openai-mcp";
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct McpAnonConfig {
142 pub daily_audits: i64,
145 pub daily_emails: i64,
148 pub shared_clients: Vec<String>,
152}
153
154impl McpAnonConfig {
155 pub fn is_shared_client(&self, user_agent: Option<&str>) -> bool {
157 let Some(ua) = user_agent else { return false };
158 let ua = ua.to_ascii_lowercase();
159 self.shared_clients.iter().any(|name| ua.contains(name))
160 }
161}
162
163impl std::fmt::Debug for Config {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("Config")
168 .field("mode", &self.mode)
169 .field("base_url", &self.base_url.as_str())
170 .field("bind", &self.bind)
171 .field("secret_key", &"<redacted>")
172 .field("smtp_url", &self.smtp_url.as_deref().map(redact_url))
173 .field("mail_from", &self.mail_from)
174 .field("github", &self.github)
175 .field("bot_ip", &self.bot_ip)
176 .field("turnstile", &self.turnstile)
177 .field("client_ip_header", &self.client_ip_header)
178 .field("admin_emails", &self.admin_emails)
179 .field("rankorg_url", &self.rankorg_url.as_str())
180 .field("billing", &self.billing)
181 .field("mcp", &self.mcp)
182 .finish()
183 }
184}
185
186fn redact_url(url: &str) -> String {
188 match Url::parse(url) {
189 Ok(mut u) => {
190 if u.password().is_some() {
191 let _ = u.set_password(Some("REDACTED"));
192 }
193 u.to_string()
194 }
195 Err(_) => "<redacted>".to_owned(),
196 }
197}
198
199#[derive(Debug, thiserror::Error)]
200pub enum ConfigError {
201 #[error("{0} is not set")]
202 Missing(&'static str),
203 #[error("{name} is invalid: {reason}")]
204 Invalid { name: &'static str, reason: String },
205}
206
207const DEFAULT_BIND: &str = "0.0.0.0:8080";
208pub const DEFAULT_MAIL_FROM: &str = "CodoSEO <hello@codoseo.com>";
210
211impl Config {
212 pub fn from_env() -> Result<Config, ConfigError> {
213 Config::from_lookup(|k| std::env::var(k).ok())
214 }
215
216 pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Config, ConfigError> {
219 let get = |k: &str| lookup(k).filter(|v| !v.trim().is_empty());
220
221 let mode = match get("CODOSEO_MODE").as_deref() {
222 None | Some("selfhost") | Some("self-host") | Some("self_hosted") => Mode::SelfHost,
223 Some("cloud") => Mode::Cloud,
224 Some(other) => {
225 return Err(ConfigError::Invalid {
226 name: "CODOSEO_MODE",
227 reason: format!("expected selfhost or cloud, got {other:?}"),
228 });
229 }
230 };
231
232 let bind_text = get("CODOSEO_BIND").unwrap_or_else(|| DEFAULT_BIND.to_owned());
233 let bind: SocketAddr = bind_text.parse().map_err(|e| ConfigError::Invalid {
234 name: "CODOSEO_BIND",
235 reason: format!("{e}"),
236 })?;
237
238 let base_url = match get("BASE_URL") {
239 Some(v) => Url::parse(&v).map_err(|e| ConfigError::Invalid {
240 name: "BASE_URL",
241 reason: e.to_string(),
242 })?,
243 None if mode == Mode::Cloud => return Err(ConfigError::Missing("BASE_URL")),
244 None => Url::parse(&format!("http://localhost:{}", bind.port()))
245 .expect("localhost url is valid"),
246 };
247
248 let secret_key = match get("SECRET_KEY") {
249 Some(v) => v,
250 None if mode == Mode::Cloud => return Err(ConfigError::Missing("SECRET_KEY")),
251 None => "codoseo-selfhost-dev-key".to_owned(),
252 };
253
254 let smtp_url = get("SMTP_URL");
257 if mode == Mode::Cloud && smtp_url.is_none() {
258 return Err(ConfigError::Missing("SMTP_URL"));
259 }
260
261 let github = match (get("GITHUB_CLIENT_ID"), get("GITHUB_CLIENT_SECRET")) {
262 (Some(client_id), Some(client_secret)) => Some(GithubConfig {
263 client_id,
264 client_secret,
265 authorize_url: Url::parse("https://github.com/login/oauth/authorize")
266 .expect("static url"),
267 token_url: Url::parse("https://github.com/login/oauth/access_token")
268 .expect("static url"),
269 api_url: Url::parse("https://api.github.com/").expect("static url"),
270 }),
271 _ => None,
272 };
273
274 let turnstile = match (get("TURNSTILE_SITE_KEY"), get("TURNSTILE_SECRET")) {
275 (Some(site_key), Some(secret)) if mode == Mode::Cloud => {
276 let verify = get("TURNSTILE_VERIFY_URL").unwrap_or_else(|| {
277 "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_owned()
278 });
279 Some(TurnstileConfig {
280 site_key,
281 secret,
282 verify_url: Url::parse(&verify).map_err(|e| ConfigError::Invalid {
283 name: "TURNSTILE_VERIFY_URL",
284 reason: e.to_string(),
285 })?,
286 })
287 }
288 _ => None,
289 };
290
291 let admin_emails = get("ADMIN_EMAILS")
292 .unwrap_or_default()
293 .split(',')
294 .map(str::trim)
295 .filter(|e| !e.is_empty())
296 .map(crate::auth::email::canonical)
297 .collect();
298 let rankorg_url =
299 Url::parse(&get("RANKORG_URL").unwrap_or_else(|| "https://rankorg.com".to_owned()))
300 .map_err(|e| ConfigError::Invalid {
301 name: "RANKORG_URL",
302 reason: e.to_string(),
303 })?;
304
305 let billing = dodo_from(&get, mode)?;
306 let mcp = McpAnonConfig {
307 daily_audits: count_from(&get, "MCP_ANON_DAILY_AUDITS", 200)?,
308 daily_emails: count_from(&get, "MCP_ANON_DAILY_EMAILS", 200)?,
309 shared_clients: get("MCP_SHARED_CLIENTS")
310 .unwrap_or_else(|| DEFAULT_SHARED_CLIENTS.to_owned())
311 .split(',')
312 .map(|name| name.trim().to_ascii_lowercase())
313 .filter(|name| !name.is_empty())
314 .collect(),
315 };
316
317 Ok(Config {
318 mode,
319 base_url,
320 bind,
321 secret_key,
322 smtp_url,
323 mail_from: get("MAIL_FROM").unwrap_or_else(|| DEFAULT_MAIL_FROM.to_owned()),
324 github,
325 bot_ip: get("CODOSEO_BOT_IP"),
326 turnstile,
327 client_ip_header: get("CLIENT_IP_HEADER")
328 .unwrap_or_else(|| "CF-Connecting-IP".to_owned()),
329 admin_emails,
330 rankorg_url,
331 billing,
332 mcp,
333 })
334 }
335
336 pub fn secure_cookies(&self) -> bool {
338 self.base_url.scheme() == "https"
339 }
340
341 pub fn origin(&self) -> String {
343 self.base_url.origin().ascii_serialization()
344 }
345
346 pub fn for_tests() -> Config {
348 Config::from_lookup(|_| None).expect("defaults are valid")
349 }
350}
351
352fn count_from(
354 get: &impl Fn(&str) -> Option<String>,
355 name: &'static str,
356 default: i64,
357) -> Result<i64, ConfigError> {
358 let Some(raw) = get(name) else {
359 return Ok(default);
360 };
361 raw.trim()
362 .parse::<i64>()
363 .ok()
364 .filter(|n| *n >= 0)
365 .ok_or_else(|| ConfigError::Invalid {
366 name,
367 reason: format!("expected a whole number of 0 or more, got {raw:?}"),
368 })
369}
370
371const DODO_KEYS: [&str; 4] = [
372 "DODO_API_KEY",
373 "DODO_WEBHOOK_SECRET",
374 "DODO_PRODUCT_PRO",
375 "DODO_PRODUCT_AGENCY",
376];
377
378fn dodo_missing_keys(get: &impl Fn(&str) -> Option<String>) -> Option<Vec<&'static str>> {
381 let missing: Vec<&'static str> = DODO_KEYS.into_iter().filter(|k| get(k).is_none()).collect();
382 (!missing.is_empty() && missing.len() < DODO_KEYS.len()).then_some(missing)
383}
384
385fn dodo_url_allowed(url: &Url) -> bool {
388 url.scheme() == "https"
389 || (url.scheme() == "http"
390 && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")))
391}
392
393fn dodo_from(
394 get: &impl Fn(&str) -> Option<String>,
395 mode: Mode,
396) -> Result<Option<DodoConfig>, ConfigError> {
397 if mode != Mode::Cloud {
398 return Ok(None);
399 }
400 if let Some(missing) = dodo_missing_keys(get) {
401 tracing::warn!(
402 missing = %missing.join(", "),
403 "Dodo billing is off: some of the four DODO_* keys are set but not all"
404 );
405 }
406 let get = |k: &str| get(k).map(|v| v.trim().to_owned());
408 let (Some(api_key), Some(webhook_secret), Some(product_pro), Some(product_agency)) = (
409 get("DODO_API_KEY"),
410 get("DODO_WEBHOOK_SECRET"),
411 get("DODO_PRODUCT_PRO"),
412 get("DODO_PRODUCT_AGENCY"),
413 ) else {
414 return Ok(None);
415 };
416 let invalid = |name, reason: &str| ConfigError::Invalid {
417 name,
418 reason: reason.to_owned(),
419 };
420 crate::billing::dodo::key(&webhook_secret).map_err(|_| {
423 invalid(
424 "DODO_WEBHOOK_SECRET",
425 "expected the whsec_ secret from Dodo (base64, at least 16 bytes)",
426 )
427 })?;
428 let api_url = match get("DODO_API_URL") {
429 Some(v) => v,
430 None => match get("DODO_ENV").as_deref() {
431 None | Some("test") => "https://test.dodopayments.com".to_owned(),
432 Some("live") => "https://live.dodopayments.com".to_owned(),
433 Some(other) => {
434 return Err(ConfigError::Invalid {
435 name: "DODO_ENV",
436 reason: format!("expected test or live, got {other:?}"),
437 });
438 }
439 },
440 };
441 let api_url = Url::parse(&api_url).map_err(|e| ConfigError::Invalid {
442 name: "DODO_API_URL",
443 reason: e.to_string(),
444 })?;
445 if !dodo_url_allowed(&api_url) {
446 return Err(invalid(
447 "DODO_API_URL",
448 "must be https (http only for localhost or 127.0.0.1)",
449 ));
450 }
451 Ok(Some(DodoConfig {
452 api_key,
453 webhook_secret,
454 product_pro,
455 product_agency,
456 api_url,
457 }))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use std::collections::HashMap;
464
465 const CLOUD: [(&str, &str); 4] = [
466 ("CODOSEO_MODE", "cloud"),
467 ("BASE_URL", "https://codoseo.com"),
468 ("SECRET_KEY", "k"),
469 ("SMTP_URL", "smtp://127.0.0.1:2525"),
470 ];
471 const DODO: [(&str, &str); 4] = [
472 ("DODO_API_KEY", "key_1"),
473 ("DODO_WEBHOOK_SECRET", "whsec_c2VjcmV0LTAxMjM0NTY3ODlhYg=="),
474 ("DODO_PRODUCT_PRO", "pdt_pro"),
475 ("DODO_PRODUCT_AGENCY", "pdt_agency"),
476 ];
477
478 fn cloud_with(extra: &[(&str, &str)]) -> Result<Config, ConfigError> {
479 let all: Vec<_> = CLOUD.iter().chain(extra.iter()).copied().collect();
480 cfg(&all)
481 }
482
483 fn cfg(pairs: &[(&str, &str)]) -> Result<Config, ConfigError> {
484 let map: HashMap<String, String> = pairs
485 .iter()
486 .map(|(k, v)| (k.to_string(), v.to_string()))
487 .collect();
488 Config::from_lookup(|k| map.get(k).cloned())
489 }
490
491 #[test]
492 fn self_hosted_defaults() {
493 let c = cfg(&[]).unwrap();
494 assert_eq!(c.mode, Mode::SelfHost);
495 assert_eq!(c.base_url.as_str(), "http://localhost:8080/");
496 assert!(!c.secure_cookies());
497 assert!(c.github.is_none());
498 }
499
500 #[test]
501 fn cloud_requires_base_url_and_secret() {
502 assert!(matches!(
503 cfg(&[("CODOSEO_MODE", "cloud")]),
504 Err(ConfigError::Missing("BASE_URL"))
505 ));
506 assert!(matches!(
507 cfg(&[
508 ("CODOSEO_MODE", "cloud"),
509 ("BASE_URL", "https://codoseo.com")
510 ]),
511 Err(ConfigError::Missing("SECRET_KEY"))
512 ));
513 let c = cloud_with(&[]).unwrap();
514 assert!(c.secure_cookies());
515 assert_eq!(c.origin(), "https://codoseo.com");
516 }
517
518 #[test]
519 fn cloud_requires_smtp_but_self_hosted_may_log_mail() {
520 let no_smtp = [
521 ("CODOSEO_MODE", "cloud"),
522 ("BASE_URL", "https://codoseo.com"),
523 ("SECRET_KEY", "k"),
524 ];
525 assert!(matches!(
526 cfg(&no_smtp),
527 Err(ConfigError::Missing("SMTP_URL"))
528 ));
529 let blank: Vec<_> = no_smtp
530 .iter()
531 .chain([("SMTP_URL", " ")].iter())
532 .copied()
533 .collect();
534 assert!(matches!(cfg(&blank), Err(ConfigError::Missing("SMTP_URL"))));
535 assert!(cfg(&[]).unwrap().smtp_url.is_none());
536 }
537
538 #[test]
539 fn debug_hides_the_secret_key_and_the_smtp_password() {
540 let c = cloud_with(&[
541 ("SECRET_KEY", "hunter2-the-key"),
542 ("SMTP_URL", "smtps://mailer:p4ssw0rd-x@mail.example.com:465"),
543 ("GITHUB_CLIENT_ID", "gh-id"),
544 ("GITHUB_CLIENT_SECRET", "gh-secret-value"),
545 ])
546 .unwrap();
547 let shown = format!("{c:?}");
548 for secret in ["hunter2-the-key", "p4ssw0rd-x", "gh-secret-value"] {
549 assert!(!shown.contains(secret), "{secret} leaked: {shown}");
550 }
551 assert!(shown.contains("mail.example.com"), "{shown}");
552 assert!(shown.contains("mailer"), "{shown}");
553 let odd = cloud_with(&[("SMTP_URL", "not a url p4ssw0rd-y")]).unwrap();
555 assert!(!format!("{odd:?}").contains("p4ssw0rd-y"));
556 }
557
558 #[test]
559 fn the_no_key_tier_has_defaults_and_reads_its_limits() {
560 let d = cfg(&[]).unwrap().mcp;
561 assert_eq!((d.daily_audits, d.daily_emails), (200, 200));
562 assert_eq!(d.shared_clients, ["claude-user", "chatgpt", "openai-mcp"]);
563
564 let c = cfg(&[
565 ("MCP_ANON_DAILY_AUDITS", " 50 "),
566 ("MCP_ANON_DAILY_EMAILS", "0"),
567 ("MCP_SHARED_CLIENTS", " Claude-User , ,Cursor "),
568 ])
569 .unwrap()
570 .mcp;
571 assert_eq!((c.daily_audits, c.daily_emails), (50, 0));
572 assert_eq!(c.shared_clients, ["claude-user", "cursor"]);
573
574 for bad in ["many", "-1", "1.5"] {
575 assert!(cfg(&[("MCP_ANON_DAILY_AUDITS", bad)]).is_err(), "{bad}");
576 assert!(cfg(&[("MCP_ANON_DAILY_EMAILS", bad)]).is_err(), "{bad}");
577 }
578 assert_eq!(
580 cfg(&[("MCP_ANON_DAILY_AUDITS", " ")])
581 .unwrap()
582 .mcp
583 .daily_audits,
584 200
585 );
586 }
587
588 #[test]
589 fn shared_clients_are_matched_by_user_agent_fragment_ignoring_case() {
590 let mcp = cfg(&[]).unwrap().mcp;
591 assert!(mcp.is_shared_client(Some("Claude-User/1.0 (+https://anthropic.com)")));
592 assert!(mcp.is_shared_client(Some("Mozilla/5.0 ChatGPT-User/1.0")));
593 assert!(mcp.is_shared_client(Some("openai-mcp/1.2")));
594 assert!(!mcp.is_shared_client(Some("claude-code/2.0 (cli)")));
595 assert!(!mcp.is_shared_client(Some("node")));
596 assert!(!mcp.is_shared_client(None));
597 let none = cfg(&[("MCP_SHARED_CLIENTS", ",")]).unwrap().mcp;
598 assert!(!none.is_shared_client(Some("claude-user")));
599 }
600
601 #[test]
602 fn unknown_mode_is_rejected() {
603 assert!(cfg(&[("CODOSEO_MODE", "nope")]).is_err());
604 }
605
606 #[test]
607 fn turnstile_needs_both_keys_and_the_cloud() {
608 let keys = [("TURNSTILE_SITE_KEY", "a"), ("TURNSTILE_SECRET", "b")];
609 let cloud = CLOUD;
610 let both: Vec<_> = cloud.iter().chain(keys.iter()).copied().collect();
611 let t = cfg(&both).unwrap().turnstile.expect("configured");
612 assert_eq!(t.site_key, "a");
613 assert!(t.verify_url.as_str().contains("challenges.cloudflare.com"));
614 let one: Vec<_> = cloud.iter().chain(keys[..1].iter()).copied().collect();
615 assert!(cfg(&one).unwrap().turnstile.is_none());
616 assert!(cfg(&keys).unwrap().turnstile.is_none());
618 }
619
620 #[test]
621 fn admins_are_matched_on_canonical_emails() {
622 let c = cfg(&[("ADMIN_EMAILS", " Boss@Example.com , o.ther+x@gmail.com ,, ")]).unwrap();
623 assert_eq!(c.admin_emails, ["boss@example.com", "other@gmail.com"]);
624 assert!(cfg(&[]).unwrap().admin_emails.is_empty());
625 }
626
627 #[test]
628 fn rankorg_has_a_default_and_rejects_nonsense() {
629 assert_eq!(
630 cfg(&[]).unwrap().rankorg_url.as_str(),
631 "https://rankorg.com/"
632 );
633 assert!(cfg(&[("RANKORG_URL", "not a url")]).is_err());
634 }
635
636 #[test]
637 fn the_client_ip_header_has_a_default() {
638 assert_eq!(cfg(&[]).unwrap().client_ip_header, "CF-Connecting-IP");
639 assert_eq!(
640 cfg(&[("CLIENT_IP_HEADER", "X-Real-IP")])
641 .unwrap()
642 .client_ip_header,
643 "X-Real-IP"
644 );
645 }
646
647 #[test]
648 fn mail_from_has_a_default() {
649 assert_eq!(cfg(&[]).unwrap().mail_from, "CodoSEO <hello@codoseo.com>");
650 assert_eq!(
651 cfg(&[("MAIL_FROM", "Me <me@example.com>")])
652 .unwrap()
653 .mail_from,
654 "Me <me@example.com>"
655 );
656 }
657
658 #[test]
659 fn github_needs_both_keys() {
660 assert!(cfg(&[("GITHUB_CLIENT_ID", "a")]).unwrap().github.is_none());
661 assert!(
662 cfg(&[("GITHUB_CLIENT_ID", "a"), ("GITHUB_CLIENT_SECRET", "b")])
663 .unwrap()
664 .github
665 .is_some()
666 );
667 }
668
669 #[test]
670 fn billing_needs_all_four_keys_and_the_cloud() {
671 let d = cloud_with(&DODO).unwrap().billing.expect("configured");
672 assert_eq!(d.api_key, "key_1");
673 assert_eq!(d.plan_for_product("pdt_pro"), Some(Plan::Pro));
674 assert_eq!(d.plan_for_product("pdt_agency"), Some(Plan::Agency));
675 assert_eq!(d.plan_for_product("pdt_other"), None);
676 assert_eq!(d.product_for(Plan::Pro), Some("pdt_pro"));
677 assert_eq!(d.product_for(Plan::Free), None);
678 for skip in 0..DODO.len() {
679 let some: Vec<_> = DODO
680 .iter()
681 .enumerate()
682 .filter(|(i, _)| *i != skip)
683 .map(|(_, kv)| *kv)
684 .collect();
685 assert!(
686 cloud_with(&some).unwrap().billing.is_none(),
687 "without {skip}"
688 );
689 }
690 assert!(cfg(&DODO).unwrap().billing.is_none());
692 }
693
694 #[test]
695 fn dodo_values_are_trimmed() {
696 let spaced = [
697 ("DODO_API_KEY", " key_1 \n"),
698 (
699 "DODO_WEBHOOK_SECRET",
700 " whsec_c2VjcmV0LTAxMjM0NTY3ODlhYg== ",
701 ),
702 ("DODO_PRODUCT_PRO", " pdt_pro "),
703 ("DODO_PRODUCT_AGENCY", "\tpdt_agency"),
704 ];
705 let d = cloud_with(&spaced).unwrap().billing.expect("configured");
706 assert_eq!(d.api_key, "key_1");
707 assert_eq!(d.product_pro, "pdt_pro");
708 assert_eq!(d.product_agency, "pdt_agency");
709 assert_eq!(d.webhook_secret, "whsec_c2VjcmV0LTAxMjM0NTY3ODlhYg==");
710 }
711
712 #[test]
713 fn a_partly_set_dodo_config_names_the_missing_keys() {
714 let get = |pairs: &'static [(&'static str, &'static str)]| {
715 move |k: &str| {
716 pairs
717 .iter()
718 .find(|(n, _)| *n == k)
719 .map(|(_, v)| (*v).to_owned())
720 }
721 };
722 assert_eq!(
723 dodo_missing_keys(&get(&[("DODO_API_KEY", "k"), ("DODO_PRODUCT_PRO", "p")])),
724 Some(vec!["DODO_WEBHOOK_SECRET", "DODO_PRODUCT_AGENCY"])
725 );
726 assert_eq!(dodo_missing_keys(&get(&[])), None);
728 assert_eq!(
729 dodo_missing_keys(&get(&[
730 ("DODO_API_KEY", "k"),
731 ("DODO_WEBHOOK_SECRET", "s"),
732 ("DODO_PRODUCT_PRO", "p"),
733 ("DODO_PRODUCT_AGENCY", "a"),
734 ])),
735 None
736 );
737 }
738
739 #[test]
740 fn the_dodo_api_url_must_be_https_unless_it_is_local() {
741 let url = |u: &str| {
742 let all: Vec<_> = DODO
743 .iter()
744 .chain([("DODO_API_URL", u)].iter())
745 .copied()
746 .collect();
747 cloud_with(&all)
748 };
749 assert!(url("https://dodo.example.com").is_ok());
750 assert!(url("http://127.0.0.1:9/").is_ok());
751 assert!(url("http://localhost:9/").is_ok());
752 assert!(url("http://test.dodopayments.com").is_err());
753 assert!(url("http://10.0.0.5/").is_err());
754 assert!(url("ftp://127.0.0.1/").is_err());
755 }
756
757 #[test]
758 fn dodo_runs_against_test_mode_unless_told_otherwise() {
759 let url = |extra: &[(&str, &str)]| {
760 let all: Vec<_> = DODO.iter().chain(extra.iter()).copied().collect();
761 cloud_with(&all)
762 .unwrap()
763 .billing
764 .unwrap()
765 .api_url
766 .to_string()
767 };
768 assert_eq!(url(&[]), "https://test.dodopayments.com/");
769 assert_eq!(
770 url(&[("DODO_ENV", "live")]),
771 "https://live.dodopayments.com/"
772 );
773 assert_eq!(
774 url(&[
775 ("DODO_ENV", "live"),
776 ("DODO_API_URL", "http://127.0.0.1:9/")
777 ]),
778 "http://127.0.0.1:9/"
779 );
780 let bad: Vec<_> = DODO
781 .iter()
782 .chain([("DODO_ENV", "prod")].iter())
783 .copied()
784 .collect();
785 assert!(cloud_with(&bad).is_err());
786 }
787
788 #[test]
789 fn the_webhook_secret_must_be_a_whsec_key() {
790 let mut keys = DODO.to_vec();
791 keys[1] = ("DODO_WEBHOOK_SECRET", "not-a-secret");
792 assert!(cloud_with(&keys).is_err());
793 keys[1] = ("DODO_WEBHOOK_SECRET", "whsec_%%%");
794 assert!(cloud_with(&keys).is_err());
795 for short in ["whsec_", "whsec_YQ==", "whsec_c2VjcmV0LTAxMjM0NQ=="] {
797 keys[1] = ("DODO_WEBHOOK_SECRET", short);
798 assert!(cloud_with(&keys).is_err(), "{short}");
799 }
800 keys[1] = ("DODO_WEBHOOK_SECRET", "whsec_MDEyMzQ1Njc4OWFiY2RlZg==");
801 assert!(cloud_with(&keys).unwrap().billing.is_some());
802 }
803}