Skip to main content

headless_lms_base/
config.rs

1use anyhow::Context;
2use secrecy::{ExposeSecret, SecretBox, SecretString};
3use std::sync::Arc;
4use std::{env, str::FromStr};
5use url::Url;
6
7/// Reads a boolean env var where missing values default to false.
8pub fn bool_env_false_by_default(key: &str) -> bool {
9    match env::var(key) {
10        Ok(value) => {
11            let normalized = value.trim().to_ascii_lowercase();
12            !matches!(
13                normalized.as_str(),
14                "" | "false" | "0" | "no" | "off" | "disabled"
15            )
16        }
17        Err(_) => false,
18    }
19}
20
21/// Reads an env var, treating a blank value the same as an unset one.
22fn non_empty_env(key: &str) -> Option<String> {
23    match env::var(key) {
24        Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
25        _ => None,
26    }
27}
28
29/// Reads an integer env var, falling back to `default` when unset, blank or unparseable.
30fn i64_env_or(key: &str, default: i64) -> i64 {
31    non_empty_env(key)
32        .and_then(|value| value.parse().ok())
33        .unwrap_or(default)
34}
35
36/// Parses a [`Url::join`] base, adding the trailing slash one needs: joining onto a base without it
37/// replaces the base's last path segment instead of extending it.
38///
39/// On the parsed path, never the raw string: a slash appended to the string lands inside any query
40/// or fragment the value carries, and makes trailing whitespace the parser would have trimmed
41/// interior, where it percent-encodes instead.
42fn parse_join_base(url: &str) -> Result<Url, url::ParseError> {
43    let mut url = Url::parse(url)?;
44    if !url.path().ends_with('/') {
45        url.set_path(&format!("{}/", url.path()));
46    }
47    Ok(url)
48}
49
50#[derive(Clone)]
51pub struct ApplicationConfiguration {
52    pub base_url: String,
53    pub test_mode: bool,
54    pub test_chatbot: bool,
55    pub test_sisu: bool,
56    pub test_suotar: bool,
57    pub disable_embedding_vector_creation_when_seeding: bool,
58    pub development_uuid_login: bool,
59    pub enable_admin_email_verification: bool,
60    pub enable_email_ownership_verification: bool,
61    pub azure_configuration: Option<AzureConfiguration>,
62    pub suotar_configuration: SuotarConfiguration,
63    pub tmc_account_creation_origin: Option<String>,
64    pub tmc_admin_access_token: SecretString,
65    pub oauth_server_configuration: OAuthServerConfiguration,
66}
67
68impl ApplicationConfiguration {
69    /// Attempts to create an ApplicationConfiguration from environment variables.
70    pub fn try_from_env() -> anyhow::Result<Self> {
71        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
72        let test_mode = bool_env_false_by_default("TEST_MODE");
73        let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
74        let enable_admin_email_verification =
75            bool_env_false_by_default("ENABLE_ADMIN_EMAIL_VERIFICATION");
76        let enable_email_ownership_verification =
77            bool_env_false_by_default("ENABLE_EMAIL_OWNERSHIP_VERIFICATION");
78        let test_chatbot = test_mode
79            && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
80                || env::var("AZURE_CHATBOT_API_KEY").is_err());
81
82        let test_sisu = test_mode && bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
83
84        // No mock fallback unlike Azure: credit registration writes to the real student registry.
85        let test_suotar = test_mode && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT");
86
87        let disable_embedding_vector_creation_when_seeding = false;
88
89        let azure_configuration = if test_chatbot {
90            AzureConfiguration::mock_conf()?
91        } else {
92            AzureConfiguration::try_from_env()?
93        };
94
95        let suotar_configuration = if test_suotar {
96            SuotarConfiguration::mock_conf(&base_url)?
97        } else {
98            SuotarConfiguration::try_from_env()?
99        };
100
101        let tmc_account_creation_origin = Some(
102            env::var("TMC_ACCOUNT_CREATION_ORIGIN")
103                .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
104        );
105
106        let tmc_admin_access_token = SecretString::new(
107            std::env::var("TMC_ACCESS_TOKEN")
108                .unwrap_or_else(|_| {
109                    if test_mode {
110                        "mock-access-token".to_string()
111                    } else {
112                        panic!("TMC_ACCESS_TOKEN must be defined in production")
113                    }
114                })
115                .into(),
116        );
117        let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
118            .context("Failed to load OAuth server configuration")?;
119
120        Ok(Self {
121            base_url,
122            test_mode,
123            test_chatbot,
124            test_sisu,
125            test_suotar,
126            disable_embedding_vector_creation_when_seeding,
127            development_uuid_login,
128            enable_admin_email_verification,
129            enable_email_ownership_verification,
130            azure_configuration,
131            suotar_configuration,
132            tmc_account_creation_origin,
133            tmc_admin_access_token,
134            oauth_server_configuration,
135        })
136    }
137
138    pub fn mock_conf() -> anyhow::Result<Self> {
139        let test_mode = true;
140        let base_url = "http://project-331.local/".to_string();
141        let development_uuid_login = false;
142        let enable_admin_email_verification = false;
143        let enable_email_ownership_verification = false;
144        let azure_configuration = AzureConfiguration::mock_conf()?;
145        let test_chatbot = true;
146        let test_sisu = true;
147        let test_suotar = false;
148        let disable_embedding_vector_creation_when_seeding = true;
149        let suotar_configuration = SuotarConfiguration::mock_conf("http://project-331.local")
150            .expect("Failed to build the mock Suotar configuration");
151        let tmc_account_creation_origin = None;
152        let tmc_admin_access_token = SecretString::new("mock-access-token".to_string().into());
153        let oauth_server_configuration = OAuthServerConfiguration {
154            rsa_public_key: "temp-change-when-needed".into(),
155            rsa_private_key: SecretString::new("test-change".into()),
156            oauth_token_hmac_key: SecretString::new("pippuri".into()),
157            dpop_nonce_key: std::sync::Arc::new(secrecy::SecretBox::new(Box::new(
158                "test-key".into(),
159            ))),
160        };
161        Ok(Self {
162            base_url,
163            test_mode,
164            test_chatbot,
165            test_sisu,
166            test_suotar,
167            disable_embedding_vector_creation_when_seeding,
168            development_uuid_login,
169            enable_admin_email_verification,
170            enable_email_ownership_verification,
171            azure_configuration,
172            suotar_configuration,
173            tmc_account_creation_origin,
174            tmc_admin_access_token,
175            oauth_server_configuration,
176        })
177    }
178}
179
180/// TODO: Suotar has not confirmed whether they want `Basic` or `Bearer`; `Basic` is what they
181/// already accept on the legacy study-registry path.
182pub const SUOTAR_AUTH_SCHEME: &str = "Basic";
183
184/// The only token the mock Suotar accepts. Public on purpose: never a real credential.
185pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
186
187/// Auto-links a student number when Sisu's address matches a verified account email. The same flag
188/// is the kill switch that stops it during an incident.
189const FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT: bool = false;
190
191/// Days an `email_verified_at` may be old and still count as fast-track proof. Bounded because a
192/// deprovisioned university address can be reissued to somebody else.
193const FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT: i64 = 365;
194
195#[derive(Clone)]
196pub struct SuotarConfiguration {
197    /// Ends in `/` because it is a [`Url::join`] base and joined paths must be relative.
198    pub api_base_url: Url,
199    pub api_token: SecretString,
200    pub fast_track_email_match_enabled: bool,
201    pub fast_track_max_email_verification_age_days: i64,
202}
203
204impl SuotarConfiguration {
205    /// Points the client at our own mock controller. Only reachable with `TEST_MODE` and
206    /// `USE_MOCK_SUOTAR_ENDPOINT` both on.
207    pub fn mock_conf(base_url: &str) -> anyhow::Result<Self> {
208        Ok(Self {
209            api_base_url: Url::parse(base_url)
210                .context("Invalid URL in BASE_URL")?
211                .join("/api/v0/mock-suotar/")?,
212            api_token: SecretString::new(MOCK_SUOTAR_TOKEN.to_string().into()),
213            fast_track_email_match_enabled: Self::fast_track_enabled_from_env(),
214            fast_track_max_email_verification_age_days: Self::fast_track_max_age_from_env(),
215        })
216    }
217
218    pub fn try_from_env() -> anyhow::Result<Self> {
219        Self::from_values(
220            non_empty_env("SUOTAR_API_BASE_URL"),
221            non_empty_env("SUOTAR_API_KEY"),
222            Self::fast_track_enabled_from_env(),
223            Self::fast_track_max_age_from_env(),
224        )
225    }
226
227    fn fast_track_enabled_from_env() -> bool {
228        match non_empty_env("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED") {
229            Some(_) => bool_env_false_by_default("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED"),
230            None => FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT,
231        }
232    }
233
234    fn fast_track_max_age_from_env() -> i64 {
235        i64_env_or(
236            "SUOTAR_FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS",
237            FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT,
238        )
239    }
240
241    /// Pure so the no-mock-fallback rule can be tested without touching process env.
242    fn from_values(
243        api_base_url: Option<String>,
244        api_token: Option<String>,
245        fast_track_email_match_enabled: bool,
246        fast_track_max_email_verification_age_days: i64,
247    ) -> anyhow::Result<Self> {
248        let api_base_url = api_base_url.context(
249            "SUOTAR_API_BASE_URL must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
250        )?;
251        let api_token = api_token.context(
252            "SUOTAR_API_KEY must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
253        )?;
254        Ok(Self {
255            api_base_url: parse_join_base(&api_base_url)
256                .context("Invalid URL in SUOTAR_API_BASE_URL")?,
257            api_token: SecretString::new(api_token.into()),
258            fast_track_email_match_enabled,
259            fast_track_max_email_verification_age_days,
260        })
261    }
262}
263
264#[derive(Clone)]
265pub struct AzureChatbotConfiguration {
266    pub api_key: SecretString,
267    /// Ends in `/` because it is a [`Url::join`] base and joined paths must be relative.
268    pub api_base: Url,
269    pub project_name: String,
270}
271
272impl AzureChatbotConfiguration {
273    /// Attempts to create an AzureChatbotConfiguration from environment variables.
274    ///
275    /// Needs all three of `AZURE_CHATBOT_API_KEY`, `AZURE_CHATBOT_API_ENDPOINT` and
276    /// `AZURE_PROJECT_NAME`. Any of them missing gives `Ok(None)` rather than an error, so a caller
277    /// that requires a chatbot has to reject the `None` itself. Errors only on a set value that
278    /// fails to parse.
279    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
280        let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
281        let api_endpoint = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
282        let project_name = env::var("AZURE_PROJECT_NAME").ok();
283
284        if let (Some(api_key), Some(api_endpoint), Some(project_name)) =
285            (api_key, api_endpoint, project_name)
286        {
287            Ok(Some(Self::from_values(
288                api_key,
289                &api_endpoint,
290                project_name,
291            )?))
292        } else {
293            Ok(None)
294        }
295    }
296
297    /// Pure so the join base can be tested without touching process env.
298    fn from_values(
299        api_key: String,
300        api_endpoint: &str,
301        project_name: String,
302    ) -> anyhow::Result<Self> {
303        Ok(Self {
304            api_key: SecretString::new(api_key.into()),
305            api_base: parse_join_base(api_endpoint)
306                .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?,
307            project_name,
308        })
309    }
310
311    pub fn responses_endpoint(&self) -> anyhow::Result<Url> {
312        Ok(self.api_base.join(&format!(
313            "api/projects/{}/openai/v1/responses",
314            self.project_name
315        ))?)
316    }
317
318    pub fn embeddings_endpoint(&self) -> anyhow::Result<Url> {
319        Ok(self.api_base.join("openai/v1/embeddings")?)
320    }
321}
322
323#[derive(Clone)]
324pub struct AzureSearchConfiguration {
325    pub vectorizer_resource_uri: String,
326    pub vectorizer_deployment_id: String,
327    pub vectorizer_api_key: SecretString,
328    pub vectorizer_model_name: String,
329    pub search_endpoint: Url,
330    pub search_api_key: SecretString,
331    pub search_connection_id: String,
332}
333
334impl AzureSearchConfiguration {
335    /// Attempts to create an AzureSearchConfiguration from environment variables.
336    /// Returns `Ok(Some(AzureSearchConfiguration))` if all related environment variables are set.
337    /// Returns `Ok(None)` if no environment variables are set for search and vectorizer.
338    /// Returns an error if set environment variables fail to parse.
339    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
340        let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
341        let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
342        let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
343        let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
344        let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
345        let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
346        let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
347
348        if let (
349            Some(vectorizer_resource_uri),
350            Some(vectorizer_deployment_id),
351            Some(vectorizer_api_key),
352            Some(vectorizer_model_name),
353            Some(search_endpoint_str),
354            Some(search_api_key),
355            Some(search_connection_id),
356        ) = (
357            vectorizer_resource_uri,
358            vectorizer_deployment_id,
359            vectorizer_api_key,
360            vectorizer_model_name,
361            search_endpoint_str,
362            search_api_key,
363            search_connection_id,
364        ) {
365            let search_endpoint =
366                Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
367            Ok(Some(AzureSearchConfiguration {
368                vectorizer_resource_uri,
369                vectorizer_deployment_id,
370                vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
371                vectorizer_model_name,
372                search_endpoint,
373                search_api_key: SecretString::new(search_api_key.into()),
374                search_connection_id,
375            }))
376        } else {
377            Ok(None)
378        }
379    }
380}
381
382#[derive(Clone)]
383pub struct AzureBlobStorageConfiguration {
384    pub storage_account: String,
385    pub access_key: SecretString,
386}
387
388impl AzureBlobStorageConfiguration {
389    /// Attempts to create an AzureBlobStorageConfiguration from environment variables.
390    /// Returns `Ok(Some(AzureBlobStorageConfiguration))` if both environment variables are set.
391    /// Returns `Ok(None)` if no environment variables are set for blob storage.
392    /// Returns an error if set environment variables fail to parse.
393    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
394        let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
395        let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
396
397        if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
398            Ok(Some(AzureBlobStorageConfiguration {
399                storage_account,
400                access_key: SecretString::new(access_key.into()),
401            }))
402        } else {
403            Ok(None)
404        }
405    }
406
407    /// Builds the Azure storage connection string. The result embeds the account
408    /// access key, so it is returned wrapped in `SecretString` (zeroized on drop,
409    /// redacted from `Debug`); call `.expose_secret()` only at the point it is handed
410    /// to the Azure SDK.
411    pub fn connection_string(&self) -> anyhow::Result<SecretString> {
412        Ok(SecretString::new(
413            format!(
414                "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
415                self.storage_account,
416                self.access_key.expose_secret()
417            )
418            .into(),
419        ))
420    }
421}
422
423#[derive(Clone)]
424pub struct AzureConfiguration {
425    pub chatbot_config: Option<AzureChatbotConfiguration>,
426    pub search_config: Option<AzureSearchConfiguration>,
427    pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
428}
429
430impl AzureConfiguration {
431    /// Attempts to create an AzureConfiguration by calling the individual try_from_env functions.
432    /// Returns `Ok(Some(AzureConfiguration))` if any of the configurations are set.
433    /// Returns `Ok(None)` if no relevant environment variables are set.
434    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
435        let chatbot = AzureChatbotConfiguration::try_from_env()?;
436        let search_config = AzureSearchConfiguration::try_from_env()?;
437        let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
438        if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
439            Ok(Some(AzureConfiguration {
440                chatbot_config: chatbot,
441                search_config,
442                blob_storage_config,
443            }))
444        } else {
445            Ok(None)
446        }
447    }
448
449    /// Creates an AzureConfiguration with empty and mock values to be used in testing and dev
450    /// environments when Azure access is not needed. Enables the azure chatbot functionality to be
451    /// mocked with the api_endpoint from our application.
452    /// Returns `Ok(Some(AzureConfiguration))`
453    pub fn mock_conf() -> anyhow::Result<Option<Self>> {
454        let base_url =
455            env::var("BASE_URL").unwrap_or_else(|_| "http://project-331.local/".to_string());
456        let chatbot_config = Some(AzureChatbotConfiguration {
457            api_key: SecretString::new(String::new().into()),
458            api_base: Url::parse(&base_url)?.join("/api/v0/mock-azure/")?,
459            project_name: String::from("test"),
460        });
461
462        let search_config = Some(AzureSearchConfiguration {
463            vectorizer_resource_uri: "".to_string(),
464            vectorizer_deployment_id: "".to_string(),
465            vectorizer_api_key: SecretString::new(String::new().into()),
466            vectorizer_model_name: "".to_string(),
467            search_api_key: SecretString::new(String::new().into()),
468            search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
469            search_connection_id: "".to_string(),
470        });
471        let blob_storage_config = Some(AzureBlobStorageConfiguration {
472            storage_account: "".to_string(),
473            access_key: SecretString::new(String::new().into()),
474        });
475
476        Ok(Some(AzureConfiguration {
477            chatbot_config,
478            search_config,
479            blob_storage_config,
480        }))
481    }
482}
483
484#[derive(Clone)]
485pub struct OAuthServerConfiguration {
486    pub rsa_public_key: String,
487    /// RSA private key (PEM) used to sign OAuth/OIDC tokens. Secret: zeroized on drop,
488    /// redacted from `Debug`; only exposed when handed to the signing key builder.
489    pub rsa_private_key: SecretString,
490    /// Secret key for HMAC-SHA-256 hashing of OAuth tokens (access tokens, refresh tokens, auth codes).
491    pub oauth_token_hmac_key: SecretString,
492    /// Secret key for signing DPoP nonces (HMAC).
493    pub dpop_nonce_key: Arc<SecretBox<String>>,
494}
495
496impl PartialEq for OAuthServerConfiguration {
497    fn eq(&self, other: &Self) -> bool {
498        self.rsa_public_key == other.rsa_public_key
499            && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
500            && self.oauth_token_hmac_key.expose_secret()
501                == other.oauth_token_hmac_key.expose_secret()
502            && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
503    }
504}
505
506impl OAuthServerConfiguration {
507    /// Attempts to create an OAuthServerConfiguration.
508    /// Return `Ok(Some(OAuthConfiguration))` if all configurations are set.
509    /// Return `Err` if any is not set.
510    pub fn try_from_env() -> anyhow::Result<Self> {
511        let rsa_public_key =
512            env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
513        let rsa_private_key = SecretString::new(
514            env::var("OAUTH_RSA_PRIVATE_PEM")
515                .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
516                .into(),
517        );
518        let oauth_token_hmac_key = SecretString::new(
519            env::var("OAUTH_TOKEN_HMAC_KEY")
520                .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
521                .into(),
522        );
523        let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
524            env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
525        )));
526
527        Ok(Self {
528            rsa_public_key,
529            rsa_private_key,
530            oauth_token_hmac_key,
531            dpop_nonce_key,
532        })
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn suotar_configuration_has_no_mock_fallback() {
542        assert!(SuotarConfiguration::from_values(None, None, false, 365).is_err());
543        assert!(
544            SuotarConfiguration::from_values(
545                Some("https://suotar.example.com/api".to_string()),
546                None,
547                false,
548                365
549            )
550            .is_err()
551        );
552        assert!(
553            SuotarConfiguration::from_values(None, Some("token".to_string()), false, 365).is_err()
554        );
555        assert!(
556            SuotarConfiguration::from_values(
557                Some("https://suotar.example.com/api".to_string()),
558                Some("token".to_string()),
559                false,
560                365
561            )
562            .is_ok()
563        );
564    }
565
566    /// `Url::join` replaces the whole path unless the base ends in `/`, so a base without one
567    /// silently drops the `/api` prefix from every call.
568    #[test]
569    fn suotar_configuration_normalises_the_join_base() {
570        let conf = SuotarConfiguration::from_values(
571            Some("https://suotar.example.com/api".to_string()),
572            Some("token".to_string()),
573            false,
574            365,
575        )
576        .expect("valid fixture values");
577        assert_eq!(
578            conf.api_base_url.as_str(),
579            "https://suotar.example.com/api/"
580        );
581        assert_eq!(
582            conf.api_base_url
583                .join("persons/resolve-by-student-numbers")
584                .expect("a relative join on a base ending in a slash")
585                .as_str(),
586            "https://suotar.example.com/api/persons/resolve-by-student-numbers"
587        );
588    }
589
590    fn azure_chatbot_conf(api_endpoint: &str) -> AzureChatbotConfiguration {
591        AzureChatbotConfiguration::from_values(
592            "key".to_string(),
593            api_endpoint,
594            "some-project".to_string(),
595        )
596        .expect("valid fixture values")
597    }
598
599    fn azure_chatbot_embeddings_endpoint(api_endpoint: &str) -> String {
600        azure_chatbot_conf(api_endpoint)
601            .embeddings_endpoint()
602            .expect("a relative join on a base ending in a slash")
603            .to_string()
604    }
605
606    #[test]
607    fn azure_chatbot_configuration_normalises_the_join_base() {
608        let conf = azure_chatbot_conf("https://example.services.ai.azure.com/foundry");
609        assert_eq!(
610            conf.api_base.as_str(),
611            "https://example.services.ai.azure.com/foundry/"
612        );
613        assert_eq!(
614            conf.responses_endpoint()
615                .expect("a relative join on a base ending in a slash")
616                .as_str(),
617            "https://example.services.ai.azure.com/foundry/api/projects/some-project/openai/v1/responses"
618        );
619        assert_eq!(
620            conf.embeddings_endpoint()
621                .expect("a relative join on a base ending in a slash")
622                .as_str(),
623            "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
624        );
625    }
626
627    /// `AZURE_CHATBOT_API_ENDPOINT` is read untrimmed, so a file-mounted secret hands over whatever
628    /// whitespace the file ends with, and a deployment pinning `api-version` hands over a query.
629    #[test]
630    fn a_query_a_fragment_or_stray_whitespace_does_not_move_the_join_base() {
631        assert_eq!(
632            azure_chatbot_embeddings_endpoint(
633                "https://example.services.ai.azure.com/foundry?api-version=2024-10-21"
634            ),
635            "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
636        );
637        assert_eq!(
638            azure_chatbot_embeddings_endpoint(
639                "https://example.services.ai.azure.com/foundry#anchor"
640            ),
641            "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
642        );
643        assert_eq!(
644            azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry "),
645            "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
646        );
647        assert_eq!(
648            azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry/ "),
649            "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
650        );
651    }
652
653    #[test]
654    fn a_join_base_that_already_ends_in_a_slash_is_left_as_it_is() {
655        assert_eq!(
656            azure_chatbot_conf("https://example.services.ai.azure.com/foundry/")
657                .api_base
658                .as_str(),
659            "https://example.services.ai.azure.com/foundry/"
660        );
661        assert_eq!(
662            azure_chatbot_conf("https://example.services.ai.azure.com/foundry//")
663                .api_base
664                .as_str(),
665            "https://example.services.ai.azure.com/foundry//"
666        );
667        assert_eq!(
668            azure_chatbot_conf("https://example.services.ai.azure.com")
669                .api_base
670                .as_str(),
671            "https://example.services.ai.azure.com/"
672        );
673    }
674
675    #[test]
676    fn mock_conf_points_at_our_own_mock_controller() {
677        let conf = SuotarConfiguration::mock_conf("http://project-331.local")
678            .expect("valid fixture values");
679        assert_eq!(
680            conf.api_base_url.as_str(),
681            "http://project-331.local/api/v0/mock-suotar/"
682        );
683        assert_eq!(conf.api_token.expose_secret(), MOCK_SUOTAR_TOKEN);
684    }
685
686    #[test]
687    fn fast_track_defaults_are_off_and_a_year() {
688        let conf = SuotarConfiguration::mock_conf("http://project-331.local")
689            .expect("valid fixture values");
690        assert!(!conf.fast_track_email_match_enabled);
691        assert_eq!(conf.fast_track_max_email_verification_age_days, 365);
692    }
693}