//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the //! `picloud_shared::SecretsService` trait that scripts see via the Rhai //! bridge. //! //! Layers added here (vs the raw repo): //! //! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary. //! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run //! `authz::require(...)`; when it's `None` (public unauthenticated //! HTTP) we skip the check. Cross-app isolation is unaffected — every //! query is keyed by `cx.app_id`, never an argument. //! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes, //! enforces the per-secret size cap, and AES-256-GCM-seals it; `get` //! decrypts and deserializes back to the same JSON shape (a String //! round-trips to a String, not a JSON-quoted `"\"…\""`). //! //! Deliberately **no `ServiceEvent` emission** — secret writes do not //! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the //! v1.1.7 brief §2). use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage, SecretsService, }; use crate::authz::{self, AuthzRepo, Capability}; use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret}; // `SecretOwner` is defined one layer down in `secrets_repo` (it keys both // the storage CRUD and the AAD). Re-exported here so the historical // `secrets_service::SecretOwner` path stays stable. pub use crate::secrets_repo::SecretOwner; /// Current AES-GCM envelope version for the per-app secret store. /// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1. pub const SECRET_ENVELOPE_V1: i16 = 1; /// The env scope under which an app's OWN secrets are stored. App secrets /// are env-agnostic — only group secrets carry a concrete environment. const APP_SECRET_SCOPE: &str = "*"; /// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row /// swap (moving one owner's ciphertext under another's slot) fails /// decryption. The **app** form is byte-identical to the pre-Phase-3 /// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting /// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}` /// namespace (the `group:` infix keeps app and group AAD disjoint even if a /// group UUID happened to equal an app UUID). fn secret_aad(owner: SecretOwner, name: &str) -> Vec { match owner { SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"), SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"), } .into_bytes() } /// Default per-secret plaintext cap (64 KB). Override with /// `PICLOUD_SECRET_MAX_VALUE_BYTES`. pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024; /// Process config for the secrets service. #[derive(Debug, Clone, Copy)] pub struct SecretsConfig { /// Maximum size of the JSON-encoded plaintext, in bytes. pub max_value_bytes: usize, } impl SecretsConfig { #[must_use] pub const fn conservative() -> Self { Self { max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES, } } /// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored /// with a warning (keeps the conservative default). #[must_use] pub fn from_env() -> Self { let mut c = Self::conservative(); if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") { match v.trim().parse::() { Ok(n) if n > 0 => c.max_value_bytes = n, _ => tracing::warn!( value = %v, "ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)" ), } } c } } impl Default for SecretsConfig { fn default() -> Self { Self::conservative() } } /// Serialize + size-check + encrypt a value into `(ciphertext, nonce, /// version)`. New writes are envelope version 1 with AAD bound to /// `"secret:{app_id}:{name}"`. /// /// Audit 2026-06-11 H-D1. /// /// # Errors /// /// [`SecretsError::TooLarge`] when the encoded plaintext exceeds /// `max_value_bytes`; [`SecretsError::Backend`] on a serialization /// failure (should not happen for a `serde_json::Value`). pub fn seal( master_key: &MasterKey, owner: SecretOwner, name: &str, value: &serde_json::Value, max_value_bytes: usize, ) -> Result<(Vec, [u8; crypto::NONCE_LEN], i16), SecretsError> { let plaintext = serde_json::to_vec(value) .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?; if plaintext.len() > max_value_bytes { return Err(SecretsError::TooLarge { limit: max_value_bytes, actual: plaintext.len(), }); } let aad = secret_aad(owner, name); let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes()); Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1)) } /// Decrypt + deserialize a stored secret back to its JSON value. /// Dispatches on `stored.version`: v0 uses the legacy no-AAD layout /// (pre-2026-06-11 rows that haven't been rewritten yet); v1 binds /// AAD = `"secret:{app_id}:{name}"`, so a cross-row swap fails the /// GCM auth tag. /// /// # Errors /// /// [`SecretsError::Corrupted`] when decryption or JSON decoding fails. pub fn open( master_key: &MasterKey, owner: SecretOwner, name: &str, stored: &StoredSecret, ) -> Result { let plaintext = match stored.version { 0 => crypto::decrypt( &stored.encrypted_value, &stored.nonce, master_key.as_bytes(), ), SECRET_ENVELOPE_V1 => { let aad = secret_aad(owner, name); crypto::decrypt_with_aad( &stored.encrypted_value, &stored.nonce, &aad, master_key.as_bytes(), ) } _ => return Err(SecretsError::Corrupted), } .map_err(|_| SecretsError::Corrupted)?; serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted) } /// Legacy v0 seal (no AAD, no version) used by the email-trigger /// inbound-secret path. The `email_trigger_details` table has no /// `version` column yet, so the AAD-binding upgrade for that surface is /// **deferred to v1.2's key-versioning pass** (audit 2026-06-11 H-D1 /// classifies the email-trigger AAD gap as Medium). Both this and /// [`open_legacy`] preserve the exact pre-audit wire format so existing /// email-trigger secrets keep decrypting. /// /// # Errors /// /// [`SecretsError::TooLarge`] / [`SecretsError::Backend`] as for [`seal`]. pub fn seal_legacy( master_key: &MasterKey, value: &serde_json::Value, max_value_bytes: usize, ) -> Result<(Vec, [u8; crypto::NONCE_LEN]), SecretsError> { let plaintext = serde_json::to_vec(value) .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?; if plaintext.len() > max_value_bytes { return Err(SecretsError::TooLarge { limit: max_value_bytes, actual: plaintext.len(), }); } let enc = crypto::encrypt(&plaintext, master_key.as_bytes()); Ok((enc.ciphertext, enc.nonce)) } /// Legacy v0 open (no AAD) paired with [`seal_legacy`]. See that /// function for why the email-trigger path is still v0. /// /// # Errors /// /// [`SecretsError::Corrupted`] when decryption or JSON decoding fails. pub fn open_legacy( master_key: &MasterKey, ciphertext: &[u8], nonce: &[u8], ) -> Result { let plaintext = crypto::decrypt(ciphertext, nonce, master_key.as_bytes()) .map_err(|_| SecretsError::Corrupted)?; serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted) } pub struct SecretsServiceImpl { repo: Arc, authz: Arc, master_key: MasterKey, max_value_bytes: usize, } impl SecretsServiceImpl { #[must_use] pub fn new( repo: Arc, authz: Arc, master_key: MasterKey, config: SecretsConfig, ) -> Self { Self { repo, authz, master_key, max_value_bytes: config.max_value_bytes, } } async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> { if let Some(ref principal) = cx.principal { authz::require( &*self.authz, principal, Capability::AppSecretsRead(cx.app_id), ) .await .map_err(|_| SecretsError::Forbidden)?; } Ok(()) } async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> { if let Some(ref principal) = cx.principal { authz::require( &*self.authz, principal, Capability::AppSecretsWrite(cx.app_id), ) .await .map_err(|_| SecretsError::Forbidden)?; } Ok(()) } } impl From for SecretsError { fn from(e: SecretsRepoError) -> Self { Self::Backend(e.to_string()) } } #[async_trait] impl SecretsService for SecretsServiceImpl { async fn get( &self, cx: &SdkCallCx, name: &str, ) -> Result, SecretsError> { validate_secret_name(name)?; self.check_read(cx).await?; // Inherited resolution: the app's own secret, else the nearest // ancestor group's, env-filtered. `resolve` anchors the walk to // `cx.app_id`, so an app can only ever read its own + its ancestors' // secrets — the cross-app isolation boundary. The winning owner comes // back with the row so we decrypt under the AAD it was sealed with. let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else { return Ok(None); }; match open(&self.master_key, resolved.owner, name, &resolved.stored) { Ok(value) => Ok(Some(value)), Err(e) => { // A decrypt failure is operationally significant — surface // the affected (app_id, name) so an operator can find the // bad row, but never log the ciphertext or key material. tracing::error!( app_id = %cx.app_id, secret = %name, "secret could not be decrypted (corrupted row or master-key mismatch)" ); Err(e) } } } async fn set( &self, cx: &SdkCallCx, name: &str, value: serde_json::Value, ) -> Result<(), SecretsError> { validate_secret_name(name)?; self.check_write(cx).await?; let owner = SecretOwner::App(cx.app_id); let (ciphertext, nonce, version) = seal(&self.master_key, owner, name, &value, self.max_value_bytes)?; self.repo .set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version) .await?; Ok(()) } async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result { validate_secret_name(name)?; self.check_write(cx).await?; Ok(self .repo .delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name) .await?) } async fn list( &self, cx: &SdkCallCx, cursor: Option<&str>, limit: u32, ) -> Result { self.check_read(cx).await?; let page = self .repo .list_names(SecretOwner::App(cx.app_id), cursor, limit) .await?; Ok(SecretsListPage { names: page.names, next_cursor: page.next_cursor, }) } } // ---------------------------------------------------------------------------- // Tests — in-memory SecretsRepo so unit tests don't need Postgres. // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::authz::{AuthzError, AuthzRepo}; use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage}; use async_trait::async_trait; use picloud_shared::{ AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId, ScriptId, UserId, }; use std::collections::BTreeMap; use tokio::sync::Mutex; /// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner /// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never /// collide. These unit tests exercise the app surface (scope `*`); the /// chain-walk `resolve` is journey-tested against real Postgres, so here /// it degrades to a plain app-own `*` lookup. #[derive(Default)] struct InMemorySecretsRepo { data: Mutex>, } fn owner_key(owner: SecretOwner) -> String { match owner { SecretOwner::App(a) => format!("app:{a}"), SecretOwner::Group(g) => format!("group:{g}"), } } #[async_trait] impl SecretsRepo for InMemorySecretsRepo { async fn resolve( &self, app_id: AppId, name: &str, ) -> Result, SecretsRepoError> { let owner = SecretOwner::App(app_id); Ok(self .data .lock() .await .get(&( owner_key(owner), APP_SECRET_SCOPE.to_string(), name.to_string(), )) .cloned() .map(|stored| ResolvedSecret { owner, stored })) } async fn get( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result, SecretsRepoError> { Ok(self .data .lock() .await .get(&(owner_key(owner), env_scope.to_string(), name.to_string())) .cloned()) } async fn set( &self, owner: SecretOwner, env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError> { self.data.lock().await.insert( (owner_key(owner), env_scope.to_string(), name.to_string()), StoredSecret { encrypted_value: encrypted_value.to_vec(), nonce: nonce.to_vec(), version, }, ); Ok(()) } async fn delete( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result { Ok(self .data .lock() .await .remove(&(owner_key(owner), env_scope.to_string(), name.to_string())) .is_some()) } async fn list_names( &self, owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result { let data = self.data.lock().await; let ok = owner_key(owner); let last = cursor.map(std::string::ToString::to_string); let mut names: Vec = data .iter() .filter(|((o, _, _), _)| *o == ok) .map(|((_, _, n), _)| n.clone()) .filter(|n| last.as_ref().is_none_or(|l| n > l)) .collect(); names.sort(); names.dedup(); let take = (limit as usize).max(1); let next_cursor = if names.len() > take { names.truncate(take); names.last().cloned() } else { None }; Ok(SecretsNamePage { names, next_cursor }) } async fn list_meta( &self, _owner: SecretOwner, _cursor: Option<&str>, _limit: u32, ) -> Result { unimplemented!("admin-only; not exercised in service tests") } } #[derive(Default)] struct DenyingAuthzRepo; #[async_trait] impl AuthzRepo for DenyingAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(None) } } fn key() -> MasterKey { MasterKey::from_bytes([0x5au8; 32]) } fn svc() -> SecretsServiceImpl { SecretsServiceImpl::new( Arc::new(InMemorySecretsRepo::default()), Arc::new(DenyingAuthzRepo), key(), SecretsConfig::conservative(), ) } fn cx_with(app_id: AppId, principal: Option) -> SdkCallCx { SdkCallCx { app_id, script_id: ScriptId::new(), principal, execution_id: ExecutionId::new(), request_id: RequestId::new(), trigger_depth: 0, root_execution_id: ExecutionId::new(), is_dead_letter_handler: false, event: None, } } fn anon_cx(app_id: AppId) -> SdkCallCx { cx_with(app_id, None) } fn member_no_role_cx(app_id: AppId) -> SdkCallCx { cx_with( app_id, Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Member, scopes: None, app_binding: None, }), ) } fn owner_cx(app_id: AppId) -> SdkCallCx { cx_with( app_id, Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, scopes: None, app_binding: None, }), ) } #[tokio::test] async fn set_get_delete_round_trip() { let s = svc(); let cx = anon_cx(AppId::new()); s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx")) .await .unwrap(); assert_eq!( s.get(&cx, "stripe_key").await.unwrap(), Some(serde_json::json!("sk_live_xxx")) ); assert!(s.delete(&cx, "stripe_key").await.unwrap()); assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None); // Idempotent delete. assert!(!s.delete(&cx, "stripe_key").await.unwrap()); } #[tokio::test] async fn get_missing_returns_none() { let s = svc(); let cx = anon_cx(AppId::new()); assert_eq!(s.get(&cx, "nope").await.unwrap(), None); } #[tokio::test] async fn empty_name_rejected() { let s = svc(); let cx = anon_cx(AppId::new()); let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err(); assert!(matches!(err, SecretsError::InvalidName(_))); let err = s.get(&cx, "").await.unwrap_err(); assert!(matches!(err, SecretsError::InvalidName(_))); } #[tokio::test] async fn name_length_capped() { let s = svc(); let cx = anon_cx(AppId::new()); let long = "a".repeat(256); let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err(); assert!(matches!(err, SecretsError::InvalidName(_))); // Exactly 255 is allowed. let ok = "b".repeat(255); s.set(&cx, &ok, serde_json::json!(1)).await.unwrap(); } #[tokio::test] async fn value_over_cap_rejected() { let s = SecretsServiceImpl::new( Arc::new(InMemorySecretsRepo::default()), Arc::new(DenyingAuthzRepo), key(), SecretsConfig { max_value_bytes: 16, }, ); let cx = anon_cx(AppId::new()); let big = serde_json::json!("x".repeat(64)); let err = s.set(&cx, "k", big).await.unwrap_err(); assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. })); } #[tokio::test] async fn cross_app_isolation() { let s = svc(); let a = AppId::new(); let b = AppId::new(); s.set(&anon_cx(a), "shared", serde_json::json!("from-a")) .await .unwrap(); s.set(&anon_cx(b), "shared", serde_json::json!("from-b")) .await .unwrap(); assert_eq!( s.get(&anon_cx(a), "shared").await.unwrap(), Some(serde_json::json!("from-a")) ); assert_eq!( s.get(&anon_cx(b), "shared").await.unwrap(), Some(serde_json::json!("from-b")) ); } #[tokio::test] async fn anonymous_skips_authz() { let s = svc(); // DenyingAuthzRepo would deny an authed principal; anon skips it. s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1)) .await .unwrap(); } #[tokio::test] async fn authed_member_without_role_forbidden() { let s = svc(); let err = s .set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1)) .await .unwrap_err(); assert!(matches!(err, SecretsError::Forbidden)); } #[tokio::test] async fn owner_can_write() { let s = svc(); s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1)) .await .unwrap(); } /// Type round-trip: a String comes back a String, a Map a Map, an /// Array an Array — the JSON encoding is transparent. #[tokio::test] async fn type_round_trip_preserves_shape() { let s = svc(); let cx = anon_cx(AppId::new()); s.set(&cx, "str", serde_json::json!("sk_live_xxx")) .await .unwrap(); assert_eq!( s.get(&cx, "str").await.unwrap(), Some(serde_json::json!("sk_live_xxx")) ); let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" }); s.set(&cx, "oauth", map.clone()).await.unwrap(); assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map)); let arr = serde_json::json!([1, 2, 3]); s.set(&cx, "arr", arr.clone()).await.unwrap(); assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr)); } #[tokio::test] async fn corrupted_ciphertext_surfaces_error() { let repo = Arc::new(InMemorySecretsRepo::default()); let s = SecretsServiceImpl::new( repo.clone(), Arc::new(DenyingAuthzRepo), key(), SecretsConfig::conservative(), ); let app = AppId::new(); s.set(&anon_cx(app), "k", serde_json::json!("v")) .await .unwrap(); // Corrupt the stored ciphertext directly. repo.data .lock() .await .get_mut(&( owner_key(SecretOwner::App(app)), "*".to_string(), "k".to_string(), )) .unwrap() .encrypted_value[0] ^= 0xff; let err = s.get(&anon_cx(app), "k").await.unwrap_err(); assert!(matches!(err, SecretsError::Corrupted)); } #[tokio::test] async fn aad_blocks_cross_app_ciphertext_swap() { // Audit 2026-06-11 H-D1 closure. Seal a secret under app A, // then move its stored row verbatim into app B's slot (the // exact attack a Postgres-write adversary would run). The v1 // AAD = "secret:{app_id}:{name}" no longer matches, so B's read // surfaces Corrupted instead of A's plaintext. let repo = Arc::new(InMemorySecretsRepo::default()); let s = SecretsServiceImpl::new( repo.clone(), Arc::new(DenyingAuthzRepo), key(), SecretsConfig::conservative(), ); let a = AppId::new(); let b = AppId::new(); s.set(&anon_cx(a), "k", serde_json::json!("a-plaintext")) .await .unwrap(); // Copy A's row into B's slot, same name. let stolen = repo .data .lock() .await .get(&( owner_key(SecretOwner::App(a)), "*".to_string(), "k".to_string(), )) .cloned() .unwrap(); repo.data.lock().await.insert( ( owner_key(SecretOwner::App(b)), "*".to_string(), "k".to_string(), ), stolen, ); let err = s.get(&anon_cx(b), "k").await.unwrap_err(); assert!( matches!(err, SecretsError::Corrupted), "cross-app swap must fail AAD, got {err:?}" ); } #[tokio::test] async fn aad_distinguishes_app_and_group_owner() { // Phase 3: app and group secrets live in disjoint AAD namespaces // (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed // for a group must not open as an app secret (and vice-versa), // even if the raw UUIDs were equal — the `group:` infix separates // them. Exercise the free seal/open functions directly. let k = key(); let app = AppId::new(); let group = GroupId::new(); let value = serde_json::json!("shared-config"); // Seal under the GROUP owner. let (ct, nonce, version) = seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap(); let stored = StoredSecret { encrypted_value: ct, nonce: nonce.to_vec(), version, }; // Opens fine as the same group owner. assert_eq!( open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(), value ); // Fails as an app owner (AAD mismatch) — even reusing the UUID. let app_from_group = AppId::from(group.into_inner()); assert!(matches!( open(&k, SecretOwner::App(app_from_group), "db_url", &stored), Err(SecretsError::Corrupted) )); // And the symmetric direction: an app-sealed row won't open as a group. let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap(); let stored2 = StoredSecret { encrypted_value: ct2, nonce: nonce2.to_vec(), version: v2, }; assert!(matches!( open( &k, SecretOwner::Group(GroupId::from(app.into_inner())), "db_url", &stored2 ), Err(SecretsError::Corrupted) )); } #[tokio::test] async fn aad_blocks_cross_name_ciphertext_swap() { // Same app, different name → AAD mismatch. let repo = Arc::new(InMemorySecretsRepo::default()); let s = SecretsServiceImpl::new( repo.clone(), Arc::new(DenyingAuthzRepo), key(), SecretsConfig::conservative(), ); let a = AppId::new(); s.set(&anon_cx(a), "real", serde_json::json!("v")) .await .unwrap(); let stolen = repo .data .lock() .await .get(&( owner_key(SecretOwner::App(a)), "*".to_string(), "real".to_string(), )) .cloned() .unwrap(); repo.data.lock().await.insert( ( owner_key(SecretOwner::App(a)), "*".to_string(), "renamed".to_string(), ), stolen, ); let err = s.get(&anon_cx(a), "renamed").await.unwrap_err(); assert!(matches!(err, SecretsError::Corrupted)); } #[tokio::test] async fn legacy_v0_row_still_decrypts() { // A pre-audit row (version 0, no AAD) must keep decrypting after // the migration: only new writes are v1. let repo = Arc::new(InMemorySecretsRepo::default()); let s = SecretsServiceImpl::new( repo.clone(), Arc::new(DenyingAuthzRepo), key(), SecretsConfig::conservative(), ); let app = AppId::new(); // Hand-seal a v0 row exactly as the pre-audit code would have. let (ct, nonce) = seal_legacy( &key(), &serde_json::json!("legacy"), DEFAULT_SECRET_MAX_VALUE_BYTES, ) .unwrap(); repo.data.lock().await.insert( ( owner_key(SecretOwner::App(app)), "*".to_string(), "old".to_string(), ), StoredSecret { encrypted_value: ct, nonce: nonce.to_vec(), version: 0, }, ); assert_eq!( s.get(&anon_cx(app), "old").await.unwrap(), Some(serde_json::json!("legacy")) ); } #[tokio::test] async fn list_returns_names_paginated() { let s = svc(); let cx = anon_cx(AppId::new()); for i in 0..5 { s.set(&cx, &format!("k{i:02}"), serde_json::json!(i)) .await .unwrap(); } let p1 = s.list(&cx, None, 2).await.unwrap(); assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]); assert!(p1.next_cursor.is_some()); let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap(); assert_eq!( p2.names, vec!["k02".to_string(), "k03".to_string(), "k04".to_string()] ); assert!(p2.next_cursor.is_none()); } }