1use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::Result;
18use crate::pagination::Paginated;
19
20use super::MANAGED_AGENTS_BETA;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct Vault {
31 pub id: String,
33 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
35 pub ty: Option<String>,
36 pub display_name: String,
38 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40 pub metadata: HashMap<String, String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub created_at: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub updated_at: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub archived_at: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize)]
54#[non_exhaustive]
55pub struct CreateVaultRequest {
56 pub display_name: String,
58 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
60 pub metadata: HashMap<String, String>,
61}
62
63impl CreateVaultRequest {
64 #[must_use]
66 pub fn new(display_name: impl Into<String>) -> Self {
67 Self {
68 display_name: display_name.into(),
69 metadata: HashMap::new(),
70 }
71 }
72
73 #[must_use]
75 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
76 self.metadata.insert(key.into(), value.into());
77 self
78 }
79}
80
81#[derive(Debug, Clone, Default)]
83#[non_exhaustive]
84pub struct ListVaultsParams {
85 pub after: Option<String>,
87 pub before: Option<String>,
89 pub limit: Option<u32>,
91 pub include_archived: Option<bool>,
93}
94
95impl ListVaultsParams {
96 fn to_query(&self) -> Vec<(&'static str, String)> {
97 let mut q = Vec::new();
98 if let Some(a) = &self.after {
99 q.push(("after", a.clone()));
100 }
101 if let Some(b) = &self.before {
102 q.push(("before", b.clone()));
103 }
104 if let Some(l) = self.limit {
105 q.push(("limit", l.to_string()));
106 }
107 if let Some(ia) = self.include_archived {
108 q.push(("include_archived", ia.to_string()));
109 }
110 q
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum TokenEndpointAuth {
123 None,
125 ClientSecretBasic {
127 client_secret: String,
129 },
130 ClientSecretPost {
132 client_secret: String,
134 },
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[non_exhaustive]
140pub struct OAuthRefresh {
141 pub token_endpoint: String,
143 pub client_id: String,
145 pub refresh_token: String,
147 pub token_endpoint_auth: TokenEndpointAuth,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub scope: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq)]
159pub enum CredentialAuth {
160 McpOauth(McpOauthAuth),
163 StaticBearer(StaticBearerAuth),
165 Other(serde_json::Value),
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct McpOauthAuth {
173 pub mcp_server_url: String,
176 pub access_token: String,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub expires_at: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub refresh: Option<OAuthRefresh>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[non_exhaustive]
190pub struct StaticBearerAuth {
191 pub mcp_server_url: String,
193 pub token: String,
195}
196
197const KNOWN_CREDENTIAL_AUTH_TAGS: &[&str] = &["mcp_oauth", "static_bearer"];
198
199impl Serialize for CredentialAuth {
200 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
201 match self {
202 Self::McpOauth(v) => {
203 use serde::ser::SerializeMap;
204 let mut map = s.serialize_map(None)?;
205 map.serialize_entry("type", "mcp_oauth")?;
206 map.serialize_entry("mcp_server_url", &v.mcp_server_url)?;
207 map.serialize_entry("access_token", &v.access_token)?;
208 if let Some(e) = &v.expires_at {
209 map.serialize_entry("expires_at", e)?;
210 }
211 if let Some(r) = &v.refresh {
212 map.serialize_entry("refresh", r)?;
213 }
214 map.end()
215 }
216 Self::StaticBearer(v) => {
217 use serde::ser::SerializeMap;
218 let mut map = s.serialize_map(None)?;
219 map.serialize_entry("type", "static_bearer")?;
220 map.serialize_entry("mcp_server_url", &v.mcp_server_url)?;
221 map.serialize_entry("token", &v.token)?;
222 map.end()
223 }
224 Self::Other(v) => v.serialize(s),
225 }
226 }
227}
228
229impl<'de> Deserialize<'de> for CredentialAuth {
230 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
231 let raw = serde_json::Value::deserialize(d)?;
232 let tag = raw.get("type").and_then(serde_json::Value::as_str);
233 match tag {
234 Some("mcp_oauth") if KNOWN_CREDENTIAL_AUTH_TAGS.contains(&"mcp_oauth") => {
235 let body = serde_json::from_value::<McpOauthAuth>(raw)
236 .map_err(serde::de::Error::custom)?;
237 Ok(Self::McpOauth(body))
238 }
239 Some("static_bearer") if KNOWN_CREDENTIAL_AUTH_TAGS.contains(&"static_bearer") => {
240 let body = serde_json::from_value::<StaticBearerAuth>(raw)
241 .map_err(serde::de::Error::custom)?;
242 Ok(Self::StaticBearer(body))
243 }
244 _ => Ok(Self::Other(raw)),
245 }
246 }
247}
248
249impl CredentialAuth {
250 #[must_use]
252 pub fn mcp_oauth(
253 mcp_server_url: impl Into<String>,
254 access_token: impl Into<String>,
255 ) -> McpOauthBuilder {
256 McpOauthBuilder {
257 mcp_server_url: mcp_server_url.into(),
258 access_token: access_token.into(),
259 expires_at: None,
260 refresh: None,
261 }
262 }
263
264 #[must_use]
266 pub fn static_bearer(mcp_server_url: impl Into<String>, token: impl Into<String>) -> Self {
267 Self::StaticBearer(StaticBearerAuth {
268 mcp_server_url: mcp_server_url.into(),
269 token: token.into(),
270 })
271 }
272}
273
274#[derive(Debug, Clone)]
276pub struct McpOauthBuilder {
277 mcp_server_url: String,
278 access_token: String,
279 expires_at: Option<String>,
280 refresh: Option<OAuthRefresh>,
281}
282
283impl McpOauthBuilder {
284 #[must_use]
286 pub fn expires_at(mut self, when: impl Into<String>) -> Self {
287 self.expires_at = Some(when.into());
288 self
289 }
290
291 #[must_use]
293 pub fn refresh(mut self, refresh: OAuthRefresh) -> Self {
294 self.refresh = Some(refresh);
295 self
296 }
297
298 #[must_use]
300 pub fn build(self) -> CredentialAuth {
301 CredentialAuth::McpOauth(McpOauthAuth {
302 mcp_server_url: self.mcp_server_url,
303 access_token: self.access_token,
304 expires_at: self.expires_at,
305 refresh: self.refresh,
306 })
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
314#[non_exhaustive]
315pub struct Credential {
316 pub id: String,
318 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
320 pub ty: Option<String>,
321 pub vault_id: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub display_name: Option<String>,
326 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
328 pub metadata: HashMap<String, String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub auth: Option<CredentialAuthResponse>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub created_at: Option<String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub updated_at: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub archived_at: Option<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(tag = "type", rename_all = "snake_case")]
351#[non_exhaustive]
352pub enum CredentialAuthResponse {
353 McpOauth {
355 mcp_server_url: String,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 expires_at: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 refresh: Option<serde_json::Value>,
363 },
364 StaticBearer {
366 mcp_server_url: String,
368 },
369 #[serde(other)]
371 Other,
372}
373
374#[derive(Debug, Clone, Serialize)]
376#[non_exhaustive]
377pub struct CreateCredentialRequest {
378 pub auth: CredentialAuth,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub display_name: Option<String>,
383}
384
385impl CreateCredentialRequest {
386 #[must_use]
388 pub fn new(auth: CredentialAuth) -> Self {
389 Self {
390 auth,
391 display_name: None,
392 }
393 }
394
395 #[must_use]
397 pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
398 self.display_name = Some(name.into());
399 self
400 }
401}
402
403#[derive(Debug, Clone, Default, Serialize)]
407#[non_exhaustive]
408pub struct UpdateCredentialRequest {
409 #[serde(skip_serializing_if = "Option::is_none")]
412 pub auth: Option<CredentialAuthPatch>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub display_name: Option<String>,
416}
417
418#[derive(Debug, Clone, Serialize)]
422#[serde(tag = "type", rename_all = "snake_case")]
423#[non_exhaustive]
424pub enum CredentialAuthPatch {
425 McpOauth {
427 #[serde(skip_serializing_if = "Option::is_none")]
429 access_token: Option<String>,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 expires_at: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
435 refresh: Option<OAuthRefreshPatch>,
436 },
437 StaticBearer {
439 #[serde(skip_serializing_if = "Option::is_none")]
441 token: Option<String>,
442 },
443}
444
445#[derive(Debug, Clone, Default, Serialize)]
447#[non_exhaustive]
448pub struct OAuthRefreshPatch {
449 #[serde(skip_serializing_if = "Option::is_none")]
451 pub refresh_token: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub scope: Option<String>,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub token_endpoint_auth: Option<TokenEndpointAuth>,
458}
459
460pub struct Vaults<'a> {
466 client: &'a Client,
467}
468
469impl<'a> Vaults<'a> {
470 pub(crate) fn new(client: &'a Client) -> Self {
471 Self { client }
472 }
473
474 pub async fn create(&self, request: CreateVaultRequest) -> Result<Vault> {
476 let body = &request;
477 self.client
478 .execute_with_retry(
479 || {
480 self.client
481 .request_builder(reqwest::Method::POST, "/v1/vaults")
482 .json(body)
483 },
484 &[MANAGED_AGENTS_BETA],
485 )
486 .await
487 }
488
489 pub async fn retrieve(&self, vault_id: &str) -> Result<Vault> {
491 let path = format!("/v1/vaults/{vault_id}");
492 self.client
493 .execute_with_retry(
494 || self.client.request_builder(reqwest::Method::GET, &path),
495 &[MANAGED_AGENTS_BETA],
496 )
497 .await
498 }
499
500 pub async fn list(&self, params: ListVaultsParams) -> Result<Paginated<Vault>> {
502 let query = params.to_query();
503 self.client
504 .execute_with_retry(
505 || {
506 let mut req = self
507 .client
508 .request_builder(reqwest::Method::GET, "/v1/vaults");
509 for (k, v) in &query {
510 req = req.query(&[(k, v)]);
511 }
512 req
513 },
514 &[MANAGED_AGENTS_BETA],
515 )
516 .await
517 }
518
519 pub async fn archive(&self, vault_id: &str) -> Result<Vault> {
521 let path = format!("/v1/vaults/{vault_id}/archive");
522 self.client
523 .execute_with_retry(
524 || self.client.request_builder(reqwest::Method::POST, &path),
525 &[MANAGED_AGENTS_BETA],
526 )
527 .await
528 }
529
530 pub async fn delete(&self, vault_id: &str) -> Result<()> {
533 let path = format!("/v1/vaults/{vault_id}");
534 let _: serde_json::Value = self
535 .client
536 .execute_with_retry(
537 || self.client.request_builder(reqwest::Method::DELETE, &path),
538 &[MANAGED_AGENTS_BETA],
539 )
540 .await?;
541 Ok(())
542 }
543
544 #[must_use]
546 pub fn credentials(&self, vault_id: impl Into<String>) -> Credentials<'_> {
547 Credentials {
548 client: self.client,
549 vault_id: vault_id.into(),
550 }
551 }
552}
553
554pub struct Credentials<'a> {
556 client: &'a Client,
557 vault_id: String,
558}
559
560impl Credentials<'_> {
561 pub async fn create(&self, request: CreateCredentialRequest) -> Result<Credential> {
563 let path = format!("/v1/vaults/{}/credentials", self.vault_id);
564 let body = &request;
565 self.client
566 .execute_with_retry(
567 || {
568 self.client
569 .request_builder(reqwest::Method::POST, &path)
570 .json(body)
571 },
572 &[MANAGED_AGENTS_BETA],
573 )
574 .await
575 }
576
577 pub async fn update(
580 &self,
581 credential_id: &str,
582 request: UpdateCredentialRequest,
583 ) -> Result<Credential> {
584 let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
585 let body = &request;
586 self.client
587 .execute_with_retry(
588 || {
589 self.client
590 .request_builder(reqwest::Method::POST, &path)
591 .json(body)
592 },
593 &[MANAGED_AGENTS_BETA],
594 )
595 .await
596 }
597
598 pub async fn retrieve(&self, credential_id: &str) -> Result<Credential> {
600 let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
601 self.client
602 .execute_with_retry(
603 || self.client.request_builder(reqwest::Method::GET, &path),
604 &[MANAGED_AGENTS_BETA],
605 )
606 .await
607 }
608
609 pub async fn list(&self, params: ListVaultsParams) -> Result<Paginated<Credential>> {
611 let path = format!("/v1/vaults/{}/credentials", self.vault_id);
612 let query = params.to_query();
613 self.client
614 .execute_with_retry(
615 || {
616 let mut req = self.client.request_builder(reqwest::Method::GET, &path);
617 for (k, v) in &query {
618 req = req.query(&[(k, v)]);
619 }
620 req
621 },
622 &[MANAGED_AGENTS_BETA],
623 )
624 .await
625 }
626
627 pub async fn archive(&self, credential_id: &str) -> Result<Credential> {
630 let path = format!(
631 "/v1/vaults/{}/credentials/{credential_id}/archive",
632 self.vault_id
633 );
634 self.client
635 .execute_with_retry(
636 || self.client.request_builder(reqwest::Method::POST, &path),
637 &[MANAGED_AGENTS_BETA],
638 )
639 .await
640 }
641
642 pub async fn delete(&self, credential_id: &str) -> Result<()> {
644 let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
645 let _: serde_json::Value = self
646 .client
647 .execute_with_retry(
648 || self.client.request_builder(reqwest::Method::DELETE, &path),
649 &[MANAGED_AGENTS_BETA],
650 )
651 .await?;
652 Ok(())
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use pretty_assertions::assert_eq;
660 use serde_json::json;
661 use wiremock::matchers::{body_partial_json, method, path};
662 use wiremock::{Mock, MockServer, ResponseTemplate};
663
664 fn client_for(mock: &MockServer) -> Client {
665 Client::builder()
666 .api_key("sk-ant-test")
667 .base_url(mock.uri())
668 .build()
669 .unwrap()
670 }
671
672 #[test]
673 fn mcp_oauth_round_trips_via_credential_auth() {
674 let auth = CredentialAuth::mcp_oauth("https://mcp.slack.com/mcp", "xoxp-token")
675 .expires_at("2026-04-15T00:00:00Z")
676 .refresh(OAuthRefresh {
677 token_endpoint: "https://slack.com/api/oauth.v2.access".into(),
678 client_id: "1234567890".into(),
679 refresh_token: "xoxe-refresh".into(),
680 token_endpoint_auth: TokenEndpointAuth::ClientSecretPost {
681 client_secret: "abc123".into(),
682 },
683 scope: Some("channels:read".into()),
684 })
685 .build();
686 let v = serde_json::to_value(&auth).unwrap();
687 assert_eq!(v["type"], "mcp_oauth");
688 assert_eq!(v["mcp_server_url"], "https://mcp.slack.com/mcp");
689 assert_eq!(v["access_token"], "xoxp-token");
690 assert_eq!(v["refresh"]["client_id"], "1234567890");
691 assert_eq!(
692 v["refresh"]["token_endpoint_auth"]["type"],
693 "client_secret_post"
694 );
695 }
696
697 #[test]
698 fn static_bearer_round_trips() {
699 let auth = CredentialAuth::static_bearer("https://mcp.linear.app/mcp", "lin_api_x");
700 let v = serde_json::to_value(&auth).unwrap();
701 assert_eq!(
702 v,
703 json!({
704 "type": "static_bearer",
705 "mcp_server_url": "https://mcp.linear.app/mcp",
706 "token": "lin_api_x"
707 })
708 );
709 }
710
711 #[test]
712 fn unknown_auth_type_falls_through_to_other() {
713 let raw = json!({"type": "future_auth", "x": 1});
714 let parsed: CredentialAuth = serde_json::from_value(raw.clone()).unwrap();
715 match parsed {
716 CredentialAuth::Other(v) => assert_eq!(v, raw),
717 CredentialAuth::McpOauth(_) | CredentialAuth::StaticBearer(_) => {
718 panic!("expected Other")
719 }
720 }
721 }
722
723 #[tokio::test]
724 async fn create_vault_posts_with_metadata() {
725 let mock = MockServer::start().await;
726 Mock::given(method("POST"))
727 .and(path("/v1/vaults"))
728 .and(body_partial_json(json!({
729 "display_name": "Alice",
730 "metadata": {"external_user_id": "usr_abc"}
731 })))
732 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
733 "id": "vlt_01",
734 "type": "vault",
735 "display_name": "Alice",
736 "metadata": {"external_user_id": "usr_abc"},
737 "created_at": "2026-04-30T12:00:00Z"
738 })))
739 .mount(&mock)
740 .await;
741
742 let client = client_for(&mock);
743 let req = CreateVaultRequest::new("Alice").with_metadata("external_user_id", "usr_abc");
744 let v = client.managed_agents().vaults().create(req).await.unwrap();
745 assert_eq!(v.id, "vlt_01");
746 assert_eq!(
747 v.metadata.get("external_user_id").map(String::as_str),
748 Some("usr_abc")
749 );
750 }
751
752 #[tokio::test]
753 async fn create_credential_serializes_static_bearer_body() {
754 let mock = MockServer::start().await;
755 Mock::given(method("POST"))
756 .and(path("/v1/vaults/vlt_01/credentials"))
757 .and(body_partial_json(json!({
758 "auth": {
759 "type": "static_bearer",
760 "mcp_server_url": "https://mcp.linear.app/mcp",
761 "token": "lin_api_x"
762 },
763 "display_name": "Linear API key"
764 })))
765 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
766 "id": "cred_01",
767 "type": "credential",
768 "vault_id": "vlt_01",
769 "auth": {
770 "type": "static_bearer",
771 "mcp_server_url": "https://mcp.linear.app/mcp"
772 }
773 })))
774 .mount(&mock)
775 .await;
776
777 let client = client_for(&mock);
778 let req = CreateCredentialRequest::new(CredentialAuth::static_bearer(
779 "https://mcp.linear.app/mcp",
780 "lin_api_x",
781 ))
782 .with_display_name("Linear API key");
783 let c = client
784 .managed_agents()
785 .vaults()
786 .credentials("vlt_01")
787 .create(req)
788 .await
789 .unwrap();
790 assert_eq!(c.id, "cred_01");
791 }
792
793 #[tokio::test]
794 async fn update_credential_rotates_token_via_patch() {
795 let mock = MockServer::start().await;
796 Mock::given(method("POST"))
797 .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
798 .and(body_partial_json(json!({
799 "auth": {
800 "type": "mcp_oauth",
801 "access_token": "xoxp-new",
802 "refresh": {"refresh_token": "xoxe-new"}
803 }
804 })))
805 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
806 "id": "cred_01",
807 "type": "credential",
808 "vault_id": "vlt_01",
809 "auth": {
810 "type": "mcp_oauth",
811 "mcp_server_url": "https://mcp.slack.com/mcp"
812 }
813 })))
814 .mount(&mock)
815 .await;
816
817 let client = client_for(&mock);
818 let patch = UpdateCredentialRequest {
819 auth: Some(CredentialAuthPatch::McpOauth {
820 access_token: Some("xoxp-new".into()),
821 expires_at: None,
822 refresh: Some(OAuthRefreshPatch {
823 refresh_token: Some("xoxe-new".into()),
824 ..Default::default()
825 }),
826 }),
827 display_name: None,
828 };
829 client
830 .managed_agents()
831 .vaults()
832 .credentials("vlt_01")
833 .update("cred_01", patch)
834 .await
835 .unwrap();
836 }
837
838 #[tokio::test]
839 async fn retrieve_vault_returns_typed_record() {
840 let mock = MockServer::start().await;
841 Mock::given(method("GET"))
842 .and(path("/v1/vaults/vlt_01"))
843 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
844 "id": "vlt_01",
845 "type": "vault",
846 "display_name": "Alice"
847 })))
848 .mount(&mock)
849 .await;
850 let client = client_for(&mock);
851 let v = client
852 .managed_agents()
853 .vaults()
854 .retrieve("vlt_01")
855 .await
856 .unwrap();
857 assert_eq!(v.id, "vlt_01");
858 }
859
860 #[tokio::test]
861 async fn list_vaults_passes_pagination_query_params() {
862 let mock = MockServer::start().await;
863 Mock::given(method("GET"))
864 .and(path("/v1/vaults"))
865 .and(wiremock::matchers::query_param("limit", "10"))
866 .and(wiremock::matchers::query_param("after", "vlt_x"))
867 .and(wiremock::matchers::query_param("include_archived", "true"))
868 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
869 "data": [{"id": "vlt_01", "display_name": "Alice"}],
870 "has_more": false
871 })))
872 .mount(&mock)
873 .await;
874 let client = client_for(&mock);
875 let page = client
876 .managed_agents()
877 .vaults()
878 .list(ListVaultsParams {
879 after: Some("vlt_x".into()),
880 limit: Some(10),
881 include_archived: Some(true),
882 ..Default::default()
883 })
884 .await
885 .unwrap();
886 assert_eq!(page.data.len(), 1);
887 }
888
889 #[tokio::test]
890 async fn archive_vault_posts_to_archive_subpath() {
891 let mock = MockServer::start().await;
892 Mock::given(method("POST"))
893 .and(path("/v1/vaults/vlt_01/archive"))
894 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
895 "id": "vlt_01",
896 "display_name": "Alice",
897 "archived_at": "2026-04-30T12:00:00Z"
898 })))
899 .mount(&mock)
900 .await;
901 let client = client_for(&mock);
902 let v = client
903 .managed_agents()
904 .vaults()
905 .archive("vlt_01")
906 .await
907 .unwrap();
908 assert!(v.archived_at.is_some());
909 }
910
911 #[tokio::test]
912 async fn delete_vault_returns_unit() {
913 let mock = MockServer::start().await;
914 Mock::given(method("DELETE"))
915 .and(path("/v1/vaults/vlt_01"))
916 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
917 .mount(&mock)
918 .await;
919 let client = client_for(&mock);
920 client
921 .managed_agents()
922 .vaults()
923 .delete("vlt_01")
924 .await
925 .unwrap();
926 }
927
928 #[tokio::test]
929 async fn retrieve_credential_returns_record_without_secrets() {
930 let mock = MockServer::start().await;
931 Mock::given(method("GET"))
932 .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
933 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
934 "id": "cred_01",
935 "type": "credential",
936 "vault_id": "vlt_01",
937 "display_name": "Linear",
938 "auth": {
939 "type": "static_bearer",
940 "mcp_server_url": "https://mcp.linear.app/mcp"
941 }
942 })))
943 .mount(&mock)
944 .await;
945 let client = client_for(&mock);
946 let c = client
947 .managed_agents()
948 .vaults()
949 .credentials("vlt_01")
950 .retrieve("cred_01")
951 .await
952 .unwrap();
953 assert_eq!(c.vault_id, "vlt_01");
954 match c.auth.unwrap() {
955 CredentialAuthResponse::StaticBearer { mcp_server_url } => {
956 assert_eq!(mcp_server_url, "https://mcp.linear.app/mcp");
957 }
958 _ => panic!("expected StaticBearer auth"),
959 }
960 }
961
962 #[tokio::test]
963 async fn list_credentials_paginates_under_vault() {
964 let mock = MockServer::start().await;
965 Mock::given(method("GET"))
966 .and(path("/v1/vaults/vlt_01/credentials"))
967 .and(wiremock::matchers::query_param("limit", "5"))
968 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
969 "data": [{"id": "cred_01", "vault_id": "vlt_01"}],
970 "has_more": false
971 })))
972 .mount(&mock)
973 .await;
974 let client = client_for(&mock);
975 let page = client
976 .managed_agents()
977 .vaults()
978 .credentials("vlt_01")
979 .list(ListVaultsParams {
980 limit: Some(5),
981 ..Default::default()
982 })
983 .await
984 .unwrap();
985 assert_eq!(page.data.len(), 1);
986 }
987
988 #[tokio::test]
989 async fn delete_credential_returns_unit() {
990 let mock = MockServer::start().await;
991 Mock::given(method("DELETE"))
992 .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
993 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
994 .mount(&mock)
995 .await;
996 let client = client_for(&mock);
997 client
998 .managed_agents()
999 .vaults()
1000 .credentials("vlt_01")
1001 .delete("cred_01")
1002 .await
1003 .unwrap();
1004 }
1005
1006 #[test]
1007 fn token_endpoint_auth_round_trips_all_three_variants() {
1008 for auth in [
1009 TokenEndpointAuth::None,
1010 TokenEndpointAuth::ClientSecretBasic {
1011 client_secret: "abc".into(),
1012 },
1013 TokenEndpointAuth::ClientSecretPost {
1014 client_secret: "def".into(),
1015 },
1016 ] {
1017 let v = serde_json::to_value(&auth).unwrap();
1018 let parsed: TokenEndpointAuth = serde_json::from_value(v).unwrap();
1019 assert_eq!(parsed, auth);
1020 }
1021 }
1022
1023 #[test]
1024 fn credential_auth_response_round_trips_known_variants() {
1025 let oauth = CredentialAuthResponse::McpOauth {
1026 mcp_server_url: "https://mcp.x/mcp".into(),
1027 expires_at: Some("2026-05-01T00:00:00Z".into()),
1028 refresh: None,
1029 };
1030 let v = serde_json::to_value(&oauth).unwrap();
1031 assert_eq!(v["type"], "mcp_oauth");
1032 assert_eq!(v["mcp_server_url"], "https://mcp.x/mcp");
1033 let parsed: CredentialAuthResponse = serde_json::from_value(v).unwrap();
1034 assert_eq!(parsed, oauth);
1035
1036 let bearer = CredentialAuthResponse::StaticBearer {
1037 mcp_server_url: "https://mcp.y/mcp".into(),
1038 };
1039 let v = serde_json::to_value(&bearer).unwrap();
1040 assert_eq!(
1041 v,
1042 json!({"type": "static_bearer", "mcp_server_url": "https://mcp.y/mcp"})
1043 );
1044 let parsed: CredentialAuthResponse = serde_json::from_value(v).unwrap();
1045 assert_eq!(parsed, bearer);
1046
1047 let unknown: CredentialAuthResponse =
1049 serde_json::from_value(json!({"type": "future_kind", "x": 1})).unwrap();
1050 assert!(matches!(unknown, CredentialAuthResponse::Other));
1051 }
1052
1053 #[tokio::test]
1054 async fn archive_credential_posts_to_archive_subpath() {
1055 let mock = MockServer::start().await;
1056 Mock::given(method("POST"))
1057 .and(path("/v1/vaults/vlt_01/credentials/cred_01/archive"))
1058 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1059 "id": "cred_01",
1060 "type": "credential",
1061 "vault_id": "vlt_01",
1062 "archived_at": "2026-04-30T12:00:00Z"
1063 })))
1064 .mount(&mock)
1065 .await;
1066
1067 let client = client_for(&mock);
1068 let c = client
1069 .managed_agents()
1070 .vaults()
1071 .credentials("vlt_01")
1072 .archive("cred_01")
1073 .await
1074 .unwrap();
1075 assert!(c.archived_at.is_some());
1076 }
1077}