Skip to main content

headless_lms_base/
config.rs

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