Skip to main content

pipedash_core/application/services/
provider_service.rs

1use std::collections::{
2    HashMap,
3    HashSet,
4};
5use std::future::Future;
6use std::sync::Arc;
7use std::time::Duration;
8
9use chrono::{
10    DateTime,
11    Utc,
12};
13use dashmap::DashMap;
14use pipedash_plugin_api::{
15    Plugin as PluginTrait,
16    PluginRegistry,
17    RateLimit,
18    CONTROLLER_HEALTH_FEATURE,
19};
20use tokio::sync::{
21    Mutex,
22    RwLock,
23};
24use tokio::time::{
25    timeout,
26    Instant,
27};
28
29use super::health_service::{
30    HealthHandle,
31    HealthInput,
32    ProviderHealthInput,
33};
34use super::sign_in_service::SignInService;
35use crate::application::fetch_scheduler::{
36    FetchScheduler,
37    ForcedClaim,
38    Generation,
39};
40use crate::application::refresh_manager::RefreshDispatcher;
41use crate::domain::{
42    DomainError,
43    DomainResult,
44    FetchStatus,
45    HealthLevel,
46    Provider,
47    ProviderConfig,
48    ProviderFreshness,
49    ProviderHealth,
50    ProviderImportResult,
51    ProviderRecovery,
52    ProviderSummary,
53    SignInCredential,
54    SignInRef,
55    SignInStart,
56    SignInStatus,
57    SIGN_IN_CONFIG_KEY,
58};
59use crate::event::{
60    CoreEvent,
61    EventBus,
62};
63use crate::infrastructure::config::sync::{
64    ProviderSyncService,
65    SyncResult,
66};
67use crate::infrastructure::config::token_ref::TokenReference;
68use crate::infrastructure::config::{
69    ConfigLoader,
70    ConfigState,
71    GeneralSettingsUpdate,
72    PipedashConfig,
73    ProviderFileConfig,
74};
75use crate::infrastructure::database::{
76    ProviderFetchStatus,
77    Repository,
78    FETCH_STATUS_ERROR,
79    FETCH_STATUS_NEVER,
80    FETCH_STATUS_SUCCESS,
81};
82use crate::infrastructure::oauth;
83use crate::infrastructure::providers::PluginAdapter;
84use crate::infrastructure::run_logs::LogStore;
85use crate::infrastructure::{
86    HttpClientManager,
87    TokenStore,
88};
89use crate::plugins;
90
91const QUOTA_PAUSE_PERCENT: u64 = 10;
92const ACQUIRE_ATTEMPTS: usize = 3;
93
94pub(crate) type ProviderFactory =
95    Arc<dyn Fn(&ProviderConfig) -> DomainResult<Arc<dyn Provider>> + Send + Sync>;
96
97#[derive(Clone)]
98pub struct ProviderLease {
99    pub id: i64,
100    pub generation: Generation,
101    pub provider: Arc<dyn Provider>,
102}
103
104enum TakenSignIn {
105    None,
106    Flow(String),
107    Kept,
108}
109
110struct ProviderSlot {
111    generation: Generation,
112    provider: Arc<dyn Provider>,
113}
114
115impl ProviderSlot {
116    fn lease(&self, id: i64) -> ProviderLease {
117        ProviderLease {
118            id,
119            generation: self.generation,
120            provider: Arc::clone(&self.provider),
121        }
122    }
123}
124
125struct ParameterFetch<'a> {
126    fetches: &'a DashMap<String, Arc<Mutex<()>>>,
127    key: &'a str,
128    lock: Arc<Mutex<()>>,
129}
130
131impl<'a> ParameterFetch<'a> {
132    fn new(fetches: &'a DashMap<String, Arc<Mutex<()>>>, key: &'a str) -> Self {
133        let lock = Arc::clone(fetches.entry(key.to_string()).or_default().value());
134
135        Self { fetches, key, lock }
136    }
137}
138
139impl Drop for ParameterFetch<'_> {
140    fn drop(&mut self) {
141        self.fetches
142            .remove_if(self.key, |_, lock| Arc::ptr_eq(lock, &self.lock));
143    }
144}
145
146pub struct ProviderService {
147    repository: Arc<Repository>,
148    config_state: Arc<ConfigState>,
149    http_client_manager: Arc<HttpClientManager>,
150    providers: RwLock<HashMap<i64, ProviderSlot>>,
151    commit_locks: DashMap<i64, Arc<Mutex<()>>>,
152    renewal_locks: DashMap<i64, Arc<Mutex<()>>>,
153    sign_ins: Arc<SignInService>,
154    plugin_registry: Arc<PluginRegistry>,
155    parameter_fetches: DashMap<String, Arc<Mutex<()>>>,
156    event_bus: Arc<dyn EventBus>,
157    scheduler: Arc<FetchScheduler>,
158    health: HealthHandle,
159    dispatcher: RefreshDispatcher,
160    provider_factory: ProviderFactory,
161    config_apply: Mutex<()>,
162    log_store: Arc<LogStore>,
163}
164
165impl ProviderService {
166    #[allow(clippy::too_many_arguments)]
167    pub fn new(
168        repository: Arc<Repository>, http_client_manager: Arc<HttpClientManager>,
169        event_bus: Arc<dyn EventBus>, scheduler: Arc<FetchScheduler>, health: HealthHandle,
170        dispatcher: RefreshDispatcher, config_state: Arc<ConfigState>, log_store: Arc<LogStore>,
171    ) -> Self {
172        let plugin_registry = Arc::new(plugins::create_plugin_registry());
173        let provider_factory: ProviderFactory = {
174            let registry = Arc::clone(&plugin_registry);
175            let http = Arc::clone(&http_client_manager);
176            Arc::new(move |config| plugin_provider(&registry, &http, config))
177        };
178
179        Self {
180            repository,
181            config_state,
182            http_client_manager,
183            providers: RwLock::new(HashMap::new()),
184            commit_locks: DashMap::new(),
185            renewal_locks: DashMap::new(),
186            sign_ins: Arc::new(SignInService::default()),
187            plugin_registry,
188            parameter_fetches: DashMap::new(),
189            event_bus,
190            scheduler,
191            health,
192            dispatcher,
193            provider_factory,
194            config_apply: Mutex::new(()),
195            log_store,
196        }
197    }
198
199    #[cfg(test)]
200    pub(crate) fn with_provider_factory(mut self, factory: ProviderFactory) -> Self {
201        self.provider_factory = factory;
202        self
203    }
204
205    pub fn repository(&self) -> &Arc<Repository> {
206        &self.repository
207    }
208
209    pub(crate) fn scheduler(&self) -> &Arc<FetchScheduler> {
210        &self.scheduler
211    }
212
213    pub(crate) fn health(&self) -> &HealthHandle {
214        &self.health
215    }
216
217    pub(crate) fn dispatcher(&self) -> &RefreshDispatcher {
218        &self.dispatcher
219    }
220
221    pub fn list_available_plugins(&self) -> Vec<pipedash_plugin_api::PluginMetadata> {
222        let mut metadata_list = Vec::new();
223        for provider_type in self.plugin_registry.provider_types() {
224            if let Some(plugin) = self.plugin_registry.get(&provider_type) {
225                metadata_list.push(plugin.metadata().clone());
226            }
227        }
228        metadata_list
229    }
230
231    pub fn retains_run_history(&self, provider_type: &str) -> bool {
232        self.plugin_registry
233            .get(provider_type)
234            .is_some_and(|plugin| plugin.metadata().capabilities.retains_run_history)
235    }
236
237    pub async fn init_plugin(
238        &self, provider_type: &str, mut config: HashMap<String, String>, provider_id: i64,
239    ) -> DomainResult<Box<dyn PluginTrait>> {
240        self.resolve_sign_in(&mut config).await?;
241        init_plugin(
242            &self.http_client_manager,
243            provider_type,
244            config,
245            provider_id,
246        )
247    }
248
249    pub async fn field_options(
250        &self, provider_type: &str, field_key: &str, mut config: HashMap<String, String>,
251    ) -> DomainResult<Vec<String>> {
252        self.resolve_sign_in(&mut config).await?;
253        let (mut plugin, client) = build_plugin(&self.http_client_manager, provider_type, &config)?;
254        let init_error = plugin.initialize(0, config.clone(), Some(client)).err();
255
256        match plugin.get_field_options(field_key, &config).await {
257            Ok(options) => Ok(options),
258            Err(e) => Err(match init_error {
259                Some(init) => {
260                    DomainError::InvalidConfig(format!("Failed to initialize plugin: {init}"))
261                }
262                None => PluginAdapter::map_error(e),
263            }),
264        }
265    }
266
267    pub(crate) fn commit_lock(&self, id: i64) -> Arc<Mutex<()>> {
268        Arc::clone(self.commit_locks.entry(id).or_default().value())
269    }
270
271    fn renewal_lock(&self, id: i64) -> Arc<Mutex<()>> {
272        Arc::clone(self.renewal_locks.entry(id).or_default().value())
273    }
274
275    async fn resolve_sign_in(&self, config: &mut HashMap<String, String>) -> DomainResult<()> {
276        let Some(reference) = config.remove(SIGN_IN_CONFIG_KEY) else {
277            return Ok(());
278        };
279        let token = match SignInRef::parse(&reference) {
280            SignInRef::Flow(flow) => self.sign_ins.credential(&flow).await?.token,
281            SignInRef::Provider(id) => {
282                SignInCredential::bearer(&self.renewed_config(id).await?.token)
283            }
284        };
285        config.insert("token".to_string(), token);
286        Ok(())
287    }
288
289    async fn take_sign_in(
290        &self, config: &mut ProviderConfig, owner: Option<i64>,
291    ) -> DomainResult<TakenSignIn> {
292        let Some(reference) = config.config.remove(SIGN_IN_CONFIG_KEY) else {
293            return Ok(TakenSignIn::None);
294        };
295        match SignInRef::parse(&reference) {
296            SignInRef::Flow(flow) => {
297                config.token = self.sign_ins.credential(&flow).await?.encode()?;
298                Ok(TakenSignIn::Flow(flow))
299            }
300            SignInRef::Provider(id) if Some(id) == owner => {
301                config.token = self.renewed_config(id).await?.token;
302                Ok(TakenSignIn::Kept)
303            }
304            SignInRef::Provider(_) => Err(DomainError::InvalidConfig(
305                "This sign-in belongs to another provider".into(),
306            )),
307        }
308    }
309
310    async fn renewed_config(&self, id: i64) -> DomainResult<ProviderConfig> {
311        let renewal = self.renewal_lock(id);
312        let _renewal = renewal.lock().await;
313        let config = self.repository.get_provider(id).await?;
314        let Some(current) = SignInCredential::decode(&config.token)
315            .filter(|credential| credential.renew_at().is_some_and(|at| at <= Utc::now()))
316        else {
317            return Ok(config);
318        };
319
320        let (_, http) = build_plugin(
321            &self.http_client_manager,
322            &config.provider_type,
323            &config.config,
324        )?;
325        let token = oauth::refresh(&http, &current).await?.encode()?;
326
327        let commit = self.commit_lock(id);
328        let _commit = commit.lock().await;
329        let mut latest = self.repository.get_provider(id).await?;
330        if latest.token != config.token {
331            return Ok(latest);
332        }
333        self.repository.store_token(id, &token).await?;
334        tracing::info!(provider_id = id, "Renewed the provider sign-in");
335        latest.token = token;
336        Ok(latest)
337    }
338
339    async fn renew(&self, stale: ProviderLease) -> DomainResult<ProviderLease> {
340        let config = self.renewed_config(stale.id).await?;
341        let provider = (self.provider_factory)(&config)?;
342        let mut providers = self.providers.write().await;
343        let slot = providers
344            .get_mut(&stale.id)
345            .ok_or_else(|| DomainError::ProviderNotFound(stale.id.to_string()))?;
346        if slot.generation == stale.generation {
347            slot.provider = provider;
348        }
349        Ok(slot.lease(stale.id))
350    }
351
352    pub async fn provider_for_editing(&self, id: i64) -> DomainResult<ProviderConfig> {
353        let mut config = self.repository.get_provider(id).await?;
354        if SignInCredential::decode(&config.token).is_some() {
355            config.token.clear();
356            config.config.insert(
357                SIGN_IN_CONFIG_KEY.to_string(),
358                SignInRef::Provider(id).to_string(),
359            );
360        }
361        Ok(config)
362    }
363
364    pub async fn stored_plugin(&self, id: i64) -> DomainResult<Box<dyn PluginTrait>> {
365        let config = self.renewed_config(id).await?;
366        init_plugin(
367            &self.http_client_manager,
368            &config.provider_type,
369            config.plugin_config(),
370            id,
371        )
372    }
373
374    pub async fn start_sign_in(
375        &self, provider_type: &str, config: HashMap<String, String>,
376    ) -> DomainResult<SignInStart> {
377        let (plugin, http) = build_plugin(&self.http_client_manager, provider_type, &config)?;
378        let client = plugin
379            .sign_in_client(&config, &http)
380            .await
381            .map_err(PluginAdapter::map_error)?;
382        self.sign_ins.start(client, http).await
383    }
384
385    pub async fn sign_in_status(&self, reference: &str) -> DomainResult<SignInStatus> {
386        match SignInRef::parse(reference) {
387            SignInRef::Flow(flow) => Ok(self.sign_ins.status(&flow)),
388            SignInRef::Provider(id) => match self.renewed_config(id).await {
389                Ok(config) => Ok(SignInCredential::decode(&config.token).map_or(
390                    SignInStatus::Expired,
391                    |credential| SignInStatus::Completed {
392                        account: credential.account,
393                    },
394                )),
395                Err(DomainError::ProviderAuthenticationFailed(message)) => {
396                    Ok(SignInStatus::Failed { message })
397                }
398                Err(e) => Err(e),
399            },
400        }
401    }
402
403    pub async fn complete_sign_in(
404        &self, flow: &str, redirect_url: &str,
405    ) -> DomainResult<SignInStatus> {
406        self.sign_ins.complete(flow, redirect_url).await
407    }
408
409    pub async fn cancel_sign_in(&self, flow: &str) {
410        self.sign_ins.cancel(flow).await;
411    }
412
413    pub async fn wait_for_sign_in(&self, flow: &str) -> SignInStatus {
414        self.sign_ins.wait(flow).await
415    }
416
417    async fn purge_run_logs(&self, id: i64) {
418        if let Err(e) = self.log_store.purge_provider(id).await {
419            tracing::warn!(provider_id = id, error = %e, "Failed to purge cached run logs");
420        }
421    }
422
423    async fn current(&self, id: i64) -> Option<ProviderLease> {
424        self.providers
425            .read()
426            .await
427            .get(&id)
428            .map(|slot| slot.lease(id))
429    }
430
431    pub(crate) async fn generation_of(&self, id: i64) -> Option<Generation> {
432        self.providers
433            .read()
434            .await
435            .get(&id)
436            .map(|slot| slot.generation)
437    }
438
439    pub(crate) async fn health_inputs(&self) -> Vec<ProviderHealthInput> {
440        let leases: Vec<ProviderLease> = self
441            .providers
442            .read()
443            .await
444            .iter()
445            .map(|(id, slot)| slot.lease(*id))
446            .collect();
447        let mut inputs = Vec::with_capacity(leases.len());
448        for lease in leases {
449            match self.repository.get_provider(lease.id).await {
450                Ok(config) => inputs.push(self.health_input(&config, &lease)),
451                Err(e) => {
452                    tracing::debug!(provider_id = lease.id, error = %e, "Provider has no config row");
453                }
454            }
455        }
456
457        inputs
458    }
459
460    async fn install(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
461        let mut providers = self.providers.write().await;
462        providers
463            .entry(id)
464            .or_insert_with(|| {
465                let generation = Generation::next();
466                self.scheduler
467                    .register(id, generation, interval_of(interval));
468                ProviderSlot {
469                    generation,
470                    provider,
471                }
472            })
473            .lease(id)
474    }
475
476    async fn replace(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
477        let generation = Generation::next();
478        let mut providers = self.providers.write().await;
479        providers.insert(
480            id,
481            ProviderSlot {
482                generation,
483                provider: Arc::clone(&provider),
484            },
485        );
486        self.scheduler.reset(id, generation, interval_of(interval));
487
488        ProviderLease {
489            id,
490            generation,
491            provider,
492        }
493    }
494
495    pub async fn acquire(&self, id: i64) -> DomainResult<ProviderLease> {
496        if let Some(lease) = self.current(id).await {
497            if lease.provider.renew_at().is_some_and(|at| at <= Utc::now()) {
498                return self.renew(lease).await;
499            }
500            return Ok(lease);
501        }
502
503        for _ in 0..ACQUIRE_ATTEMPTS {
504            let config = self.renewed_config(id).await?;
505            let provider = (self.provider_factory)(&config)?;
506            let lock = self.commit_lock(id);
507            let _guard = lock.lock().await;
508            if let Some(lease) = self.current(id).await {
509                return Ok(lease);
510            }
511            let row = self.repository.get_provider(id).await?;
512            if row.version == config.version {
513                let lease = self.install(id, provider, row.refresh_interval).await;
514                self.health.send(HealthInput::ProvidersChanged {
515                    changed: vec![self.health_input(&row, &lease)],
516                });
517                return Ok(lease);
518            }
519        }
520
521        Err(DomainError::ConcurrentModification(format!(
522            "Provider {id} kept changing while it was loaded"
523        )))
524    }
525
526    pub async fn commit<F, Fut, T>(&self, lease: &ProviderLease, f: F) -> Option<T>
527    where
528        F: FnOnce() -> Fut,
529        Fut: Future<Output = T>,
530    {
531        self.commit_at(lease.id, lease.generation, f).await
532    }
533
534    async fn commit_at<F, Fut, T>(&self, id: i64, generation: Generation, f: F) -> Option<T>
535    where
536        F: FnOnce() -> Fut,
537        Fut: Future<Output = T>,
538    {
539        let lock = self.commit_lock(id);
540        let _guard = lock.lock().await;
541        let current = self
542            .providers
543            .read()
544            .await
545            .get(&id)
546            .map(|slot| slot.generation);
547        if current != Some(generation) {
548            return None;
549        }
550
551        Some(f().await)
552    }
553
554    pub async fn publish_summary(&self, id: i64, generation: Generation) {
555        self.commit_at(id, generation, || self.emit_status(id))
556            .await;
557    }
558
559    pub(crate) async fn emit_status(&self, id: i64) {
560        match self.summary_of(id).await {
561            Ok(provider) => {
562                self.event_bus
563                    .emit(CoreEvent::ProviderStatusUpdated { provider })
564                    .await;
565            }
566            Err(e) => {
567                tracing::warn!(provider_id = id, error = %e, "Failed to build provider summary");
568            }
569        }
570    }
571
572    async fn summary_of(&self, id: i64) -> DomainResult<ProviderSummary> {
573        let config = self.repository.get_provider(id).await?;
574
575        self.build_summary(id, &config).await
576    }
577
578    async fn build_summary(
579        &self, id: i64, config: &ProviderConfig,
580    ) -> DomainResult<ProviderSummary> {
581        let cached_pipelines = self.repository.get_cached_pipelines(Some(id)).await?;
582        let fetch = match self.repository.get_provider_fetch_status(id).await {
583            Ok(fetch) => fetch,
584            Err(e) => {
585                tracing::warn!(provider_id = id, error = %e, "Failed to read provider fetch status");
586                ProviderFetchStatus {
587                    status: FETCH_STATUS_NEVER.to_string(),
588                    error: None,
589                    fetched_at: None,
590                    succeeded_at: None,
591                }
592            }
593        };
594        let last_fetch_status = match fetch.status.as_str() {
595            FETCH_STATUS_SUCCESS => FetchStatus::Success,
596            FETCH_STATUS_ERROR => FetchStatus::Error,
597            _ => FetchStatus::Never,
598        };
599        let now = Instant::now();
600        let wall = Utc::now();
601        let view = self.scheduler.view(id, now);
602        let generation = self
603            .providers
604            .read()
605            .await
606            .get(&id)
607            .map(|slot| slot.generation);
608        let snapshot = self.health.snapshot();
609        let state = snapshot
610            .providers
611            .get(&id)
612            .filter(|state| Some(state.generation) == generation);
613        let paused_until = self.scheduler.paused_until(id, wall);
614        let level = state.map_or(HealthLevel::Operational, |state| state.level);
615        let freshness = ProviderFreshness::compute(
616            &last_fetch_status,
617            level,
618            fetch.fetched_at,
619            paused_until,
620            config.refresh_interval,
621            wall,
622        );
623
624        let last_fetch_error_kind = view.as_ref().and_then(|view| view.last_failure);
625        let recovery = ProviderRecovery::for_failure(
626            matches!(last_fetch_status, FetchStatus::Error)
627                .then_some(last_fetch_error_kind)
628                .flatten(),
629            SignInCredential::decode(&config.token).is_some(),
630        );
631
632        Ok(ProviderSummary {
633            id,
634            name: config.display_name().to_string(),
635            provider_type: config.provider_type.clone(),
636            icon: self
637                .plugin_registry
638                .get(&config.provider_type)
639                .and_then(|plugin| plugin.metadata().icon.clone()),
640            pipeline_count: cached_pipelines.len(),
641            last_updated: cached_pipelines.iter().map(|p| p.last_updated).max(),
642            refresh_interval: config.refresh_interval,
643            configured_repositories: configured_repositories(config),
644            last_fetch_status,
645            last_fetch_error: fetch.error,
646            last_fetch_at: fetch.fetched_at,
647            last_success_at: fetch.succeeded_at,
648            paused_until,
649            last_fetch_error_kind,
650            health: ProviderHealth {
651                level,
652                signals: state.map(|state| state.signals.clone()).unwrap_or_default(),
653                next_attempt_at: view
654                    .as_ref()
655                    .filter(|view| view.failures > 0)
656                    .and_then(|_| {
657                        let floor = state
658                            .filter(|state| state.outage_floor)
659                            .map(|_| self.scheduler.policy().outage_floor);
660                        self.scheduler.next_due_in(id, now, floor)
661                    })
662                    .and_then(|wait| chrono::Duration::from_std(wait).ok())
663                    .map(|wait| wall + wait),
664                in_flight: view.as_ref().is_some_and(|view| view.in_flight),
665                status_page: state.and_then(|state| state.status_page.clone()),
666            },
667            version: config.version.unwrap_or(1),
668            freshness,
669            recovery,
670        })
671    }
672
673    fn health_input(&self, config: &ProviderConfig, lease: &ProviderLease) -> ProviderHealthInput {
674        ProviderHealthInput {
675            id: lease.id,
676            generation: Some(lease.generation),
677            display_name: config.display_name().to_string(),
678            source: lease.provider.status_source().cloned(),
679            has_instance_checks: controller_health(&self.plugin_registry, &config.provider_type),
680        }
681    }
682
683    fn queue_fetch(&self, lease: &ProviderLease) {
684        if let ForcedClaim::Claimed(claim) =
685            self.scheduler
686                .claim_forced(lease.id, lease.generation, Instant::now())
687        {
688            self.dispatcher.fetch_now(vec![claim]);
689        }
690    }
691
692    pub async fn add_provider(&self, mut config: ProviderConfig) -> DomainResult<i64> {
693        let sign_in = self.take_sign_in(&mut config, None).await?;
694        let plugin = self
695            .init_plugin(&config.provider_type, config.plugin_config(), 0)
696            .await?;
697
698        let (validation_result, permission_status) =
699            tokio::join!(plugin.validate_credentials(), plugin.check_permissions());
700
701        validation_result.map_err(|e| {
702            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
703        })?;
704
705        let provider_type = plugin.provider_type();
706        if provider_type != config.provider_type {
707            return Err(DomainError::InvalidConfig(format!(
708                "Provider type mismatch: expected '{}', got '{}'",
709                config.provider_type, provider_type
710            )));
711        }
712
713        keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
714
715        let permission_status = permission_status.ok();
716
717        let id = self.repository.add_provider(&config).await?;
718
719        if let Some(status) = &permission_status {
720            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
721                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
722            }
723        }
724
725        let mut config_with_id = config.clone();
726        config_with_id.id = Some(id);
727        let provider = (self.provider_factory)(&config_with_id)?;
728
729        let lock = self.commit_lock(id);
730        let _guard = lock.lock().await;
731        let lease = self.install(id, provider, config.refresh_interval).await;
732        self.health.send(HealthInput::ProvidersChanged {
733            changed: vec![self.health_input(&config_with_id, &lease)],
734        });
735        let provider = self.summary_of(id).await?;
736        self.event_bus
737            .emit(CoreEvent::ProviderAdded {
738                provider,
739                timestamp: Utc::now().timestamp_millis(),
740            })
741            .await;
742        if let TakenSignIn::Flow(flow) = sign_in {
743            self.sign_ins.forget(&flow);
744        }
745
746        Ok(id)
747    }
748
749    pub async fn export_providers_toml(&self) -> DomainResult<String> {
750        let providers = self.repository.config_backend().list_providers().await?;
751
752        ProviderSyncService::export_toml(&providers)
753    }
754
755    pub async fn import_providers_toml(&self, content: &str) -> DomainResult<ProviderImportResult> {
756        let file = ConfigLoader::parse_raw(content).map_err(|e| {
757            DomainError::InvalidConfig(format!("Couldn't read the providers file: {e}"))
758        })?;
759        if file.providers.is_empty() {
760            return Err(DomainError::InvalidConfig(
761                "The file has no [providers] entries".to_string(),
762            ));
763        }
764        if let Some((key, provider)) = file
765            .providers
766            .iter()
767            .find(|(_, provider)| !self.plugin_registry.is_registered(&provider.provider_type))
768        {
769            return Err(DomainError::InvalidProviderType(format!(
770                "Provider '{key}' has an unknown type '{}'",
771                provider.provider_type
772            )));
773        }
774
775        let existing: HashSet<String> = self
776            .repository
777            .config_backend()
778            .list_providers()
779            .await?
780            .into_iter()
781            .map(|provider| provider.name)
782            .collect();
783        let mut result = ProviderImportResult::default();
784        let mut failure = None;
785
786        for (key, provider) in &file.providers {
787            let name = provider.display_name(key).to_string();
788            if existing.contains(key) {
789                result.skipped.push(name);
790                continue;
791            }
792
793            let config = ProviderSyncService::provider_config(key, provider, String::new());
794            match self.repository.add_provider(&config).await {
795                Ok(_) => result.added.push(name),
796                Err(e) => {
797                    let added_elsewhere = matches!(e, DomainError::InvalidConfig(_))
798                        && self
799                            .repository
800                            .config_backend()
801                            .list_providers()
802                            .await
803                            .is_ok_and(|providers| providers.iter().any(|p| p.name == *key));
804                    if !added_elsewhere {
805                        failure = Some(e);
806                        break;
807                    }
808                    result.skipped.push(name);
809                }
810            }
811        }
812
813        if !result.added.is_empty() {
814            self.event_bus.emit(CoreEvent::ProvidersChanged).await;
815        }
816
817        failure.map_or(Ok(result), Err)
818    }
819
820    pub async fn apply_config_file(&self, config_state: &ConfigState) -> DomainResult<SyncResult> {
821        let _apply = self.config_apply.lock().await;
822        let PipedashConfig {
823            providers: file,
824            views,
825            ..
826        } = config_state.load_file()?;
827        let current = config_state.get().await;
828        let changed: Vec<(String, ProviderFileConfig)> = file
829            .iter()
830            .filter(|(key, provider)| current.providers.get(*key) != Some(*provider))
831            .map(|(key, provider)| (key.clone(), provider.clone()))
832            .collect();
833
834        for (key, provider) in &changed {
835            if !self.plugin_registry.is_registered(&provider.provider_type) {
836                return Err(DomainError::InvalidProviderType(format!(
837                    "Provider '{key}' has an unknown type '{}'",
838                    provider.provider_type
839                )));
840            }
841            (self.provider_factory)(&ProviderSyncService::provider_config(
842                key,
843                provider,
844                provider.token.clone(),
845            ))?;
846        }
847
848        let mut result = SyncResult::default();
849        if !changed.is_empty() {
850            let existing = self.repository.config_backend().list_providers().await?;
851            let token_store = config_state.token_store();
852            for (key, provider) in &changed {
853                let id = existing.iter().find(|p| p.name == *key).and_then(|p| p.id);
854                self.apply_config_provider(key, provider, id, token_store.as_ref())
855                    .await?;
856                config_state.remember_provider(key, provider).await;
857                match id {
858                    Some(_) => result.updated.push(key.clone()),
859                    None => result.added.push(key.clone()),
860                }
861            }
862
863            tracing::info!(
864                added = result.added.len(),
865                updated = result.updated.len(),
866                "Applied provider changes from config file"
867            );
868            self.event_bus.emit(CoreEvent::ProvidersChanged).await;
869        }
870        if current.views != views {
871            config_state.remember_views(views).await;
872            self.event_bus.emit(CoreEvent::PipelineViewsChanged).await;
873        }
874        config_state.remember_providers(file).await;
875
876        Ok(result)
877    }
878
879    pub async fn update_general_settings(
880        &self, config_state: &ConfigState, update: GeneralSettingsUpdate,
881    ) -> DomainResult<()> {
882        let proxy_url = update.proxy_url.clone();
883        config_state.update_general_settings(update).await?;
884        if self.http_client_manager.set_proxy(&proxy_url)? {
885            self.rebuild_loaded_providers().await;
886        }
887
888        Ok(())
889    }
890
891    async fn rebuild_loaded_providers(&self) {
892        let ids: Vec<i64> = self.providers.read().await.keys().copied().collect();
893        let mut changed = Vec::new();
894
895        for id in ids {
896            let lock = self.commit_lock(id);
897            let _guard = lock.lock().await;
898            if !self.providers.read().await.contains_key(&id) {
899                continue;
900            }
901
902            let rebuilt = match self.repository.get_provider(id).await {
903                Ok(config) => (self.provider_factory)(&config).map(|provider| (config, provider)),
904                Err(e) => Err(e),
905            };
906            match rebuilt {
907                Ok((config, provider)) => {
908                    let lease = self.replace(id, provider, config.refresh_interval).await;
909                    changed.push(self.health_input(&config, &lease));
910                }
911                Err(e) => {
912                    tracing::warn!(provider_id = id, error = %e, "Failed to rebuild provider");
913                }
914            }
915        }
916
917        if !changed.is_empty() {
918            self.health.send(HealthInput::ProvidersChanged { changed });
919        }
920    }
921
922    async fn apply_config_provider(
923        &self, key: &str, provider: &ProviderFileConfig, existing: Option<i64>,
924        token_store: &dyn TokenStore,
925    ) -> DomainResult<()> {
926        let sync = ProviderSyncService::new();
927        let backend = self.repository.config_backend().as_ref();
928        let id = match existing {
929            Some(id) => id,
930            None => sync.create_from_toml(key, provider, backend).await?,
931        };
932
933        let lock = self.commit_lock(id);
934        let guard = lock.lock().await;
935        if existing.is_some() {
936            let row = backend
937                .get_provider(id)
938                .await?
939                .ok_or_else(|| DomainError::ProviderNotFound(id.to_string()))?;
940            sync.update_from_toml(key, provider, &row, backend).await?;
941        }
942        ProviderSyncService::store_resolved_token(
943            id,
944            &provider.token,
945            existing.is_none(),
946            token_store,
947        )
948        .await?;
949
950        let config = self.repository.get_provider(id).await?;
951        let instance = (self.provider_factory)(&config)?;
952        if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
953            tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
954        }
955        let lease = self.replace(id, instance, config.refresh_interval).await;
956        self.purge_run_logs(id).await;
957        self.health.send(HealthInput::ProvidersChanged {
958            changed: vec![self.health_input(&config, &lease)],
959        });
960        drop(guard);
961
962        self.queue_fetch(&lease);
963
964        Ok(())
965    }
966
967    pub async fn get_provider_config(&self, id: i64) -> DomainResult<ProviderConfig> {
968        self.repository.get_provider(id).await
969    }
970
971    pub async fn list_providers(&self) -> DomainResult<Vec<ProviderSummary>> {
972        let configs = self.repository.list_providers().await?;
973        let mut summaries = Vec::with_capacity(configs.len());
974
975        for config in configs {
976            if let Some(id) = config.id {
977                summaries.push(self.build_summary(id, &config).await?);
978            }
979        }
980
981        Ok(summaries)
982    }
983
984    pub async fn update_provider(&self, id: i64, mut config: ProviderConfig) -> DomainResult<()> {
985        let saved_name = self.repository.get_provider(id).await?.name;
986        if self
987            .config_state
988            .get()
989            .await
990            .providers
991            .contains_key(&saved_name)
992        {
993            let display_name = std::mem::replace(&mut config.name, saved_name);
994            config
995                .config
996                .insert("display_name".to_string(), display_name);
997        }
998        let sign_in = self.take_sign_in(&mut config, Some(id)).await?;
999        let plugin = self
1000            .init_plugin(&config.provider_type, config.plugin_config(), id)
1001            .await?;
1002
1003        plugin.validate_credentials().await.map_err(|e| {
1004            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
1005        })?;
1006
1007        keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
1008
1009        let permission_status = plugin.check_permissions().await.ok();
1010
1011        let lock = self.commit_lock(id);
1012        let guard = lock.lock().await;
1013
1014        let current = self.repository.get_provider(id).await?;
1015        if matches!(sign_in, TakenSignIn::Kept) {
1016            config.token.clone_from(&current.token);
1017        }
1018        let mut config_with_id = config.clone();
1019        config_with_id.id = Some(id);
1020        let new_provider = (self.provider_factory)(&config_with_id)?;
1021        let success = self
1022            .repository
1023            .update_provider_with_version(id, &config, current.version.unwrap_or(1))
1024            .await?;
1025
1026        if !success {
1027            return Err(DomainError::ConcurrentModification(format!(
1028                "Provider {} was modified by another client",
1029                id
1030            )));
1031        }
1032
1033        if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
1034            tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
1035        }
1036        let lease = self
1037            .replace(id, new_provider, config.refresh_interval)
1038            .await;
1039        self.purge_run_logs(id).await;
1040        self.health.send(HealthInput::ProvidersChanged {
1041            changed: vec![self.health_input(&config_with_id, &lease)],
1042        });
1043        match self.summary_of(id).await {
1044            Ok(provider) => {
1045                self.event_bus
1046                    .emit(CoreEvent::ProviderUpdated {
1047                        provider,
1048                        timestamp: Utc::now().timestamp_millis(),
1049                    })
1050                    .await;
1051            }
1052            Err(e) => {
1053                tracing::warn!(provider_id = id, error = %e, "Failed to build updated provider summary");
1054                self.event_bus.emit(CoreEvent::ProvidersChanged).await;
1055            }
1056        }
1057        drop(guard);
1058
1059        if let Some(status) = &permission_status {
1060            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
1061                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
1062            }
1063        }
1064
1065        self.queue_fetch(&lease);
1066        if let TakenSignIn::Flow(flow) = sign_in {
1067            self.sign_ins.forget(&flow);
1068        }
1069
1070        Ok(())
1071    }
1072
1073    pub async fn update_provider_refresh_interval(
1074        &self, id: i64, refresh_interval: i64,
1075    ) -> DomainResult<()> {
1076        let lock = self.commit_lock(id);
1077        let _guard = lock.lock().await;
1078
1079        let mut config = self.repository.get_provider(id).await?;
1080        config.refresh_interval = refresh_interval;
1081        self.repository.update_provider(id, &config).await?;
1082
1083        let generation = self
1084            .providers
1085            .read()
1086            .await
1087            .get(&id)
1088            .map(|slot| slot.generation);
1089        if let Some(generation) = generation {
1090            self.scheduler
1091                .set_interval(id, generation, interval_of(refresh_interval));
1092        }
1093
1094        Ok(())
1095    }
1096
1097    pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
1098        let _apply = self.config_apply.lock().await;
1099        let lock = self.commit_lock(id);
1100        let _guard = lock.lock().await;
1101
1102        let config = self.repository.get_provider(id).await?;
1103        let provider = self.build_summary(id, &config).await?;
1104
1105        self.repository
1106            .remove_provider(id, &self.config_state)
1107            .await?;
1108        self.providers.write().await.remove(&id);
1109        self.purge_run_logs(id).await;
1110        self.scheduler.remove(id);
1111        self.health.send(HealthInput::ProvidersChanged {
1112            changed: vec![ProviderHealthInput {
1113                id,
1114                generation: None,
1115                display_name: config.display_name().to_string(),
1116                source: None,
1117                has_instance_checks: false,
1118            }],
1119        });
1120        self.event_bus
1121            .emit(CoreEvent::ProviderRemoved {
1122                provider,
1123                timestamp: Utc::now().timestamp_millis(),
1124            })
1125            .await;
1126
1127        Ok(())
1128    }
1129
1130    pub async fn get_provider(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1131        Ok(self.acquire(id).await?.provider)
1132    }
1133
1134    pub async fn check_quota(&self, lease: &ProviderLease) -> Option<DateTime<Utc>> {
1135        let rate_limit =
1136            match timeout(Duration::from_secs(10), lease.provider.fetch_rate_limit()).await {
1137                Ok(Ok(rate_limit)) => rate_limit,
1138                Ok(Err(e)) => {
1139                    tracing::debug!(provider_id = lease.id, error = %e, "Rate limit check failed");
1140                    None
1141                }
1142                Err(_) => {
1143                    tracing::debug!(provider_id = lease.id, "Rate limit check timed out");
1144                    None
1145                }
1146            };
1147        let paused_until = rate_limit.and_then(|rate_limit| quota_pause(&rate_limit, Utc::now()));
1148
1149        self.commit(lease, || async {
1150            let changed = self.scheduler.set_quota_pause(
1151                lease.id,
1152                lease.generation,
1153                paused_until,
1154                Instant::now(),
1155                Utc::now(),
1156            );
1157            if let (true, Some(until)) = (changed, paused_until) {
1158                tracing::info!(provider_id = lease.id, %until, "API quota almost used up, pausing provider");
1159            }
1160            if changed {
1161                self.emit_status(lease.id).await;
1162            }
1163
1164            paused_until
1165        })
1166        .await
1167        .flatten()
1168    }
1169
1170    pub async fn get_provider_instance(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1171        self.get_provider(id).await
1172    }
1173
1174    pub async fn load_missing_providers(&self) {
1175        let ids = match self.repository.list_provider_ids().await {
1176            Ok(ids) => ids,
1177            Err(e) => {
1178                tracing::warn!(error = %e, "Failed to list providers");
1179                return;
1180            }
1181        };
1182        let loaded: Vec<i64> = self.providers.read().await.keys().copied().collect();
1183        for id in ids.into_iter().filter(|id| !loaded.contains(id)) {
1184            if let Err(e) = self.acquire(id).await {
1185                tracing::debug!(provider_id = id, error = %e, "Provider not loaded yet");
1186            }
1187        }
1188    }
1189
1190    pub async fn load_all_providers(&self) -> DomainResult<()> {
1191        let configs = self.repository.list_providers().await?;
1192        let mut changed = Vec::new();
1193
1194        for config in configs {
1195            let Some(id) = config.id else {
1196                continue;
1197            };
1198            if config.token.is_empty() {
1199                tracing::debug!(
1200                    provider_id = id,
1201                    provider_type = %config.provider_type,
1202                    "Skipping provider load - token not available yet (vault initializing in background)"
1203                );
1204                continue;
1205            }
1206
1207            match (self.provider_factory)(&config) {
1208                Ok(provider) => {
1209                    let lock = self.commit_lock(id);
1210                    let _guard = lock.lock().await;
1211                    let lease = self.replace(id, provider, config.refresh_interval).await;
1212                    changed.push(self.health_input(&config, &lease));
1213                }
1214                Err(e) => {
1215                    tracing::warn!(provider_id = id, error = %e, "Failed to load provider");
1216                }
1217            }
1218        }
1219
1220        if !changed.is_empty() {
1221            self.health.send(HealthInput::ProvidersChanged { changed });
1222        }
1223
1224        Ok(())
1225    }
1226
1227    pub async fn get_workflow_parameters(
1228        &self, provider_id: i64, workflow_id: &str,
1229    ) -> DomainResult<Vec<pipedash_plugin_api::WorkflowParameter>> {
1230        if let Some(cached) = self.cached_parameters(workflow_id).await {
1231            return Ok(cached);
1232        }
1233
1234        let lease = self.acquire(provider_id).await?;
1235        let fetch = ParameterFetch::new(&self.parameter_fetches, workflow_id);
1236        let _guard = fetch.lock.lock().await;
1237
1238        if let Some(cached) = self.cached_parameters(workflow_id).await {
1239            return Ok(cached);
1240        }
1241
1242        let parameters = lease.provider.get_workflow_parameters(workflow_id).await?;
1243
1244        let cached = self
1245            .commit(&lease, || {
1246                self.repository
1247                    .cache_workflow_parameters(workflow_id, &parameters)
1248            })
1249            .await;
1250        if let Some(Err(e)) = cached {
1251            tracing::warn!(provider_id, workflow_id, error = %e, "Failed to cache workflow parameters");
1252        }
1253
1254        Ok(parameters)
1255    }
1256
1257    async fn cached_parameters(
1258        &self, workflow_id: &str,
1259    ) -> Option<Vec<pipedash_plugin_api::WorkflowParameter>> {
1260        match self
1261            .repository
1262            .get_cached_workflow_parameters(workflow_id)
1263            .await
1264        {
1265            Ok(cached) => cached,
1266            Err(e) => {
1267                tracing::warn!(workflow_id, error = %e, "Failed to read cached workflow parameters");
1268                None
1269            }
1270        }
1271    }
1272
1273    pub async fn get_provider_permissions(
1274        &self, provider_id: i64,
1275    ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
1276        self.repository.get_provider_permissions(provider_id).await
1277    }
1278
1279    pub async fn recheck_provider_permissions(
1280        &self, provider_id: i64,
1281    ) -> DomainResult<pipedash_plugin_api::PermissionStatus> {
1282        let plugin = self.stored_plugin(provider_id).await?;
1283
1284        let status = plugin
1285            .check_permissions()
1286            .await
1287            .map_err(|e| DomainError::InvalidConfig(format!("Failed to check permissions: {e}")))?;
1288
1289        self.repository
1290            .store_provider_permissions(provider_id, &status)
1291            .await?;
1292
1293        Ok(status)
1294    }
1295}
1296
1297fn interval_of(seconds: i64) -> Duration {
1298    Duration::from_secs(u64::try_from(seconds).unwrap_or(0))
1299}
1300
1301fn build_plugin(
1302    http: &HttpClientManager, provider_type: &str, config: &HashMap<String, String>,
1303) -> DomainResult<(Box<dyn PluginTrait>, Arc<reqwest::Client>)> {
1304    let plugin = plugins::create_plugin(provider_type).ok_or_else(|| {
1305        DomainError::InvalidProviderType(format!("Unknown provider type: {}", provider_type))
1306    })?;
1307    let client = http.client_for(&plugin.client_options(config))?;
1308
1309    Ok((plugin, client))
1310}
1311
1312async fn keep_selection_with_ci(
1313    plugin: &dyn PluginTrait, config: &mut ProviderConfig,
1314) -> DomainResult<()> {
1315    let Some(selected) = config.config.get("selected_items") else {
1316        return Ok(());
1317    };
1318    let ids: Vec<String> = selected
1319        .split(',')
1320        .map(str::trim)
1321        .filter(|id| !id.is_empty())
1322        .map(str::to_owned)
1323        .collect();
1324    if ids.is_empty() {
1325        return Ok(());
1326    }
1327    let Ok(kept) = plugin.ids_with_ci(ids.clone()).await else {
1328        return Ok(());
1329    };
1330    if kept.is_empty() {
1331        return Err(DomainError::InvalidConfig(
1332            "None of the selected repositories has a CI file or a pipeline run yet".to_string(),
1333        ));
1334    }
1335    if kept.len() < ids.len() {
1336        tracing::info!(
1337            selected = ids.len(),
1338            kept = kept.len(),
1339            "Skipped selected repositories without a CI file or a pipeline run"
1340        );
1341        config
1342            .config
1343            .insert("selected_items".to_string(), kept.join(","));
1344    }
1345    Ok(())
1346}
1347
1348fn init_plugin(
1349    http: &HttpClientManager, provider_type: &str, config: HashMap<String, String>,
1350    provider_id: i64,
1351) -> DomainResult<Box<dyn PluginTrait>> {
1352    let (mut plugin, client) = build_plugin(http, provider_type, &config)?;
1353    plugin
1354        .initialize(provider_id, config, Some(client))
1355        .map_err(|e| DomainError::InvalidConfig(format!("Failed to initialize plugin: {e}")))?;
1356
1357    Ok(plugin)
1358}
1359
1360pub(crate) fn controller_health(registry: &PluginRegistry, provider_type: &str) -> bool {
1361    registry.get(provider_type).is_some_and(|plugin| {
1362        plugin
1363            .metadata()
1364            .features
1365            .iter()
1366            .any(|feature| feature.id == CONTROLLER_HEALTH_FEATURE)
1367    })
1368}
1369
1370pub(crate) fn plugin_provider(
1371    registry: &PluginRegistry, http: &HttpClientManager, config: &ProviderConfig,
1372) -> DomainResult<Arc<dyn Provider>> {
1373    if !registry.is_registered(&config.provider_type) {
1374        return Err(DomainError::InvalidProviderType(format!(
1375            "Plugin not found for provider type: {}",
1376            config.provider_type
1377        )));
1378    }
1379
1380    let provider_id = config.id.unwrap_or(0);
1381    let (token, renew_at) = match SignInCredential::decode(&config.token) {
1382        Some(credential) => {
1383            let renew_at = credential.renew_at();
1384            (credential.token, renew_at)
1385        }
1386        None => (resolve_token_reference(provider_id, &config.token), None),
1387    };
1388    let mut plugin_config = config.config.clone();
1389    plugin_config.insert("token".to_string(), token);
1390
1391    let plugin = init_plugin(http, &config.provider_type, plugin_config, provider_id)?;
1392
1393    Ok(Arc::new(PluginAdapter::new(
1394        plugin,
1395        config.provider_type.clone(),
1396        provider_id,
1397        renew_at,
1398    )))
1399}
1400
1401fn resolve_token_reference(provider_id: i64, token: &str) -> String {
1402    if token.is_empty() {
1403        return String::new();
1404    }
1405    match TokenReference::parse(token) {
1406        Ok(TokenReference::EnvVar(var_name)) => std::env::var(&var_name).unwrap_or_else(|_| {
1407            tracing::warn!(
1408                provider_id = provider_id,
1409                var_name = %var_name,
1410                "Environment variable not found, using empty token"
1411            );
1412            String::new()
1413        }),
1414        Ok(TokenReference::None) => String::new(),
1415        Ok(_) | Err(_) => token.to_string(),
1416    }
1417}
1418
1419fn configured_repositories(config: &ProviderConfig) -> Vec<String> {
1420    config
1421        .config
1422        .get("selected_items")
1423        .map(|items| {
1424            if matches!(
1425                config.provider_type.as_str(),
1426                "argocd" | "fluxcd" | "komodo"
1427            ) {
1428                return Vec::new();
1429            }
1430
1431            items
1432                .split(',')
1433                .map(|s| s.trim().to_string())
1434                .filter(|s| !s.is_empty())
1435                .map(|item| {
1436                    if config.provider_type == "tekton" && item.contains("__") {
1437                        item.replace("__", "/")
1438                    } else if config.provider_type == "jenkins" && !item.contains('/') {
1439                        format!("(root)/{}", item)
1440                    } else {
1441                        item
1442                    }
1443                })
1444                .collect()
1445        })
1446        .unwrap_or_default()
1447}
1448
1449pub(crate) fn quota_pause(rate_limit: &RateLimit, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
1450    let low = rate_limit.remaining * 100 < rate_limit.limit * QUOTA_PAUSE_PERCENT;
1451
1452    (low && rate_limit.reset_at > now).then_some(rate_limit.reset_at)
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use std::io::{
1458        Read,
1459        Write,
1460    };
1461
1462    use pipedash_plugin_api::PluginError;
1463
1464    use super::*;
1465    use crate::application::test_support::{
1466        pipeline,
1467        FeedStub,
1468        Gate,
1469        Harness,
1470        StubProvider,
1471    };
1472    use crate::application::FetchResult;
1473    use crate::domain::PipelineStatus;
1474    use crate::infrastructure::config::ConfigLoader;
1475    use crate::infrastructure::oauth::test_idp::Idp;
1476    use crate::test_env::EnvGuard;
1477
1478    const GITHUB_SECRET: &str = "ghp_exampleSECRET0123456789";
1479    const GITLAB_SECRET: &str = "glpat-SECRET-VALUE";
1480    const JENKINS_DEFAULT_SECRET: &str = "hunter2-default";
1481
1482    fn provider(
1483        name: &str, provider_type: &str, token: &str, refresh_interval: i64,
1484        config: &[(&str, &str)],
1485    ) -> ProviderConfig {
1486        ProviderConfig {
1487            id: None,
1488            name: name.to_string(),
1489            provider_type: provider_type.to_string(),
1490            token: token.to_string(),
1491            config: config
1492                .iter()
1493                .map(|(k, v)| (k.to_string(), v.to_string()))
1494                .collect(),
1495            refresh_interval,
1496            version: None,
1497        }
1498    }
1499
1500    struct CiCheck(Option<&'static [&'static str]>);
1501
1502    #[async_trait::async_trait]
1503    impl PluginTrait for CiCheck {
1504        fn metadata(&self) -> &pipedash_plugin_api::PluginMetadata {
1505            unimplemented!()
1506        }
1507
1508        fn initialize(
1509            &mut self, _: i64, _: HashMap<String, String>, _: Option<Arc<reqwest::Client>>,
1510        ) -> pipedash_plugin_api::PluginResult<()> {
1511            Ok(())
1512        }
1513
1514        async fn validate_credentials(&self) -> pipedash_plugin_api::PluginResult<bool> {
1515            Ok(true)
1516        }
1517
1518        async fn fetch_pipelines(
1519            &self,
1520        ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::Pipeline>> {
1521            Ok(Vec::new())
1522        }
1523
1524        async fn fetch_run_history(
1525            &self, _: &str, _: usize,
1526        ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::PipelineRun>> {
1527            Ok(Vec::new())
1528        }
1529
1530        async fn fetch_run_details(
1531            &self, _: &str, _: i64,
1532        ) -> pipedash_plugin_api::PluginResult<pipedash_plugin_api::PipelineRun> {
1533            Err(PluginError::NotSupported(String::new()))
1534        }
1535
1536        async fn trigger_pipeline(
1537            &self, _: pipedash_plugin_api::TriggerParams,
1538        ) -> pipedash_plugin_api::PluginResult<String> {
1539            Err(PluginError::NotSupported(String::new()))
1540        }
1541
1542        async fn ids_with_ci(
1543            &self, _: Vec<String>,
1544        ) -> pipedash_plugin_api::PluginResult<Vec<String>> {
1545            self.0
1546                .map(|kept| kept.iter().map(|id| id.to_string()).collect())
1547                .ok_or_else(|| PluginError::NetworkError("reset".to_string()))
1548        }
1549    }
1550
1551    #[tokio::test]
1552    async fn a_saved_selection_keeps_only_repositories_with_ci() {
1553        let selected = &[("selected_items", "acme/api, acme/empty,acme/web")];
1554
1555        let mut config = provider("gh", "github", "t", 30, selected);
1556        keep_selection_with_ci(&CiCheck(Some(&["acme/api", "acme/web"])), &mut config)
1557            .await
1558            .unwrap();
1559        assert_eq!(config.config["selected_items"], "acme/api,acme/web");
1560
1561        let mut config = provider("gh", "github", "t", 30, selected);
1562        keep_selection_with_ci(&CiCheck(None), &mut config)
1563            .await
1564            .unwrap();
1565        assert_eq!(
1566            config.config["selected_items"],
1567            "acme/api, acme/empty,acme/web"
1568        );
1569
1570        let mut config = provider("gh", "github", "t", 30, selected);
1571        let error = keep_selection_with_ci(&CiCheck(Some(&[])), &mut config)
1572            .await
1573            .unwrap_err();
1574        assert!(matches!(error, DomainError::InvalidConfig(_)), "{error}");
1575        assert_eq!(
1576            config.config["selected_items"],
1577            "acme/api, acme/empty,acme/web"
1578        );
1579    }
1580
1581    #[tokio::test]
1582    async fn test_export_providers_toml_round_trips_without_tokens() {
1583        let source = Harness::with_real_plugins().await;
1584        let repository = source.repository.clone();
1585        let service = &source.provider_service;
1586
1587        let originals = [
1588            provider(
1589                "github-main",
1590                "github",
1591                GITHUB_SECRET,
1592                45,
1593                &[("selected_items", "acme/api,acme/web")],
1594            ),
1595            provider(
1596                "My Jenkins",
1597                "jenkins",
1598                &format!("${{JENKINS_API_TOKEN:-{JENKINS_DEFAULT_SECRET}}}"),
1599                120,
1600                &[
1601                    ("display_name", "Prod Jenkins"),
1602                    ("server_url", "http://127.0.0.1:9"),
1603                    ("username", "ci"),
1604                    ("selected_items", "(root)/deploy"),
1605                ],
1606            ),
1607            provider("gitlab_main", "gitlab", GITLAB_SECRET, 30, &[]),
1608        ];
1609        for original in &originals {
1610            repository.add_provider(original).await.unwrap();
1611        }
1612
1613        let exported = service.export_providers_toml().await.unwrap();
1614
1615        for secret in [GITHUB_SECRET, GITLAB_SECRET, JENKINS_DEFAULT_SECRET] {
1616            assert!(!exported.contains(secret), "export leaked {secret}");
1617        }
1618        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITHUB_MAIN:-}\""));
1619        assert!(exported.contains("token = \"${JENKINS_API_TOKEN:-}\""));
1620        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITLAB_MAIN:-}\""));
1621
1622        let _env = EnvGuard::set(&[
1623            ("PIPEDASH_TOKEN_GITHUB_MAIN", Some("env-github-token")),
1624            ("JENKINS_API_TOKEN", Some("env-jenkins-token")),
1625            ("PIPEDASH_TOKEN_GITLAB_MAIN", None),
1626        ]);
1627        let parsed = ConfigLoader::parse(&exported).unwrap();
1628
1629        let mut keys: Vec<_> = parsed.providers.keys().map(String::as_str).collect();
1630        keys.sort_unstable();
1631        assert_eq!(keys, ["My Jenkins", "github-main", "gitlab_main"]);
1632        assert_eq!(parsed.providers["github-main"].token, "env-github-token");
1633        assert_eq!(parsed.providers["My Jenkins"].token, "env-jenkins-token");
1634        assert_eq!(parsed.providers["gitlab_main"].token, "");
1635        assert_eq!(
1636            parsed.providers["My Jenkins"].name.as_deref(),
1637            Some("Prod Jenkins")
1638        );
1639
1640        let target_harness = Harness::with_real_plugins().await;
1641        let target = &target_harness.repository;
1642        crate::infrastructure::config::sync::ProviderSyncService::new()
1643            .sync_toml_to_db(
1644                parsed.providers,
1645                target.config_backend().as_ref(),
1646                &crate::infrastructure::MemoryTokenStore::new(),
1647                false,
1648            )
1649            .await
1650            .unwrap();
1651
1652        let imported = target.config_backend().list_providers().await.unwrap();
1653
1654        assert_eq!(imported.len(), originals.len());
1655        for original in &originals {
1656            let imported = imported.iter().find(|p| p.name == original.name).unwrap();
1657            assert_eq!(imported.name, original.name);
1658            assert_eq!(imported.provider_type, original.provider_type);
1659            assert_eq!(imported.refresh_interval, original.refresh_interval);
1660            assert_eq!(imported.config, original.config);
1661        }
1662    }
1663
1664    #[tokio::test]
1665    async fn test_import_providers_toml_adds_missing_providers_without_tokens() {
1666        let source = Harness::with_real_plugins().await;
1667        let jenkins = provider(
1668            "My Jenkins",
1669            "jenkins",
1670            "${JENKINS_API_TOKEN}",
1671            120,
1672            &[
1673                ("display_name", "Prod Jenkins"),
1674                ("server_url", "http://127.0.0.1:9"),
1675                ("username", "ci"),
1676                ("selected_items", "(root)/deploy"),
1677            ],
1678        );
1679        for original in [
1680            provider(
1681                "github-main",
1682                "github",
1683                GITHUB_SECRET,
1684                45,
1685                &[("selected_items", "acme/api")],
1686            ),
1687            jenkins.clone(),
1688        ] {
1689            source.repository.add_provider(&original).await.unwrap();
1690        }
1691        let exported = source
1692            .provider_service
1693            .export_providers_toml()
1694            .await
1695            .unwrap();
1696
1697        let target = Harness::with_real_plugins().await;
1698        let kept = provider(
1699            "github-main",
1700            "github",
1701            "target-token",
1702            300,
1703            &[("selected_items", "acme/web")],
1704        );
1705        let kept_id = target.repository.add_provider(&kept).await.unwrap();
1706
1707        let _env = EnvGuard::set(&[("JENKINS_API_TOKEN", Some("env-jenkins-token"))]);
1708        let result = target
1709            .provider_service
1710            .import_providers_toml(&exported)
1711            .await
1712            .unwrap();
1713
1714        assert_eq!(
1715            result,
1716            ProviderImportResult {
1717                added: vec!["Prod Jenkins".to_string()],
1718                skipped: vec!["github-main".to_string()],
1719            }
1720        );
1721        let unchanged = target.repository.get_provider(kept_id).await.unwrap();
1722        assert_eq!(unchanged.token, kept.token);
1723        assert_eq!(unchanged.refresh_interval, kept.refresh_interval);
1724        assert_eq!(unchanged.config, kept.config);
1725
1726        let imported = target.repository.list_providers().await.unwrap();
1727        let imported = imported.iter().find(|p| p.name == jenkins.name).unwrap();
1728        let row = target
1729            .repository
1730            .config_backend()
1731            .get_provider(imported.id.unwrap())
1732            .await
1733            .unwrap()
1734            .unwrap();
1735        assert_eq!(imported.token, "");
1736        assert_eq!(row.token, "");
1737        assert_eq!(imported.provider_type, jenkins.provider_type);
1738        assert_eq!(imported.refresh_interval, jenkins.refresh_interval);
1739        assert_eq!(imported.config, jenkins.config);
1740        assert_eq!(target.bus.count("providers-changed"), 1);
1741    }
1742
1743    #[tokio::test]
1744    async fn test_import_providers_toml_rejects_unknown_type_before_adding() {
1745        let h = Harness::with_real_plugins().await;
1746        let content = r#"
1747[providers.github-main]
1748type = "github"
1749
1750[providers.mystery]
1751type = "travis"
1752"#;
1753
1754        let err = h
1755            .provider_service
1756            .import_providers_toml(content)
1757            .await
1758            .unwrap_err();
1759
1760        assert!(matches!(err, DomainError::InvalidProviderType(_)), "{err}");
1761        assert!(h.repository.list_providers().await.unwrap().is_empty());
1762        assert_eq!(h.bus.count("providers-changed"), 0);
1763    }
1764
1765    #[test]
1766    fn quota_pause_starts_under_ten_percent_until_reset() {
1767        let now = Utc::now();
1768        let reset_at = now + chrono::Duration::minutes(20);
1769        let pause = |remaining, reset_at| {
1770            quota_pause(
1771                &RateLimit {
1772                    limit: 5000,
1773                    remaining,
1774                    reset_at,
1775                },
1776                now,
1777            )
1778        };
1779
1780        assert_eq!(pause(500, reset_at), None);
1781        assert_eq!(pause(499, reset_at), Some(reset_at));
1782        assert_eq!(pause(0, reset_at), Some(reset_at));
1783        assert_eq!(pause(0, now), None);
1784        assert_eq!(
1785            quota_pause(
1786                &RateLimit {
1787                    limit: 0,
1788                    remaining: 0,
1789                    reset_at,
1790                },
1791                now,
1792            ),
1793            None
1794        );
1795    }
1796
1797    type StubGate = Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>;
1798
1799    async fn stub_github(
1800        remaining: u64, reset: i64, gate: StubGate,
1801    ) -> (String, Arc<Mutex<Vec<String>>>) {
1802        use tokio::io::{
1803            AsyncReadExt,
1804            AsyncWriteExt,
1805        };
1806
1807        let _ = rustls::crypto::ring::default_provider().install_default();
1808        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1809        let addr = listener.local_addr().unwrap();
1810        let requests: Arc<Mutex<Vec<String>>> = Arc::default();
1811        let log = requests.clone();
1812        tokio::spawn(async move {
1813            loop {
1814                let (mut stream, _) = listener.accept().await.unwrap();
1815                let mut request = Vec::new();
1816                let mut buffer = [0; 4096];
1817                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1818                    let count = stream.read(&mut buffer).await.unwrap();
1819                    if count == 0 {
1820                        break;
1821                    }
1822                    request.extend_from_slice(&buffer[..count]);
1823                }
1824                let request = String::from_utf8(request).unwrap();
1825                let path = request.split_whitespace().nth(1).unwrap_or_default();
1826                let path = path.split('?').next().unwrap().to_string();
1827                let (status, body) = if path == "/api/v3/rate_limit" {
1828                    if let Some((arrived, release)) = &gate {
1829                        arrived.notify_one();
1830                        release.notified().await;
1831                    }
1832                    let rate = serde_json::json!({
1833                        "limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset
1834                    });
1835                    let search = serde_json::json!({"limit": 30, "used": 0, "remaining": 30, "reset": reset});
1836                    let body = serde_json::json!({
1837                        "resources": {"core": rate, "search": search},
1838                        "rate": rate
1839                    });
1840                    ("200 OK", body.to_string())
1841                } else {
1842                    ("404 Not Found", r#"{"message":"Not Found"}"#.to_string())
1843                };
1844                log.lock().await.push(path);
1845                let response = format!(
1846                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1847                    body.len()
1848                );
1849                let _ = stream.write_all(response.as_bytes()).await;
1850            }
1851        });
1852
1853        (format!("http://{addr}"), requests)
1854    }
1855
1856    #[tokio::test]
1857    async fn refresh_skips_a_github_provider_with_its_quota_almost_used_up() {
1858        let h = Harness::with_real_plugins().await;
1859        h.start().await;
1860        let reset = Utc::now().timestamp() + 1200;
1861        let (base_url, requests) = stub_github(0, reset, None).await;
1862        let id = h
1863            .repository
1864            .add_provider(&provider(
1865                "github",
1866                "github",
1867                "ghp_stub",
1868                30,
1869                &[("base_url", &base_url), ("selected_items", "acme/app")],
1870            ))
1871            .await
1872            .unwrap();
1873
1874        let first = h
1875            .pipeline_service
1876            .fetch_now(&[id], Duration::from_secs(5))
1877            .await;
1878        let again = h
1879            .pipeline_service
1880            .fetch_now(&[id], Duration::from_secs(5))
1881            .await;
1882        let summaries = h.provider_service.list_providers().await.unwrap();
1883
1884        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1885        assert_eq!(
1886            summaries[0].paused_until,
1887            DateTime::from_timestamp(reset, 0)
1888        );
1889        assert!(matches!(summaries[0].last_fetch_status, FetchStatus::Never));
1890        assert_eq!(first[0].state, crate::domain::RefreshState::Recent);
1891        assert_eq!(again[0].state, crate::domain::RefreshState::Recent);
1892        assert_eq!(again[0].kind, Some(crate::domain::FailureKind::RateLimited));
1893        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1894    }
1895
1896    #[tokio::test]
1897    async fn refresh_fetches_a_github_provider_with_quota_left() {
1898        let h = Harness::with_real_plugins().await;
1899        h.start().await;
1900        let (base_url, requests) = stub_github(4000, Utc::now().timestamp() + 1200, None).await;
1901        let id = h
1902            .repository
1903            .add_provider(&provider(
1904                "github",
1905                "github",
1906                "ghp_stub",
1907                30,
1908                &[("base_url", &base_url), ("selected_items", "acme/app")],
1909            ))
1910            .await
1911            .unwrap();
1912
1913        h.pipeline_service
1914            .fetch_now(&[id], Duration::from_secs(5))
1915            .await;
1916        let summaries = h.provider_service.list_providers().await.unwrap();
1917        let requests = requests.lock().await;
1918
1919        assert_eq!(requests[0], "/api/v3/rate_limit");
1920        assert!(requests
1921            .iter()
1922            .any(|path| path.starts_with("/api/v3/repos/acme/app")));
1923        assert_eq!(summaries[0].paused_until, None);
1924    }
1925
1926    #[tokio::test]
1927    async fn quota_answer_for_replaced_credentials_does_not_pause_the_new_ones() {
1928        let h = Harness::with_real_plugins().await;
1929        let reset = Utc::now().timestamp() + 1200;
1930        let arrived = Arc::new(tokio::sync::Notify::new());
1931        let release = Arc::new(tokio::sync::Notify::new());
1932        let (base_url, _) = stub_github(0, reset, Some((arrived.clone(), release.clone()))).await;
1933        let id = h
1934            .repository
1935            .add_provider(&provider(
1936                "github",
1937                "github",
1938                "ghp_old",
1939                30,
1940                &[("base_url", &base_url), ("selected_items", "acme/app")],
1941            ))
1942            .await
1943            .unwrap();
1944        let service = Arc::clone(&h.provider_service);
1945        let lease = service.acquire(id).await.unwrap();
1946
1947        let check = tokio::spawn({
1948            let service = Arc::clone(&service);
1949            let lease = lease.clone();
1950            async move { service.check_quota(&lease).await }
1951        });
1952        arrived.notified().await;
1953        let replacement =
1954            (service.provider_factory)(&h.repository.get_provider(id).await.unwrap()).unwrap();
1955        service.replace(id, replacement, 30).await;
1956        release.notify_one();
1957
1958        assert_eq!(check.await.unwrap(), None);
1959        assert_eq!(h.scheduler.paused_until(id, Utc::now()), None);
1960    }
1961
1962    fn updated(h: &Harness, id: i64) -> ProviderConfig {
1963        let mut config = h.config("renamed", 45);
1964        config.id = Some(id);
1965        config
1966    }
1967
1968    #[tokio::test]
1969    async fn an_update_during_a_blocked_fetch_supersedes_the_old_attempt() {
1970        let h = Harness::new().await;
1971        h.start().await;
1972        let old_gate = Gate::new();
1973        let (id, old) = h
1974            .add_stub(|id| {
1975                StubProvider::new(id)
1976                    .gated(&old_gate)
1977                    .returning(vec![pipeline(id, "old", PipelineStatus::Success)])
1978            })
1979            .await;
1980        let new = h.stubs.queue(StubProvider::new(id).returning(vec![pipeline(
1981            id,
1982            "new",
1983            PipelineStatus::Failed,
1984        )]));
1985        let blocked = h.spawn_fetch(id).await;
1986        old_gate.arrived().await;
1987        let before = h.provider_service.acquire(id).await.unwrap().generation;
1988
1989        h.provider_service
1990            .update_provider(id, updated(&h, id))
1991            .await
1992            .unwrap();
1993        let updated_at = h
1994            .bus
1995            .names()
1996            .iter()
1997            .position(|name| *name == "provider-updated")
1998            .unwrap();
1999        h.bus
2000            .wait_for("the fetch queued by the update", |events| {
2001                events[updated_at..]
2002                    .iter()
2003                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2004            })
2005            .await;
2006        old_gate.open();
2007        let old_result = blocked.await.unwrap();
2008
2009        assert!(matches!(old_result, FetchResult::Superseded));
2010        assert_eq!(h.cached_names(id).await, ["new"]);
2011        let published: Vec<_> = h.bus.events()[updated_at..]
2012            .iter()
2013            .filter_map(|event| match event {
2014                CoreEvent::PipelinesUpdated { pipelines, .. } => {
2015                    Some(pipelines.iter().map(|p| p.name.clone()).collect::<Vec<_>>())
2016                }
2017                _ => None,
2018            })
2019            .collect();
2020        assert_eq!(published, vec![vec!["new".to_string()]]);
2021        let view = h.scheduler.view(id, Instant::now()).unwrap();
2022        assert_eq!(
2023            (view.failures, view.last_failure, view.in_flight),
2024            (0, None, false)
2025        );
2026        assert_ne!(
2027            h.provider_service.acquire(id).await.unwrap().generation,
2028            before
2029        );
2030        assert_eq!((old.fetch_count(), new.fetch_count()), (1, 1));
2031    }
2032
2033    #[tokio::test]
2034    async fn an_update_resets_the_fetch_status_and_the_schedule() {
2035        let h = Harness::new().await;
2036        let (id, _) = h
2037            .add_stub(|id| StubProvider::new(id).failing(DomainError::NetworkError("down".into())))
2038            .await;
2039        h.fetch_directly(id).await;
2040        h.health_inputs();
2041        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 1);
2042
2043        h.provider_service
2044            .update_provider(id, updated(&h, id))
2045            .await
2046            .unwrap();
2047        let inputs = h.health_inputs();
2048
2049        let event = h
2050            .bus
2051            .events()
2052            .into_iter()
2053            .find_map(|event| match event {
2054                CoreEvent::ProviderUpdated { provider, .. } => Some(provider),
2055                _ => None,
2056            })
2057            .unwrap();
2058        assert!(matches!(event.last_fetch_status, FetchStatus::Never));
2059        assert_eq!(event.last_fetch_error, None);
2060        assert_eq!(event.last_fetch_error_kind, None);
2061        assert_eq!(event.health.next_attempt_at, None);
2062        assert_eq!(event.name, "renamed");
2063        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2064        let lease = h.provider_service.acquire(id).await.unwrap();
2065        assert!(matches!(
2066            inputs.as_slice(),
2067            [HealthInput::ProvidersChanged { changed }]
2068                if changed.len() == 1 && changed[0].generation == Some(lease.generation)
2069        ));
2070    }
2071
2072    #[tokio::test]
2073    async fn an_old_failure_finishing_after_an_update_leaves_the_new_schedule_clean() {
2074        let h = Harness::new().await;
2075        h.start().await;
2076        let old_gate = Gate::new();
2077        let new_gate = Gate::new();
2078        let (id, _) = h
2079            .add_stub(|id| {
2080                StubProvider::new(id)
2081                    .gated(&old_gate)
2082                    .failing(DomainError::NetworkError("down".into()))
2083            })
2084            .await;
2085        h.stubs.queue(StubProvider::new(id).gated(&new_gate));
2086        let blocked = h.spawn_fetch(id).await;
2087        old_gate.arrived().await;
2088
2089        h.provider_service
2090            .update_provider(id, updated(&h, id))
2091            .await
2092            .unwrap();
2093        new_gate.arrived().await;
2094        old_gate.open();
2095        let old_result = blocked.await.unwrap();
2096
2097        assert!(matches!(old_result, FetchResult::Superseded));
2098        let view = h.scheduler.view(id, Instant::now()).unwrap();
2099        assert_eq!(
2100            (view.failures, view.last_failure, view.in_flight),
2101            (0, None, true)
2102        );
2103        let fetch = h.repository.get_provider_fetch_status(id).await.unwrap();
2104        assert_eq!((fetch.status.as_str(), fetch.error), ("never", None));
2105        new_gate.open();
2106        h.bus
2107            .wait_for("the new fetch", |events| {
2108                events
2109                    .iter()
2110                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2111            })
2112            .await;
2113        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2114    }
2115
2116    #[tokio::test]
2117    async fn a_removal_during_a_blocked_fetch_leaves_nothing_behind() {
2118        let h = Harness::new().await;
2119        let gate = Gate::new();
2120        let (id, _) = h.add_stub(|id| StubProvider::new(id).gated(&gate)).await;
2121        let blocked = h.spawn_fetch(id).await;
2122        gate.arrived().await;
2123        h.health_inputs();
2124
2125        h.provider_service.remove_provider(id).await.unwrap();
2126        let removed_at = h.bus.events().len();
2127        gate.open();
2128        let result = blocked.await.unwrap();
2129        let inputs = h.health_inputs();
2130
2131        assert!(matches!(result, FetchResult::Superseded));
2132        assert!(h.repository.get_provider(id).await.is_err());
2133        assert!(h.repository.get_provider_fetch_status(id).await.is_err());
2134        assert!(h.cached_names(id).await.is_empty());
2135        assert!(h.scheduler.view(id, Instant::now()).is_none());
2136        assert!(h.bus.names()[removed_at..].is_empty());
2137        assert_eq!(h.bus.names()[removed_at - 1], "provider-removed");
2138        assert!(matches!(
2139            inputs.as_slice(),
2140            [HealthInput::ProvidersChanged { changed }]
2141                if changed[0].id == id && changed[0].generation.is_none()
2142        ));
2143    }
2144
2145    #[tokio::test]
2146    async fn concurrent_acquires_share_one_provider() {
2147        let h = Harness::new().await;
2148        let id = h.add_provider("shared").await;
2149
2150        let leases =
2151            futures::future::join_all((0..8).map(|_| h.provider_service.acquire(id))).await;
2152
2153        let first = leases[0].as_ref().unwrap();
2154        for lease in &leases {
2155            let lease = lease.as_ref().unwrap();
2156            assert!(Arc::ptr_eq(&lease.provider, &first.provider));
2157            assert_eq!(lease.generation, first.generation);
2158        }
2159    }
2160
2161    #[tokio::test]
2162    async fn a_lazy_acquire_racing_a_removal_does_not_bring_the_provider_back() {
2163        let h = Harness::new().await;
2164        let id = h.add_provider("racing").await;
2165        let lock = h.provider_service.commit_lock(id);
2166        let held = lock.lock().await;
2167        let removal = tokio::spawn({
2168            let service = Arc::clone(&h.provider_service);
2169            async move { service.remove_provider(id).await }
2170        });
2171        tokio::time::sleep(Duration::from_millis(100)).await;
2172        let acquire = tokio::spawn({
2173            let service = Arc::clone(&h.provider_service);
2174            async move { service.acquire(id).await }
2175        });
2176        tokio::time::sleep(Duration::from_millis(100)).await;
2177
2178        drop(held);
2179        removal.await.unwrap().unwrap();
2180        let acquired = acquire.await.unwrap();
2181
2182        assert!(matches!(acquired, Err(DomainError::ProviderNotFound(_))));
2183        assert!(h.provider_service.current(id).await.is_none());
2184        assert!(h.scheduler.view(id, Instant::now()).is_none());
2185    }
2186
2187    #[tokio::test]
2188    async fn changing_the_refresh_interval_keeps_the_generation_and_reschedules() {
2189        let h = Harness::new().await;
2190        h.start().await;
2191        let (id, _) = h.add_stub(StubProvider::new).await;
2192        h.pipeline_service
2193            .fetch_now(&[id], Duration::from_secs(5))
2194            .await;
2195        let before = h.provider_service.acquire(id).await.unwrap().generation;
2196        assert!(
2197            h.scheduler
2198                .view(id, Instant::now())
2199                .unwrap()
2200                .next_attempt_in
2201                > Duration::from_secs(20)
2202        );
2203
2204        h.provider_service
2205            .update_provider_refresh_interval(id, 5)
2206            .await
2207            .unwrap();
2208
2209        let after = h.provider_service.acquire(id).await.unwrap().generation;
2210        assert_eq!(before, after);
2211        assert!(
2212            h.scheduler
2213                .view(id, Instant::now())
2214                .unwrap()
2215                .next_attempt_in
2216                <= Duration::from_secs(5)
2217        );
2218        assert_eq!(
2219            h.repository
2220                .get_provider(id)
2221                .await
2222                .unwrap()
2223                .refresh_interval,
2224            5
2225        );
2226    }
2227
2228    #[tokio::test]
2229    async fn health_hears_about_every_provider_change() {
2230        let h = Harness::new().await;
2231
2232        let id = h
2233            .provider_service
2234            .add_provider(h.config("argo", 30))
2235            .await
2236            .unwrap();
2237        let added = h.health_inputs();
2238        let added_generation = h.provider_service.acquire(id).await.unwrap().generation;
2239        h.provider_service
2240            .update_provider(id, updated(&h, id))
2241            .await
2242            .unwrap();
2243        let updated_inputs = h.health_inputs();
2244        h.provider_service.load_all_providers().await.unwrap();
2245        let reloaded = h.health_inputs();
2246        let reloaded_generation = h.provider_service.acquire(id).await.unwrap().generation;
2247        h.provider_service.remove_provider(id).await.unwrap();
2248        let removed = h.health_inputs();
2249
2250        let generations = |inputs: &[HealthInput]| match inputs {
2251            [HealthInput::ProvidersChanged { changed }] => changed
2252                .iter()
2253                .map(|input| (input.id, input.generation))
2254                .collect::<Vec<_>>(),
2255            other => panic!("unexpected health inputs: {other:?}"),
2256        };
2257        assert_eq!(generations(&added), [(id, Some(added_generation))]);
2258        let updated_generation = generations(&updated_inputs)[0].1;
2259        assert!(updated_generation.is_some());
2260        assert_ne!(updated_generation, Some(added_generation));
2261        assert_eq!(generations(&reloaded), [(id, Some(reloaded_generation))]);
2262        assert_ne!(Some(reloaded_generation), updated_generation);
2263        assert_eq!(generations(&removed), [(id, None)]);
2264        assert_eq!(h.bus.count("provider-added"), 1);
2265        assert_eq!(h.bus.count("provider-updated"), 1);
2266        assert_eq!(h.bus.count("provider-removed"), 1);
2267    }
2268
2269    #[tokio::test]
2270    async fn workflow_parameters_are_cached_for_ten_minutes() {
2271        let h = Harness::new().await;
2272        let (id, stub) = h.add_stub(StubProvider::new).await;
2273        let workflow = format!("github__{id}__web");
2274
2275        let first = h
2276            .provider_service
2277            .get_workflow_parameters(id, &workflow)
2278            .await
2279            .unwrap();
2280        h.provider_service
2281            .get_workflow_parameters(id, &workflow)
2282            .await
2283            .unwrap();
2284        let cached_fetches = stub
2285            .parameter_fetches
2286            .load(std::sync::atomic::Ordering::SeqCst);
2287        sqlx::query(
2288            "UPDATE workflow_parameters_cache SET cached_at = datetime('now', '-11 minutes')",
2289        )
2290        .execute(&h.pool)
2291        .await
2292        .unwrap();
2293        h.provider_service
2294            .get_workflow_parameters(id, &workflow)
2295            .await
2296            .unwrap();
2297
2298        assert_eq!(first.len(), 1);
2299        assert_eq!(cached_fetches, 1);
2300        assert_eq!(
2301            stub.parameter_fetches
2302                .load(std::sync::atomic::Ordering::SeqCst),
2303            2
2304        );
2305    }
2306
2307    #[tokio::test]
2308    async fn workflow_parameter_failures_keep_their_kind_and_leave_no_lock_behind() {
2309        let h = Harness::new().await;
2310        let (id, stub) = h.add_stub(StubProvider::new).await;
2311        *stub.parameters.lock().unwrap() = Err(DomainError::RateLimited {
2312            message: "slow".to_string(),
2313            retry_after: None,
2314        });
2315
2316        let failed = h
2317            .provider_service
2318            .get_workflow_parameters(id, "github__1__web")
2319            .await;
2320        let missing = h
2321            .provider_service
2322            .get_workflow_parameters(4242, "github__4242__web")
2323            .await;
2324
2325        assert!(matches!(failed, Err(DomainError::RateLimited { .. })));
2326        assert!(matches!(missing, Err(DomainError::ProviderNotFound(_))));
2327        assert!(h.provider_service.parameter_fetches.is_empty());
2328    }
2329
2330    #[tokio::test]
2331    async fn fetching_workflow_parameters_does_not_block_the_provider_map() {
2332        let h = Harness::new().await;
2333        let gate = Gate::new();
2334        let (id, _) = h
2335            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2336            .await;
2337        let other = h.add_provider("other").await;
2338        let fetch = tokio::spawn({
2339            let service = Arc::clone(&h.provider_service);
2340            async move { service.get_workflow_parameters(id, "github__1__web").await }
2341        });
2342        gate.arrived().await;
2343
2344        let acquired =
2345            tokio::time::timeout(Duration::from_secs(2), h.provider_service.acquire(other)).await;
2346        gate.open();
2347
2348        assert!(acquired.is_ok());
2349        fetch.await.unwrap().unwrap();
2350    }
2351
2352    #[tokio::test]
2353    async fn workflow_parameters_fetched_across_a_removal_are_not_cached() {
2354        let h = Harness::new().await;
2355        let gate = Gate::new();
2356        let (id, _) = h
2357            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2358            .await;
2359        let workflow = format!("github__{id}__web");
2360        let fetch = tokio::spawn({
2361            let service = Arc::clone(&h.provider_service);
2362            let workflow = workflow.clone();
2363            async move { service.get_workflow_parameters(id, &workflow).await }
2364        });
2365        gate.arrived().await;
2366
2367        h.provider_service.remove_provider(id).await.unwrap();
2368        gate.open();
2369
2370        assert_eq!(fetch.await.unwrap().unwrap().len(), 1);
2371        assert!(h
2372            .repository
2373            .get_cached_workflow_parameters(&workflow)
2374            .await
2375            .unwrap()
2376            .is_none());
2377    }
2378
2379    #[tokio::test]
2380    async fn field_options_work_when_the_plugin_rejects_the_partial_form() {
2381        let h = Harness::with_real_plugins().await;
2382        let kubeconfig = h._temp_dir.path().join("kubeconfig");
2383        std::fs::write(
2384            &kubeconfig,
2385            "apiVersion: v1\nkind: Config\nclusters:\n- name: c\n  cluster: {server: 'https://127.0.0.1:6443'}\nusers:\n- name: u\n  user: {token: t}\ncontexts:\n- name: ctx-b\n  context: {cluster: c, user: u}\n- name: ctx-a\n  context: {cluster: c, user: u}\ncurrent-context: ctx-a\n",
2386        )
2387        .unwrap();
2388        let config = HashMap::from([
2389            ("kubeconfig".to_string(), kubeconfig.display().to_string()),
2390            ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2391        ]);
2392        assert!(h
2393            .provider_service
2394            .init_plugin("fluxcd", config.clone(), 0)
2395            .await
2396            .is_err());
2397
2398        let contexts = h
2399            .provider_service
2400            .field_options("fluxcd", "context", config)
2401            .await
2402            .unwrap();
2403
2404        assert_eq!(contexts, ["ctx-a", "ctx-b"]);
2405    }
2406
2407    #[tokio::test]
2408    async fn field_options_that_need_a_client_use_the_initialized_plugin() {
2409        let h = Harness::with_real_plugins().await;
2410        let ready = HashMap::from([
2411            ("server_url".to_string(), h.argocd_url.clone()),
2412            ("token".to_string(), "token".to_string()),
2413        ]);
2414        let incomplete = HashMap::from([("token".to_string(), "token".to_string())]);
2415
2416        let organizations = h
2417            .provider_service
2418            .field_options("argocd", "organizations", ready)
2419            .await;
2420        let rejected = h
2421            .provider_service
2422            .field_options("argocd", "organizations", incomplete)
2423            .await;
2424
2425        assert_eq!(organizations.unwrap(), Vec::<String>::new());
2426        assert!(
2427            matches!(rejected, Err(DomainError::InvalidConfig(message)) if message.contains("initialize"))
2428        );
2429    }
2430
2431    fn serve_tls(stream: std::net::TcpStream, config: Arc<rustls::ServerConfig>) {
2432        let Ok(connection) = rustls::ServerConnection::new(config) else {
2433            return;
2434        };
2435        let mut tls = rustls::StreamOwned::new(connection, stream);
2436        let mut request = Vec::new();
2437        let mut buffer = [0; 4096];
2438        while !request.windows(4).any(|window| window == b"\r\n\r\n") {
2439            match tls.read(&mut buffer) {
2440                Ok(0) | Err(_) => return,
2441                Ok(count) => request.extend_from_slice(&buffer[..count]),
2442            }
2443        }
2444        let body = r#"{"items":[]}"#;
2445        let _ = write!(
2446            tls,
2447            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2448            body.len()
2449        );
2450        let _ = tls.flush();
2451        tls.conn.send_close_notify();
2452        let _ = tls.flush();
2453    }
2454
2455    fn self_signed_argocd() -> String {
2456        let _ = rustls::crypto::ring::default_provider().install_default();
2457        let cert = rustls::pki_types::CertificateDer::from(
2458            include_bytes!("../../../tests/fixtures/self_signed_cert.der").to_vec(),
2459        );
2460        let key = rustls::pki_types::PrivateKeyDer::Pkcs8(
2461            include_bytes!("../../../tests/fixtures/self_signed_key.der")
2462                .to_vec()
2463                .into(),
2464        );
2465        let config = Arc::new(
2466            rustls::ServerConfig::builder()
2467                .with_no_client_auth()
2468                .with_single_cert(vec![cert], key)
2469                .unwrap(),
2470        );
2471        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2472        let addr = listener.local_addr().unwrap();
2473        std::thread::spawn(move || {
2474            for stream in listener.incoming().flatten() {
2475                let config = Arc::clone(&config);
2476                std::thread::spawn(move || serve_tls(stream, config));
2477            }
2478        });
2479
2480        format!("https://{addr}")
2481    }
2482
2483    fn argocd_settings(url: &str, insecure: bool) -> HashMap<String, String> {
2484        HashMap::from([
2485            ("server_url".to_string(), url.to_string()),
2486            ("token".to_string(), "token".to_string()),
2487            ("insecure".to_string(), insecure.to_string()),
2488        ])
2489    }
2490
2491    #[tokio::test]
2492    async fn argocd_insecure_tls_is_honored_by_add_validate_and_preview() {
2493        let h = Harness::with_real_plugins().await;
2494        let service = &h.provider_service;
2495        let url = self_signed_argocd();
2496        let page = Some(pipedash_plugin_api::PaginationParams {
2497            page: 1,
2498            page_size: 10,
2499        });
2500        let add = |insecure| {
2501            let mut config = h.config("argo", 30);
2502            config.config = argocd_settings(&url, insecure);
2503            config.config.remove("token");
2504            service.add_provider(config)
2505        };
2506
2507        let id = add(true).await.unwrap();
2508        let insecure = service
2509            .init_plugin("argocd", argocd_settings(&url, true), 0)
2510            .await
2511            .unwrap();
2512        let secure = service
2513            .init_plugin("argocd", argocd_settings(&url, false), 0)
2514            .await
2515            .unwrap();
2516        let rejected = add(false).await.unwrap_err();
2517
2518        assert!(id > 0);
2519        assert!(insecure.validate_credentials().await.unwrap());
2520        assert!(insecure
2521            .fetch_available_pipelines_filtered(None, None, page.clone())
2522            .await
2523            .unwrap()
2524            .items
2525            .is_empty());
2526        assert!(matches!(
2527            secure.validate_credentials().await,
2528            Err(PluginError::Tls(_))
2529        ));
2530        assert!(matches!(
2531            secure
2532                .fetch_available_pipelines_filtered(None, None, page)
2533                .await,
2534            Err(PluginError::Tls(_))
2535        ));
2536        assert!(matches!(rejected, DomainError::InvalidConfig(message) if message.contains("TLS")));
2537    }
2538
2539    fn config_file(h: &Harness, providers: &[(&str, &str, i64)]) {
2540        let mut content = String::new();
2541        for (key, name, refresh_interval) in providers {
2542            content.push_str(&format!(
2543                "[providers.{key}]\nname = \"{name}\"\ntype = \"argocd\"\ntoken = \"token-{key}\"\nrefresh_interval = {refresh_interval}\n\n[providers.{key}.config]\nserver_url = \"{}\"\n\n",
2544                h.argocd_url
2545            ));
2546        }
2547        std::fs::write(h.config_state.config_path(), content).unwrap();
2548    }
2549
2550    async fn provider_id(h: &Harness, key: &str) -> Option<i64> {
2551        h.repository
2552            .config_backend()
2553            .list_providers()
2554            .await
2555            .unwrap()
2556            .into_iter()
2557            .find(|p| p.name == key)
2558            .and_then(|p| p.id)
2559    }
2560
2561    #[tokio::test]
2562    async fn removal_preserves_the_provider_and_toml_when_token_deletion_fails() {
2563        let h = Harness::new().await;
2564        config_file(&h, &[("ci-a", "CI A", 30)]);
2565        h.provider_service
2566            .apply_config_file(&h.config_state)
2567            .await
2568            .unwrap();
2569        let id = provider_id(&h, "ci-a").await.unwrap();
2570        h.token_store.fail_next_delete();
2571
2572        assert!(h.provider_service.remove_provider(id).await.is_err());
2573        assert!(h.repository.get_provider(id).await.is_ok());
2574        assert!(h
2575            .config_state
2576            .load_providers()
2577            .unwrap()
2578            .contains_key("ci-a"));
2579
2580        h.provider_service.remove_provider(id).await.unwrap();
2581        h.config_state.reload().await.unwrap();
2582        h.config_state
2583            .sync_providers_from_toml(h.repository.config_backend().as_ref(), false)
2584            .await
2585            .unwrap();
2586        assert!(h.repository.list_providers().await.unwrap().is_empty());
2587        assert!(h.token_store.get_all_tokens().await.unwrap().is_empty());
2588    }
2589
2590    #[tokio::test]
2591    async fn removal_awaits_token_cleanup_before_deleting_the_provider() {
2592        let h = Harness::new().await;
2593        let id = h.add_provider("await-delete").await;
2594        let gate = Gate::new();
2595        h.token_store.gate_delete(Arc::clone(&gate));
2596        let remove = tokio::spawn({
2597            let service = Arc::clone(&h.provider_service);
2598            async move { service.remove_provider(id).await }
2599        });
2600        gate.arrived().await;
2601
2602        assert!(!remove.is_finished());
2603        assert!(h.repository.get_provider(id).await.is_ok());
2604
2605        gate.open();
2606        remove.await.unwrap().unwrap();
2607        assert!(h.repository.get_provider(id).await.is_err());
2608        assert!(h.token_store.get_all_tokens().await.unwrap().is_empty());
2609    }
2610
2611    #[tokio::test]
2612    async fn failed_config_deletion_keeps_the_provider_available_for_retry() {
2613        let h = Harness::new().await;
2614        config_file(&h, &[("ci-a", "CI A", 30)]);
2615        h.provider_service
2616            .apply_config_file(&h.config_state)
2617            .await
2618            .unwrap();
2619        let id = provider_id(&h, "ci-a").await.unwrap();
2620        let path = h.config_state.config_path();
2621        std::fs::remove_file(path).unwrap();
2622        std::fs::create_dir(path).unwrap();
2623
2624        assert!(h.provider_service.remove_provider(id).await.is_err());
2625        assert!(h.repository.get_provider(id).await.is_ok());
2626
2627        std::fs::remove_dir(path).unwrap();
2628        config_file(&h, &[("ci-a", "CI A", 30)]);
2629        h.provider_service.remove_provider(id).await.unwrap();
2630        assert!(h.repository.get_provider(id).await.is_err());
2631        assert!(h.config_state.load_providers().unwrap().is_empty());
2632    }
2633
2634    #[tokio::test]
2635    async fn renaming_a_toml_provider_keeps_the_file_key_for_removal() {
2636        let h = Harness::new().await;
2637        config_file(&h, &[("ci-a", "CI A", 30)]);
2638        h.provider_service
2639            .apply_config_file(&h.config_state)
2640            .await
2641            .unwrap();
2642        let id = provider_id(&h, "ci-a").await.unwrap();
2643        let mut renamed = h.repository.get_provider(id).await.unwrap();
2644        renamed.name = "Renamed CI".to_string();
2645
2646        h.provider_service
2647            .update_provider(id, renamed)
2648            .await
2649            .unwrap();
2650        let saved = h.repository.get_provider(id).await.unwrap();
2651        assert_eq!(
2652            (saved.name.as_str(), saved.display_name()),
2653            ("ci-a", "Renamed CI")
2654        );
2655
2656        h.provider_service.remove_provider(id).await.unwrap();
2657        h.config_state.reload().await.unwrap();
2658        h.config_state
2659            .sync_providers_from_toml(h.repository.config_backend().as_ref(), false)
2660            .await
2661            .unwrap();
2662        assert!(h.repository.list_providers().await.unwrap().is_empty());
2663    }
2664
2665    #[tokio::test]
2666    async fn test_apply_config_file_adds_and_updates_changed_blocks_only() {
2667        let h = Harness::new().await;
2668        config_file(&h, &[("ci-a", "CI A", 30), ("ci-b", "CI B", 30)]);
2669
2670        let first = h
2671            .provider_service
2672            .apply_config_file(&h.config_state)
2673            .await
2674            .unwrap();
2675
2676        assert_eq!(first.added, ["ci-a", "ci-b"]);
2677        let a = provider_id(&h, "ci-a").await.unwrap();
2678        let b = provider_id(&h, "ci-b").await.unwrap();
2679        let loaded = h.provider_service.generation_of(a).await.unwrap();
2680        assert!(h.provider_service.generation_of(b).await.is_some());
2681        assert!(h
2682            .bus
2683            .events()
2684            .iter()
2685            .any(|e| matches!(e, CoreEvent::ProvidersChanged)));
2686
2687        h.provider_service
2688            .update_provider_refresh_interval(b, 120)
2689            .await
2690            .unwrap();
2691        config_file(
2692            &h,
2693            &[
2694                ("ci-a", "CI Renamed", 30),
2695                ("ci-b", "CI B", 30),
2696                ("ci-c", "CI C", 60),
2697            ],
2698        );
2699
2700        let second = h
2701            .provider_service
2702            .apply_config_file(&h.config_state)
2703            .await
2704            .unwrap();
2705
2706        assert_eq!(second.added, ["ci-c"]);
2707        assert_eq!(second.updated, ["ci-a"]);
2708        let c = provider_id(&h, "ci-c").await.unwrap();
2709        assert_eq!(
2710            h.repository.get_provider(c).await.unwrap().token,
2711            "token-ci-c"
2712        );
2713        assert!(h.provider_service.generation_of(c).await.is_some());
2714        assert_eq!(
2715            h.repository.get_provider(a).await.unwrap().display_name(),
2716            "CI Renamed"
2717        );
2718        assert_ne!(h.provider_service.generation_of(a).await, Some(loaded));
2719        assert_eq!(
2720            h.repository.get_provider(b).await.unwrap().refresh_interval,
2721            120
2722        );
2723
2724        let unchanged = h
2725            .provider_service
2726            .apply_config_file(&h.config_state)
2727            .await
2728            .unwrap();
2729        assert!(unchanged.added.is_empty() && unchanged.updated.is_empty());
2730    }
2731
2732    #[tokio::test]
2733    async fn test_apply_config_file_rejects_an_invalid_file_and_keeps_providers() {
2734        let h = Harness::new().await;
2735        config_file(&h, &[("ci-a", "CI A", 30)]);
2736        h.provider_service
2737            .apply_config_file(&h.config_state)
2738            .await
2739            .unwrap();
2740        let a = provider_id(&h, "ci-a").await.unwrap();
2741
2742        let write = |content: &str| std::fs::write(h.config_state.config_path(), content).unwrap();
2743
2744        write("[providers.ci-a]\nname = \"CI Broken\"\ntype = \"\"\n");
2745        let empty = h.provider_service.apply_config_file(&h.config_state).await;
2746        write("[providers.ci-a]\nname = \"CI Broken\"\ntype = \"circleci\"\n");
2747        let unknown = h.provider_service.apply_config_file(&h.config_state).await;
2748
2749        assert!(matches!(empty, Err(DomainError::InvalidConfig(_))));
2750        assert!(matches!(unknown, Err(DomainError::InvalidProviderType(_))));
2751
2752        assert_eq!(
2753            h.repository.get_provider(a).await.unwrap().display_name(),
2754            "CI A"
2755        );
2756        assert_eq!(
2757            h.config_state.get().await.providers["ci-a"].provider_type,
2758            "argocd"
2759        );
2760    }
2761
2762    async fn execute(h: &Harness, sql: &'static str) {
2763        sqlx::query(sql).execute(&h.pool).await.unwrap();
2764    }
2765
2766    #[tokio::test]
2767    async fn test_apply_config_file_retry_replaces_providers_saved_by_a_failed_apply() {
2768        let h = Harness::new().await;
2769        config_file(&h, &[("ci-a", "CI A", 30), ("ci-b", "CI B", 30)]);
2770        h.provider_service
2771            .apply_config_file(&h.config_state)
2772            .await
2773            .unwrap();
2774        let a = provider_id(&h, "ci-a").await.unwrap();
2775        let b = provider_id(&h, "ci-b").await.unwrap();
2776        let loaded_a = h.provider_service.generation_of(a).await.unwrap();
2777        let loaded_b = h.provider_service.generation_of(b).await.unwrap();
2778
2779        execute(
2780            &h,
2781            "CREATE TRIGGER fail_ci_b BEFORE UPDATE ON providers WHEN NEW.name = 'ci-b' \
2782             BEGIN SELECT RAISE(ABORT, 'write failed'); END",
2783        )
2784        .await;
2785        config_file(&h, &[("ci-a", "CI A2", 30), ("ci-b", "CI B2", 30)]);
2786        assert!(h
2787            .provider_service
2788            .apply_config_file(&h.config_state)
2789            .await
2790            .is_err());
2791        assert_eq!(
2792            h.repository.get_provider(a).await.unwrap().display_name(),
2793            "CI A2"
2794        );
2795
2796        execute(&h, "DROP TRIGGER fail_ci_b").await;
2797        h.provider_service
2798            .apply_config_file(&h.config_state)
2799            .await
2800            .unwrap();
2801
2802        assert_eq!(
2803            h.repository.get_provider(b).await.unwrap().display_name(),
2804            "CI B2"
2805        );
2806        assert_ne!(h.provider_service.generation_of(a).await, Some(loaded_a));
2807        assert_ne!(h.provider_service.generation_of(b).await, Some(loaded_b));
2808        assert_eq!(
2809            h.config_state.get().await.providers["ci-b"].name.as_deref(),
2810            Some("CI B2")
2811        );
2812    }
2813
2814    #[tokio::test]
2815    async fn test_apply_config_file_stores_an_empty_token_for_a_new_provider_without_one() {
2816        let h = Harness::new().await;
2817        config_file(&h, &[("ci-a", "CI A", 30)]);
2818        let path = h.config_state.config_path();
2819        let unset = std::fs::read_to_string(path)
2820            .unwrap()
2821            .replace("token-ci-a", "${PIPEDASH_TOKEN_UNSET_IN_TESTS:-}");
2822        std::fs::write(path, unset).unwrap();
2823
2824        h.provider_service
2825            .apply_config_file(&h.config_state)
2826            .await
2827            .unwrap();
2828
2829        let a = provider_id(&h, "ci-a").await.unwrap();
2830        assert_eq!(h.token_store.get_token(a).await.unwrap(), "");
2831    }
2832
2833    #[tokio::test]
2834    async fn test_apply_config_file_retries_a_token_the_store_failed_to_save() {
2835        let h = Harness::new().await;
2836        config_file(&h, &[("ci-a", "CI A", 30)]);
2837        h.provider_service
2838            .apply_config_file(&h.config_state)
2839            .await
2840            .unwrap();
2841        let a = provider_id(&h, "ci-a").await.unwrap();
2842        let path = h.config_state.config_path();
2843        let rotated = std::fs::read_to_string(path)
2844            .unwrap()
2845            .replace("token-ci-a", "rotated");
2846        std::fs::write(path, rotated).unwrap();
2847
2848        h.token_store.fail_next_store();
2849        assert!(h
2850            .provider_service
2851            .apply_config_file(&h.config_state)
2852            .await
2853            .is_err());
2854        assert_eq!(h.stubs.token_of(a).as_deref(), Some("token-ci-a"));
2855
2856        h.provider_service
2857            .apply_config_file(&h.config_state)
2858            .await
2859            .unwrap();
2860
2861        assert_eq!(h.stubs.token_of(a).as_deref(), Some("rotated"));
2862        assert_eq!(
2863            h.config_state.get().await.providers["ci-a"].token,
2864            "rotated"
2865        );
2866    }
2867
2868    #[tokio::test]
2869    async fn test_config_watcher_retries_a_failed_apply_without_a_new_edit() {
2870        let h = Harness::new().await;
2871        let watcher = crate::application::ConfigWatcher::new(
2872            Arc::clone(&h.provider_service),
2873            Arc::clone(&h.config_state),
2874        )
2875        .with_poll(Duration::from_millis(20));
2876        watcher.start().await;
2877
2878        execute(
2879            &h,
2880            "CREATE TRIGGER fail_insert BEFORE INSERT ON providers \
2881             BEGIN SELECT RAISE(ABORT, 'write failed'); END",
2882        )
2883        .await;
2884        config_file(&h, &[("ci-a", "CI A", 30)]);
2885        tokio::time::sleep(Duration::from_millis(200)).await;
2886        assert_eq!(provider_id(&h, "ci-a").await, None);
2887        execute(&h, "DROP TRIGGER fail_insert").await;
2888
2889        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2890        let id = loop {
2891            if let Some(id) = provider_id(&h, "ci-a").await {
2892                break id;
2893            }
2894            assert!(
2895                std::time::Instant::now() < deadline,
2896                "the watcher never retried the file"
2897            );
2898            tokio::time::sleep(Duration::from_millis(20)).await;
2899        };
2900        watcher.stop().await;
2901
2902        assert!(h.provider_service.generation_of(id).await.is_some());
2903    }
2904
2905    fn sign_in(token_endpoint: String, expires_in: chrono::Duration) -> SignInCredential {
2906        SignInCredential {
2907            token: "bearer".into(),
2908            refresh_token: Some("r1".into()),
2909            expires_at: Some(Utc::now() + expires_in),
2910            token_endpoint,
2911            client_id: "cli".into(),
2912            kind: pipedash_plugin_api::SignInToken::AccessToken,
2913            account: Some("dev@example.com".into()),
2914        }
2915    }
2916
2917    async fn stored_sign_in(h: &Harness, id: i64) -> SignInCredential {
2918        SignInCredential::decode(&h.repository.get_provider(id).await.unwrap().token).unwrap()
2919    }
2920
2921    #[tokio::test]
2922    async fn renew_is_scheduled_a_minute_before_the_sign_in_expires() {
2923        let h = Harness::with_real_plugins().await;
2924        let id = h.add_provider("sso").await;
2925        let credential = sign_in(
2926            "http://127.0.0.1:9/token".into(),
2927            chrono::Duration::minutes(30),
2928        );
2929        h.repository
2930            .store_token(id, &credential.encode().unwrap())
2931            .await
2932            .unwrap();
2933
2934        let lease = h.provider_service.acquire(id).await.unwrap();
2935
2936        assert_eq!(
2937            lease.provider.renew_at(),
2938            Some(credential.expires_at.unwrap() - chrono::Duration::seconds(60))
2939        );
2940    }
2941
2942    #[tokio::test]
2943    async fn renew_refreshes_and_stores_an_expired_sign_in() {
2944        let h = Harness::new().await;
2945        let idp = Idp::start().await;
2946        idp.reply(
2947            200,
2948            serde_json::json!({"access_token": "new", "refresh_token": "r2", "expires_in": 3600}),
2949        );
2950        let (id, _) = h.add_stub(StubProvider::new).await;
2951        let expired = sign_in(idp.token_endpoint(), chrono::Duration::minutes(-1));
2952        h.repository
2953            .store_token(id, &expired.encode().unwrap())
2954            .await
2955            .unwrap();
2956
2957        h.provider_service.acquire(id).await.unwrap();
2958
2959        let renewed = stored_sign_in(&h, id).await;
2960        assert_eq!(renewed.token, "new");
2961        assert_eq!(renewed.refresh_token.as_deref(), Some("r2"));
2962        assert_eq!(renewed.account.as_deref(), Some("dev@example.com"));
2963    }
2964
2965    #[tokio::test]
2966    async fn renew_with_a_revoked_sign_in_asks_to_sign_in_again() {
2967        let h = Harness::new().await;
2968        let idp = Idp::start().await;
2969        idp.reply(400, serde_json::json!({"error": "invalid_grant"}));
2970        let (id, _) = h.add_stub(StubProvider::new).await;
2971        let expired = sign_in(idp.token_endpoint(), chrono::Duration::minutes(-1));
2972        h.repository
2973            .store_token(id, &expired.encode().unwrap())
2974            .await
2975            .unwrap();
2976
2977        let error = h.provider_service.acquire(id).await.err().unwrap();
2978
2979        assert!(
2980            matches!(error, DomainError::ProviderAuthenticationFailed(m) if m == crate::domain::SIGN_IN_EXPIRED)
2981        );
2982    }
2983
2984    #[tokio::test]
2985    async fn editing_a_signed_in_provider_keeps_its_session() {
2986        let h = Harness::with_real_plugins().await;
2987        let id = h.add_provider("sso").await;
2988        let credential = sign_in(
2989            "http://127.0.0.1:9/token".into(),
2990            chrono::Duration::minutes(30),
2991        );
2992        h.repository
2993            .store_token(id, &credential.encode().unwrap())
2994            .await
2995            .unwrap();
2996
2997        let editing = h.provider_service.provider_for_editing(id).await.unwrap();
2998        assert_eq!(editing.token, "");
2999        assert_eq!(
3000            editing.config.get(SIGN_IN_CONFIG_KEY),
3001            Some(&format!("provider:{id}"))
3002        );
3003
3004        h.provider_service
3005            .update_provider(id, editing)
3006            .await
3007            .unwrap();
3008
3009        assert_eq!(stored_sign_in(&h, id).await, credential);
3010        let other = h.add_provider("other").await;
3011        let mut stolen = h
3012            .provider_service
3013            .provider_for_editing(other)
3014            .await
3015            .unwrap();
3016        stolen
3017            .config
3018            .insert(SIGN_IN_CONFIG_KEY.into(), format!("provider:{id}"));
3019        assert!(matches!(
3020            h.provider_service.update_provider(other, stolen).await,
3021            Err(DomainError::InvalidConfig(_))
3022        ));
3023    }
3024
3025    #[tokio::test]
3026    async fn editing_during_a_renewal_keeps_the_renewed_session() {
3027        let h = Harness::with_real_plugins().await;
3028        let argocd = FeedStub::start().await;
3029        argocd.respond_json(serde_json::json!({ "items": [] }));
3030        let original = sign_in(
3031            "http://127.0.0.1:9/token".into(),
3032            chrono::Duration::minutes(30),
3033        );
3034        let mut config = h.config("sso", 30);
3035        config
3036            .config
3037            .insert("server_url".into(), argocd.base_url.clone());
3038        config.token = original.encode().unwrap();
3039        let id = h.repository.add_provider(&config).await.unwrap();
3040        let editing = h.provider_service.provider_for_editing(id).await.unwrap();
3041
3042        let checks = Gate::new();
3043        argocd.set_gate(Some(&checks));
3044        let edit = tokio::spawn({
3045            let service = Arc::clone(&h.provider_service);
3046            async move { service.update_provider(id, editing).await }
3047        });
3048        checks.arrived().await;
3049        let renewed = SignInCredential {
3050            token: "renewed".into(),
3051            refresh_token: Some("r2".into()),
3052            expires_at: Some(Utc::now() + chrono::Duration::minutes(45)),
3053            ..original.clone()
3054        };
3055        h.repository
3056            .store_token(id, &renewed.encode().unwrap())
3057            .await
3058            .unwrap();
3059        checks.open();
3060        edit.await.unwrap().unwrap();
3061
3062        assert_eq!(stored_sign_in(&h, id).await, renewed);
3063        assert_eq!(
3064            h.provider_service
3065                .acquire(id)
3066                .await
3067                .unwrap()
3068                .provider
3069                .renew_at(),
3070            renewed.renew_at()
3071        );
3072    }
3073}