Skip to main content

headless_lms_base/
config.rs

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