Skip to main content

cedros_login/
lib.rs

1#![recursion_limit = "256"]
2
3//! # Cedros Login Server
4//!
5//! Authentication server library with email/password, Google OAuth, and Solana wallet sign-in.
6//!
7//! ## Features
8//!
9//! - **Email/Password**: Traditional registration and login with argon2id password hashing
10//! - **Google OAuth**: Social sign-in via Google ID token verification
11//! - **Solana Wallet**: Sign-in by signing a challenge message with an ed25519 wallet
12//!
13//! ## Usage
14//!
15//! ### Standalone Server
16//!
17//! Run the binary directly:
18//! ```bash
19//! cedros-login-server
20//! ```
21//!
22//! ### Embedded Library
23//!
24//! Integrate into your own Axum application:
25//! ```text
26//! use cedros_login::{router, Config, NoopCallback};
27//! use std::sync::Arc;
28//!
29//! let config = Config::from_env()?;
30//! let callback = Arc::new(NoopCallback);
31//! let auth_router = router(config, callback);
32//!
33//! let app = Router::new()
34//!     .nest("/auth", auth_router)
35//!     .layer(/* your middleware */);
36//! ```
37
38pub mod callback;
39pub mod config;
40pub mod errors;
41pub mod handlers;
42pub mod middleware;
43pub mod models;
44pub mod repositories;
45pub mod services;
46pub mod storage;
47pub mod utils;
48
49mod router;
50
51pub use callback::{AuthCallback, AuthCallbackPayload, NoopCallback};
52pub use config::{Config, DatabaseConfig, NotificationConfig};
53pub use errors::AppError;
54pub use router::create_router;
55// Re-export NotificationService trait for create_withdrawal_worker
56pub use services::NotificationService;
57pub use services::{
58    EmailService, InstantLinkEmailData, LogEmailService, NoopEmailService, PasswordResetEmailData,
59    VerificationEmailData,
60};
61#[cfg(feature = "postgres")]
62pub use sqlx::PgPool;
63pub use storage::Storage;
64
65use axum::Router;
66use repositories::{
67    ApiKeyRepository, AuditLogRepository, CredentialRepository, CreditHoldRepository,
68    CreditRefundRequestRepository, CreditRepository, CustomRoleRepository, DepositRepository,
69    DerivedWalletRepository, InviteRepository, LoginAttemptConfig, LoginAttemptRepository,
70    MembershipRepository, NonceRepository, OrgRepository, OutboxRepository, PolicyRepository,
71    PrivacyNoteRepository,
72    SessionRepository, SystemSettingsRepository, TotpRepository, TreasuryConfigRepository,
73    UserRepository, UserWithdrawalLogRepository, VerificationRepository, WalletMaterialRepository,
74    WalletRotationHistoryRepository, WebAuthnRepository,
75};
76use services::{
77    create_wallet_unlock_cache, AppleService, AuditService, CommsService, DepositCreditService,
78    DepositFeeService, EncryptionService, GoogleService, JupiterSwapService, JwtService,
79    MfaAttemptService, NoteEncryptionService, OidcService, PasswordService, PrivacySidecarClient,
80    SettingsService, SidecarClientConfig, SolPriceService, SolanaService, StepUpService,
81    TotpService, WalletSigningService, WalletUnlockCache, WebAuthnService,
82};
83use std::sync::Arc;
84use utils::TokenCipher;
85
86fn build_privacy_sidecar_client(config: &Config) -> Result<PrivacySidecarClient, AppError> {
87    let api_key = config
88        .privacy
89        .sidecar_api_key
90        .clone()
91        .ok_or_else(|| AppError::Config("SIDECAR_API_KEY is required".into()))?;
92    PrivacySidecarClient::new(SidecarClientConfig {
93        base_url: config.privacy.sidecar_url.clone(),
94        timeout_ms: config.privacy.sidecar_timeout_ms,
95        api_key,
96    })
97}
98
99fn decode_note_encryption_key(key: &str) -> Result<Vec<u8>, base64::DecodeError> {
100    use base64::{engine::general_purpose::STANDARD, Engine as _};
101
102    STANDARD.decode(key)
103}
104
105fn build_note_encryption_service(
106    key_bytes: &[u8],
107    key_id: &str,
108) -> Result<NoteEncryptionService, AppError> {
109    NoteEncryptionService::new(key_bytes, key_id)
110}
111
112fn preload_settings_cache(settings_service: &Arc<SettingsService>) {
113    if let Ok(handle) = tokio::runtime::Handle::try_current() {
114        if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
115            tokio::task::block_in_place(|| {
116                if let Err(error) = handle.block_on(settings_service.refresh()) {
117                    tracing::warn!(error = %error, "Failed to preload settings cache during router setup");
118                }
119            });
120        }
121    }
122}
123
124/// Application state shared across all handlers
125pub struct AppState<C: AuthCallback, E: EmailService = LogEmailService> {
126    pub config: Config,
127    pub callback: Arc<C>,
128    pub jwt_service: JwtService,
129    pub password_service: PasswordService,
130    pub google_service: GoogleService,
131    pub apple_service: AppleService,
132    pub solana_service: SolanaService,
133    pub totp_service: TotpService,
134    pub webauthn_service: WebAuthnService,
135    pub oidc_service: OidcService,
136    pub encryption_service: EncryptionService,
137    pub phantom_email: std::marker::PhantomData<E>,
138    pub audit_service: AuditService,
139    pub comms_service: CommsService,
140    pub user_repo: Arc<dyn UserRepository>,
141    pub session_repo: Arc<dyn SessionRepository>,
142    pub nonce_repo: Arc<dyn NonceRepository>,
143    pub verification_repo: Arc<dyn VerificationRepository>,
144    pub org_repo: Arc<dyn OrgRepository>,
145    pub membership_repo: Arc<dyn MembershipRepository>,
146    pub invite_repo: Arc<dyn InviteRepository>,
147    pub audit_repo: Arc<dyn AuditLogRepository>,
148    pub login_attempt_repo: Arc<dyn LoginAttemptRepository>,
149    pub login_attempt_config: LoginAttemptConfig,
150    pub totp_repo: Arc<dyn TotpRepository>,
151    pub custom_role_repo: Arc<dyn CustomRoleRepository>,
152    pub policy_repo: Arc<dyn PolicyRepository>,
153    pub outbox_repo: Arc<dyn OutboxRepository>,
154    pub api_key_repo: Arc<dyn ApiKeyRepository>,
155    pub wallet_material_repo: Arc<dyn WalletMaterialRepository>,
156    pub derived_wallet_repo: Arc<dyn DerivedWalletRepository>,
157    pub wallet_rotation_history_repo: Arc<dyn WalletRotationHistoryRepository>,
158    pub credential_repo: Arc<dyn CredentialRepository>,
159    pub webauthn_repo: Arc<dyn WebAuthnRepository>,
160    pub deposit_repo: Arc<dyn DepositRepository>,
161    pub credit_repo: Arc<dyn CreditRepository>,
162    pub credit_hold_repo: Arc<dyn CreditHoldRepository>,
163    pub credit_refund_request_repo: Arc<dyn CreditRefundRequestRepository>,
164    pub privacy_note_repo: Arc<dyn PrivacyNoteRepository>,
165    /// System settings repository for runtime-configurable values
166    pub system_settings_repo: Arc<dyn SystemSettingsRepository>,
167    /// Treasury configuration repository for micro deposit batching
168    pub treasury_config_repo: Arc<dyn TreasuryConfigRepository>,
169    /// User withdrawal log repository for tracking user-initiated withdrawals
170    pub user_withdrawal_log_repo: Arc<dyn UserWithdrawalLogRepository>,
171    /// Settings service with caching for runtime configuration
172    pub settings_service: Arc<SettingsService>,
173    /// SEC-04: Per-user MFA attempt tracking to prevent brute-force
174    pub mfa_attempt_service: MfaAttemptService,
175    pub step_up_service: StepUpService,
176    /// Wallet signing service for server-side transaction signing
177    pub wallet_signing_service: WalletSigningService,
178    /// Wallet unlock cache for session-based credential caching
179    pub wallet_unlock_cache: Arc<WalletUnlockCache>,
180    /// Storage layer for accessing repositories
181    pub storage: Storage,
182    /// Privacy Cash sidecar client (None if privacy not enabled)
183    pub privacy_sidecar_client: Option<Arc<PrivacySidecarClient>>,
184    /// Note encryption service for privacy notes (None if privacy not enabled)
185    pub note_encryption_service: Option<Arc<NoteEncryptionService>>,
186    /// SOL price service for fetching current SOL/USD price
187    pub sol_price_service: Arc<SolPriceService>,
188    /// Jupiter swap service for public deposits (None if company wallet not configured)
189    pub jupiter_swap_service: Option<Arc<JupiterSwapService>>,
190    /// Deposit credit service for calculating credits from deposits
191    pub deposit_credit_service: Arc<DepositCreditService>,
192    #[cfg(feature = "postgres")]
193    pub postgres_pool: Option<PgPool>,
194}
195
196/// Create the authentication router with in-memory storage.
197///
198/// This is the simplest entry point, useful for development and testing.
199/// For production with PostgreSQL, use `router_with_storage` instead.
200pub fn router<C: AuthCallback + 'static>(config: Config, callback: Arc<C>) -> Router {
201    router_with_storage(config, callback, Storage::in_memory())
202}
203
204/// Create the authentication router with custom storage backend.
205///
206/// Use this when you need PostgreSQL or a custom storage implementation.
207///
208/// ## Example with PostgreSQL
209///
210/// ```text
211/// use cedros_login::{router_with_storage, Config, Storage, NoopCallback};
212/// use std::sync::Arc;
213///
214/// let config = Config::from_env()?;
215/// let storage = Storage::from_config(&config.database).await?;
216/// let callback = Arc::new(NoopCallback);
217/// let auth_router = router_with_storage(config, callback, storage);
218/// ```
219pub fn router_with_storage<C: AuthCallback + 'static>(
220    config: Config,
221    callback: Arc<C>,
222    storage: Storage,
223) -> Router {
224    let jwt_service = JwtService::new(&config.jwt);
225    let password_service = PasswordService::default();
226    let google_service = GoogleService::new(&config.google);
227    let apple_service = AppleService::new(&config.apple);
228    let solana_service = SolanaService::new(&config.solana, "Cedros Login".to_string());
229    let totp_service = TotpService::new("Cedros");
230    let webauthn_service = WebAuthnService::new(&config.webauthn);
231    let audit_service = AuditService::new(storage.audit_repo.clone(), config.server.trust_proxy);
232    let step_up_service = StepUpService::new(storage.session_repo.clone());
233
234    // Create SSO services
235    // SEC-02: Use HTTPS for SSO callback URL when frontend URL is HTTPS
236    let protocol = if config
237        .server
238        .frontend_url
239        .as_ref()
240        .map(|u| u.starts_with("https://"))
241        .unwrap_or(false)
242    {
243        "https"
244    } else {
245        "http"
246    };
247    let sso_callback_url = config.server.sso_callback_url.clone().unwrap_or_else(|| {
248        format!(
249            "{}://{}:{}/auth/sso/callback",
250            protocol, config.server.host, config.server.port
251        )
252    });
253    let oidc_service = OidcService::new(sso_callback_url);
254    let encryption_service = EncryptionService::from_secret(&config.jwt.secret);
255
256    // Create CommsService for async email/notification delivery
257    let base_url = config
258        .server
259        .frontend_url
260        .clone()
261        .unwrap_or_else(|| "http://localhost:3000".to_string());
262    let token_cipher = TokenCipher::new(&config.jwt.secret);
263    let comms_service = CommsService::new(storage.outbox_repo.clone(), base_url, token_cipher);
264
265    // Create SettingsService for runtime configuration
266    // Note: The cache starts empty. Sync cache accessors used during router setup
267    // (e.g., rate limit configuration) will return None and fall back to config defaults.
268    // The cache is populated on first async access (e.g., deposit handler, withdrawal worker).
269    let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo.clone()));
270    preload_settings_cache(&settings_service);
271
272    // Create privacy services if enabled
273    let (privacy_sidecar_client, note_encryption_service) = if config.privacy.enabled {
274        let mut errors = Vec::new();
275
276        let sidecar = match build_privacy_sidecar_client(&config) {
277            Ok(s) => Some(Arc::new(s)),
278            Err(e) => {
279                errors.push(format!("Failed to create privacy sidecar client: {}", e));
280                None
281            }
282        };
283
284        let note_encryption = match config.privacy.note_encryption_key.as_ref() {
285            Some(key) => match decode_note_encryption_key(key) {
286                Ok(key_bytes) => match build_note_encryption_service(
287                    &key_bytes,
288                    &config.privacy.note_encryption_key_id,
289                ) {
290                    Ok(n) => Some(Arc::new(n)),
291                    Err(e) => {
292                        errors.push(format!("Failed to create note encryption service: {}", e));
293                        None
294                    }
295                },
296                Err(e) => {
297                    errors.push(format!("Invalid base64 in note_encryption_key: {}", e));
298                    None
299                }
300            },
301            None => {
302                errors.push("note_encryption_key is required when privacy is enabled".to_string());
303                None
304            }
305        };
306
307        // S-04: Fail startup when privacy is enabled but required services can't be created.
308        // Silently disabling would allow the server to accept deposits it cannot process.
309        if !errors.is_empty() {
310            for error in &errors {
311                tracing::error!("{}", error);
312            }
313            panic!(
314                "Privacy is enabled but required services failed to initialize: {}",
315                errors.join("; ")
316            );
317        } else {
318            (sidecar, note_encryption)
319        }
320    } else {
321        (None, None)
322    };
323
324    // Build Jupiter swap service if company wallet is configured (for public deposits)
325    let jupiter_swap_service = config
326        .privacy
327        .company_wallet_address
328        .as_ref()
329        .and_then(|wallet| {
330            match JupiterSwapService::new(
331                wallet.clone(),
332                &config.privacy.company_currency,
333                None, // API key from env could be added later
334            ) {
335                Ok(service) => Some(Arc::new(service)),
336                Err(e) => {
337                    tracing::error!(error = %e, "Failed to create Jupiter swap service, swap features disabled");
338                    None
339                }
340            }
341        });
342
343    // Create SOL price service (shared across deposit services)
344    let sol_price_service = Arc::new(SolPriceService::new());
345
346    // Create deposit fee and credit services
347    let fee_service = Arc::new(DepositFeeService::new(settings_service.clone()));
348    let deposit_credit_service = Arc::new(DepositCreditService::new(
349        sol_price_service.clone(),
350        fee_service,
351        config.privacy.company_currency.clone(),
352    ));
353
354    let state = Arc::new(AppState {
355        config,
356        callback,
357        jwt_service,
358        password_service,
359        google_service,
360        apple_service,
361        solana_service,
362        totp_service,
363        webauthn_service,
364        oidc_service,
365        encryption_service,
366        phantom_email: std::marker::PhantomData::<LogEmailService>,
367        audit_service,
368        comms_service,
369        user_repo: storage.user_repo.clone(),
370        session_repo: storage.session_repo.clone(),
371        nonce_repo: storage.nonce_repo.clone(),
372        verification_repo: storage.verification_repo.clone(),
373        org_repo: storage.org_repo.clone(),
374        membership_repo: storage.membership_repo.clone(),
375        invite_repo: storage.invite_repo.clone(),
376        audit_repo: storage.audit_repo.clone(),
377        login_attempt_repo: storage.login_attempt_repo.clone(),
378        login_attempt_config: LoginAttemptConfig::default(),
379        totp_repo: storage.totp_repo.clone(),
380        custom_role_repo: storage.custom_role_repo.clone(),
381        policy_repo: storage.policy_repo.clone(),
382        outbox_repo: storage.outbox_repo.clone(),
383        api_key_repo: storage.api_key_repo.clone(),
384        wallet_material_repo: storage.wallet_material_repo.clone(),
385        derived_wallet_repo: storage.derived_wallet_repo.clone(),
386        wallet_rotation_history_repo: storage.wallet_rotation_history_repo.clone(),
387        credential_repo: storage.credential_repo.clone(),
388        webauthn_repo: storage.webauthn_repo.clone(),
389        deposit_repo: storage.deposit_repo.clone(),
390        credit_repo: storage.credit_repo.clone(),
391        credit_hold_repo: storage.credit_hold_repo.clone(),
392        credit_refund_request_repo: storage.credit_refund_request_repo.clone(),
393        privacy_note_repo: storage.privacy_note_repo.clone(),
394        system_settings_repo: storage.system_settings_repo.clone(),
395        treasury_config_repo: storage.treasury_config_repo.clone(),
396        user_withdrawal_log_repo: storage.user_withdrawal_log_repo.clone(),
397        settings_service: settings_service.clone(),
398        mfa_attempt_service: MfaAttemptService::new(),
399        step_up_service,
400        wallet_signing_service: WalletSigningService::new(),
401        wallet_unlock_cache: create_wallet_unlock_cache(),
402        privacy_sidecar_client,
403        note_encryption_service,
404        sol_price_service,
405        jupiter_swap_service,
406        deposit_credit_service,
407        #[cfg(feature = "postgres")]
408        postgres_pool: storage.pg_pool.clone(),
409        storage,
410    });
411    create_router(state)
412}
413
414/// Create a withdrawal worker for Privacy Cash deposits.
415///
416/// Returns `Some(JoinHandle)` if privacy is enabled, `None` otherwise.
417/// The worker will poll for completed deposits and withdraw them to the company wallet.
418///
419/// Runtime-tunable settings (poll_interval, batch_size, timeout, retries, percentage,
420/// partial_withdrawal_*) are read from the database via SettingsService.
421pub fn create_withdrawal_worker(
422    config: &Config,
423    storage: &Storage,
424    settings_service: Arc<SettingsService>,
425    notification_service: Arc<dyn services::NotificationService>,
426    cancel_token: tokio_util::sync::CancellationToken,
427) -> Option<tokio::task::JoinHandle<()>> {
428    if !config.privacy.enabled {
429        return None;
430    }
431
432    // Create sidecar client
433    let sidecar = match build_privacy_sidecar_client(config) {
434        Ok(s) => Arc::new(s),
435        Err(e) => {
436            tracing::error!(error = %e, "Failed to create privacy sidecar client for withdrawal worker");
437            return None;
438        }
439    };
440
441    // S-05: Gracefully handle missing key instead of panicking
442    let encryption_key = match config.privacy.note_encryption_key.as_ref() {
443        Some(k) => k,
444        None => {
445            tracing::error!("note_encryption_key is required when privacy is enabled");
446            return None;
447        }
448    };
449    let key_bytes = match decode_note_encryption_key(encryption_key) {
450        Ok(k) => k,
451        Err(e) => {
452            tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
453            return None;
454        }
455    };
456    let note_encryption = match build_note_encryption_service(
457        &key_bytes,
458        &config.privacy.note_encryption_key_id,
459    ) {
460        Ok(s) => Arc::new(s),
461        Err(e) => {
462            tracing::error!(error = %e, "Failed to create note encryption service for withdrawal worker");
463            return None;
464        }
465    };
466
467    // Create withdrawal worker - runtime settings come from SettingsService (DB)
468    // Only company_currency stays in config (env var)
469    use services::{WithdrawalWorker, WithdrawalWorkerConfig};
470    let worker_config = WithdrawalWorkerConfig {
471        company_currency: config.privacy.company_currency.clone(),
472    };
473    let worker = WithdrawalWorker::new(
474        storage.deposit_repo.clone(),
475        storage.withdrawal_history_repo.clone(),
476        sidecar,
477        note_encryption,
478        notification_service,
479        settings_service,
480        worker_config,
481    );
482
483    Some(worker.start(cancel_token))
484}
485
486/// Create a micro batch worker for SOL micro deposits.
487///
488/// Returns `Some(JoinHandle)` if privacy is enabled and treasury is configured,
489/// `None` otherwise. The worker polls for pending micro deposits and batches them
490/// when the accumulated value reaches the threshold ($10).
491///
492/// Runtime-tunable settings (poll_interval, threshold_usd) are read from the database
493/// via SettingsService.
494pub fn create_micro_batch_worker(
495    config: &Config,
496    storage: &Storage,
497    settings_service: Arc<SettingsService>,
498    cancel_token: tokio_util::sync::CancellationToken,
499) -> Option<tokio::task::JoinHandle<()>> {
500    if !config.privacy.enabled {
501        return None;
502    }
503
504    // Create sidecar client
505    let sidecar = match build_privacy_sidecar_client(config) {
506        Ok(s) => Arc::new(s),
507        Err(e) => {
508            tracing::error!(error = %e, "Failed to create privacy sidecar client for micro batch worker");
509            return None;
510        }
511    };
512
513    // S-05: Gracefully handle missing key instead of panicking
514    let encryption_key = match config.privacy.note_encryption_key.as_ref() {
515        Some(k) => k,
516        None => {
517            tracing::error!("note_encryption_key is required when privacy is enabled");
518            return None;
519        }
520    };
521    let key_bytes = match decode_note_encryption_key(encryption_key) {
522        Ok(k) => k,
523        Err(e) => {
524            tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
525            return None;
526        }
527    };
528    let note_encryption = match build_note_encryption_service(
529        &key_bytes,
530        &config.privacy.note_encryption_key_id,
531    ) {
532        Ok(s) => Arc::new(s),
533        Err(e) => {
534            tracing::error!(error = %e, "Failed to create note encryption service for micro batch worker");
535            return None;
536        }
537    };
538
539    // Create sol price service
540    let sol_price_service = Arc::new(services::SolPriceService::new());
541
542    // Create the worker
543    use services::MicroBatchWorker;
544    let worker = MicroBatchWorker::new(
545        storage.deposit_repo.clone(),
546        storage.treasury_config_repo.clone(),
547        sidecar,
548        sol_price_service,
549        note_encryption,
550        settings_service,
551        config.privacy.company_currency.clone(),
552    );
553
554    Some(worker.start(cancel_token))
555}
556
557/// Create a hold expiration worker for credit holds.
558///
559/// This worker periodically expires stale credit holds that have exceeded their TTL,
560/// releasing the held credits back to users' available balance.
561///
562/// Returns the JoinHandle for the background task.
563pub fn create_hold_expiration_worker(
564    storage: &Storage,
565    cancel_token: tokio_util::sync::CancellationToken,
566) -> tokio::task::JoinHandle<()> {
567    use services::{HoldExpirationConfig, HoldExpirationWorker};
568
569    let worker = HoldExpirationWorker::new(
570        storage.credit_repo.clone(),
571        storage.credit_hold_repo.clone(),
572        HoldExpirationConfig::default(),
573    );
574
575    worker.start(cancel_token)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use base64::Engine;
582
583    fn base_config() -> Config {
584        use crate::config::{
585            default_access_expiry, default_audience, default_issuer, default_refresh_expiry,
586            AppleConfig, CookieConfig, CorsConfig, DatabaseConfig, EmailConfig, GoogleConfig,
587            JwtConfig, NotificationConfig, PrivacyConfig, RateLimitConfig, ServerConfig,
588            SolanaConfig, SsoConfig, WalletConfig, WebAuthnConfig, WebhookConfig,
589        };
590
591        Config {
592            server: ServerConfig::default(),
593            jwt: JwtConfig {
594                secret: "s".repeat(32),
595                rsa_private_key_pem: None,
596                issuer: default_issuer(),
597                audience: default_audience(),
598                access_token_expiry: default_access_expiry(),
599                refresh_token_expiry: default_refresh_expiry(),
600            },
601            email: EmailConfig::default(),
602            google: GoogleConfig {
603                enabled: false,
604                client_id: None,
605            },
606            apple: AppleConfig {
607                enabled: false,
608                client_id: None,
609                team_id: None,
610            },
611            solana: SolanaConfig::default(),
612            webauthn: WebAuthnConfig::default(),
613            cors: CorsConfig::default(),
614            cookie: CookieConfig::default(),
615            webhook: WebhookConfig::default(),
616            rate_limit: RateLimitConfig::default(),
617            database: DatabaseConfig::default(),
618            notification: NotificationConfig::default(),
619            sso: SsoConfig::default(),
620            wallet: WalletConfig::default(),
621            privacy: PrivacyConfig::default(),
622        }
623    }
624
625    #[test]
626    fn test_decode_note_encryption_key_valid() {
627        let key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
628        let bytes = decode_note_encryption_key(&key).expect("valid base64 should decode");
629        assert_eq!(bytes.len(), 32);
630        assert!(bytes.iter().all(|byte| *byte == 0));
631    }
632
633    #[test]
634    fn test_decode_note_encryption_key_invalid() {
635        assert!(decode_note_encryption_key("not-base64").is_err());
636    }
637
638    #[test]
639    fn test_build_privacy_sidecar_client_requires_api_key() {
640        let mut config = base_config();
641        config.privacy.enabled = true;
642        config.privacy.sidecar_api_key = None;
643
644        match build_privacy_sidecar_client(&config) {
645            Ok(_) => panic!("expected error for missing SIDECAR_API_KEY"),
646            Err(err) => {
647                assert!(err.to_string().contains("SIDECAR_API_KEY is required"));
648            }
649        }
650    }
651
652    #[test]
653    fn test_build_privacy_sidecar_client_with_api_key() {
654        let mut config = base_config();
655        config.privacy.enabled = true;
656        config.privacy.sidecar_api_key = Some("test-key".to_string());
657
658        assert!(build_privacy_sidecar_client(&config).is_ok());
659    }
660
661    #[test]
662    fn test_preload_settings_cache_populates_cached_values() {
663        let storage = Storage::in_memory();
664        let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo));
665        let runtime = tokio::runtime::Builder::new_multi_thread()
666            .worker_threads(1)
667            .enable_all()
668            .build()
669            .expect("runtime");
670
671        runtime.block_on(async {
672            preload_settings_cache(&settings_service);
673        });
674
675        assert!(settings_service
676            .get_cached_u32_sync("rate_limit_auth")
677            .is_some());
678    }
679}