1use std::collections::BTreeMap;
4use std::path::Path;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use stackless_core::def::{Namespace, StackDef};
10use stackless_core::substrate::StepResource;
11use stackless_core::types::DnsName;
12use stackless_provider_sdk::{
13 BlockedSetting, ConfigScope, Hostable, IntegrationError, IntegrationHosting,
14 IntegrationObservation, config_bool, config_optional_string, config_string, host_bound_hosts,
15};
16
17use stackless_stripe_projects::ProjectsError;
18use stackless_stripe_projects::catalog::verify::CatalogService;
19use stackless_stripe_projects::project;
20use stackless_stripe_projects::provision::{
21 ProvisionContext, ProvisionedCredentials, provision_with_credentials,
22};
23use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects};
24
25pub const RESOURCE_KIND: &str = "integration-clerk";
26const CLERK_API_BASE: &str = "https://api.clerk.com/v1";
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct ClerkPayload {
30 pub stripe_resource: String,
31 pub app_name: String,
32 pub credential_set: String,
33 #[serde(default)]
34 pub organizations: bool,
35 pub outputs: BTreeMap<String, String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct ClerkCredentialOutputs {
40 pub publishable_key: String,
41 pub secret_key: String,
42}
43
44#[derive(Debug, Serialize)]
47pub struct ClerkAuthConfig {
48 pub app_name: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub production_domain: Option<String>,
51}
52
53impl CatalogService for ClerkAuthConfig {
54 const REFERENCE: &'static str = "clerk/auth";
55}
56
57fn clerk_env_keys(resource_name: &str) -> Vec<String> {
61 let resource_prefix = resource_name.to_ascii_uppercase().replace('-', "_");
62 vec![
63 format!("{resource_prefix}_AUTH_ENVIRONMENTS"),
64 format!("{resource_prefix}_ENVIRONMENTS"),
65 "CLERK_AUTH_ENVIRONMENTS".to_owned(),
66 "CLERK_ENVIRONMENTS".to_owned(),
67 ]
68}
69
70#[derive(Debug)]
71pub struct ClerkAuth;
72
73impl Hostable for ClerkAuth {
74 const PROVIDER: &'static str = "clerk";
76 const HOSTING: IntegrationHosting = IntegrationHosting::Managed;
78 const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly;
80 const RESOURCE_KIND: &'static str = RESOURCE_KIND;
82 const OUTPUTS: &'static [&'static str] = &["secret_key", "publishable_key"];
84 const BLOCKED_SETTINGS: &'static [BlockedSetting] = &[
89 BlockedSetting {
90 key: "username",
91 remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
92 Enable username in the Clerk Dashboard \u{2192} User & authentication \u{2192} \
93 Username (Sign-in with username), or via `clerk config patch`.",
94 },
95 BlockedSetting {
96 key: "email",
97 remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
98 Configure the email identifier in the Clerk Dashboard \u{2192} User & \
99 authentication \u{2192} Email, or via `clerk config patch`.",
100 },
101 BlockedSetting {
102 key: "phone",
103 remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
104 Configure the phone identifier in the Clerk Dashboard \u{2192} User & \
105 authentication \u{2192} Phone, or via `clerk config patch`.",
106 },
107 ];
108}
109
110#[async_trait]
111impl crate::ProviderOps for ClerkAuth {
112 #[allow(clippy::too_many_arguments)]
113 async fn provision(
114 &self,
115 stripe: &StripeProjects<&dyn CommandRunner>,
116 def: &StackDef,
117 definition_dir: &Path,
118 instance: &str,
119 name: &str,
120 substrate: &str,
121 skip_stripe_instance_context: bool,
122 ) -> Result<StepResource, IntegrationError> {
123 provision_stripe(
124 stripe,
125 def,
126 definition_dir,
127 instance,
128 name,
129 substrate,
130 skip_stripe_instance_context,
131 )
132 .await
133 }
134
135 async fn apply(
136 &self,
137 _stripe: &StripeProjects<&dyn CommandRunner>,
138 _def: &StackDef,
139 _name: &str,
140 _substrate: &str,
141 provisioned: &StepResource,
142 ) -> Result<(), IntegrationError> {
143 let payload: ClerkPayload = serde_json::from_str(&provisioned.payload).map_err(|err| {
144 IntegrationError::ProvisionFailed {
145 integration: provisioned.resource_id.clone(),
146 detail: format!("Clerk checkpoint payload is invalid: {err}"),
147 }
148 })?;
149 if payload.organizations {
150 let secret_key = payload.outputs.get("secret_key").ok_or_else(|| {
151 IntegrationError::ProvisionFailed {
152 integration: payload.stripe_resource.clone(),
153 detail: "Clerk checkpoint missing secret_key output".into(),
154 }
155 })?;
156 enable_clerk_organizations(secret_key, &payload.stripe_resource).await?;
157 }
158 Ok(())
159 }
160
161 async fn observe(
162 &self,
163 stripe: &StripeProjects<&dyn CommandRunner>,
164 checkpoint_payload: &str,
165 fallback_resource: &str,
166 ) -> Result<IntegrationObservation, IntegrationError> {
167 observe(stripe, checkpoint_payload, fallback_resource).await
168 }
169
170 async fn destroy(
171 &self,
172 stripe: &StripeProjects<&dyn CommandRunner>,
173 checkpoint_payload: &str,
174 fallback_resource: &str,
175 ) -> Result<(), IntegrationError> {
176 destroy(stripe, checkpoint_payload, fallback_resource).await
177 }
178}
179
180fn build_clerk_config(ctx: &ProvisionContext<'_>) -> Result<ClerkAuthConfig, ProjectsError> {
182 let resource = ctx.resource_name();
183 let fail = |detail: String| ProjectsError::ProvisionFailed {
184 resource: resource.clone(),
185 detail,
186 };
187 let spec = ctx
188 .def
189 .integrations
190 .get(ctx.logical_name)
191 .ok_or_else(|| fail("integration not in definition".into()))?;
192 let config = spec.effective_config(ctx.substrate, host_bound_hosts(ClerkAuth::HOSTING));
193 let app_name_raw = config_string(&config, "app_name").map_err(|err| fail(err.to_string()))?;
194 let namespace = Namespace {
195 stack_name: ctx.def.stack.name.clone(),
196 instance_name: DnsName::from_stored(ctx.instance),
197 ..Namespace::default()
198 };
199 let location = format!("integrations.{}.app_name", ctx.logical_name);
200 let app_name = stackless_core::def::interp::resolve(&app_name_raw, &namespace, &location)
201 .map_err(|err| fail(err.to_string()))?;
202 let production_domain = match config_optional_string(&config, "production_domain") {
203 None => None,
204 Some(domain) => {
205 let location = format!("integrations.{}.production_domain", ctx.logical_name);
206 Some(
207 stackless_core::def::interp::resolve(&domain, &namespace, &location)
208 .map_err(|err| fail(err.to_string()))?,
209 )
210 }
211 };
212 Ok(ClerkAuthConfig {
213 app_name,
214 production_domain,
215 })
216}
217
218fn parse_clerk_credentials(
220 raw: &str,
221 credential_set: &str,
222 resource: &str,
223) -> Result<ClerkCredentialOutputs, ProjectsError> {
224 let parsed: ClerkAuthEnvironments =
225 serde_json::from_str(raw).map_err(|err| ProjectsError::ProvisionFailed {
226 resource: resource.to_owned(),
227 detail: format!("Clerk environments JSON is invalid: {err}"),
228 })?;
229 let credentials = match credential_set {
230 "development" => parsed.development,
231 "production" => parsed.production,
232 other => {
233 return Err(ProjectsError::ProvisionFailed {
234 resource: resource.to_owned(),
235 detail: format!("unknown Clerk credential_set {other:?}"),
236 });
237 }
238 };
239 let credentials = credentials.ok_or_else(|| ProjectsError::ProvisionFailed {
240 resource: resource.to_owned(),
241 detail: format!("Clerk environments JSON has no {credential_set} credentials"),
242 })?;
243 Ok(ClerkCredentialOutputs {
244 publishable_key: credentials.publishable_key,
245 secret_key: credentials.secret_key,
246 })
247}
248
249#[derive(Debug, Deserialize)]
250struct ClerkAuthEnvironments {
251 development: Option<ClerkCredentials>,
252 production: Option<ClerkCredentials>,
253}
254
255#[derive(Debug, Deserialize)]
256struct ClerkCredentials {
257 publishable_key: String,
258 secret_key: String,
259}
260
261pub fn validate_config(
262 name: &str,
263 config: &std::collections::BTreeMap<String, toml::Value>,
264) -> Result<(), IntegrationError> {
265 config_string(config, "app_name").map_err(|err| IntegrationError::ConfigInvalid {
266 location: format!("integrations.{name}.app_name"),
267 detail: err.to_string(),
268 })?;
269 let credential_set =
270 config_string(config, "credential_set").map_err(|err| IntegrationError::ConfigInvalid {
271 location: format!("integrations.{name}.credential_set"),
272 detail: err.to_string(),
273 })?;
274 match credential_set.as_str() {
275 "development" => Ok(()),
276 "production" => {
277 if config_optional_string(config, "production_domain").is_none() {
278 Err(IntegrationError::ConfigInvalid {
279 location: format!("integrations.{name}.production_domain"),
280 detail: "credential_set = \"production\" requires production_domain".into(),
281 })
282 } else {
283 Ok(())
284 }
285 }
286 other => Err(IntegrationError::ConfigInvalid {
287 location: format!("integrations.{name}.credential_set"),
288 detail: format!(
289 "credential_set must be \"development\" or \"production\", got {other:?}"
290 ),
291 }),
292 }
293}
294
295pub async fn provision_stripe<R: CommandRunner>(
296 stripe: &StripeProjects<R>,
297 def: &StackDef,
298 definition_dir: &Path,
299 instance: &str,
300 name: &str,
301 substrate: &str,
302 skip_instance_context: bool,
303) -> Result<StepResource, IntegrationError> {
304 if !def.integrations.contains_key(name) {
305 return Err(IntegrationError::ConfigInvalid {
306 location: format!("integrations.{name}"),
307 detail: "integration not in definition".into(),
308 });
309 }
310 let ctx = ProvisionContext {
311 def,
312 instance,
313 logical_name: name,
314 definition_dir,
315 substrate,
316 skip_instance_context,
317 };
318 let config = build_clerk_config(&ctx)?;
319 let app_name = config.app_name.clone();
320 let catalog = stripe
321 .catalog_for_reference(ClerkAuthConfig::REFERENCE)
322 .await?;
323 let env_key_owned = clerk_env_keys(&ctx.resource_name());
324 let env_keys: Vec<&str> = env_key_owned.iter().map(String::as_str).collect();
325 let ProvisionedCredentials { resource_name, raw } =
326 provision_with_credentials(stripe, &catalog, &ctx, &config, &env_keys).await?;
327
328 let spec = &def.integrations[name];
329 let effective = spec.effective_config(substrate, host_bound_hosts(ClerkAuth::HOSTING));
330 let credential_set = config_string(&effective, "credential_set").map_err(|err| {
331 IntegrationError::ConfigInvalid {
332 location: format!("integrations.{name}.credential_set"),
333 detail: err.to_string(),
334 }
335 })?;
336 let outputs = parse_clerk_credentials(&raw, &credential_set, &resource_name)?;
337
338 let organizations = config_bool(&effective, "organizations");
339
340 let mut output_map = BTreeMap::new();
341 output_map.insert("publishable_key".to_owned(), outputs.publishable_key);
342 output_map.insert("secret_key".to_owned(), outputs.secret_key);
343
344 let payload = ClerkPayload {
345 stripe_resource: resource_name.clone(),
346 app_name,
347 credential_set,
348 organizations,
349 outputs: output_map,
350 };
351 Ok(StepResource {
352 resource_kind: RESOURCE_KIND.into(),
353 resource_id: resource_name,
354 payload: serde_json::to_string(&payload).unwrap_or_default(),
355 })
356}
357
358pub async fn observe<R: CommandRunner>(
359 stripe: &StripeProjects<R>,
360 checkpoint_payload: &str,
361 fallback_resource: &str,
362) -> Result<IntegrationObservation, IntegrationError> {
363 let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
364 let present = project::resource_registered(stripe, &resource).await?;
365 Ok(if present {
366 IntegrationObservation::Present { drift: vec![] }
367 } else {
368 IntegrationObservation::Gone
369 })
370}
371
372pub async fn destroy<R: CommandRunner>(
373 stripe: &StripeProjects<R>,
374 checkpoint_payload: &str,
375 fallback_resource: &str,
376) -> Result<(), IntegrationError> {
377 let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
378 project::remove_resource(stripe, &resource).await?;
379 Ok(())
380}
381
382fn stripe_resource(payload: &str) -> Option<String> {
383 serde_json::from_str::<ClerkPayload>(payload)
384 .ok()
385 .map(|payload| payload.stripe_resource)
386}
387
388async fn enable_clerk_organizations(
389 secret_key: &str,
390 resource: &str,
391) -> Result<(), IntegrationError> {
392 update_clerk_organization_settings(CLERK_API_BASE, secret_key, true, resource).await
393}
394
395async fn update_clerk_organization_settings(
396 base: &str,
397 secret_key: &str,
398 enabled: bool,
399 resource: &str,
400) -> Result<(), IntegrationError> {
401 let url = format!(
402 "{}/instance/organization_settings",
403 base.trim_end_matches('/')
404 );
405 let response = reqwest::Client::new()
406 .patch(url)
407 .bearer_auth(secret_key)
408 .header(reqwest::header::ACCEPT, "application/json")
409 .json(&serde_json::json!({
410 "enabled": enabled,
411 "slug_disabled": !enabled,
412 }))
413 .timeout(Duration::from_secs(30))
414 .send()
415 .await
416 .map_err(|err| IntegrationError::ProvisionFailed {
417 integration: resource.to_owned(),
418 detail: format!("Clerk organization settings request failed: {err}"),
419 })?;
420 let status = response.status();
421 let text = response
422 .text()
423 .await
424 .map_err(|err| IntegrationError::ProvisionFailed {
425 integration: resource.to_owned(),
426 detail: format!("Clerk organization settings response failed: {err}"),
427 })?;
428 if !status.is_success() {
429 return Err(IntegrationError::ProvisionFailed {
430 integration: resource.to_owned(),
431 detail: format!(
432 "Clerk organization settings update failed: {}: {}",
433 status.as_u16(),
434 text.chars().take(300).collect::<String>()
435 ),
436 });
437 }
438 Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
445 use stackless_stripe_projects::test_support::ScriptedRunner;
446 use wiremock::matchers::{body_json, header, method, path};
447 use wiremock::{Mock, MockServer, ResponseTemplate};
448
449 fn out(stdout: &str) -> CommandOutput {
450 CommandOutput {
451 status: 0,
452 stdout: stdout.to_owned(),
453 stderr: String::new(),
454 }
455 }
456
457 #[test]
460 fn clerk_config_matches_catalog() {
461 const FIXTURE: &str = include_str!(concat!(
462 env!("CARGO_MANIFEST_DIR"),
463 "/../stackless-stripe-projects/tests/fixtures/catalog.json"
464 ));
465 let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
466 let failures = stackless_stripe_projects::verify_service(
467 &catalog,
468 &ClerkAuthConfig {
469 app_name: "atto-demo".into(),
470 production_domain: None,
471 },
472 );
473 assert!(
474 failures.is_empty(),
475 "clerk catalog gaps:\n{}",
476 failures.join("\n")
477 );
478 }
479
480 const CLERK_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
482 "last_updated":"2026-06-12T00:00:00Z","services":[{
483 "id":"prvsvc_clerk","object":"v2.provisioning.provider_service_detail",
484 "provider_id":"prvdr_clerk","provider_name":"Clerk","service_id":"auth",
485 "categories":["auth"],"kind":"deployable","scope":"project","availability":"available",
486 "development":false,"livemode":true,"pricing":{"type":"component"},
487 "configuration_schema":{"type":"object","required":["app_name"],"additionalProperties":false,
488 "properties":{"app_name":{"type":"string"},"production_domain":{"type":"string"}}}
489 }]}}"#;
490
491 fn test_def() -> StackDef {
492 StackDef::parse(
493 r#"
494[stack]
495name = "atto"
496[stack.projects.stripe]
497project = "project_1"
498
499[integrations.clerk]
500provider = "clerk"
501app_name = "${stack.name}-${instance.name}"
502credential_set = "development"
503
504[services.api]
505source = { repo = "r", ref = "main" }
506env = { CLERK_SECRET_KEY = "${integrations.clerk.secret_key}" }
507health = { path = "/health" }
508[services.api.local]
509run = "true"
510"#,
511 )
512 .unwrap()
513 }
514
515 #[tokio::test]
516 async fn provision_clerk_adds_resource_and_records_outputs() {
517 let auth_env = serde_json::json!({
518 "development": {
519 "publishable_key": "pk_test_123",
520 "secret_key": "sk_test_123"
521 }
522 })
523 .to_string();
524 let runner = ScriptedRunner::new(vec![
525 out(CLERK_CATALOG_ENVELOPE),
526 out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
527 out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
528 out(r#"{"ok":true,"data":null}"#),
529 out(r#"{"ok":true,"data":{"services":[]}}"#),
530 out(&serde_json::json!({
531 "ok": true,
532 "data": {
533 "variables": {
534 "CLERK_AUTH_ENVIRONMENTS": auth_env
535 }
536 }
537 })
538 .to_string()),
539 out(r#"{"ok":true,"data":null}"#),
540 ]);
541 let dir = tempfile::tempdir().unwrap();
542 std::fs::write(
543 dir.path().join("stackless.toml"),
544 "[stack]\nname=\"atto\"\n",
545 )
546 .unwrap();
547 let stripe = StripeProjects::new(&runner, dir.path());
548 let resource = provision_stripe(
549 &stripe,
550 &test_def(),
551 dir.path(),
552 "demo",
553 "clerk",
554 "local",
555 false,
556 )
557 .await
558 .unwrap();
559
560 assert_eq!(resource.resource_kind, "integration-clerk");
561 assert_eq!(resource.resource_id, "demo-clerk");
562 let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
563 assert_eq!(payload.app_name, "atto-demo");
564 assert!(!payload.organizations);
565 assert_eq!(payload.outputs["secret_key"], "sk_test_123");
566 assert_eq!(payload.outputs["publishable_key"], "pk_test_123");
567
568 let calls = runner.calls();
569 assert!(calls.iter().any(|call| {
570 call.starts_with(&[
571 "add".to_owned(),
572 "clerk/auth".to_owned(),
573 "--name".to_owned(),
574 "demo-clerk".to_owned(),
575 ])
576 }));
577 }
578
579 #[test]
580 fn clerk_env_keys_prefer_resource_prefix() {
581 assert_eq!(
582 clerk_env_keys("e2e-clerk"),
583 [
584 "E2E_CLERK_AUTH_ENVIRONMENTS",
585 "E2E_CLERK_ENVIRONMENTS",
586 "CLERK_AUTH_ENVIRONMENTS",
587 "CLERK_ENVIRONMENTS",
588 ]
589 );
590 }
591
592 #[tokio::test]
593 async fn provision_clerk_prefers_resource_prefixed_env_blob() {
594 let resource_env = serde_json::json!({
595 "development": {
596 "publishable_key": "pk_resource",
597 "secret_key": "sk_resource"
598 }
599 })
600 .to_string();
601 let provider_env = serde_json::json!({
602 "development": {
603 "publishable_key": "pk_provider",
604 "secret_key": "sk_provider"
605 }
606 })
607 .to_string();
608 let runner = ScriptedRunner::new(vec![
609 out(CLERK_CATALOG_ENVELOPE),
610 out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
611 out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
612 out(r#"{"ok":true,"data":null}"#),
613 out(r#"{"ok":true,"data":{"services":[]}}"#),
614 out(&serde_json::json!({
615 "ok": true,
616 "data": {
617 "variables": {
618 "DEMO_CLERK_ENVIRONMENTS": resource_env,
619 "CLERK_ENVIRONMENTS": provider_env
620 }
621 }
622 })
623 .to_string()),
624 out(r#"{"ok":true,"data":null}"#),
625 ]);
626 let dir = tempfile::tempdir().unwrap();
627 std::fs::write(
628 dir.path().join("stackless.toml"),
629 "[stack]\nname=\"atto\"\n",
630 )
631 .unwrap();
632 let stripe = StripeProjects::new(&runner, dir.path());
633 let resource = provision_stripe(
634 &stripe,
635 &test_def(),
636 dir.path(),
637 "demo",
638 "clerk",
639 "local",
640 false,
641 )
642 .await
643 .unwrap();
644 let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
645 assert_eq!(payload.outputs["secret_key"], "sk_resource");
646 assert_eq!(payload.outputs["publishable_key"], "pk_resource");
647 }
648
649 #[tokio::test]
652 async fn provision_clerk_resource_env_wins_over_shared_auth_env() {
653 let resource_env = serde_json::json!({
654 "development": {
655 "publishable_key": "pk_resource",
656 "secret_key": "sk_resource"
657 }
658 })
659 .to_string();
660 let shared_auth_env = serde_json::json!({
661 "development": {
662 "publishable_key": "pk_shared_auth",
663 "secret_key": "sk_shared_auth"
664 }
665 })
666 .to_string();
667 let runner = ScriptedRunner::new(vec![
668 out(CLERK_CATALOG_ENVELOPE),
669 out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
670 out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
671 out(r#"{"ok":true,"data":null}"#),
672 out(r#"{"ok":true,"data":{"services":[]}}"#),
673 out(&serde_json::json!({
674 "ok": true,
675 "data": {
676 "variables": {
677 "DEMO_CLERK_ENVIRONMENTS": resource_env,
678 "CLERK_AUTH_ENVIRONMENTS": shared_auth_env
679 }
680 }
681 })
682 .to_string()),
683 out(r#"{"ok":true,"data":null}"#),
684 ]);
685 let dir = tempfile::tempdir().unwrap();
686 std::fs::write(
687 dir.path().join("stackless.toml"),
688 "[stack]\nname=\"atto\"\n",
689 )
690 .unwrap();
691 let stripe = StripeProjects::new(&runner, dir.path());
692 let resource = provision_stripe(
693 &stripe,
694 &test_def(),
695 dir.path(),
696 "demo",
697 "clerk",
698 "local",
699 false,
700 )
701 .await
702 .unwrap();
703 let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
704 assert_eq!(payload.outputs["secret_key"], "sk_resource");
705 assert_eq!(payload.outputs["publishable_key"], "pk_resource");
706 }
707
708 #[tokio::test]
709 async fn observe_and_destroy_use_stripe_resource_from_payload() {
710 let payload = serde_json::to_string(&ClerkPayload {
711 stripe_resource: "demo-clerk".into(),
712 app_name: "atto-demo".into(),
713 credential_set: "development".into(),
714 organizations: true,
715 outputs: BTreeMap::new(),
716 })
717 .unwrap();
718 let runner = ScriptedRunner::new(vec![
719 out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
721 out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
723 out(r#"{"ok":true,"data":null}"#),
725 ]);
726 let stripe = StripeProjects::new(&runner, std::env::temp_dir());
727
728 assert_eq!(
729 observe(&stripe, &payload, "fallback").await.unwrap(),
730 IntegrationObservation::Present { drift: vec![] }
731 );
732 destroy(&stripe, &payload, "fallback").await.unwrap();
733 let calls = runner.calls();
734 assert!(
735 calls
736 .iter()
737 .any(|call| call.starts_with(&["remove".to_owned(), "demo-clerk".to_owned()]))
738 );
739 }
740
741 #[tokio::test]
745 async fn apply_skips_toggle_when_organizations_disabled() {
746 use crate::ProviderOps;
747
748 let payload = serde_json::to_string(&ClerkPayload {
749 stripe_resource: "demo-clerk".into(),
750 app_name: "atto-demo".into(),
751 credential_set: "development".into(),
752 organizations: false,
753 outputs: BTreeMap::new(),
754 })
755 .unwrap();
756 let provisioned = StepResource {
757 resource_kind: RESOURCE_KIND.into(),
758 resource_id: "demo-clerk".into(),
759 payload,
760 };
761 let runner = ScriptedRunner::new(vec![]);
762 let stripe = StripeProjects::new(&runner, std::env::temp_dir());
763 ClerkAuth
764 .apply(
765 &stripe.as_dyn(),
766 &test_def(),
767 "clerk",
768 "local",
769 &provisioned,
770 )
771 .await
772 .unwrap();
773 assert!(runner.calls().is_empty(), "apply must not touch Stripe");
774
775 let corrupt = StepResource {
776 resource_kind: RESOURCE_KIND.into(),
777 resource_id: "demo-clerk".into(),
778 payload: "not-json".into(),
779 };
780 let err = ClerkAuth
781 .apply(&stripe.as_dyn(), &test_def(), "clerk", "local", &corrupt)
782 .await
783 .unwrap_err();
784 assert!(err.to_string().contains("payload is invalid"));
785 }
786
787 #[tokio::test]
788 async fn enabling_clerk_organizations_patches_instance_settings() {
789 let server = MockServer::start().await;
790 Mock::given(method("PATCH"))
791 .and(path("/instance/organization_settings"))
792 .and(header("authorization", "Bearer sk_test_123"))
793 .and(body_json(serde_json::json!({
794 "enabled": true,
795 "slug_disabled": false,
796 })))
797 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
798 "object": "organization_settings",
799 "enabled": true
800 })))
801 .mount(&server)
802 .await;
803
804 update_clerk_organization_settings(&server.uri(), "sk_test_123", true, "demo-clerk")
805 .await
806 .unwrap();
807 }
808}