Skip to main content

codoseo_web/
config.rs

1//! Web configuration, read from the environment (spec section 11).
2
3use std::net::SocketAddr;
4
5use codoseo_core::plan::Plan;
6use url::Url;
7
8/// `CODOSEO_MODE`: self-hosted (the default) or the codoseo.com cloud.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Mode {
11    SelfHost,
12    Cloud,
13}
14
15/// GitHub OAuth app credentials, plus the endpoints so tests can point them at a fake.
16#[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/// Cloudflare Turnstile keys, plus the verify endpoint so tests can point it at a fake.
38#[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/// Dodo Payments billing (cloud only). All four keys are needed; with any missing, billing is
56/// off: the billing pages say so and checkout is disabled.
57#[derive(Clone)]
58pub struct DodoConfig {
59    /// Bearer token for the Dodo API (`DODO_API_KEY`).
60    pub api_key: String,
61    /// `whsec_<base64 key>` the webhooks are signed with (`DODO_WEBHOOK_SECRET`).
62    pub webhook_secret: String,
63    pub product_pro: String,
64    pub product_agency: String,
65    /// `https://test.dodopayments.com` or `https://live.dodopayments.com` (`DODO_ENV`), or
66    /// `DODO_API_URL` when set (tests point it at a fake).
67    pub api_url: Url,
68}
69
70/// Written by hand so a stray `{:?}` of the config can't put the API key or the webhook
71/// secret in a log.
72impl 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    /// The plan a Dodo product sells, or `None` for a product that isn't ours.
86    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    /// The Dodo product that sells `plan`.
97    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    /// The public address of the app; used for magic links, OAuth callbacks and the `Origin`
110    /// check on every POST.
111    pub base_url: Url,
112    pub bind: SocketAddr,
113    /// Keys the channel-target encryption (`codoseo_notify::ChannelKey`).
114    pub secret_key: String,
115    pub smtp_url: Option<String>,
116    /// The sender of every email (`MAIL_FROM`).
117    pub mail_from: String,
118    pub github: Option<GithubConfig>,
119    /// The fixed address cloud crawls come from, listed on the bot page (`CODOSEO_BOT_IP`).
120    pub bot_ip: Option<String>,
121    /// Turnstile on the audit form: `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`. Cloud only.
122    pub turnstile: Option<TurnstileConfig>,
123    /// The request header carrying the visitor's address behind the cloud's proxy
124    /// (`CLIENT_IP_HEADER`, default `CF-Connecting-IP`). Read in cloud mode only.
125    pub client_ip_header: String,
126    /// Who may open `/admin` in the cloud: canonical emails from `ADMIN_EMAILS`.
127    pub admin_emails: Vec<String>,
128    /// Where RankOrg links go (`RANKORG_URL`).
129    pub rankorg_url: Url,
130    /// Billing through Dodo Payments; `None` in self-hosted mode or when keys are missing.
131    pub billing: Option<DodoConfig>,
132    /// The no-key MCP tier (cloud only; self-hosted refuses it).
133    pub mcp: McpAnonConfig,
134}
135
136/// The `User-Agent`s of the hosted connectors that call from shared servers.
137pub const DEFAULT_SHARED_CLIENTS: &str = "claude-user,chatgpt,openai-mcp";
138
139/// The no-key MCP tier's limits.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct McpAnonConfig {
142    /// Fresh audits agents may start in any 24 hours, over all of them (`MCP_ANON_DAILY_AUDITS`,
143    /// default 200). Cached and joined audits don't count.
144    pub daily_audits: i64,
145    /// Start-monitoring emails the tool may send in any 24 hours (`MCP_ANON_DAILY_EMAILS`,
146    /// default 200).
147    pub daily_emails: i64,
148    /// Lowercase fragments of the `User-Agent` of clients that connect from shared servers
149    /// (`MCP_SHARED_CLIENTS`, comma separated, matched case-insensitively). Per-IP limits don't
150    /// apply to them, since everyone behind the connector shares an address.
151    pub shared_clients: Vec<String>,
152}
153
154impl McpAnonConfig {
155    /// Whether a request with this `User-Agent` comes from a shared connector.
156    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
163/// Written by hand so a stray `{:?}` of the config can't put `SECRET_KEY` or the SMTP password
164/// in a log.
165impl 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
186/// `url` with its password hidden; text that isn't a URL is hidden whole.
187fn 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";
208/// The default `MAIL_FROM`; shared with the worker, which sends the alert and digest mail.
209pub 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    /// Reads configuration through `lookup`, so tests can pass a map instead of the process
217    /// environment.
218    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        // The cloud's login mail, alert mail and digests all go through this; without it they
255        // would only be logged and nobody would ever get one.
256        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    /// Cookies get `Secure` whenever the app is served over https.
337    pub fn secure_cookies(&self) -> bool {
338        self.base_url.scheme() == "https"
339    }
340
341    /// The origin every POST must come from (`scheme://host[:port]`).
342    pub fn origin(&self) -> String {
343        self.base_url.origin().ascii_serialization()
344    }
345
346    /// A config for tests and local tools: self-hosted, `http://localhost:8080`.
347    pub fn for_tests() -> Config {
348        Config::from_lookup(|_| None).expect("defaults are valid")
349    }
350}
351
352/// A whole number of at least 0 from `name`, or `default` when it isn't set.
353fn 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
378/// The Dodo keys that are missing when some, but not all, of the four are set: a half-filled
379/// config silently turns billing off, which is worth a startup warning.
380fn 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
385/// Dodo's API carries the bearer key, so it is https only; a local address is allowed for
386/// tests and local fakes.
387fn 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    // Values pasted from a dashboard often carry a trailing newline or space.
407    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    // The same check the webhook verifier makes, so a bad key fails at startup, not on the
421    // first delivery.
422    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        // A value that isn't a URL is hidden whole.
554        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        // A blank value is "not set".
579        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        // Self-hosted never shows Turnstile, even with keys set.
617        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        // Self-hosted never bills, even with every key set.
691        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        // None set, or all set: nothing to warn about.
727        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        // An empty or tiny key would sign (and verify) anything cheaply.
796        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}