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;
90mod discovery;
91
92const QUOTA_PAUSE_PERCENT: u64 = 10;
93const ACQUIRE_ATTEMPTS: usize = 3;
94
95pub(crate) type ProviderFactory =
96    Arc<dyn Fn(&ProviderConfig) -> DomainResult<Arc<dyn Provider>> + Send + Sync>;
97
98#[derive(Clone)]
99pub struct ProviderLease {
100    pub id: i64,
101    pub generation: Generation,
102    pub provider: Arc<dyn Provider>,
103}
104
105enum TakenSignIn {
106    None,
107    Flow(String),
108    Kept,
109}
110
111struct ProviderSlot {
112    generation: Generation,
113    provider: Arc<dyn Provider>,
114}
115
116impl ProviderSlot {
117    fn lease(&self, id: i64) -> ProviderLease {
118        ProviderLease {
119            id,
120            generation: self.generation,
121            provider: Arc::clone(&self.provider),
122        }
123    }
124}
125
126struct ParameterFetch<'a> {
127    fetches: &'a DashMap<String, Arc<Mutex<()>>>,
128    key: &'a str,
129    lock: Arc<Mutex<()>>,
130}
131
132impl<'a> ParameterFetch<'a> {
133    fn new(fetches: &'a DashMap<String, Arc<Mutex<()>>>, key: &'a str) -> Self {
134        let lock = Arc::clone(fetches.entry(key.to_string()).or_default().value());
135
136        Self { fetches, key, lock }
137    }
138}
139
140impl Drop for ParameterFetch<'_> {
141    fn drop(&mut self) {
142        self.fetches
143            .remove_if(self.key, |_, lock| Arc::ptr_eq(lock, &self.lock));
144    }
145}
146
147pub struct ProviderService {
148    repository: Arc<Repository>,
149    config_state: Arc<ConfigState>,
150    http_client_manager: Arc<HttpClientManager>,
151    providers: RwLock<HashMap<i64, ProviderSlot>>,
152    commit_locks: DashMap<i64, Arc<Mutex<()>>>,
153    renewal_locks: DashMap<i64, Arc<Mutex<()>>>,
154    sign_ins: Arc<SignInService>,
155    plugin_registry: Arc<PluginRegistry>,
156    parameter_fetches: DashMap<String, Arc<Mutex<()>>>,
157    discovery: discovery::DiscoveryCache,
158    event_bus: Arc<dyn EventBus>,
159    scheduler: Arc<FetchScheduler>,
160    health: HealthHandle,
161    dispatcher: RefreshDispatcher,
162    provider_factory: ProviderFactory,
163    config_apply: Mutex<()>,
164    log_store: Arc<LogStore>,
165}
166
167impl ProviderService {
168    #[allow(clippy::too_many_arguments)]
169    pub fn new(
170        repository: Arc<Repository>, http_client_manager: Arc<HttpClientManager>,
171        event_bus: Arc<dyn EventBus>, scheduler: Arc<FetchScheduler>, health: HealthHandle,
172        dispatcher: RefreshDispatcher, config_state: Arc<ConfigState>, log_store: Arc<LogStore>,
173    ) -> Self {
174        let plugin_registry = Arc::new(plugins::create_plugin_registry());
175        let provider_factory: ProviderFactory = {
176            let registry = Arc::clone(&plugin_registry);
177            let http = Arc::clone(&http_client_manager);
178            Arc::new(move |config| plugin_provider(&registry, &http, config))
179        };
180
181        Self {
182            repository,
183            config_state,
184            http_client_manager,
185            providers: RwLock::new(HashMap::new()),
186            commit_locks: DashMap::new(),
187            renewal_locks: DashMap::new(),
188            sign_ins: Arc::new(SignInService::default()),
189            plugin_registry,
190            parameter_fetches: DashMap::new(),
191            discovery: discovery::DiscoveryCache::default(),
192            event_bus,
193            scheduler,
194            health,
195            dispatcher,
196            provider_factory,
197            config_apply: Mutex::new(()),
198            log_store,
199        }
200    }
201
202    #[cfg(test)]
203    pub(crate) fn with_provider_factory(mut self, factory: ProviderFactory) -> Self {
204        self.provider_factory = factory;
205        self
206    }
207
208    pub fn repository(&self) -> &Arc<Repository> {
209        &self.repository
210    }
211
212    pub(crate) fn scheduler(&self) -> &Arc<FetchScheduler> {
213        &self.scheduler
214    }
215
216    pub(crate) fn health(&self) -> &HealthHandle {
217        &self.health
218    }
219
220    pub(crate) fn dispatcher(&self) -> &RefreshDispatcher {
221        &self.dispatcher
222    }
223
224    pub fn list_available_plugins(&self) -> Vec<pipedash_plugin_api::PluginMetadata> {
225        let mut metadata_list = Vec::new();
226        for provider_type in self.plugin_registry.provider_types() {
227            if let Some(plugin) = self.plugin_registry.get(&provider_type) {
228                metadata_list.push(plugin.metadata().clone());
229            }
230        }
231        metadata_list
232    }
233
234    pub fn retains_run_history(&self, provider_type: &str) -> bool {
235        self.plugin_registry
236            .get(provider_type)
237            .is_some_and(|plugin| plugin.metadata().capabilities.retains_run_history)
238    }
239
240    pub async fn init_plugin(
241        &self, provider_type: &str, mut config: HashMap<String, String>, provider_id: i64,
242    ) -> DomainResult<Box<dyn PluginTrait>> {
243        self.resolve_sign_in(&mut config).await?;
244        init_plugin(
245            &self.http_client_manager,
246            provider_type,
247            config,
248            provider_id,
249        )
250    }
251
252    pub async fn field_options(
253        &self, provider_type: &str, field_key: &str, mut config: HashMap<String, String>,
254    ) -> DomainResult<Vec<String>> {
255        self.resolve_sign_in(&mut config).await?;
256        let (mut plugin, client) = build_plugin(&self.http_client_manager, provider_type, &config)?;
257        let init_error = plugin.initialize(0, config.clone(), Some(client)).err();
258
259        match plugin.get_field_options(field_key, &config).await {
260            Ok(options) => Ok(options),
261            Err(e) => Err(match init_error {
262                Some(init) => {
263                    DomainError::InvalidConfig(format!("Failed to initialize plugin: {init}"))
264                }
265                None => PluginAdapter::map_error(e),
266            }),
267        }
268    }
269
270    pub(crate) fn commit_lock(&self, id: i64) -> Arc<Mutex<()>> {
271        Arc::clone(self.commit_locks.entry(id).or_default().value())
272    }
273
274    fn renewal_lock(&self, id: i64) -> Arc<Mutex<()>> {
275        Arc::clone(self.renewal_locks.entry(id).or_default().value())
276    }
277
278    async fn resolve_sign_in(&self, config: &mut HashMap<String, String>) -> DomainResult<()> {
279        let Some(reference) = config.remove(SIGN_IN_CONFIG_KEY) else {
280            return Ok(());
281        };
282        let token = match SignInRef::parse(&reference) {
283            SignInRef::Flow(flow) => self.sign_ins.credential(&flow).await?.token,
284            SignInRef::Provider(id) => {
285                SignInCredential::bearer(&self.renewed_config(id).await?.token)
286            }
287        };
288        config.insert("token".to_string(), token);
289        Ok(())
290    }
291
292    async fn take_sign_in(
293        &self, config: &mut ProviderConfig, owner: Option<i64>,
294    ) -> DomainResult<TakenSignIn> {
295        let Some(reference) = config.config.remove(SIGN_IN_CONFIG_KEY) else {
296            return Ok(TakenSignIn::None);
297        };
298        match SignInRef::parse(&reference) {
299            SignInRef::Flow(flow) => {
300                config.token = self.sign_ins.credential(&flow).await?.encode()?;
301                Ok(TakenSignIn::Flow(flow))
302            }
303            SignInRef::Provider(id) if Some(id) == owner => {
304                config.token = self.renewed_config(id).await?.token;
305                Ok(TakenSignIn::Kept)
306            }
307            SignInRef::Provider(_) => Err(DomainError::InvalidConfig(
308                "This sign-in belongs to another provider".into(),
309            )),
310        }
311    }
312
313    async fn renewed_config(&self, id: i64) -> DomainResult<ProviderConfig> {
314        let renewal = self.renewal_lock(id);
315        let _renewal = renewal.lock().await;
316        let config = self.repository.get_provider(id).await?;
317        let Some(current) = SignInCredential::decode(&config.token)
318            .filter(|credential| credential.renew_at().is_some_and(|at| at <= Utc::now()))
319        else {
320            return Ok(config);
321        };
322
323        let (_, http) = build_plugin(
324            &self.http_client_manager,
325            &config.provider_type,
326            &config.config,
327        )?;
328        let token = oauth::refresh(&http, &current).await?.encode()?;
329
330        let commit = self.commit_lock(id);
331        let _commit = commit.lock().await;
332        let mut latest = self.repository.get_provider(id).await?;
333        if latest.token != config.token {
334            return Ok(latest);
335        }
336        self.repository.store_token(id, &token).await?;
337        tracing::info!(provider_id = id, "Renewed the provider sign-in");
338        latest.token = token;
339        Ok(latest)
340    }
341
342    async fn renew(&self, stale: ProviderLease) -> DomainResult<ProviderLease> {
343        let config = self.renewed_config(stale.id).await?;
344        let provider = (self.provider_factory)(&config)?;
345        let mut providers = self.providers.write().await;
346        let slot = providers
347            .get_mut(&stale.id)
348            .ok_or_else(|| DomainError::ProviderNotFound(stale.id.to_string()))?;
349        if slot.generation == stale.generation {
350            slot.provider = provider;
351        }
352        Ok(slot.lease(stale.id))
353    }
354
355    pub async fn provider_for_editing(&self, id: i64) -> DomainResult<ProviderConfig> {
356        let mut config = self.repository.get_provider(id).await?;
357        if SignInCredential::decode(&config.token).is_some() {
358            config.token.clear();
359            config.config.insert(
360                SIGN_IN_CONFIG_KEY.to_string(),
361                SignInRef::Provider(id).to_string(),
362            );
363        }
364        Ok(config)
365    }
366
367    pub async fn stored_plugin(&self, id: i64) -> DomainResult<Box<dyn PluginTrait>> {
368        let config = self.renewed_config(id).await?;
369        init_plugin(
370            &self.http_client_manager,
371            &config.provider_type,
372            config.plugin_config(),
373            id,
374        )
375    }
376
377    pub async fn start_sign_in(
378        &self, provider_type: &str, config: HashMap<String, String>,
379    ) -> DomainResult<SignInStart> {
380        let (plugin, http) = build_plugin(&self.http_client_manager, provider_type, &config)?;
381        let client = plugin
382            .sign_in_client(&config, &http)
383            .await
384            .map_err(PluginAdapter::map_error)?;
385        self.sign_ins.start(client, http).await
386    }
387
388    pub async fn sign_in_status(&self, reference: &str) -> DomainResult<SignInStatus> {
389        match SignInRef::parse(reference) {
390            SignInRef::Flow(flow) => Ok(self.sign_ins.status(&flow)),
391            SignInRef::Provider(id) => match self.renewed_config(id).await {
392                Ok(config) => Ok(SignInCredential::decode(&config.token).map_or(
393                    SignInStatus::Expired,
394                    |credential| SignInStatus::Completed {
395                        account: credential.account,
396                    },
397                )),
398                Err(DomainError::ProviderAuthenticationFailed(message)) => {
399                    Ok(SignInStatus::Failed { message })
400                }
401                Err(e) => Err(e),
402            },
403        }
404    }
405
406    pub async fn complete_sign_in(
407        &self, flow: &str, redirect_url: &str,
408    ) -> DomainResult<SignInStatus> {
409        self.sign_ins.complete(flow, redirect_url).await
410    }
411
412    pub async fn cancel_sign_in(&self, flow: &str) {
413        self.sign_ins.cancel(flow).await;
414    }
415
416    pub async fn wait_for_sign_in(&self, flow: &str) -> SignInStatus {
417        self.sign_ins.wait(flow).await
418    }
419
420    async fn purge_run_logs(&self, id: i64) {
421        if let Err(e) = self.log_store.purge_provider(id).await {
422            tracing::warn!(provider_id = id, error = %e, "Failed to purge cached run logs");
423        }
424    }
425
426    async fn current(&self, id: i64) -> Option<ProviderLease> {
427        self.providers
428            .read()
429            .await
430            .get(&id)
431            .map(|slot| slot.lease(id))
432    }
433
434    pub(crate) async fn generation_of(&self, id: i64) -> Option<Generation> {
435        self.providers
436            .read()
437            .await
438            .get(&id)
439            .map(|slot| slot.generation)
440    }
441
442    pub(crate) async fn health_inputs(&self) -> Vec<ProviderHealthInput> {
443        let leases: Vec<ProviderLease> = self
444            .providers
445            .read()
446            .await
447            .iter()
448            .map(|(id, slot)| slot.lease(*id))
449            .collect();
450        let mut inputs = Vec::with_capacity(leases.len());
451        for lease in leases {
452            match self.repository.get_provider(lease.id).await {
453                Ok(config) => inputs.push(self.health_input(&config, &lease)),
454                Err(e) => {
455                    tracing::debug!(provider_id = lease.id, error = %e, "Provider has no config row");
456                }
457            }
458        }
459
460        inputs
461    }
462
463    async fn install(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
464        let mut providers = self.providers.write().await;
465        providers
466            .entry(id)
467            .or_insert_with(|| {
468                let generation = Generation::next();
469                self.scheduler
470                    .register(id, generation, interval_of(interval));
471                ProviderSlot {
472                    generation,
473                    provider,
474                }
475            })
476            .lease(id)
477    }
478
479    async fn replace(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
480        let generation = Generation::next();
481        let mut providers = self.providers.write().await;
482        providers.insert(
483            id,
484            ProviderSlot {
485                generation,
486                provider: Arc::clone(&provider),
487            },
488        );
489        self.scheduler.reset(id, generation, interval_of(interval));
490
491        ProviderLease {
492            id,
493            generation,
494            provider,
495        }
496    }
497
498    pub async fn acquire(&self, id: i64) -> DomainResult<ProviderLease> {
499        if let Some(lease) = self.current(id).await {
500            if lease.provider.renew_at().is_some_and(|at| at <= Utc::now()) {
501                return self.renew(lease).await;
502            }
503            return Ok(lease);
504        }
505
506        for _ in 0..ACQUIRE_ATTEMPTS {
507            let config = self.renewed_config(id).await?;
508            let provider = (self.provider_factory)(&config)?;
509            let lock = self.commit_lock(id);
510            let _guard = lock.lock().await;
511            if let Some(lease) = self.current(id).await {
512                return Ok(lease);
513            }
514            let row = self.repository.get_provider(id).await?;
515            if row.version == config.version {
516                let lease = self.install(id, provider, row.refresh_interval).await;
517                self.health.send(HealthInput::ProvidersChanged {
518                    changed: vec![self.health_input(&row, &lease)],
519                });
520                return Ok(lease);
521            }
522        }
523
524        Err(DomainError::ConcurrentModification(format!(
525            "Provider {id} kept changing while it was loaded"
526        )))
527    }
528
529    pub async fn commit<F, Fut, T>(&self, lease: &ProviderLease, f: F) -> Option<T>
530    where
531        F: FnOnce() -> Fut,
532        Fut: Future<Output = T>,
533    {
534        self.commit_at(lease.id, lease.generation, f).await
535    }
536
537    async fn commit_at<F, Fut, T>(&self, id: i64, generation: Generation, f: F) -> Option<T>
538    where
539        F: FnOnce() -> Fut,
540        Fut: Future<Output = T>,
541    {
542        let lock = self.commit_lock(id);
543        let _guard = lock.lock().await;
544        let current = self
545            .providers
546            .read()
547            .await
548            .get(&id)
549            .map(|slot| slot.generation);
550        if current != Some(generation) {
551            return None;
552        }
553
554        Some(f().await)
555    }
556
557    pub async fn publish_summary(&self, id: i64, generation: Generation) {
558        self.commit_at(id, generation, || self.emit_status(id))
559            .await;
560    }
561
562    pub(crate) async fn emit_status(&self, id: i64) {
563        match self.summary_of(id).await {
564            Ok(provider) => {
565                self.event_bus
566                    .emit(CoreEvent::ProviderStatusUpdated { provider })
567                    .await;
568            }
569            Err(e) => {
570                tracing::warn!(provider_id = id, error = %e, "Failed to build provider summary");
571            }
572        }
573    }
574
575    async fn summary_of(&self, id: i64) -> DomainResult<ProviderSummary> {
576        let config = self.repository.get_provider(id).await?;
577
578        self.build_summary(id, &config).await
579    }
580
581    async fn build_summary(
582        &self, id: i64, config: &ProviderConfig,
583    ) -> DomainResult<ProviderSummary> {
584        let cached_pipelines = self.repository.get_cached_pipelines(Some(id)).await?;
585        let fetch = match self.repository.get_provider_fetch_status(id).await {
586            Ok(fetch) => fetch,
587            Err(e) => {
588                tracing::warn!(provider_id = id, error = %e, "Failed to read provider fetch status");
589                ProviderFetchStatus {
590                    status: FETCH_STATUS_NEVER.to_string(),
591                    error: None,
592                    fetched_at: None,
593                    succeeded_at: None,
594                }
595            }
596        };
597        let last_fetch_status = match fetch.status.as_str() {
598            FETCH_STATUS_SUCCESS => FetchStatus::Success,
599            FETCH_STATUS_ERROR => FetchStatus::Error,
600            _ => FetchStatus::Never,
601        };
602        let now = Instant::now();
603        let wall = Utc::now();
604        let view = self.scheduler.view(id, now);
605        let generation = self
606            .providers
607            .read()
608            .await
609            .get(&id)
610            .map(|slot| slot.generation);
611        let snapshot = self.health.snapshot();
612        let state = snapshot
613            .providers
614            .get(&id)
615            .filter(|state| Some(state.generation) == generation);
616        let paused_until = self.scheduler.paused_until(id, wall);
617        let level = state.map_or(HealthLevel::Operational, |state| state.level);
618        let freshness = ProviderFreshness::compute(
619            &last_fetch_status,
620            level,
621            fetch.fetched_at,
622            paused_until,
623            config.refresh_interval,
624            wall,
625        );
626
627        let last_fetch_error_kind = view.as_ref().and_then(|view| view.last_failure);
628        let recovery = ProviderRecovery::for_failure(
629            matches!(last_fetch_status, FetchStatus::Error)
630                .then_some(last_fetch_error_kind)
631                .flatten(),
632            SignInCredential::decode(&config.token).is_some(),
633        );
634
635        Ok(ProviderSummary {
636            id,
637            name: config.display_name().to_string(),
638            provider_type: config.provider_type.clone(),
639            icon: self
640                .plugin_registry
641                .get(&config.provider_type)
642                .and_then(|plugin| plugin.metadata().icon.clone()),
643            pipeline_count: cached_pipelines.len(),
644            last_updated: cached_pipelines.iter().map(|p| p.last_updated).max(),
645            refresh_interval: config.refresh_interval,
646            configured_repositories: configured_repositories(config),
647            last_fetch_status,
648            last_fetch_error: fetch.error,
649            last_fetch_at: fetch.fetched_at,
650            last_success_at: fetch.succeeded_at,
651            paused_until,
652            last_fetch_error_kind,
653            health: ProviderHealth {
654                level,
655                signals: state.map(|state| state.signals.clone()).unwrap_or_default(),
656                next_attempt_at: view
657                    .as_ref()
658                    .filter(|view| view.failures > 0)
659                    .and_then(|_| {
660                        let floor = state
661                            .filter(|state| state.outage_floor)
662                            .map(|_| self.scheduler.policy().outage_floor);
663                        self.scheduler.next_due_in(id, now, floor)
664                    })
665                    .and_then(|wait| chrono::Duration::from_std(wait).ok())
666                    .map(|wait| wall + wait),
667                in_flight: view.as_ref().is_some_and(|view| view.in_flight),
668                status_page: state.and_then(|state| state.status_page.clone()),
669            },
670            version: config.version.unwrap_or(1),
671            freshness,
672            recovery,
673        })
674    }
675
676    fn health_input(&self, config: &ProviderConfig, lease: &ProviderLease) -> ProviderHealthInput {
677        ProviderHealthInput {
678            id: lease.id,
679            generation: Some(lease.generation),
680            display_name: config.display_name().to_string(),
681            source: lease.provider.status_source().cloned(),
682            has_instance_checks: controller_health(&self.plugin_registry, &config.provider_type),
683        }
684    }
685
686    fn queue_fetch(&self, lease: &ProviderLease) {
687        if let ForcedClaim::Claimed(claim) =
688            self.scheduler
689                .claim_forced(lease.id, lease.generation, Instant::now())
690        {
691            self.dispatcher.fetch_now(vec![claim]);
692        }
693    }
694
695    pub async fn add_provider(&self, mut config: ProviderConfig) -> DomainResult<i64> {
696        let sign_in = self.take_sign_in(&mut config, None).await?;
697        let plugin = self
698            .init_plugin(&config.provider_type, config.plugin_config(), 0)
699            .await?;
700
701        let (validation_result, permission_status) =
702            tokio::join!(plugin.validate_credentials(), plugin.check_permissions());
703
704        validation_result.map_err(|e| {
705            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
706        })?;
707
708        let provider_type = plugin.provider_type();
709        if provider_type != config.provider_type {
710            return Err(DomainError::InvalidConfig(format!(
711                "Provider type mismatch: expected '{}', got '{}'",
712                config.provider_type, provider_type
713            )));
714        }
715
716        keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
717
718        let permission_status = permission_status.ok();
719
720        let id = self.repository.add_provider(&config).await?;
721
722        if let Some(status) = &permission_status {
723            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
724                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
725            }
726        }
727
728        let mut config_with_id = config.clone();
729        config_with_id.id = Some(id);
730        let provider = (self.provider_factory)(&config_with_id)?;
731
732        let lock = self.commit_lock(id);
733        let _guard = lock.lock().await;
734        let lease = self.install(id, provider, config.refresh_interval).await;
735        self.health.send(HealthInput::ProvidersChanged {
736            changed: vec![self.health_input(&config_with_id, &lease)],
737        });
738        let provider = self.summary_of(id).await?;
739        self.event_bus
740            .emit(CoreEvent::ProviderAdded {
741                provider,
742                timestamp: Utc::now().timestamp_millis(),
743            })
744            .await;
745        if let TakenSignIn::Flow(flow) = sign_in {
746            self.sign_ins.forget(&flow);
747        }
748
749        Ok(id)
750    }
751
752    pub async fn export_providers_toml(&self) -> DomainResult<String> {
753        let providers = self.repository.config_backend().list_providers().await?;
754
755        ProviderSyncService::export_toml(&providers)
756    }
757
758    pub async fn import_providers_toml(&self, content: &str) -> DomainResult<ProviderImportResult> {
759        let file = ConfigLoader::parse_raw(content).map_err(|e| {
760            DomainError::InvalidConfig(format!("Couldn't read the providers file: {e}"))
761        })?;
762        if file.providers.is_empty() {
763            return Err(DomainError::InvalidConfig(
764                "The file has no [providers] entries".to_string(),
765            ));
766        }
767        if let Some((key, provider)) = file
768            .providers
769            .iter()
770            .find(|(_, provider)| !self.plugin_registry.is_registered(&provider.provider_type))
771        {
772            return Err(DomainError::InvalidProviderType(format!(
773                "Provider '{key}' has an unknown type '{}'",
774                provider.provider_type
775            )));
776        }
777
778        let existing: HashSet<String> = self
779            .repository
780            .config_backend()
781            .list_providers()
782            .await?
783            .into_iter()
784            .map(|provider| provider.name)
785            .collect();
786        let mut result = ProviderImportResult::default();
787        let mut failure = None;
788
789        for (key, provider) in &file.providers {
790            let name = provider.display_name(key).to_string();
791            if existing.contains(key) {
792                result.skipped.push(name);
793                continue;
794            }
795
796            let config = ProviderSyncService::provider_config(key, provider, String::new());
797            match self.repository.add_provider(&config).await {
798                Ok(_) => result.added.push(name),
799                Err(e) => {
800                    let added_elsewhere = matches!(e, DomainError::InvalidConfig(_))
801                        && self
802                            .repository
803                            .config_backend()
804                            .list_providers()
805                            .await
806                            .is_ok_and(|providers| providers.iter().any(|p| p.name == *key));
807                    if !added_elsewhere {
808                        failure = Some(e);
809                        break;
810                    }
811                    result.skipped.push(name);
812                }
813            }
814        }
815
816        if !result.added.is_empty() {
817            self.event_bus.emit(CoreEvent::ProvidersChanged).await;
818        }
819
820        failure.map_or(Ok(result), Err)
821    }
822
823    pub async fn apply_config_file(&self, config_state: &ConfigState) -> DomainResult<SyncResult> {
824        let _apply = self.config_apply.lock().await;
825        let PipedashConfig {
826            providers: file,
827            views,
828            ..
829        } = config_state.load_file()?;
830        let current = config_state.get().await;
831        let changed: Vec<(String, ProviderFileConfig)> = file
832            .iter()
833            .filter(|(key, provider)| current.providers.get(*key) != Some(*provider))
834            .map(|(key, provider)| (key.clone(), provider.clone()))
835            .collect();
836
837        for (key, provider) in &changed {
838            if !self.plugin_registry.is_registered(&provider.provider_type) {
839                return Err(DomainError::InvalidProviderType(format!(
840                    "Provider '{key}' has an unknown type '{}'",
841                    provider.provider_type
842                )));
843            }
844            (self.provider_factory)(&ProviderSyncService::provider_config(
845                key,
846                provider,
847                provider.token.clone(),
848            ))?;
849        }
850
851        let mut result = SyncResult::default();
852        if !changed.is_empty() {
853            let existing = self.repository.config_backend().list_providers().await?;
854            let token_store = config_state.token_store();
855            for (key, provider) in &changed {
856                let id = existing.iter().find(|p| p.name == *key).and_then(|p| p.id);
857                self.apply_config_provider(key, provider, id, token_store.as_ref())
858                    .await?;
859                config_state.remember_provider(key, provider).await;
860                match id {
861                    Some(_) => result.updated.push(key.clone()),
862                    None => result.added.push(key.clone()),
863                }
864            }
865
866            tracing::info!(
867                added = result.added.len(),
868                updated = result.updated.len(),
869                "Applied provider changes from config file"
870            );
871            self.event_bus.emit(CoreEvent::ProvidersChanged).await;
872        }
873        if current.views != views {
874            config_state.remember_views(views).await;
875            self.event_bus.emit(CoreEvent::PipelineViewsChanged).await;
876        }
877        config_state.remember_providers(file).await;
878
879        Ok(result)
880    }
881
882    pub async fn update_general_settings(
883        &self, config_state: &ConfigState, update: GeneralSettingsUpdate,
884    ) -> DomainResult<()> {
885        let proxy_url = update.proxy_url.clone();
886        config_state.update_general_settings(update).await?;
887        if self.http_client_manager.set_proxy(&proxy_url)? {
888            self.rebuild_loaded_providers().await;
889        }
890
891        Ok(())
892    }
893
894    async fn rebuild_loaded_providers(&self) {
895        let ids: Vec<i64> = self.providers.read().await.keys().copied().collect();
896        let mut changed = Vec::new();
897
898        for id in ids {
899            let lock = self.commit_lock(id);
900            let _guard = lock.lock().await;
901            if !self.providers.read().await.contains_key(&id) {
902                continue;
903            }
904
905            let rebuilt = match self.repository.get_provider(id).await {
906                Ok(config) => (self.provider_factory)(&config).map(|provider| (config, provider)),
907                Err(e) => Err(e),
908            };
909            match rebuilt {
910                Ok((config, provider)) => {
911                    let lease = self.replace(id, provider, config.refresh_interval).await;
912                    changed.push(self.health_input(&config, &lease));
913                }
914                Err(e) => {
915                    tracing::warn!(provider_id = id, error = %e, "Failed to rebuild provider");
916                }
917            }
918        }
919
920        if !changed.is_empty() {
921            self.health.send(HealthInput::ProvidersChanged { changed });
922        }
923    }
924
925    async fn apply_config_provider(
926        &self, key: &str, provider: &ProviderFileConfig, existing: Option<i64>,
927        token_store: &dyn TokenStore,
928    ) -> DomainResult<()> {
929        let sync = ProviderSyncService::new();
930        let backend = self.repository.config_backend().as_ref();
931        let id = match existing {
932            Some(id) => id,
933            None => sync.create_from_toml(key, provider, backend).await?,
934        };
935
936        let lock = self.commit_lock(id);
937        let guard = lock.lock().await;
938        if existing.is_some() {
939            let row = backend
940                .get_provider(id)
941                .await?
942                .ok_or_else(|| DomainError::ProviderNotFound(id.to_string()))?;
943            sync.update_from_toml(key, provider, &row, backend).await?;
944        }
945        ProviderSyncService::store_resolved_token(
946            id,
947            &provider.token,
948            existing.is_none(),
949            token_store,
950        )
951        .await?;
952
953        let config = self.repository.get_provider(id).await?;
954        let instance = (self.provider_factory)(&config)?;
955        if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
956            tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
957        }
958        let lease = self.replace(id, instance, config.refresh_interval).await;
959        self.purge_run_logs(id).await;
960        self.health.send(HealthInput::ProvidersChanged {
961            changed: vec![self.health_input(&config, &lease)],
962        });
963        drop(guard);
964
965        self.queue_fetch(&lease);
966
967        Ok(())
968    }
969
970    pub async fn get_provider_config(&self, id: i64) -> DomainResult<ProviderConfig> {
971        self.repository.get_provider(id).await
972    }
973
974    pub async fn list_providers(&self) -> DomainResult<Vec<ProviderSummary>> {
975        let configs = self.repository.list_providers().await?;
976        let mut summaries = Vec::with_capacity(configs.len());
977
978        for config in configs {
979            if let Some(id) = config.id {
980                summaries.push(self.build_summary(id, &config).await?);
981            }
982        }
983
984        Ok(summaries)
985    }
986
987    pub async fn update_provider(&self, id: i64, mut config: ProviderConfig) -> DomainResult<()> {
988        let saved_name = self.repository.get_provider(id).await?.name;
989        if self
990            .config_state
991            .get()
992            .await
993            .providers
994            .contains_key(&saved_name)
995        {
996            let display_name = std::mem::replace(&mut config.name, saved_name);
997            config
998                .config
999                .insert("display_name".to_string(), display_name);
1000        }
1001        let sign_in = self.take_sign_in(&mut config, Some(id)).await?;
1002        let plugin = self
1003            .init_plugin(&config.provider_type, config.plugin_config(), id)
1004            .await?;
1005
1006        plugin.validate_credentials().await.map_err(|e| {
1007            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
1008        })?;
1009
1010        keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
1011
1012        let permission_status = plugin.check_permissions().await.ok();
1013
1014        let lock = self.commit_lock(id);
1015        let guard = lock.lock().await;
1016
1017        let current = self.repository.get_provider(id).await?;
1018        if matches!(sign_in, TakenSignIn::Kept) {
1019            config.token.clone_from(&current.token);
1020        }
1021        let mut config_with_id = config.clone();
1022        config_with_id.id = Some(id);
1023        let new_provider = (self.provider_factory)(&config_with_id)?;
1024        let success = self
1025            .repository
1026            .update_provider_with_version(id, &config, current.version.unwrap_or(1))
1027            .await?;
1028
1029        if !success {
1030            return Err(DomainError::ConcurrentModification(format!(
1031                "Provider {} was modified by another client",
1032                id
1033            )));
1034        }
1035
1036        if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
1037            tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
1038        }
1039        let lease = self
1040            .replace(id, new_provider, config.refresh_interval)
1041            .await;
1042        self.purge_run_logs(id).await;
1043        self.health.send(HealthInput::ProvidersChanged {
1044            changed: vec![self.health_input(&config_with_id, &lease)],
1045        });
1046        match self.summary_of(id).await {
1047            Ok(provider) => {
1048                self.event_bus
1049                    .emit(CoreEvent::ProviderUpdated {
1050                        provider,
1051                        timestamp: Utc::now().timestamp_millis(),
1052                    })
1053                    .await;
1054            }
1055            Err(e) => {
1056                tracing::warn!(provider_id = id, error = %e, "Failed to build updated provider summary");
1057                self.event_bus.emit(CoreEvent::ProvidersChanged).await;
1058            }
1059        }
1060        drop(guard);
1061
1062        if let Some(status) = &permission_status {
1063            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
1064                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
1065            }
1066        }
1067
1068        self.queue_fetch(&lease);
1069        if let TakenSignIn::Flow(flow) = sign_in {
1070            self.sign_ins.forget(&flow);
1071        }
1072
1073        Ok(())
1074    }
1075
1076    pub async fn update_provider_refresh_interval(
1077        &self, id: i64, refresh_interval: i64,
1078    ) -> DomainResult<()> {
1079        let lock = self.commit_lock(id);
1080        let _guard = lock.lock().await;
1081
1082        let mut config = self.repository.get_provider(id).await?;
1083        config.refresh_interval = refresh_interval;
1084        self.repository.update_provider(id, &config).await?;
1085
1086        let generation = self
1087            .providers
1088            .read()
1089            .await
1090            .get(&id)
1091            .map(|slot| slot.generation);
1092        if let Some(generation) = generation {
1093            self.scheduler
1094                .set_interval(id, generation, interval_of(refresh_interval));
1095        }
1096
1097        Ok(())
1098    }
1099
1100    pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
1101        let _apply = self.config_apply.lock().await;
1102        let lock = self.commit_lock(id);
1103        let _guard = lock.lock().await;
1104
1105        let config = self.repository.get_provider(id).await?;
1106        let provider = self.build_summary(id, &config).await?;
1107
1108        self.repository
1109            .remove_provider(id, &self.config_state)
1110            .await?;
1111        self.providers.write().await.remove(&id);
1112        self.purge_run_logs(id).await;
1113        self.scheduler.remove(id);
1114        self.health.send(HealthInput::ProvidersChanged {
1115            changed: vec![ProviderHealthInput {
1116                id,
1117                generation: None,
1118                display_name: config.display_name().to_string(),
1119                source: None,
1120                has_instance_checks: false,
1121            }],
1122        });
1123        self.event_bus
1124            .emit(CoreEvent::ProviderRemoved {
1125                provider,
1126                timestamp: Utc::now().timestamp_millis(),
1127            })
1128            .await;
1129
1130        Ok(())
1131    }
1132
1133    pub async fn get_provider(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1134        Ok(self.acquire(id).await?.provider)
1135    }
1136
1137    pub async fn check_quota(&self, lease: &ProviderLease) -> Option<DateTime<Utc>> {
1138        let rate_limit =
1139            match timeout(Duration::from_secs(10), lease.provider.fetch_rate_limit()).await {
1140                Ok(Ok(rate_limit)) => rate_limit,
1141                Ok(Err(e)) => {
1142                    tracing::debug!(provider_id = lease.id, error = %e, "Rate limit check failed");
1143                    None
1144                }
1145                Err(_) => {
1146                    tracing::debug!(provider_id = lease.id, "Rate limit check timed out");
1147                    None
1148                }
1149            };
1150        let paused_until = rate_limit.and_then(|rate_limit| quota_pause(&rate_limit, Utc::now()));
1151
1152        self.commit(lease, || async {
1153            let changed = self.scheduler.set_quota_pause(
1154                lease.id,
1155                lease.generation,
1156                paused_until,
1157                Instant::now(),
1158                Utc::now(),
1159            );
1160            if let (true, Some(until)) = (changed, paused_until) {
1161                tracing::info!(provider_id = lease.id, %until, "API quota almost used up, pausing provider");
1162            }
1163            if changed {
1164                self.emit_status(lease.id).await;
1165            }
1166
1167            paused_until
1168        })
1169        .await
1170        .flatten()
1171    }
1172
1173    pub async fn get_provider_instance(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1174        self.get_provider(id).await
1175    }
1176
1177    pub async fn load_missing_providers(&self) {
1178        let ids = match self.repository.list_provider_ids().await {
1179            Ok(ids) => ids,
1180            Err(e) => {
1181                tracing::warn!(error = %e, "Failed to list providers");
1182                return;
1183            }
1184        };
1185        let loaded: Vec<i64> = self.providers.read().await.keys().copied().collect();
1186        for id in ids.into_iter().filter(|id| !loaded.contains(id)) {
1187            if let Err(e) = self.acquire(id).await {
1188                tracing::debug!(provider_id = id, error = %e, "Provider not loaded yet");
1189            }
1190        }
1191    }
1192
1193    pub async fn load_all_providers(&self) -> DomainResult<()> {
1194        let configs = self.repository.list_providers().await?;
1195        let mut changed = Vec::new();
1196
1197        for config in configs {
1198            let Some(id) = config.id else {
1199                continue;
1200            };
1201            if config.token.is_empty() {
1202                tracing::debug!(
1203                    provider_id = id,
1204                    provider_type = %config.provider_type,
1205                    "Skipping provider load - token not available yet (vault initializing in background)"
1206                );
1207                continue;
1208            }
1209
1210            match (self.provider_factory)(&config) {
1211                Ok(provider) => {
1212                    let lock = self.commit_lock(id);
1213                    let _guard = lock.lock().await;
1214                    let lease = self.replace(id, provider, config.refresh_interval).await;
1215                    changed.push(self.health_input(&config, &lease));
1216                }
1217                Err(e) => {
1218                    tracing::warn!(provider_id = id, error = %e, "Failed to load provider");
1219                }
1220            }
1221        }
1222
1223        if !changed.is_empty() {
1224            self.health.send(HealthInput::ProvidersChanged { changed });
1225        }
1226
1227        Ok(())
1228    }
1229
1230    pub async fn get_workflow_parameters(
1231        &self, provider_id: i64, workflow_id: &str,
1232    ) -> DomainResult<Vec<pipedash_plugin_api::WorkflowParameter>> {
1233        if let Some(cached) = self.cached_parameters(workflow_id).await {
1234            return Ok(cached);
1235        }
1236
1237        let lease = self.acquire(provider_id).await?;
1238        let fetch = ParameterFetch::new(&self.parameter_fetches, workflow_id);
1239        let _guard = fetch.lock.lock().await;
1240
1241        if let Some(cached) = self.cached_parameters(workflow_id).await {
1242            return Ok(cached);
1243        }
1244
1245        let parameters = lease.provider.get_workflow_parameters(workflow_id).await?;
1246
1247        let cached = self
1248            .commit(&lease, || {
1249                self.repository
1250                    .cache_workflow_parameters(workflow_id, &parameters)
1251            })
1252            .await;
1253        if let Some(Err(e)) = cached {
1254            tracing::warn!(provider_id, workflow_id, error = %e, "Failed to cache workflow parameters");
1255        }
1256
1257        Ok(parameters)
1258    }
1259
1260    async fn cached_parameters(
1261        &self, workflow_id: &str,
1262    ) -> Option<Vec<pipedash_plugin_api::WorkflowParameter>> {
1263        match self
1264            .repository
1265            .get_cached_workflow_parameters(workflow_id)
1266            .await
1267        {
1268            Ok(cached) => cached,
1269            Err(e) => {
1270                tracing::warn!(workflow_id, error = %e, "Failed to read cached workflow parameters");
1271                None
1272            }
1273        }
1274    }
1275
1276    pub async fn get_provider_permissions(
1277        &self, provider_id: i64,
1278    ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
1279        self.repository.get_provider_permissions(provider_id).await
1280    }
1281
1282    pub async fn recheck_provider_permissions(
1283        &self, provider_id: i64,
1284    ) -> DomainResult<pipedash_plugin_api::PermissionStatus> {
1285        let plugin = self.stored_plugin(provider_id).await?;
1286
1287        let status = plugin
1288            .check_permissions()
1289            .await
1290            .map_err(|e| DomainError::InvalidConfig(format!("Failed to check permissions: {e}")))?;
1291
1292        self.repository
1293            .store_provider_permissions(provider_id, &status)
1294            .await?;
1295
1296        Ok(status)
1297    }
1298}
1299
1300fn interval_of(seconds: i64) -> Duration {
1301    Duration::from_secs(u64::try_from(seconds).unwrap_or(0))
1302}
1303
1304fn build_plugin(
1305    http: &HttpClientManager, provider_type: &str, config: &HashMap<String, String>,
1306) -> DomainResult<(Box<dyn PluginTrait>, Arc<reqwest::Client>)> {
1307    let plugin = plugins::create_plugin(provider_type).ok_or_else(|| {
1308        DomainError::InvalidProviderType(format!("Unknown provider type: {}", provider_type))
1309    })?;
1310    let client = http.client_for(&plugin.client_options(config))?;
1311
1312    Ok((plugin, client))
1313}
1314
1315async fn keep_selection_with_ci(
1316    plugin: &dyn PluginTrait, config: &mut ProviderConfig,
1317) -> DomainResult<()> {
1318    let Some(selected) = config.config.get("selected_items") else {
1319        return Ok(());
1320    };
1321    let ids: Vec<String> = selected
1322        .split(',')
1323        .map(str::trim)
1324        .filter(|id| !id.is_empty())
1325        .map(str::to_owned)
1326        .collect();
1327    if ids.is_empty() {
1328        return Ok(());
1329    }
1330    let Ok(kept) = plugin.ids_with_ci(ids.clone()).await else {
1331        return Ok(());
1332    };
1333    if kept.is_empty() {
1334        return Err(DomainError::InvalidConfig(
1335            "None of the selected repositories has a CI file or a pipeline run yet".to_string(),
1336        ));
1337    }
1338    if kept.len() < ids.len() {
1339        tracing::info!(
1340            selected = ids.len(),
1341            kept = kept.len(),
1342            "Skipped selected repositories without a CI file or a pipeline run"
1343        );
1344        config
1345            .config
1346            .insert("selected_items".to_string(), kept.join(","));
1347    }
1348    Ok(())
1349}
1350
1351fn init_plugin(
1352    http: &HttpClientManager, provider_type: &str, config: HashMap<String, String>,
1353    provider_id: i64,
1354) -> DomainResult<Box<dyn PluginTrait>> {
1355    let (mut plugin, client) = build_plugin(http, provider_type, &config)?;
1356    plugin
1357        .initialize(provider_id, config, Some(client))
1358        .map_err(|e| DomainError::InvalidConfig(format!("Failed to initialize plugin: {e}")))?;
1359
1360    Ok(plugin)
1361}
1362
1363pub(crate) fn controller_health(registry: &PluginRegistry, provider_type: &str) -> bool {
1364    registry.get(provider_type).is_some_and(|plugin| {
1365        plugin
1366            .metadata()
1367            .features
1368            .iter()
1369            .any(|feature| feature.id == CONTROLLER_HEALTH_FEATURE)
1370    })
1371}
1372
1373pub(crate) fn plugin_provider(
1374    registry: &PluginRegistry, http: &HttpClientManager, config: &ProviderConfig,
1375) -> DomainResult<Arc<dyn Provider>> {
1376    if !registry.is_registered(&config.provider_type) {
1377        return Err(DomainError::InvalidProviderType(format!(
1378            "Plugin not found for provider type: {}",
1379            config.provider_type
1380        )));
1381    }
1382
1383    let provider_id = config.id.unwrap_or(0);
1384    let (token, renew_at) = match SignInCredential::decode(&config.token) {
1385        Some(credential) => {
1386            let renew_at = credential.renew_at();
1387            (credential.token, renew_at)
1388        }
1389        None => (resolve_token_reference(provider_id, &config.token), None),
1390    };
1391    let mut plugin_config = config.config.clone();
1392    plugin_config.insert("token".to_string(), token);
1393
1394    let plugin = init_plugin(http, &config.provider_type, plugin_config, provider_id)?;
1395
1396    Ok(Arc::new(PluginAdapter::new(
1397        plugin,
1398        config.provider_type.clone(),
1399        provider_id,
1400        renew_at,
1401    )))
1402}
1403
1404fn resolve_token_reference(provider_id: i64, token: &str) -> String {
1405    if token.is_empty() {
1406        return String::new();
1407    }
1408    match TokenReference::parse(token) {
1409        Ok(TokenReference::EnvVar(var_name)) => std::env::var(&var_name).unwrap_or_else(|_| {
1410            tracing::warn!(
1411                provider_id = provider_id,
1412                var_name = %var_name,
1413                "Environment variable not found, using empty token"
1414            );
1415            String::new()
1416        }),
1417        Ok(TokenReference::None) => String::new(),
1418        Ok(_) | Err(_) => token.to_string(),
1419    }
1420}
1421
1422fn configured_repositories(config: &ProviderConfig) -> Vec<String> {
1423    config
1424        .config
1425        .get("selected_items")
1426        .map(|items| {
1427            if matches!(
1428                config.provider_type.as_str(),
1429                "argocd" | "fluxcd" | "komodo" | "prow"
1430            ) {
1431                return Vec::new();
1432            }
1433
1434            items
1435                .split(',')
1436                .map(|s| s.trim().to_string())
1437                .filter(|s| !s.is_empty())
1438                .map(|item| {
1439                    if config.provider_type == "tekton" && item.contains("__") {
1440                        item.replace("__", "/")
1441                    } else if config.provider_type == "jenkins" && !item.contains('/') {
1442                        format!("(root)/{}", item)
1443                    } else {
1444                        item
1445                    }
1446                })
1447                .collect()
1448        })
1449        .unwrap_or_default()
1450}
1451
1452pub(crate) fn quota_pause(rate_limit: &RateLimit, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
1453    let low = rate_limit.remaining * 100 < rate_limit.limit * QUOTA_PAUSE_PERCENT;
1454
1455    (low && rate_limit.reset_at > now).then_some(rate_limit.reset_at)
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use std::io::{
1461        Read,
1462        Write,
1463    };
1464
1465    use pipedash_plugin_api::PluginError;
1466
1467    use super::*;
1468    use crate::application::test_support::{
1469        pipeline,
1470        FeedStub,
1471        Gate,
1472        Harness,
1473        StubProvider,
1474    };
1475    use crate::application::FetchResult;
1476    use crate::domain::PipelineStatus;
1477    use crate::infrastructure::config::ConfigLoader;
1478    use crate::infrastructure::oauth::test_idp::Idp;
1479    use crate::test_env::EnvGuard;
1480
1481    const GITHUB_SECRET: &str = "ghp_exampleSECRET0123456789";
1482    const GITLAB_SECRET: &str = "glpat-SECRET-VALUE";
1483    const JENKINS_DEFAULT_SECRET: &str = "hunter2-default";
1484
1485    fn provider(
1486        name: &str, provider_type: &str, token: &str, refresh_interval: i64,
1487        config: &[(&str, &str)],
1488    ) -> ProviderConfig {
1489        ProviderConfig {
1490            id: None,
1491            name: name.to_string(),
1492            provider_type: provider_type.to_string(),
1493            token: token.to_string(),
1494            config: config
1495                .iter()
1496                .map(|(k, v)| (k.to_string(), v.to_string()))
1497                .collect(),
1498            refresh_interval,
1499            version: None,
1500        }
1501    }
1502
1503    struct CiCheck(Option<&'static [&'static str]>);
1504
1505    #[async_trait::async_trait]
1506    impl PluginTrait for CiCheck {
1507        fn metadata(&self) -> &pipedash_plugin_api::PluginMetadata {
1508            unimplemented!()
1509        }
1510
1511        fn initialize(
1512            &mut self, _: i64, _: HashMap<String, String>, _: Option<Arc<reqwest::Client>>,
1513        ) -> pipedash_plugin_api::PluginResult<()> {
1514            Ok(())
1515        }
1516
1517        async fn validate_credentials(&self) -> pipedash_plugin_api::PluginResult<bool> {
1518            Ok(true)
1519        }
1520
1521        async fn fetch_pipelines(
1522            &self,
1523        ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::Pipeline>> {
1524            Ok(Vec::new())
1525        }
1526
1527        async fn fetch_run_history(
1528            &self, _: &str, _: usize,
1529        ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::PipelineRun>> {
1530            Ok(Vec::new())
1531        }
1532
1533        async fn fetch_run_details(
1534            &self, _: &str, _: i64,
1535        ) -> pipedash_plugin_api::PluginResult<pipedash_plugin_api::PipelineRun> {
1536            Err(PluginError::NotSupported(String::new()))
1537        }
1538
1539        async fn trigger_pipeline(
1540            &self, _: pipedash_plugin_api::TriggerParams,
1541        ) -> pipedash_plugin_api::PluginResult<String> {
1542            Err(PluginError::NotSupported(String::new()))
1543        }
1544
1545        async fn ids_with_ci(
1546            &self, _: Vec<String>,
1547        ) -> pipedash_plugin_api::PluginResult<Vec<String>> {
1548            self.0
1549                .map(|kept| kept.iter().map(|id| id.to_string()).collect())
1550                .ok_or_else(|| PluginError::NetworkError("reset".to_string()))
1551        }
1552    }
1553
1554    #[tokio::test]
1555    async fn a_saved_selection_keeps_only_repositories_with_ci() {
1556        let selected = &[("selected_items", "acme/api, acme/empty,acme/web")];
1557
1558        let mut config = provider("gh", "github", "t", 30, selected);
1559        keep_selection_with_ci(&CiCheck(Some(&["acme/api", "acme/web"])), &mut config)
1560            .await
1561            .unwrap();
1562        assert_eq!(config.config["selected_items"], "acme/api,acme/web");
1563
1564        let mut config = provider("gh", "github", "t", 30, selected);
1565        keep_selection_with_ci(&CiCheck(None), &mut config)
1566            .await
1567            .unwrap();
1568        assert_eq!(
1569            config.config["selected_items"],
1570            "acme/api, acme/empty,acme/web"
1571        );
1572
1573        let mut config = provider("gh", "github", "t", 30, selected);
1574        let error = keep_selection_with_ci(&CiCheck(Some(&[])), &mut config)
1575            .await
1576            .unwrap_err();
1577        assert!(matches!(error, DomainError::InvalidConfig(_)), "{error}");
1578        assert_eq!(
1579            config.config["selected_items"],
1580            "acme/api, acme/empty,acme/web"
1581        );
1582    }
1583
1584    #[tokio::test]
1585    async fn test_export_providers_toml_round_trips_without_tokens() {
1586        let source = Harness::with_real_plugins().await;
1587        let repository = source.repository.clone();
1588        let service = &source.provider_service;
1589
1590        let originals = [
1591            provider(
1592                "github-main",
1593                "github",
1594                GITHUB_SECRET,
1595                45,
1596                &[("selected_items", "acme/api,acme/web")],
1597            ),
1598            provider(
1599                "My Jenkins",
1600                "jenkins",
1601                &format!("${{JENKINS_API_TOKEN:-{JENKINS_DEFAULT_SECRET}}}"),
1602                120,
1603                &[
1604                    ("display_name", "Prod Jenkins"),
1605                    ("server_url", "http://127.0.0.1:9"),
1606                    ("username", "ci"),
1607                    ("selected_items", "(root)/deploy"),
1608                ],
1609            ),
1610            provider("gitlab_main", "gitlab", GITLAB_SECRET, 30, &[]),
1611        ];
1612        for original in &originals {
1613            repository.add_provider(original).await.unwrap();
1614        }
1615
1616        let exported = service.export_providers_toml().await.unwrap();
1617
1618        for secret in [GITHUB_SECRET, GITLAB_SECRET, JENKINS_DEFAULT_SECRET] {
1619            assert!(!exported.contains(secret), "export leaked {secret}");
1620        }
1621        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITHUB_MAIN:-}\""));
1622        assert!(exported.contains("token = \"${JENKINS_API_TOKEN:-}\""));
1623        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITLAB_MAIN:-}\""));
1624
1625        let _env = EnvGuard::set(&[
1626            ("PIPEDASH_TOKEN_GITHUB_MAIN", Some("env-github-token")),
1627            ("JENKINS_API_TOKEN", Some("env-jenkins-token")),
1628            ("PIPEDASH_TOKEN_GITLAB_MAIN", None),
1629        ]);
1630        let parsed = ConfigLoader::parse(&exported).unwrap();
1631
1632        let mut keys: Vec<_> = parsed.providers.keys().map(String::as_str).collect();
1633        keys.sort_unstable();
1634        assert_eq!(keys, ["My Jenkins", "github-main", "gitlab_main"]);
1635        assert_eq!(parsed.providers["github-main"].token, "env-github-token");
1636        assert_eq!(parsed.providers["My Jenkins"].token, "env-jenkins-token");
1637        assert_eq!(parsed.providers["gitlab_main"].token, "");
1638        assert_eq!(
1639            parsed.providers["My Jenkins"].name.as_deref(),
1640            Some("Prod Jenkins")
1641        );
1642
1643        let target_harness = Harness::with_real_plugins().await;
1644        let target = &target_harness.repository;
1645        crate::infrastructure::config::sync::ProviderSyncService::new()
1646            .sync_toml_to_db(
1647                parsed.providers,
1648                target.config_backend().as_ref(),
1649                &crate::infrastructure::MemoryTokenStore::new(),
1650                false,
1651            )
1652            .await
1653            .unwrap();
1654
1655        let imported = target.config_backend().list_providers().await.unwrap();
1656
1657        assert_eq!(imported.len(), originals.len());
1658        for original in &originals {
1659            let imported = imported.iter().find(|p| p.name == original.name).unwrap();
1660            assert_eq!(imported.name, original.name);
1661            assert_eq!(imported.provider_type, original.provider_type);
1662            assert_eq!(imported.refresh_interval, original.refresh_interval);
1663            assert_eq!(imported.config, original.config);
1664        }
1665    }
1666
1667    #[tokio::test]
1668    async fn test_import_providers_toml_adds_missing_providers_without_tokens() {
1669        let source = Harness::with_real_plugins().await;
1670        let jenkins = provider(
1671            "My Jenkins",
1672            "jenkins",
1673            "${JENKINS_API_TOKEN}",
1674            120,
1675            &[
1676                ("display_name", "Prod Jenkins"),
1677                ("server_url", "http://127.0.0.1:9"),
1678                ("username", "ci"),
1679                ("selected_items", "(root)/deploy"),
1680            ],
1681        );
1682        for original in [
1683            provider(
1684                "github-main",
1685                "github",
1686                GITHUB_SECRET,
1687                45,
1688                &[("selected_items", "acme/api")],
1689            ),
1690            jenkins.clone(),
1691        ] {
1692            source.repository.add_provider(&original).await.unwrap();
1693        }
1694        let exported = source
1695            .provider_service
1696            .export_providers_toml()
1697            .await
1698            .unwrap();
1699
1700        let target = Harness::with_real_plugins().await;
1701        let kept = provider(
1702            "github-main",
1703            "github",
1704            "target-token",
1705            300,
1706            &[("selected_items", "acme/web")],
1707        );
1708        let kept_id = target.repository.add_provider(&kept).await.unwrap();
1709
1710        let _env = EnvGuard::set(&[("JENKINS_API_TOKEN", Some("env-jenkins-token"))]);
1711        let result = target
1712            .provider_service
1713            .import_providers_toml(&exported)
1714            .await
1715            .unwrap();
1716
1717        assert_eq!(
1718            result,
1719            ProviderImportResult {
1720                added: vec!["Prod Jenkins".to_string()],
1721                skipped: vec!["github-main".to_string()],
1722            }
1723        );
1724        let unchanged = target.repository.get_provider(kept_id).await.unwrap();
1725        assert_eq!(unchanged.token, kept.token);
1726        assert_eq!(unchanged.refresh_interval, kept.refresh_interval);
1727        assert_eq!(unchanged.config, kept.config);
1728
1729        let imported = target.repository.list_providers().await.unwrap();
1730        let imported = imported.iter().find(|p| p.name == jenkins.name).unwrap();
1731        let row = target
1732            .repository
1733            .config_backend()
1734            .get_provider(imported.id.unwrap())
1735            .await
1736            .unwrap()
1737            .unwrap();
1738        assert_eq!(imported.token, "");
1739        assert_eq!(row.token, "");
1740        assert_eq!(imported.provider_type, jenkins.provider_type);
1741        assert_eq!(imported.refresh_interval, jenkins.refresh_interval);
1742        assert_eq!(imported.config, jenkins.config);
1743        assert_eq!(target.bus.count("providers-changed"), 1);
1744    }
1745
1746    #[tokio::test]
1747    async fn test_import_providers_toml_rejects_unknown_type_before_adding() {
1748        let h = Harness::with_real_plugins().await;
1749        let content = r#"
1750[providers.github-main]
1751type = "github"
1752
1753[providers.mystery]
1754type = "travis"
1755"#;
1756
1757        let err = h
1758            .provider_service
1759            .import_providers_toml(content)
1760            .await
1761            .unwrap_err();
1762
1763        assert!(matches!(err, DomainError::InvalidProviderType(_)), "{err}");
1764        assert!(h.repository.list_providers().await.unwrap().is_empty());
1765        assert_eq!(h.bus.count("providers-changed"), 0);
1766    }
1767
1768    #[test]
1769    fn quota_pause_starts_under_ten_percent_until_reset() {
1770        let now = Utc::now();
1771        let reset_at = now + chrono::Duration::minutes(20);
1772        let pause = |remaining, reset_at| {
1773            quota_pause(
1774                &RateLimit {
1775                    limit: 5000,
1776                    remaining,
1777                    reset_at,
1778                },
1779                now,
1780            )
1781        };
1782
1783        assert_eq!(pause(500, reset_at), None);
1784        assert_eq!(pause(499, reset_at), Some(reset_at));
1785        assert_eq!(pause(0, reset_at), Some(reset_at));
1786        assert_eq!(pause(0, now), None);
1787        assert_eq!(
1788            quota_pause(
1789                &RateLimit {
1790                    limit: 0,
1791                    remaining: 0,
1792                    reset_at,
1793                },
1794                now,
1795            ),
1796            None
1797        );
1798    }
1799
1800    type StubGate = Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>;
1801
1802    async fn stub_github(
1803        remaining: u64, reset: i64, gate: StubGate,
1804    ) -> (String, Arc<Mutex<Vec<String>>>) {
1805        use tokio::io::{
1806            AsyncReadExt,
1807            AsyncWriteExt,
1808        };
1809
1810        let _ = rustls::crypto::ring::default_provider().install_default();
1811        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1812        let addr = listener.local_addr().unwrap();
1813        let requests: Arc<Mutex<Vec<String>>> = Arc::default();
1814        let log = requests.clone();
1815        tokio::spawn(async move {
1816            loop {
1817                let (mut stream, _) = listener.accept().await.unwrap();
1818                let mut request = Vec::new();
1819                let mut buffer = [0; 4096];
1820                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1821                    let count = stream.read(&mut buffer).await.unwrap();
1822                    if count == 0 {
1823                        break;
1824                    }
1825                    request.extend_from_slice(&buffer[..count]);
1826                }
1827                let request = String::from_utf8(request).unwrap();
1828                let path = request.split_whitespace().nth(1).unwrap_or_default();
1829                let path = path.split('?').next().unwrap().to_string();
1830                let (status, body) = if path == "/api/v3/rate_limit" {
1831                    if let Some((arrived, release)) = &gate {
1832                        arrived.notify_one();
1833                        release.notified().await;
1834                    }
1835                    let rate = serde_json::json!({
1836                        "limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset
1837                    });
1838                    let search = serde_json::json!({"limit": 30, "used": 0, "remaining": 30, "reset": reset});
1839                    let body = serde_json::json!({
1840                        "resources": {"core": rate, "search": search},
1841                        "rate": rate
1842                    });
1843                    ("200 OK", body.to_string())
1844                } else {
1845                    ("404 Not Found", r#"{"message":"Not Found"}"#.to_string())
1846                };
1847                log.lock().await.push(path);
1848                let response = format!(
1849                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1850                    body.len()
1851                );
1852                let _ = stream.write_all(response.as_bytes()).await;
1853            }
1854        });
1855
1856        (format!("http://{addr}"), requests)
1857    }
1858
1859    #[tokio::test]
1860    async fn refresh_skips_a_github_provider_with_its_quota_almost_used_up() {
1861        let h = Harness::with_real_plugins().await;
1862        h.start().await;
1863        let reset = Utc::now().timestamp() + 1200;
1864        let (base_url, requests) = stub_github(0, reset, None).await;
1865        let id = h
1866            .repository
1867            .add_provider(&provider(
1868                "github",
1869                "github",
1870                "ghp_stub",
1871                30,
1872                &[("base_url", &base_url), ("selected_items", "acme/app")],
1873            ))
1874            .await
1875            .unwrap();
1876
1877        let first = h
1878            .pipeline_service
1879            .fetch_now(&[id], Duration::from_secs(5))
1880            .await;
1881        let again = h
1882            .pipeline_service
1883            .fetch_now(&[id], Duration::from_secs(5))
1884            .await;
1885        let summaries = h.provider_service.list_providers().await.unwrap();
1886
1887        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1888        assert_eq!(
1889            summaries[0].paused_until,
1890            DateTime::from_timestamp(reset, 0)
1891        );
1892        assert!(matches!(summaries[0].last_fetch_status, FetchStatus::Never));
1893        assert_eq!(first[0].state, crate::domain::RefreshState::Recent);
1894        assert_eq!(again[0].state, crate::domain::RefreshState::Recent);
1895        assert_eq!(again[0].kind, Some(crate::domain::FailureKind::RateLimited));
1896        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1897    }
1898
1899    #[tokio::test]
1900    async fn refresh_fetches_a_github_provider_with_quota_left() {
1901        let h = Harness::with_real_plugins().await;
1902        h.start().await;
1903        let (base_url, requests) = stub_github(4000, Utc::now().timestamp() + 1200, None).await;
1904        let id = h
1905            .repository
1906            .add_provider(&provider(
1907                "github",
1908                "github",
1909                "ghp_stub",
1910                30,
1911                &[("base_url", &base_url), ("selected_items", "acme/app")],
1912            ))
1913            .await
1914            .unwrap();
1915
1916        h.pipeline_service
1917            .fetch_now(&[id], Duration::from_secs(5))
1918            .await;
1919        let summaries = h.provider_service.list_providers().await.unwrap();
1920        let requests = requests.lock().await;
1921
1922        assert_eq!(requests[0], "/api/v3/rate_limit");
1923        assert!(requests
1924            .iter()
1925            .any(|path| path.starts_with("/api/v3/repos/acme/app")));
1926        assert_eq!(summaries[0].paused_until, None);
1927    }
1928
1929    #[tokio::test]
1930    async fn quota_answer_for_replaced_credentials_does_not_pause_the_new_ones() {
1931        let h = Harness::with_real_plugins().await;
1932        let reset = Utc::now().timestamp() + 1200;
1933        let arrived = Arc::new(tokio::sync::Notify::new());
1934        let release = Arc::new(tokio::sync::Notify::new());
1935        let (base_url, _) = stub_github(0, reset, Some((arrived.clone(), release.clone()))).await;
1936        let id = h
1937            .repository
1938            .add_provider(&provider(
1939                "github",
1940                "github",
1941                "ghp_old",
1942                30,
1943                &[("base_url", &base_url), ("selected_items", "acme/app")],
1944            ))
1945            .await
1946            .unwrap();
1947        let service = Arc::clone(&h.provider_service);
1948        let lease = service.acquire(id).await.unwrap();
1949
1950        let check = tokio::spawn({
1951            let service = Arc::clone(&service);
1952            let lease = lease.clone();
1953            async move { service.check_quota(&lease).await }
1954        });
1955        arrived.notified().await;
1956        let replacement =
1957            (service.provider_factory)(&h.repository.get_provider(id).await.unwrap()).unwrap();
1958        service.replace(id, replacement, 30).await;
1959        release.notify_one();
1960
1961        assert_eq!(check.await.unwrap(), None);
1962        assert_eq!(h.scheduler.paused_until(id, Utc::now()), None);
1963    }
1964
1965    fn updated(h: &Harness, id: i64) -> ProviderConfig {
1966        let mut config = h.config("renamed", 45);
1967        config.id = Some(id);
1968        config
1969    }
1970
1971    #[tokio::test]
1972    async fn an_update_during_a_blocked_fetch_supersedes_the_old_attempt() {
1973        let h = Harness::new().await;
1974        h.start().await;
1975        let old_gate = Gate::new();
1976        let (id, old) = h
1977            .add_stub(|id| {
1978                StubProvider::new(id)
1979                    .gated(&old_gate)
1980                    .returning(vec![pipeline(id, "old", PipelineStatus::Success)])
1981            })
1982            .await;
1983        let new = h.stubs.queue(StubProvider::new(id).returning(vec![pipeline(
1984            id,
1985            "new",
1986            PipelineStatus::Failed,
1987        )]));
1988        let blocked = h.spawn_fetch(id).await;
1989        old_gate.arrived().await;
1990        let before = h.provider_service.acquire(id).await.unwrap().generation;
1991
1992        h.provider_service
1993            .update_provider(id, updated(&h, id))
1994            .await
1995            .unwrap();
1996        let updated_at = h
1997            .bus
1998            .names()
1999            .iter()
2000            .position(|name| *name == "provider-updated")
2001            .unwrap();
2002        h.bus
2003            .wait_for("the fetch queued by the update", |events| {
2004                events[updated_at..]
2005                    .iter()
2006                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2007            })
2008            .await;
2009        old_gate.open();
2010        let old_result = blocked.await.unwrap();
2011
2012        assert!(matches!(old_result, FetchResult::Superseded));
2013        assert_eq!(h.cached_names(id).await, ["new"]);
2014        let published: Vec<_> = h.bus.events()[updated_at..]
2015            .iter()
2016            .filter_map(|event| match event {
2017                CoreEvent::PipelinesUpdated { pipelines, .. } => {
2018                    Some(pipelines.iter().map(|p| p.name.clone()).collect::<Vec<_>>())
2019                }
2020                _ => None,
2021            })
2022            .collect();
2023        assert_eq!(published, vec![vec!["new".to_string()]]);
2024        let view = h.scheduler.view(id, Instant::now()).unwrap();
2025        assert_eq!(
2026            (view.failures, view.last_failure, view.in_flight),
2027            (0, None, false)
2028        );
2029        assert_ne!(
2030            h.provider_service.acquire(id).await.unwrap().generation,
2031            before
2032        );
2033        assert_eq!((old.fetch_count(), new.fetch_count()), (1, 1));
2034    }
2035
2036    #[tokio::test]
2037    async fn an_update_resets_the_fetch_status_and_the_schedule() {
2038        let h = Harness::new().await;
2039        let (id, _) = h
2040            .add_stub(|id| StubProvider::new(id).failing(DomainError::NetworkError("down".into())))
2041            .await;
2042        h.fetch_directly(id).await;
2043        h.health_inputs();
2044        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 1);
2045
2046        h.provider_service
2047            .update_provider(id, updated(&h, id))
2048            .await
2049            .unwrap();
2050        let inputs = h.health_inputs();
2051
2052        let event = h
2053            .bus
2054            .events()
2055            .into_iter()
2056            .find_map(|event| match event {
2057                CoreEvent::ProviderUpdated { provider, .. } => Some(provider),
2058                _ => None,
2059            })
2060            .unwrap();
2061        assert!(matches!(event.last_fetch_status, FetchStatus::Never));
2062        assert_eq!(event.last_fetch_error, None);
2063        assert_eq!(event.last_fetch_error_kind, None);
2064        assert_eq!(event.health.next_attempt_at, None);
2065        assert_eq!(event.name, "renamed");
2066        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2067        let lease = h.provider_service.acquire(id).await.unwrap();
2068        assert!(matches!(
2069            inputs.as_slice(),
2070            [HealthInput::ProvidersChanged { changed }]
2071                if changed.len() == 1 && changed[0].generation == Some(lease.generation)
2072        ));
2073    }
2074
2075    #[tokio::test]
2076    async fn an_old_failure_finishing_after_an_update_leaves_the_new_schedule_clean() {
2077        let h = Harness::new().await;
2078        h.start().await;
2079        let old_gate = Gate::new();
2080        let new_gate = Gate::new();
2081        let (id, _) = h
2082            .add_stub(|id| {
2083                StubProvider::new(id)
2084                    .gated(&old_gate)
2085                    .failing(DomainError::NetworkError("down".into()))
2086            })
2087            .await;
2088        h.stubs.queue(StubProvider::new(id).gated(&new_gate));
2089        let blocked = h.spawn_fetch(id).await;
2090        old_gate.arrived().await;
2091
2092        h.provider_service
2093            .update_provider(id, updated(&h, id))
2094            .await
2095            .unwrap();
2096        new_gate.arrived().await;
2097        old_gate.open();
2098        let old_result = blocked.await.unwrap();
2099
2100        assert!(matches!(old_result, FetchResult::Superseded));
2101        let view = h.scheduler.view(id, Instant::now()).unwrap();
2102        assert_eq!(
2103            (view.failures, view.last_failure, view.in_flight),
2104            (0, None, true)
2105        );
2106        let fetch = h.repository.get_provider_fetch_status(id).await.unwrap();
2107        assert_eq!((fetch.status.as_str(), fetch.error), ("never", None));
2108        new_gate.open();
2109        h.bus
2110            .wait_for("the new fetch", |events| {
2111                events
2112                    .iter()
2113                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2114            })
2115            .await;
2116        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2117    }
2118
2119    #[tokio::test]
2120    async fn a_removal_during_a_blocked_fetch_leaves_nothing_behind() {
2121        let h = Harness::new().await;
2122        let gate = Gate::new();
2123        let (id, _) = h.add_stub(|id| StubProvider::new(id).gated(&gate)).await;
2124        let blocked = h.spawn_fetch(id).await;
2125        gate.arrived().await;
2126        h.health_inputs();
2127
2128        h.provider_service.remove_provider(id).await.unwrap();
2129        let removed_at = h.bus.events().len();
2130        gate.open();
2131        let result = blocked.await.unwrap();
2132        let inputs = h.health_inputs();
2133
2134        assert!(matches!(result, FetchResult::Superseded));
2135        assert!(h.repository.get_provider(id).await.is_err());
2136        assert!(h.repository.get_provider_fetch_status(id).await.is_err());
2137        assert!(h.cached_names(id).await.is_empty());
2138        assert!(h.scheduler.view(id, Instant::now()).is_none());
2139        assert!(h.bus.names()[removed_at..].is_empty());
2140        assert_eq!(h.bus.names()[removed_at - 1], "provider-removed");
2141        assert!(matches!(
2142            inputs.as_slice(),
2143            [HealthInput::ProvidersChanged { changed }]
2144                if changed[0].id == id && changed[0].generation.is_none()
2145        ));
2146    }
2147
2148    #[tokio::test]
2149    async fn concurrent_acquires_share_one_provider() {
2150        let h = Harness::new().await;
2151        let id = h.add_provider("shared").await;
2152
2153        let leases =
2154            futures::future::join_all((0..8).map(|_| h.provider_service.acquire(id))).await;
2155
2156        let first = leases[0].as_ref().unwrap();
2157        for lease in &leases {
2158            let lease = lease.as_ref().unwrap();
2159            assert!(Arc::ptr_eq(&lease.provider, &first.provider));
2160            assert_eq!(lease.generation, first.generation);
2161        }
2162    }
2163
2164    #[tokio::test]
2165    async fn a_lazy_acquire_racing_a_removal_does_not_bring_the_provider_back() {
2166        let h = Harness::new().await;
2167        let id = h.add_provider("racing").await;
2168        let lock = h.provider_service.commit_lock(id);
2169        let held = lock.lock().await;
2170        let removal = tokio::spawn({
2171            let service = Arc::clone(&h.provider_service);
2172            async move { service.remove_provider(id).await }
2173        });
2174        tokio::time::sleep(Duration::from_millis(100)).await;
2175        let acquire = tokio::spawn({
2176            let service = Arc::clone(&h.provider_service);
2177            async move { service.acquire(id).await }
2178        });
2179        tokio::time::sleep(Duration::from_millis(100)).await;
2180
2181        drop(held);
2182        removal.await.unwrap().unwrap();
2183        let acquired = acquire.await.unwrap();
2184
2185        assert!(matches!(acquired, Err(DomainError::ProviderNotFound(_))));
2186        assert!(h.provider_service.current(id).await.is_none());
2187        assert!(h.scheduler.view(id, Instant::now()).is_none());
2188    }
2189
2190    #[tokio::test]
2191    async fn changing_the_refresh_interval_keeps_the_generation_and_reschedules() {
2192        let h = Harness::new().await;
2193        h.start().await;
2194        let (id, _) = h.add_stub(StubProvider::new).await;
2195        h.pipeline_service
2196            .fetch_now(&[id], Duration::from_secs(5))
2197            .await;
2198        let before = h.provider_service.acquire(id).await.unwrap().generation;
2199        assert!(
2200            h.scheduler
2201                .view(id, Instant::now())
2202                .unwrap()
2203                .next_attempt_in
2204                > Duration::from_secs(20)
2205        );
2206
2207        h.provider_service
2208            .update_provider_refresh_interval(id, 5)
2209            .await
2210            .unwrap();
2211
2212        let after = h.provider_service.acquire(id).await.unwrap().generation;
2213        assert_eq!(before, after);
2214        assert!(
2215            h.scheduler
2216                .view(id, Instant::now())
2217                .unwrap()
2218                .next_attempt_in
2219                <= Duration::from_secs(5)
2220        );
2221        assert_eq!(
2222            h.repository
2223                .get_provider(id)
2224                .await
2225                .unwrap()
2226                .refresh_interval,
2227            5
2228        );
2229    }
2230
2231    #[tokio::test]
2232    async fn health_hears_about_every_provider_change() {
2233        let h = Harness::new().await;
2234
2235        let id = h
2236            .provider_service
2237            .add_provider(h.config("argo", 30))
2238            .await
2239            .unwrap();
2240        let added = h.health_inputs();
2241        let added_generation = h.provider_service.acquire(id).await.unwrap().generation;
2242        h.provider_service
2243            .update_provider(id, updated(&h, id))
2244            .await
2245            .unwrap();
2246        let updated_inputs = h.health_inputs();
2247        h.provider_service.load_all_providers().await.unwrap();
2248        let reloaded = h.health_inputs();
2249        let reloaded_generation = h.provider_service.acquire(id).await.unwrap().generation;
2250        h.provider_service.remove_provider(id).await.unwrap();
2251        let removed = h.health_inputs();
2252
2253        let generations = |inputs: &[HealthInput]| match inputs {
2254            [HealthInput::ProvidersChanged { changed }] => changed
2255                .iter()
2256                .map(|input| (input.id, input.generation))
2257                .collect::<Vec<_>>(),
2258            other => panic!("unexpected health inputs: {other:?}"),
2259        };
2260        assert_eq!(generations(&added), [(id, Some(added_generation))]);
2261        let updated_generation = generations(&updated_inputs)[0].1;
2262        assert!(updated_generation.is_some());
2263        assert_ne!(updated_generation, Some(added_generation));
2264        assert_eq!(generations(&reloaded), [(id, Some(reloaded_generation))]);
2265        assert_ne!(Some(reloaded_generation), updated_generation);
2266        assert_eq!(generations(&removed), [(id, None)]);
2267        assert_eq!(h.bus.count("provider-added"), 1);
2268        assert_eq!(h.bus.count("provider-updated"), 1);
2269        assert_eq!(h.bus.count("provider-removed"), 1);
2270    }
2271
2272    #[tokio::test]
2273    async fn workflow_parameters_are_cached_for_ten_minutes() {
2274        let h = Harness::new().await;
2275        let (id, stub) = h.add_stub(StubProvider::new).await;
2276        let workflow = format!("github__{id}__web");
2277
2278        let first = h
2279            .provider_service
2280            .get_workflow_parameters(id, &workflow)
2281            .await
2282            .unwrap();
2283        h.provider_service
2284            .get_workflow_parameters(id, &workflow)
2285            .await
2286            .unwrap();
2287        let cached_fetches = stub
2288            .parameter_fetches
2289            .load(std::sync::atomic::Ordering::SeqCst);
2290        sqlx::query(
2291            "UPDATE workflow_parameters_cache SET cached_at = datetime('now', '-11 minutes')",
2292        )
2293        .execute(&h.pool)
2294        .await
2295        .unwrap();
2296        h.provider_service
2297            .get_workflow_parameters(id, &workflow)
2298            .await
2299            .unwrap();
2300
2301        assert_eq!(first.len(), 1);
2302        assert_eq!(cached_fetches, 1);
2303        assert_eq!(
2304            stub.parameter_fetches
2305                .load(std::sync::atomic::Ordering::SeqCst),
2306            2
2307        );
2308    }
2309
2310    #[tokio::test]
2311    async fn workflow_parameter_failures_keep_their_kind_and_leave_no_lock_behind() {
2312        let h = Harness::new().await;
2313        let (id, stub) = h.add_stub(StubProvider::new).await;
2314        *stub.parameters.lock().unwrap() = Err(DomainError::RateLimited {
2315            message: "slow".to_string(),
2316            retry_after: None,
2317        });
2318
2319        let failed = h
2320            .provider_service
2321            .get_workflow_parameters(id, "github__1__web")
2322            .await;
2323        let missing = h
2324            .provider_service
2325            .get_workflow_parameters(4242, "github__4242__web")
2326            .await;
2327
2328        assert!(matches!(failed, Err(DomainError::RateLimited { .. })));
2329        assert!(matches!(missing, Err(DomainError::ProviderNotFound(_))));
2330        assert!(h.provider_service.parameter_fetches.is_empty());
2331    }
2332
2333    #[tokio::test]
2334    async fn fetching_workflow_parameters_does_not_block_the_provider_map() {
2335        let h = Harness::new().await;
2336        let gate = Gate::new();
2337        let (id, _) = h
2338            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2339            .await;
2340        let other = h.add_provider("other").await;
2341        let fetch = tokio::spawn({
2342            let service = Arc::clone(&h.provider_service);
2343            async move { service.get_workflow_parameters(id, "github__1__web").await }
2344        });
2345        gate.arrived().await;
2346
2347        let acquired =
2348            tokio::time::timeout(Duration::from_secs(2), h.provider_service.acquire(other)).await;
2349        gate.open();
2350
2351        assert!(acquired.is_ok());
2352        fetch.await.unwrap().unwrap();
2353    }
2354
2355    #[tokio::test]
2356    async fn workflow_parameters_fetched_across_a_removal_are_not_cached() {
2357        let h = Harness::new().await;
2358        let gate = Gate::new();
2359        let (id, _) = h
2360            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2361            .await;
2362        let workflow = format!("github__{id}__web");
2363        let fetch = tokio::spawn({
2364            let service = Arc::clone(&h.provider_service);
2365            let workflow = workflow.clone();
2366            async move { service.get_workflow_parameters(id, &workflow).await }
2367        });
2368        gate.arrived().await;
2369
2370        h.provider_service.remove_provider(id).await.unwrap();
2371        gate.open();
2372
2373        assert_eq!(fetch.await.unwrap().unwrap().len(), 1);
2374        assert!(h
2375            .repository
2376            .get_cached_workflow_parameters(&workflow)
2377            .await
2378            .unwrap()
2379            .is_none());
2380    }
2381
2382    #[tokio::test]
2383    async fn field_options_work_when_the_plugin_rejects_the_partial_form() {
2384        let h = Harness::with_real_plugins().await;
2385        let kubeconfig = h._temp_dir.path().join("kubeconfig");
2386        std::fs::write(
2387            &kubeconfig,
2388            "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",
2389        )
2390        .unwrap();
2391        let config = HashMap::from([
2392            ("kubeconfig".to_string(), kubeconfig.display().to_string()),
2393            ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2394        ]);
2395        assert!(h
2396            .provider_service
2397            .init_plugin("fluxcd", config.clone(), 0)
2398            .await
2399            .is_err());
2400
2401        let contexts = h
2402            .provider_service
2403            .field_options("fluxcd", "context", config)
2404            .await
2405            .unwrap();
2406
2407        assert_eq!(contexts, ["ctx-a", "ctx-b"]);
2408    }
2409
2410    #[tokio::test]
2411    async fn failed_field_options_report_why_the_plugin_could_not_initialize() {
2412        let h = Harness::with_real_plugins().await;
2413        let config = HashMap::from([
2414            (
2415                "kubeconfig".to_string(),
2416                h._temp_dir.path().join("missing").display().to_string(),
2417            ),
2418            ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2419        ]);
2420
2421        let rejected = h
2422            .provider_service
2423            .field_options("fluxcd", "namespaces", config)
2424            .await;
2425
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}