Skip to main content

stackless_stripe_projects/
stripe.rs

1//! The Stripe Projects CLI driver (ARCHITECTURE.md §4).
2//!
3//! Drives the `stripe projects` plugin non-interactively. The driver is
4//! generic over a [`CommandRunner`] so tests inject canned CLI envelopes.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::Deserialize;
12
13use crate::error::ProjectsError;
14
15const STRIPE_LOCK_BUDGET: Duration = Duration::from_secs(15);
16const STRIPE_CMD_BUDGET: Duration = Duration::from_secs(90);
17
18/// Wire-format category filters matching [`crate::catalog::Category`] (excludes
19/// `Unknown`). Used when unfiltered `catalog --json` returns an empty pipe.
20const CATALOG_CATEGORY_FILTERS: &[&str] = &[
21    "ai",
22    "analytics",
23    "auth",
24    "browser",
25    "cache",
26    "cdn",
27    "ci",
28    "communications",
29    "compute",
30    "database",
31    "domains",
32    "ecommerce",
33    "email",
34    "feature_flags",
35    "messaging",
36    "notification",
37    "observability",
38    "payments",
39    "queue",
40    "sandbox",
41    "search",
42    "storage",
43];
44
45fn json_object_slice(stdout: &str) -> Option<&str> {
46    stdout.find('{').map(|start| &stdout[start..])
47}
48
49fn envelope_service_count(json: &str) -> usize {
50    serde_json::from_str::<serde_json::Value>(json)
51        .ok()
52        .map(|value| services_in_envelope(&value))
53        .unwrap_or(0)
54}
55
56fn services_in_envelope(envelope: &serde_json::Value) -> usize {
57    envelope
58        .pointer("/data/services")
59        .and_then(serde_json::Value::as_array)
60        .map(Vec::len)
61        .unwrap_or(0)
62}
63
64#[derive(Debug, Clone)]
65pub struct CommandOutput {
66    pub status: i32,
67    pub stdout: String,
68    pub stderr: String,
69}
70
71#[async_trait]
72pub trait CommandRunner: Send + Sync {
73    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError>;
74}
75
76#[async_trait]
77impl<T: CommandRunner + ?Sized> CommandRunner for &T {
78    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
79        (**self).run(args, cwd).await
80    }
81}
82
83#[derive(Debug, Default)]
84pub struct TokioRunner;
85
86#[async_trait]
87impl CommandRunner for TokioRunner {
88    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
89        let args = args.to_vec();
90        let cwd: PathBuf = cwd.to_path_buf();
91        tokio::task::spawn_blocking(move || run_stripe_locked(&args, &cwd))
92            .await
93            .map_err(|err| ProjectsError::Unavailable {
94                detail: format!("stripe task panicked: {err}"),
95            })?
96    }
97}
98
99fn run_stripe_locked(args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
100    let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(cwd);
101    let _guard =
102        stackless_core::lockfile::FileLock::acquire_with_wait(&lock_path, STRIPE_LOCK_BUDGET)
103            .map_err(|err| ProjectsError::LockHeld {
104                definition_dir: cwd.display().to_string(),
105                detail: err.to_string(),
106            })?;
107    let mut cmd = std::process::Command::new("stripe");
108    cmd.arg("projects").args(args).current_dir(cwd);
109    match stackless_core::process::run_with_timeout(&mut cmd, STRIPE_CMD_BUDGET) {
110        stackless_core::process::TimedCommand::Finished(output) => Ok(CommandOutput {
111            status: output.status.code().unwrap_or(-1),
112            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
113            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
114        }),
115        stackless_core::process::TimedCommand::TimedOut { pid } => Err(ProjectsError::Timeout {
116            budget_secs: STRIPE_CMD_BUDGET.as_secs(),
117            detail: timeout_detail(pid, args),
118        }),
119        stackless_core::process::TimedCommand::Spawn(err) => Err(ProjectsError::Unavailable {
120            detail: format!("could not run `stripe`: {err}"),
121        }),
122    }
123}
124
125/// Timeout faults name only the Stripe verb. Later argv can be `--config`
126/// JSON with secrets and must not land in error/log surfaces.
127fn timeout_detail(pid: u32, args: &[String]) -> String {
128    let verb = args.first().map(String::as_str).unwrap_or("?");
129    format!("killed process group {pid} after `stripe projects {verb}`")
130}
131
132#[derive(Debug, Deserialize)]
133struct Envelope {
134    ok: bool,
135    #[serde(default)]
136    error: Option<EnvelopeError>,
137    #[serde(default)]
138    data: Option<serde_json::Value>,
139    #[serde(default)]
140    meta: Option<EnvelopeMeta>,
141}
142
143#[derive(Debug, Deserialize)]
144struct EnvelopeError {
145    #[serde(default)]
146    code: Option<String>,
147    #[serde(default)]
148    message: Option<String>,
149    #[serde(default)]
150    details: Option<serde_json::Value>,
151}
152
153#[derive(Debug, Deserialize)]
154struct EnvelopeMeta {
155    #[serde(default)]
156    authenticated: Option<bool>,
157}
158
159#[derive(Debug, Clone)]
160pub struct StripeResult {
161    pub ok: bool,
162    pub error_code: Option<String>,
163    pub error_message: Option<String>,
164    pub error_details: Option<serde_json::Value>,
165    pub authenticated: bool,
166    pub data: serde_json::Value,
167}
168
169const PLAIN_FALLBACK_CODES: &[&str] = &[
170    "JSON_REQUIRES_CONFIRMATION",
171    "JSON_REQUIRES_AUTH",
172    "DIRECTORY_SELECTION_REQUIRED",
173];
174
175pub struct StripeProjects<R: CommandRunner> {
176    runner: R,
177    dir: PathBuf,
178}
179
180impl<R: CommandRunner> std::fmt::Debug for StripeProjects<R> {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("StripeProjects")
183            .field("dir", &self.dir)
184            .finish_non_exhaustive()
185    }
186}
187
188impl<R: CommandRunner> StripeProjects<R> {
189    pub fn new(runner: R, dir: impl Into<PathBuf>) -> Self {
190        Self {
191            runner,
192            dir: dir.into(),
193        }
194    }
195
196    pub fn dir(&self) -> &Path {
197        &self.dir
198    }
199
200    /// Borrow as a `StripeProjects<&dyn CommandRunner>` so callers holding a
201    /// `dyn` dispatch target can run commands without being generic over `R`
202    /// (`impl CommandRunner for &T` makes the erased runner a valid runner).
203    pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> {
204        StripeProjects {
205            runner: &self.runner as &dyn CommandRunner,
206            dir: self.dir.clone(),
207        }
208    }
209
210    #[cfg(test)]
211    fn runner(&self) -> &R {
212        &self.runner
213    }
214
215    pub async fn json(&self, args: &[&str]) -> Result<StripeResult, ProjectsError> {
216        let mut argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
217        argv.push("--json".into());
218        let out = self.runner.run(&argv, &self.dir).await?;
219        let Some(start) = out.stdout.find('{') else {
220            let stderr = out.stderr.trim();
221            return Err(ProjectsError::Unavailable {
222                detail: format!(
223                    "`stripe projects {}` exited without delivering a JSON envelope{}",
224                    args.join(" "),
225                    if stderr.is_empty() {
226                        String::new()
227                    } else {
228                        format!(" (stderr: {stderr})")
229                    }
230                ),
231            });
232        };
233        let envelope: Envelope = serde_json::from_str(&out.stdout[start..]).map_err(|err| {
234            ProjectsError::Unavailable {
235                detail: format!(
236                    "`stripe projects {}` exited without delivering a parseable JSON envelope: {err}",
237                    args.join(" ")
238                ),
239            }
240        })?;
241        Ok(StripeResult {
242            ok: envelope.ok,
243            error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
244            error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
245            error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
246            authenticated: envelope
247                .meta
248                .as_ref()
249                .and_then(|m| m.authenticated)
250                .unwrap_or(true),
251            data: envelope.data.unwrap_or(serde_json::Value::Null),
252        })
253    }
254
255    /// Unfiltered catalog (`stripe projects catalog --json`).
256    ///
257    /// For live drift / full-model tooling only. Provisioning must use
258    /// [`Self::catalog_for_reference`].
259    pub async fn catalog(&self) -> Result<crate::catalog::Catalog, ProjectsError> {
260        let json = self.catalog_envelope_json().await?;
261        crate::catalog::Catalog::from_json_envelope(&json).map_err(|err| {
262            ProjectsError::Unavailable {
263                detail: format!("`stripe projects catalog` returned an unmodeled catalog: {err}"),
264            }
265        })
266    }
267
268    /// Full `{ok, data, …}` envelope text for `catalog --json`.
269    ///
270    /// Plugin 0.29.0 (and possibly later) often emits an empty stdout for the
271    /// unfiltered catalog when stdout is a pipe. Filtered
272    /// `catalog <category> --json` still works, so we fall back to merging every
273    /// known [`crate::catalog::Category`] filter when the unfiltered call is
274    /// empty or non-JSON. Auth / `ok: false` envelopes are classified and
275    /// returned as faults — they must not trigger the empty-pipe fallback.
276    pub async fn catalog_envelope_json(&self) -> Result<String, ProjectsError> {
277        let raw = self.plain(&["catalog", "--json"]).await?;
278        let Some(json) = json_object_slice(&raw.stdout) else {
279            return self.catalog_envelope_json_by_categories().await;
280        };
281        if let Some(fault) = self.catalog_envelope_fault(json) {
282            return Err(fault);
283        }
284        if envelope_service_count(json) > 0 {
285            return Ok(json.to_owned());
286        }
287        // Authenticated ok:true with an empty services list is a real empty
288        // catalog, not the empty-pipe bug. Return it as-is.
289        Ok(json.to_owned())
290    }
291
292    /// Classify a parsed catalog envelope that is not a successful service list.
293    /// Returns `None` when the JSON is `ok: true` (caller decides empty vs full).
294    fn catalog_envelope_fault(&self, json: &str) -> Option<ProjectsError> {
295        let envelope: Envelope = serde_json::from_str(json).ok()?;
296        if envelope.ok {
297            return None;
298        }
299        let result = StripeResult {
300            ok: envelope.ok,
301            error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
302            error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
303            error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
304            authenticated: envelope
305                .meta
306                .as_ref()
307                .and_then(|m| m.authenticated)
308                .unwrap_or(true),
309            data: envelope.data.unwrap_or(serde_json::Value::Null),
310        };
311        Some(self.classify_failure("catalog", &result))
312    }
313
314    async fn catalog_envelope_json_by_categories(&self) -> Result<String, ProjectsError> {
315        let mut services: BTreeMap<String, serde_json::Value> = BTreeMap::new();
316        let mut template: Option<serde_json::Value> = None;
317        for filter in CATALOG_CATEGORY_FILTERS {
318            let raw = self.plain(&["catalog", filter, "--json"]).await?;
319            let Some(json) = json_object_slice(&raw.stdout) else {
320                continue;
321            };
322            if let Some(fault) = self.catalog_envelope_fault(json) {
323                return Err(fault);
324            }
325            let mut envelope: serde_json::Value =
326                serde_json::from_str(json).map_err(|err| ProjectsError::Unavailable {
327                    detail: format!(
328                        "`stripe projects catalog {filter} --json` emitted malformed JSON: {err}"
329                    ),
330                })?;
331            if let Some(list) = envelope
332                .pointer_mut("/data/services")
333                .and_then(serde_json::Value::as_array_mut)
334            {
335                for service in list.drain(..) {
336                    let id = service
337                        .get("id")
338                        .and_then(serde_json::Value::as_str)
339                        .unwrap_or_default()
340                        .to_owned();
341                    if !id.is_empty() {
342                        services.insert(id, service);
343                    }
344                }
345            }
346            if template.is_none() {
347                template = Some(envelope);
348            }
349        }
350        let mut envelope = template.ok_or_else(|| ProjectsError::Unavailable {
351            detail: "`stripe projects catalog --json` produced no JSON, and every category filter was empty"
352                .into(),
353        })?;
354        if let Some(data) = envelope
355            .get_mut("data")
356            .and_then(serde_json::Value::as_object_mut)
357        {
358            data.insert("provider".into(), serde_json::Value::Null);
359            data.insert("category_filter".into(), serde_json::Value::Null);
360            data.insert("provider_filter".into(), serde_json::Value::Null);
361            data.insert(
362                "services".into(),
363                serde_json::Value::Array(services.into_values().collect()),
364            );
365        }
366        if services_in_envelope(&envelope) == 0 {
367            return Err(ProjectsError::Unavailable {
368                detail: "`stripe projects catalog` returned no services via unfiltered or category filters"
369                    .into(),
370            });
371        }
372        Ok(envelope.to_string())
373    }
374
375    /// Provider-scoped catalog for a `provider/service` reference.
376    ///
377    /// Runs `stripe projects catalog <provider> --json` once. The CLI filter
378    /// accepts a category or provider name; we pass the provider slug from the
379    /// reference (everything before the first `/`).
380    pub async fn catalog_for_reference(
381        &self,
382        reference: &str,
383    ) -> Result<crate::catalog::Catalog, ProjectsError> {
384        let provider = provider_from_reference(reference);
385        let data = self.run_ok("catalog", &["catalog", provider], &[]).await?;
386        let catalog: crate::catalog::Catalog =
387            serde_json::from_value(data).map_err(|err| ProjectsError::Unavailable {
388                detail: format!(
389                    "`stripe projects catalog {provider}` returned an unmodeled catalog: {err}"
390                ),
391            })?;
392        if catalog.lookup(reference).is_none() {
393            return Err(ProjectsError::CatalogMissing {
394                reference: reference.to_owned(),
395            });
396        }
397        Ok(catalog)
398    }
399
400    /// Provider-scoped catalog for a [`crate::catalog::verify::CatalogService`].
401    pub async fn catalog_for<C: crate::catalog::verify::CatalogService>(
402        &self,
403    ) -> Result<crate::catalog::Catalog, ProjectsError> {
404        self.catalog_for_reference(C::REFERENCE).await
405    }
406
407    pub async fn plain(&self, args: &[&str]) -> Result<CommandOutput, ProjectsError> {
408        let argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
409        self.runner.run(&argv, &self.dir).await
410    }
411
412    pub fn classify_failure(&self, command: &str, result: &StripeResult) -> ProjectsError {
413        let message = result
414            .error_message
415            .clone()
416            .unwrap_or_else(|| "unknown error".into());
417        let code = result.error_code.as_deref().unwrap_or("");
418        let auth_like = !result.authenticated
419            || code == "JSON_REQUIRES_AUTH"
420            || message.to_ascii_lowercase().contains("not authenticated")
421            || message.to_ascii_lowercase().contains("log in");
422        if auth_like {
423            ProjectsError::Auth { detail: message }
424        } else {
425            ProjectsError::Failed {
426                command: command.to_owned(),
427                detail: format!(
428                    "{message}{}",
429                    if code.is_empty() {
430                        String::new()
431                    } else {
432                        format!(" ({code})")
433                    }
434                ),
435            }
436        }
437    }
438
439    pub async fn run_ok(
440        &self,
441        command: &str,
442        args: &[&str],
443        plain_extra: &[&str],
444    ) -> Result<serde_json::Value, ProjectsError> {
445        let result = self.json(args).await?;
446        if result.ok {
447            return Ok(result.data);
448        }
449        let code = result.error_code.as_deref().unwrap_or("");
450        let message = result.error_message.clone().unwrap_or_default();
451        let live_mode = message.to_ascii_lowercase().contains("live mode");
452        if PLAIN_FALLBACK_CODES.contains(&code) || live_mode {
453            let mut plain_args: Vec<&str> = args.to_vec();
454            plain_args.extend_from_slice(plain_extra);
455            let out = self.plain(&plain_args).await?;
456            if out.status != 0 || out.stdout.contains('✗') || out.stderr.contains('✗') {
457                return Err(ProjectsError::Failed {
458                    command: command.to_owned(),
459                    detail: merge_output(&out),
460                });
461            }
462            return Ok(serde_json::Value::Null);
463        }
464        Err(self.classify_failure(command, &result))
465    }
466}
467
468fn merge_output(out: &CommandOutput) -> String {
469    let merged = format!("{}{}", out.stdout.trim(), out.stderr.trim());
470    merged.trim().to_owned()
471}
472
473/// Provider filter token for `stripe projects catalog <provider>`.
474pub fn provider_from_reference(reference: &str) -> &str {
475    reference
476        .split_once('/')
477        .map(|(p, _)| p)
478        .unwrap_or(reference)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use stackless_core::fault::{Fault, codes};
485    use std::sync::Mutex;
486
487    struct ScriptRunner {
488        outputs: Mutex<std::collections::VecDeque<CommandOutput>>,
489        calls: Mutex<Vec<Vec<String>>>,
490    }
491
492    impl ScriptRunner {
493        fn new(outputs: Vec<CommandOutput>) -> Self {
494            Self {
495                outputs: Mutex::new(outputs.into()),
496                calls: Mutex::new(Vec::new()),
497            }
498        }
499
500        fn calls(&self) -> Vec<Vec<String>> {
501            self.calls.lock().unwrap().clone()
502        }
503    }
504
505    #[async_trait]
506    impl CommandRunner for ScriptRunner {
507        async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
508            self.calls.lock().unwrap().push(args.to_vec());
509            self.outputs
510                .lock()
511                .unwrap()
512                .pop_front()
513                .ok_or_else(|| ProjectsError::Unavailable {
514                    detail: "ScriptRunner exhausted".into(),
515                })
516        }
517    }
518
519    fn out(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
520        CommandOutput {
521            status,
522            stdout: stdout.to_owned(),
523            stderr: stderr.to_owned(),
524        }
525    }
526
527    fn driver(outputs: Vec<CommandOutput>) -> StripeProjects<ScriptRunner> {
528        StripeProjects::new(ScriptRunner::new(outputs), std::env::temp_dir())
529    }
530
531    #[tokio::test]
532    async fn parses_ok_envelope() {
533        let d = driver(vec![out(
534            0,
535            r#"{"ok":true,"command":"status","version":"0.19.0","data":{"project":{"id":"proj_1"}}}"#,
536            "",
537        )]);
538        let result = d.json(&["status"]).await.unwrap();
539        assert!(result.ok);
540        assert_eq!(result.data["project"]["id"], "proj_1");
541    }
542
543    #[tokio::test]
544    async fn no_json_is_unavailable() {
545        let d = driver(vec![out(127, "", "command not found: stripe")]);
546        let err = d.json(&["status"]).await.unwrap_err();
547        assert_eq!(err.code(), codes::STRIPE_PROJECTS_UNAVAILABLE);
548        assert!(
549            err.to_string()
550                .contains("exited without delivering a JSON envelope"),
551            "detail should name the missing envelope, got: {err}"
552        );
553    }
554
555    #[test]
556    fn stripe_cli_timeout_kills_sleeper_and_is_timeout_fault() {
557        let mut cmd = std::process::Command::new("sleep");
558        cmd.arg("30");
559        match stackless_core::process::run_with_timeout(
560            &mut cmd,
561            STRIPE_CMD_BUDGET.min(Duration::from_millis(200)),
562        ) {
563            stackless_core::process::TimedCommand::TimedOut { pid } => {
564                assert!(pid > 0);
565                let err = ProjectsError::Timeout {
566                    budget_secs: STRIPE_CMD_BUDGET.as_secs(),
567                    detail: format!("killed process group {pid}"),
568                };
569                assert_eq!(err.code(), codes::STRIPE_PROJECTS_TIMEOUT);
570            }
571            other => panic!("expected timeout, got {other:?}"),
572        }
573    }
574
575    #[test]
576    fn timeout_detail_omits_config_payload() {
577        let detail = timeout_detail(
578            42,
579            &[
580                "add".into(),
581                "--config".into(),
582                r#"{"apiKey":"sk_test_secret"}"#.into(),
583            ],
584        );
585        assert!(detail.contains("stripe projects add"));
586        assert!(
587            !detail.contains("sk_test_secret") && !detail.contains("--config"),
588            "timeout detail leaked argv: {detail}"
589        );
590    }
591
592    #[test]
593    fn provider_from_reference_splits_on_first_slash() {
594        assert_eq!(provider_from_reference("clerk/auth"), "clerk");
595        assert_eq!(
596            provider_from_reference("wordpress.com/site"),
597            "wordpress.com"
598        );
599        assert_eq!(
600            provider_from_reference("cloudflare/r2:bucket"),
601            "cloudflare"
602        );
603        assert_eq!(
604            provider_from_reference("laravel_cloud/mysql"),
605            "laravel_cloud"
606        );
607        assert_eq!(provider_from_reference("bare"), "bare");
608    }
609
610    fn clerk_auth_catalog_envelope() -> String {
611        // Filtered live responses may set `provider` / `provider_filter` to an
612        // object rather than a string (observed on vercel/render smokes).
613        serde_json::json!({
614            "ok": true,
615            "command": "catalog",
616            "data": {
617                "last_updated": "1970-01-01T00:00:00.000Z",
618                "provider": { "id": "prvdr_clerk", "name": "Clerk" },
619                "provider_filter": { "name": "clerk" },
620                "source": "cache",
621                "services": [{
622                    "id": "clerk_auth",
623                    "object": "service",
624                    "provider_id": "clerk",
625                    "provider_name": "Clerk",
626                    "service_id": "auth",
627                    "kind": "saas",
628                    "scope": "account",
629                    "availability": "available",
630                    "development": true,
631                    "livemode": true,
632                    "pricing": { "type": "free" }
633                }]
634            }
635        })
636        .to_string()
637    }
638
639    #[tokio::test]
640    async fn catalog_for_reference_passes_provider_filter() {
641        let d = driver(vec![out(0, &clerk_auth_catalog_envelope(), "")]);
642        let catalog = d
643            .catalog_for_reference("clerk/auth")
644            .await
645            .expect("scoped catalog");
646        assert!(catalog.lookup("clerk/auth").is_some());
647        assert_eq!(
648            d.runner().calls(),
649            vec![vec![
650                "catalog".to_owned(),
651                "clerk".to_owned(),
652                "--json".to_owned()
653            ]]
654        );
655    }
656
657    #[tokio::test]
658    async fn catalog_for_reference_missing_service_is_catalog_missing() {
659        let empty = serde_json::json!({
660            "ok": true,
661            "command": "catalog",
662            "data": {
663                "last_updated": "1970-01-01T00:00:00.000Z",
664                "provider_filter": "clerk",
665                "services": []
666            }
667        })
668        .to_string();
669        let d = driver(vec![out(0, &empty, "")]);
670        let err = d.catalog_for_reference("clerk/auth").await.unwrap_err();
671        assert_eq!(err.code(), codes::STRIPE_PROJECTS_CATALOG_MISSING);
672    }
673
674    #[tokio::test]
675    async fn catalog_envelope_falls_back_to_category_filters() {
676        let service = r#"{"id":"prvsvc_1","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_1","provider_name":"Neon","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"}}"#;
677        let filtered = format!(
678            r#"{{"ok":true,"command":"projects catalog","version":"0.1","data":{{"last_updated":"t","provider":null,"category_filter":"database","provider_filter":null,"services":[{service}],"source":null}}}}"#
679        );
680        // Unfiltered empty, then one hit on the database filter; remaining
681        // category probes return empty objects so the merge still completes.
682        let mut outputs = vec![out(0, "", "")];
683        for filter in CATALOG_CATEGORY_FILTERS {
684            if *filter == "database" {
685                outputs.push(out(0, &filtered, ""));
686            } else {
687                outputs.push(out(
688                    0,
689                    r#"{"ok":true,"command":"projects catalog","version":"0.1","data":{"last_updated":"t","provider":null,"category_filter":null,"provider_filter":null,"services":[],"source":null}}"#,
690                    "",
691                ));
692            }
693        }
694        let d = driver(outputs);
695        let json = d.catalog_envelope_json().await.unwrap();
696        let catalog = crate::catalog::Catalog::from_json_envelope(&json).unwrap();
697        assert_eq!(catalog.services.len(), 1);
698        assert_eq!(catalog.services[0].reference(), "neon/postgres");
699        assert!(catalog.category_filter.is_none());
700    }
701
702    #[tokio::test]
703    async fn unauthenticated_envelope_is_auth_fault() {
704        let d = driver(vec![out(
705            0,
706            r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
707            "",
708        )]);
709        let err = d.run_ok("status", &["status"], &[]).await.unwrap_err();
710        assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
711    }
712
713    #[tokio::test]
714    async fn unauthenticated_catalog_envelope_is_auth_fault_not_empty_pipe_fallback() {
715        let d = driver(vec![out(
716            0,
717            r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
718            "",
719        )]);
720        let err = d.catalog_envelope_json().await.unwrap_err();
721        assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
722        // Only the unfiltered call — must not probe category filters.
723        assert_eq!(d.runner().calls().len(), 1);
724        assert_eq!(
725            d.runner().calls()[0],
726            vec!["catalog".to_owned(), "--json".to_owned()]
727        );
728    }
729
730    /// Opt-in live check (`STRIPE_CATALOG_LIVE=1`): run the real
731    /// `stripe projects catalog --json` and assert the typed model still fully
732    /// covers it. No-op in CI; the canonical way to catch a stale fixture.
733    #[tokio::test]
734    async fn live_catalog_matches_model() {
735        if std::env::var("STRIPE_CATALOG_LIVE").as_deref() != Ok("1") {
736            return;
737        }
738        let dir = std::env::current_dir().unwrap();
739        let stripe = StripeProjects::new(TokioRunner, dir);
740        let catalog = stripe.catalog().await.expect("live catalog should fetch");
741        let report = catalog.drift_report();
742        assert!(
743            report.is_empty(),
744            "LIVE catalog drift — refresh tests/fixtures/catalog.json and update the model:\n{}",
745            report.join("\n")
746        );
747    }
748
749    /// The per-catalog-state content version (`data.last_updated`) is stable
750    /// across requests but bumps whenever Stripe republishes the catalog —
751    /// noise unrelated to a real service/schema change. Normalize it so the
752    /// committed `catalog.json` only diffs on content we actually model.
753    const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
754
755    /// Bless mode (`STRIPE_PROJECTS_REFRESH=1`): regenerate the three committed
756    /// snapshots — `plugin-version.txt`, `catalog.json`, `command-surface.txt` —
757    /// from the LOCALLY INSTALLED plugin. The only path that writes fixtures;
758    /// invoked by `mise run stripe-refresh` and the CI watcher. A no-op (and
759    /// needs no `stripe`) otherwise, so it stays inert in the hermetic gate.
760    #[tokio::test]
761    async fn refresh_blesses_snapshots() {
762        if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") {
763            return;
764        }
765        let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
766        let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap());
767
768        // 1. Pinned plugin version.
769        let version = crate::surface::plugin_version(&stripe)
770            .await
771            .expect("probe `stripe projects --version`");
772
773        // 2. Catalog envelope — refuse to bless anything the typed model can't
774        //    fully represent (forces a src/catalog.rs update first). Uses
775        //    [`StripeProjects::catalog_envelope_json`] so an empty unfiltered
776        //    pipe (plugin 0.29.0) still blesses via category filters.
777        let json = stripe
778            .catalog_envelope_json()
779            .await
780            .expect("run `stripe projects catalog --json`");
781        let report = crate::catalog::Catalog::from_json_envelope(&json)
782            .expect("catalog envelope parses")
783            .drift_report();
784        assert!(
785            report.is_empty(),
786            "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}",
787            report.join("\n")
788        );
789        let mut envelope: serde_json::Value =
790            serde_json::from_str(&json).expect("catalog envelope is JSON");
791        if let Some(data) = envelope
792            .get_mut("data")
793            .and_then(serde_json::Value::as_object_mut)
794            && data.contains_key("last_updated")
795        {
796            data.insert(
797                "last_updated".into(),
798                serde_json::Value::String(NORMALIZED_TIMESTAMP.into()),
799            );
800        }
801        let catalog_pretty = format!(
802            "{}\n",
803            serde_json::to_string_pretty(&envelope).expect("serialize catalog")
804        );
805
806        // 3. Command surface (header carries the pinned version).
807        let body = crate::surface::command_surface(&stripe)
808            .await
809            .expect("capture command surface");
810        let surface = crate::surface::render_surface(&version, &body);
811
812        std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap();
813        std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap();
814        std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap();
815        eprintln!("blessed snapshots for stripe projects plugin v{version}");
816    }
817
818    #[tokio::test]
819    async fn confirmation_code_falls_back_to_plain_mode() {
820        let d = driver(vec![
821            out(
822                0,
823                r#"{"ok":false,"error":{"code":"JSON_REQUIRES_CONFIRMATION","message":"needs confirmation"}}"#,
824                "",
825            ),
826            out(0, "✓ created project", ""),
827        ]);
828        d.run_ok(
829            "init",
830            &["init", "atto", "--skip-skills", "--accept-tos"],
831            &["--accept-tos", "--yes"],
832        )
833        .await
834        .unwrap();
835        let calls = d.runner().calls();
836        assert_eq!(calls.len(), 2);
837        assert!(calls[0].contains(&"--json".to_owned()));
838        assert!(!calls[1].contains(&"--json".to_owned()));
839        assert!(calls[1].contains(&"--yes".to_owned()));
840    }
841}