1use std::path::Path;
20use std::sync::Arc;
21
22use chrono::{Duration, Utc};
23use tracing::{info, warn};
24use uuid::Uuid;
25
26use vta_sdk::protocols::backup_management::descriptors::{
27 AbortBundleBody, AbortBundleResultBody, BundleDescriptor, CompleteExportBody,
28 CompleteExportResultBody, FinalizeImportBody, FinalizeImportResultBody, InitiateExportBody,
29 InitiateExportResultBody, InitiateImportBody, InitiateImportResultBody,
30};
31use vta_sdk::protocols::backup_management::types::BackupEnvelope;
32
33use crate::backup_bundle_store::{
34 self, BundleKind, BundleRecord, BundleState, BundleToken, mint_token,
35};
36use vta_config::AppConfig;
37use vta_keys::seed_store::SeedStore;
38use vti_common::auth::AuthClaims;
39use vti_common::error::AppError;
40use vti_common::store::{KeyspaceHandle, Store};
41
42pub const DEFAULT_BUNDLE_TTL_SECS: u64 = 300;
46
47pub const MAX_BUNDLE_TTL_SECS: u64 = 3600;
51
52pub const MAX_OPEN_BUNDLES_PER_DID: usize = 3;
57
58pub struct DescriptorDeps<'a> {
65 pub bundles_ks: &'a KeyspaceHandle,
66 pub blob_dir: &'a Path,
67 pub keyspaces: vta_keyspaces::Keyspaces<'a>,
68 pub seed_store: &'a Arc<dyn SeedStore>,
69 pub config: &'a tokio::sync::RwLock<AppConfig>,
70 pub store: Option<&'a Store>,
71 #[cfg(feature = "tee")]
75 pub re_encryptor: Option<&'a dyn crate::BootstrapReEncryptor>,
76}
77
78pub async fn initiate_export(
100 deps: &DescriptorDeps<'_>,
101 auth: &AuthClaims,
102 body: InitiateExportBody,
103) -> Result<InitiateExportResultBody, AppError> {
104 auth.require_super_admin()?;
105 if blob_transport_base_url(deps.config).await.is_none() {
108 return Err(transport_unavailable_internal());
109 }
110 validate_algorithm(&body.algorithm)?;
111 enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
112
113 let envelope = {
115 let config_guard = deps.config.read().await;
116 super::export_backup(
117 &deps.keyspaces,
118 deps.seed_store.as_ref(),
119 &config_guard,
120 auth,
121 &body.password,
122 body.include_audit,
123 )
124 .await?
125 };
126
127 let bytes = serde_json::to_vec(&envelope)
132 .map_err(|e| AppError::Internal(format!("serialize backup envelope: {e}")))?;
133 let sha256_hex = sha256_hex(&bytes);
134 let size = bytes.len() as u64;
135
136 let bundle_id = Uuid::new_v4();
140 let (token, token_hash) = mint_token()?;
141
142 tokio::fs::create_dir_all(deps.blob_dir)
143 .await
144 .map_err(AppError::Io)?;
145 #[cfg(unix)]
146 set_dir_mode_700(deps.blob_dir).await?;
147 let blob_path = deps.blob_dir.join(format!("{bundle_id}.vtabak"));
148 tokio::fs::write(&blob_path, &bytes)
149 .await
150 .map_err(AppError::Io)?;
151 #[cfg(unix)]
152 set_file_mode_600(&blob_path).await?;
153
154 let now = Utc::now();
155 let record = BundleRecord {
156 bundle_id,
157 kind: BundleKind::Export,
158 state: BundleState::ExportReady,
159 created_at: now,
160 expires_at: now + bundle_ttl(),
161 created_by: auth.did.clone(),
162 algorithm: body.algorithm,
163 expected_sha256: sha256_hex.clone(),
164 expected_size_bytes: size,
165 token_hash,
166 blob_path: Some(blob_path),
167 };
168 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
169
170 info!(bundle_id = %bundle_id, size, "initiate-export: bundle ready");
171
172 Ok(InitiateExportResultBody {
173 descriptor: build_descriptor(&record, token, deps.config).await?,
174 completion_hint: format!(
175 "Download with: pnm backup save --bundle-id {bundle_id} --output backup.vtabak"
176 ),
177 })
178}
179
180pub async fn complete_export(
188 deps: &DescriptorDeps<'_>,
189 auth: &AuthClaims,
190 body: CompleteExportBody,
191) -> Result<CompleteExportResultBody, AppError> {
192 auth.require_super_admin()?;
193 let bundle_id = parse_bundle_id(&body.bundle_id)?;
194
195 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
196 enforce_kind(&record, BundleKind::Export)?;
197
198 if record.algorithm == super::chunked_algorithm() && record.state == BundleState::ExportReady {
203 let downloaded = super::chunked::all_served(deps.bundles_ks, &bundle_id)
204 .await?
205 .unwrap_or(false);
206 if let Some(path) = record.blob_path.take()
207 && let Err(e) = tokio::fs::remove_file(&path).await
208 && e.kind() != std::io::ErrorKind::NotFound
209 {
210 warn!(
211 bundle_id = %bundle_id,
212 path = %path.display(),
213 error = %e,
214 "complete-export: failed to delete chunked blob; sweeper will retry"
215 );
216 record.blob_path = Some(path);
217 }
218 record.state = BundleState::ExportAcked;
219 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
220 super::chunked::delete_plan(deps.bundles_ks, &bundle_id).await?;
221 info!(bundle_id = %bundle_id, downloaded, "complete-export (chunked): released");
222 return Ok(CompleteExportResultBody {
223 bundle_id: bundle_id.to_string(),
224 downloaded,
225 });
226 }
227
228 let downloaded = match record.state {
229 BundleState::ExportDownloaded => {
230 record.state = BundleState::ExportAcked;
231 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
232 true
233 }
234 BundleState::ExportAcked => true, BundleState::ExportReady => false, BundleState::Aborted | BundleState::Expired => {
237 return Err(AppError::Conflict(format!(
238 "bundle {bundle_id} is in terminal state {:?}; cannot ack",
239 record.state
240 )));
241 }
242 _ => {
245 return Err(AppError::Internal(format!(
246 "unexpected state for export bundle {bundle_id}: {:?}",
247 record.state
248 )));
249 }
250 };
251
252 info!(bundle_id = %bundle_id, downloaded, "complete-export: acked");
253 Ok(CompleteExportResultBody {
254 bundle_id: bundle_id.to_string(),
255 downloaded,
256 })
257}
258
259pub async fn initiate_import(
266 deps: &DescriptorDeps<'_>,
267 auth: &AuthClaims,
268 body: InitiateImportBody,
269) -> Result<InitiateImportResultBody, AppError> {
270 auth.require_super_admin()?;
271 if blob_transport_base_url(deps.config).await.is_none() {
273 return Err(transport_unavailable_internal());
274 }
275 validate_algorithm(&body.algorithm)?;
276 enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
277
278 if body.expected_sha256.len() != 64
281 || !body.expected_sha256.chars().all(|c| c.is_ascii_hexdigit())
282 {
283 return Err(AppError::Validation(format!(
284 "expected_sha256 must be 64 lowercase hex chars; got `{}`",
285 body.expected_sha256
286 )));
287 }
288 if body.expected_size_bytes == 0 {
289 return Err(AppError::Validation(
290 "expected_size_bytes must be > 0".into(),
291 ));
292 }
293
294 let bundle_id = Uuid::new_v4();
295 let (token, token_hash) = mint_token()?;
296 let now = Utc::now();
297 let record = BundleRecord {
298 bundle_id,
299 kind: BundleKind::Import,
300 state: BundleState::ImportPending,
301 created_at: now,
302 expires_at: now + bundle_ttl(),
303 created_by: auth.did.clone(),
304 algorithm: body.algorithm,
305 expected_sha256: body.expected_sha256,
306 expected_size_bytes: body.expected_size_bytes,
307 token_hash,
308 blob_path: None,
310 };
311 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
312
313 info!(bundle_id = %bundle_id, "initiate-import: slot ready");
314 Ok(InitiateImportResultBody {
315 descriptor: build_descriptor(&record, token, deps.config).await?,
316 completion_hint: format!(
317 "Upload with: pnm backup restore --bundle-id {bundle_id} --input <path> --password <pw>"
318 ),
319 })
320}
321
322pub async fn finalize_import(
329 deps: &DescriptorDeps<'_>,
330 auth: &AuthClaims,
331 body: FinalizeImportBody,
332) -> Result<FinalizeImportResultBody, AppError> {
333 auth.require_super_admin()?;
334 let bundle_id = parse_bundle_id(&body.bundle_id)?;
335
336 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
337 enforce_kind(&record, BundleKind::Import)?;
338
339 match record.state {
343 BundleState::ImportReceived | BundleState::ImportPreviewed => {}
344 BundleState::ImportPending => {
345 return Err(AppError::Conflict(format!(
346 "bundle {bundle_id} has no uploaded bytes yet; \
347 POST to /backup/blob/{bundle_id} first"
348 )));
349 }
350 BundleState::ImportCommitted => {
351 return Err(AppError::Conflict(format!(
352 "bundle {bundle_id} already committed"
353 )));
354 }
355 BundleState::Aborted | BundleState::Expired => {
356 return Err(AppError::Conflict(format!(
357 "bundle {bundle_id} in terminal state {:?}",
358 record.state
359 )));
360 }
361 _ => {
362 return Err(AppError::Internal(format!(
363 "unexpected state for import bundle {bundle_id}: {:?}",
364 record.state
365 )));
366 }
367 }
368
369 let blob_path = record.blob_path.clone().ok_or_else(|| {
370 AppError::Internal(format!("bundle {bundle_id} has no blob_path on disk"))
371 })?;
372 let bytes = tokio::fs::read(&blob_path).await.map_err(AppError::Io)?;
373
374 let envelope: BackupEnvelope = serde_json::from_slice(&bytes).map_err(|e| {
375 AppError::Validation(format!("uploaded bytes are not a BackupEnvelope: {e}"))
376 })?;
377
378 if body.confirm {
379 let result = super::apply_import(
381 &super::preview_import(&envelope, &body.password).await?.0,
382 &deps.keyspaces,
383 deps.seed_store,
384 deps.config,
385 deps.store,
386 #[cfg(feature = "tee")]
387 deps.re_encryptor,
388 )
389 .await?;
390
391 if let Err(e) = tokio::fs::remove_file(&blob_path).await {
393 warn!(
394 bundle_id = %bundle_id,
395 path = %blob_path.display(),
396 error = %e,
397 "finalize-import: failed to delete blob after commit"
398 );
399 }
400 record.state = BundleState::ImportCommitted;
401 record.blob_path = None;
402 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
403
404 info!(bundle_id = %bundle_id, "finalize-import: committed");
405 Ok(FinalizeImportResultBody {
406 bundle_id: bundle_id.to_string(),
407 status: "committed".into(),
408 source_did: result.source_did,
409 key_count: result.key_count,
410 acl_count: result.acl_count,
411 context_count: result.context_count,
412 audit_count: result.audit_count,
413 imported_secret_count: result.imported_secret_count,
414 message: result.message,
415 })
416 } else {
417 let (_payload, result) = super::preview_import(&envelope, &body.password).await?;
419 record.state = BundleState::ImportPreviewed;
420 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
421
422 info!(bundle_id = %bundle_id, "finalize-import: preview");
423 Ok(FinalizeImportResultBody {
424 bundle_id: bundle_id.to_string(),
425 status: "preview".into(),
426 source_did: result.source_did,
427 key_count: result.key_count,
428 acl_count: result.acl_count,
429 context_count: result.context_count,
430 audit_count: result.audit_count,
431 imported_secret_count: result.imported_secret_count,
432 message: result.message,
433 })
434 }
435}
436
437pub async fn abort_bundle(
443 deps: &DescriptorDeps<'_>,
444 auth: &AuthClaims,
445 body: AbortBundleBody,
446) -> Result<AbortBundleResultBody, AppError> {
447 auth.require_super_admin()?;
448 let bundle_id = parse_bundle_id(&body.bundle_id)?;
449
450 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
451
452 if record.state.is_terminal() {
453 info!(bundle_id = %bundle_id, state = ?record.state, "abort: bundle already terminal");
454 return Ok(AbortBundleResultBody {
455 bundle_id: bundle_id.to_string(),
456 aborted: false,
457 });
458 }
459
460 if let Some(path) = record.blob_path.clone()
463 && let Err(e) = tokio::fs::remove_file(&path).await
464 {
465 if e.kind() != std::io::ErrorKind::NotFound {
468 warn!(
469 bundle_id = %bundle_id,
470 path = %path.display(),
471 error = %e,
472 "abort: failed to delete staged bytes; sweeper will retry"
473 );
474 }
475 }
476
477 record.state = BundleState::Aborted;
478 record.blob_path = None;
479 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
480 super::chunked::delete_plan(deps.bundles_ks, &bundle_id).await?;
482
483 info!(bundle_id = %bundle_id, "abort: bundle cancelled");
484 Ok(AbortBundleResultBody {
485 bundle_id: bundle_id.to_string(),
486 aborted: true,
487 })
488}
489
490pub(crate) fn bundle_ttl() -> Duration {
493 Duration::seconds(DEFAULT_BUNDLE_TTL_SECS as i64)
494}
495
496fn validate_algorithm(algorithm: &str) -> Result<(), AppError> {
497 if algorithm != "stream" {
498 return Err(AppError::Validation(format!(
499 "unsupported transport algorithm: `{algorithm}`; this VTA supports: stream"
500 )));
501 }
502 Ok(())
503}
504
505pub(crate) async fn enforce_open_bundle_cap(
506 ks: &KeyspaceHandle,
507 did: &str,
508) -> Result<(), AppError> {
509 let all = backup_bundle_store::list_bundles(ks).await?;
510 let open = all
511 .iter()
512 .filter(|r| r.created_by == did && !r.state.is_terminal())
513 .count();
514 if open >= MAX_OPEN_BUNDLES_PER_DID {
515 return Err(AppError::Conflict(format!(
516 "operator `{did}` has {open} open backup bundles; \
517 abort or wait for expiry before initiating another \
518 (cap: {MAX_OPEN_BUNDLES_PER_DID})"
519 )));
520 }
521 Ok(())
522}
523
524pub(crate) fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
525 Uuid::parse_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
526}
527
528pub(crate) async fn require_owned(
532 ks: &KeyspaceHandle,
533 id: &Uuid,
534 caller_did: &str,
535) -> Result<BundleRecord, AppError> {
536 let record = backup_bundle_store::get_bundle(ks, id)
537 .await?
538 .ok_or_else(|| AppError::NotFound(format!("bundle not found: {id}")))?;
539 if record.created_by != caller_did {
540 warn!(
542 bundle_id = %id,
543 caller = %caller_did,
544 owner = %record.created_by,
545 "bundle owned by a different super-admin; treating as not-found"
546 );
547 return Err(AppError::NotFound(format!("bundle not found: {id}")));
548 }
549 Ok(record)
550}
551
552pub(crate) fn enforce_kind(record: &BundleRecord, expected: BundleKind) -> Result<(), AppError> {
553 if record.kind != expected {
554 return Err(AppError::NotFound(format!(
557 "bundle not found: {}",
558 record.bundle_id
559 )));
560 }
561 Ok(())
562}
563
564async fn build_descriptor(
565 record: &BundleRecord,
566 token: BundleToken,
567 config: &tokio::sync::RwLock<AppConfig>,
568) -> Result<BundleDescriptor, AppError> {
569 let public_url = blob_transport_base_url(config)
570 .await
571 .ok_or_else(transport_unavailable_internal)?;
572 let transport_url = build_blob_url(&public_url, &record.bundle_id);
573 Ok(BundleDescriptor {
574 bundle_id: record.bundle_id.to_string(),
575 algorithm: record.algorithm.clone(),
576 transport_url,
577 transport_token: token.as_str().to_string(),
578 expected_sha256: record.expected_sha256.clone(),
579 expected_size_bytes: record.expected_size_bytes,
580 expires_at: record.expires_at,
581 })
582}
583
584pub const TRANSPORT_UNAVAILABLE_MESSAGE: &str = "this agent publishes no HTTPS address at which backup bytes can be \
590 transferred, so it cannot produce a `stream` descriptor; the fix is on the \
591 agent's configuration, not in the request";
592
593pub async fn blob_transport_base_url(config: &tokio::sync::RwLock<AppConfig>) -> Option<String> {
604 config
605 .read()
606 .await
607 .public_url
608 .clone()
609 .filter(|u| !u.trim().is_empty())
610}
611
612fn transport_unavailable_internal() -> AppError {
613 AppError::Internal(
614 "VTA `public_url` is not configured; cannot build backup bundle URL. \
615 Set `public_url` in config (or VTA_PUBLIC_URL env var) and restart."
616 .into(),
617 )
618}
619
620fn build_blob_url(public_url: &str, bundle_id: &Uuid) -> String {
621 let base = public_url.trim_end_matches('/');
622 format!("{base}/backup/blob/{bundle_id}")
623}
624
625pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
626 use sha2::{Digest, Sha256};
627 let mut hasher = Sha256::new();
628 hasher.update(bytes);
629 let out = hasher.finalize();
630 let mut s = String::with_capacity(out.len() * 2);
631 for b in out {
632 s.push_str(&format!("{b:02x}"));
633 }
634 s
635}
636
637#[cfg(unix)]
638pub(crate) async fn set_dir_mode_700(path: &Path) -> Result<(), AppError> {
639 use std::os::unix::fs::PermissionsExt;
640 let perms = std::fs::Permissions::from_mode(0o700);
641 tokio::fs::set_permissions(path, perms)
642 .await
643 .map_err(AppError::Io)
644}
645
646#[cfg(unix)]
647pub(crate) async fn set_file_mode_600(path: &Path) -> Result<(), AppError> {
648 use std::os::unix::fs::PermissionsExt;
649 let perms = std::fs::Permissions::from_mode(0o600);
650 tokio::fs::set_permissions(path, perms)
651 .await
652 .map_err(AppError::Io)
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
669 use crate::backup_bundle_store::{BundleKind, BundleRecord, BundleState};
670 use chrono::Duration;
671 use std::sync::Arc;
672 use tokio::sync::RwLock;
673 use vti_common::acl::Role;
674 use vti_common::config::StoreConfig as VtiStoreConfig;
675
676 fn super_admin(did: &str) -> AuthClaims {
677 AuthClaims {
678 did: did.into(),
679 role: Role::Admin,
680 allowed_contexts: Vec::new(),
681 session_id: "test-session".into(),
682 access_expires_at: 0,
683 issued_at: 0,
684 amr: Vec::new(),
685 acr: String::new(),
686 }
687 }
688
689 fn context_admin(did: &str) -> AuthClaims {
690 AuthClaims {
691 did: did.into(),
692 role: Role::Admin,
693 allowed_contexts: vec!["ctx1".into()],
694 session_id: "test-session".into(),
695 access_expires_at: 0,
696 issued_at: 0,
697 amr: Vec::new(),
698 acr: String::new(),
699 }
700 }
701
702 async fn open_bundles_ks() -> (tempfile::TempDir, KeyspaceHandle) {
703 let dir = tempfile::tempdir().unwrap();
704 let store = Store::open(&VtiStoreConfig {
705 data_dir: dir.path().into(),
706 })
707 .unwrap();
708 let ks = store.keyspace(vta_keyspaces::BACKUP_BUNDLES).unwrap();
709 (dir, ks)
710 }
711
712 fn config_with_public_url(url: &str) -> Arc<RwLock<AppConfig>> {
713 let mut config: AppConfig = toml::from_str(&format!(
714 r#"
715 vta_did = "did:key:zTestVTA"
716 public_url = "{url}"
717 [store]
718 data_dir = "/tmp/does-not-matter-for-this-test"
719 [auth]
720 "#
721 ))
722 .expect("parse config");
723 config.config_path = std::path::PathBuf::from("/tmp/does-not-matter");
727 Arc::new(RwLock::new(config))
728 }
729
730 fn seed_export_ready(bundle_id: Uuid, owner: &str, token_hash: [u8; 32]) -> BundleRecord {
731 let now = Utc::now();
732 BundleRecord {
733 bundle_id,
734 kind: BundleKind::Export,
735 state: BundleState::ExportReady,
736 created_at: now,
737 expires_at: now + Duration::minutes(5),
738 created_by: owner.into(),
739 algorithm: "stream".into(),
740 expected_sha256: "deadbeef".into(),
741 expected_size_bytes: 1024,
742 token_hash,
743 blob_path: None,
744 }
745 }
746
747 #[test]
748 fn validate_algorithm_accepts_stream_only() {
749 assert!(validate_algorithm("stream").is_ok());
750 let err = validate_algorithm("s3-presigned").unwrap_err();
751 assert!(
752 matches!(err, AppError::Validation(_)),
753 "unknown algorithm must surface as Validation: {err:?}"
754 );
755 assert!(validate_algorithm("").is_err());
757 assert!(validate_algorithm("Stream").is_err());
759 }
760
761 #[test]
762 fn parse_bundle_id_rejects_malformed() {
763 assert!(parse_bundle_id("00000000-0000-0000-0000-000000000000").is_ok());
764 assert!(parse_bundle_id("not-a-uuid").is_err());
765 assert!(parse_bundle_id("").is_err());
766 }
767
768 #[test]
769 fn build_blob_url_strips_trailing_slash() {
770 let id = Uuid::nil();
771 let url = build_blob_url("https://vta.example/", &id);
773 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
774 let url = build_blob_url("https://vta.example", &id);
776 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
777 }
778
779 #[tokio::test]
780 async fn require_owned_returns_record_for_owner() {
781 let (_dir, ks) = open_bundles_ks().await;
782 let id = Uuid::new_v4();
783 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
784 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
785 let restored = require_owned(&ks, &id, "did:example:alice").await.unwrap();
786 assert_eq!(restored.bundle_id, id);
787 }
788
789 #[tokio::test]
790 async fn require_owned_treats_cross_did_as_not_found() {
791 let (_dir, ks) = open_bundles_ks().await;
795 let id = Uuid::new_v4();
796 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
797 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
798 let err = require_owned(&ks, &id, "did:example:bob")
799 .await
800 .unwrap_err();
801 assert!(
802 matches!(err, AppError::NotFound(_)),
803 "cross-DID lookup must report NotFound (don't leak existence): {err:?}"
804 );
805 }
806
807 #[tokio::test]
808 async fn require_owned_404_for_unknown_bundle() {
809 let (_dir, ks) = open_bundles_ks().await;
810 let err = require_owned(&ks, &Uuid::new_v4(), "did:example:alice")
811 .await
812 .unwrap_err();
813 assert!(matches!(err, AppError::NotFound(_)));
814 }
815
816 #[tokio::test]
817 async fn enforce_kind_rejects_wrong_kind_as_not_found() {
818 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
819 let err = enforce_kind(&r, BundleKind::Import).unwrap_err();
820 assert!(
821 matches!(err, AppError::NotFound(_)),
822 "wrong-kind must report NotFound (don't leak the kind): {err:?}"
823 );
824 assert!(enforce_kind(&r, BundleKind::Export).is_ok());
825 }
826
827 #[tokio::test]
828 async fn enforce_open_bundle_cap_allows_under_limit() {
829 let (_dir, ks) = open_bundles_ks().await;
830 for _ in 0..2 {
832 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
833 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
834 }
835 assert!(
836 enforce_open_bundle_cap(&ks, "did:example:alice")
837 .await
838 .is_ok()
839 );
840 }
841
842 #[tokio::test]
843 async fn enforce_open_bundle_cap_rejects_at_limit() {
844 let (_dir, ks) = open_bundles_ks().await;
845 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
846 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
847 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
848 }
849 let err = enforce_open_bundle_cap(&ks, "did:example:alice")
850 .await
851 .unwrap_err();
852 assert!(matches!(err, AppError::Conflict(_)));
853 }
854
855 #[tokio::test]
856 async fn enforce_open_bundle_cap_ignores_terminal_states() {
857 let (_dir, ks) = open_bundles_ks().await;
859 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
860 let mut r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
861 r.state = BundleState::Aborted;
862 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
863 }
864 assert!(
865 enforce_open_bundle_cap(&ks, "did:example:alice")
866 .await
867 .is_ok()
868 );
869 }
870
871 #[tokio::test]
872 async fn enforce_open_bundle_cap_scopes_to_did() {
873 let (_dir, ks) = open_bundles_ks().await;
875 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
876 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
877 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
878 }
879 assert!(
880 enforce_open_bundle_cap(&ks, "did:example:bob")
881 .await
882 .is_ok()
883 );
884 }
885
886 #[tokio::test]
889 async fn initiate_import_then_abort_round_trip() {
890 let (dir, bundles_ks) = open_bundles_ks().await;
891 let config = config_with_public_url("https://vta.example");
892 let blob_dir = dir.path().join("backups");
893
894 let _ = config; let auth = super_admin("did:example:alice");
904 validate_algorithm("stream").unwrap();
905 enforce_open_bundle_cap(&bundles_ks, &auth.did)
906 .await
907 .unwrap();
908 let (token, token_hash) = mint_token().unwrap();
909 let id = Uuid::new_v4();
910 let now = Utc::now();
911 let record = BundleRecord {
912 bundle_id: id,
913 kind: BundleKind::Import,
914 state: BundleState::ImportPending,
915 created_at: now,
916 expires_at: now + Duration::minutes(5),
917 created_by: auth.did.clone(),
918 algorithm: "stream".into(),
919 expected_sha256: "a".repeat(64),
920 expected_size_bytes: 100,
921 token_hash,
922 blob_path: None,
923 };
924 backup_bundle_store::store_bundle(&bundles_ks, &record)
925 .await
926 .unwrap();
927 assert!(!token.as_str().is_empty());
929
930 let mut r = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
936 assert_eq!(r.state, BundleState::ImportPending);
937 r.state = BundleState::Aborted;
938 backup_bundle_store::store_bundle(&bundles_ks, &r)
939 .await
940 .unwrap();
941
942 let r2 = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
944 assert!(r2.state.is_terminal());
945 let _ = blob_dir;
946 }
947
948 fn config_without_public_url() -> Arc<RwLock<AppConfig>> {
949 let config: AppConfig = toml::from_str(
950 r#"
951 vta_did = "did:key:zTestVTA"
952 [store]
953 data_dir = "/tmp/does-not-matter-for-this-test"
954 [auth]
955 "#,
956 )
957 .expect("parse config");
958 Arc::new(RwLock::new(config))
959 }
960
961 #[tokio::test]
966 async fn blob_transport_is_unavailable_without_a_public_url() {
967 assert_eq!(
968 blob_transport_base_url(&config_without_public_url()).await,
969 None
970 );
971 assert_eq!(
972 blob_transport_base_url(&config_with_public_url(" ")).await,
973 None,
974 "a blank public_url must read as unavailable"
975 );
976 assert_eq!(
977 blob_transport_base_url(&config_with_public_url("https://vta.example")).await,
978 Some("https://vta.example".to_string())
979 );
980 }
981
982 #[test]
985 fn transport_unavailable_message_names_no_config_key() {
986 assert!(!TRANSPORT_UNAVAILABLE_MESSAGE.contains("public_url"));
987 }
988
989 #[test]
990 fn context_admin_is_not_super_admin() {
991 let auth = context_admin("did:example:ctx-admin");
997 assert!(
998 auth.require_super_admin().is_err(),
999 "context-admin must NOT pass require_super_admin"
1000 );
1001 }
1002}