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 validate_algorithm(&body.algorithm)?;
106 enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
107
108 let envelope = {
110 let config_guard = deps.config.read().await;
111 super::export_backup(
112 &deps.keyspaces,
113 deps.seed_store.as_ref(),
114 &config_guard,
115 auth,
116 &body.password,
117 body.include_audit,
118 )
119 .await?
120 };
121
122 let bytes = serde_json::to_vec(&envelope)
127 .map_err(|e| AppError::Internal(format!("serialize backup envelope: {e}")))?;
128 let sha256_hex = sha256_hex(&bytes);
129 let size = bytes.len() as u64;
130
131 let bundle_id = Uuid::new_v4();
135 let (token, token_hash) = mint_token()?;
136
137 tokio::fs::create_dir_all(deps.blob_dir)
138 .await
139 .map_err(AppError::Io)?;
140 #[cfg(unix)]
141 set_dir_mode_700(deps.blob_dir).await?;
142 let blob_path = deps.blob_dir.join(format!("{bundle_id}.vtabak"));
143 tokio::fs::write(&blob_path, &bytes)
144 .await
145 .map_err(AppError::Io)?;
146 #[cfg(unix)]
147 set_file_mode_600(&blob_path).await?;
148
149 let now = Utc::now();
150 let record = BundleRecord {
151 bundle_id,
152 kind: BundleKind::Export,
153 state: BundleState::ExportReady,
154 created_at: now,
155 expires_at: now + bundle_ttl(),
156 created_by: auth.did.clone(),
157 algorithm: body.algorithm,
158 expected_sha256: sha256_hex.clone(),
159 expected_size_bytes: size,
160 token_hash,
161 blob_path: Some(blob_path),
162 };
163 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
164
165 info!(bundle_id = %bundle_id, size, "initiate-export: bundle ready");
166
167 Ok(InitiateExportResultBody {
168 descriptor: build_descriptor(&record, token, deps.config).await?,
169 completion_hint: format!(
170 "Download with: pnm backup save --bundle-id {bundle_id} --output backup.vtabak"
171 ),
172 })
173}
174
175pub async fn complete_export(
183 deps: &DescriptorDeps<'_>,
184 auth: &AuthClaims,
185 body: CompleteExportBody,
186) -> Result<CompleteExportResultBody, AppError> {
187 auth.require_super_admin()?;
188 let bundle_id = parse_bundle_id(&body.bundle_id)?;
189
190 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
191 enforce_kind(&record, BundleKind::Export)?;
192
193 let downloaded = match record.state {
194 BundleState::ExportDownloaded => {
195 record.state = BundleState::ExportAcked;
196 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
197 true
198 }
199 BundleState::ExportAcked => true, BundleState::ExportReady => false, BundleState::Aborted | BundleState::Expired => {
202 return Err(AppError::Conflict(format!(
203 "bundle {bundle_id} is in terminal state {:?}; cannot ack",
204 record.state
205 )));
206 }
207 _ => {
210 return Err(AppError::Internal(format!(
211 "unexpected state for export bundle {bundle_id}: {:?}",
212 record.state
213 )));
214 }
215 };
216
217 info!(bundle_id = %bundle_id, downloaded, "complete-export: acked");
218 Ok(CompleteExportResultBody {
219 bundle_id: bundle_id.to_string(),
220 downloaded,
221 })
222}
223
224pub async fn initiate_import(
231 deps: &DescriptorDeps<'_>,
232 auth: &AuthClaims,
233 body: InitiateImportBody,
234) -> Result<InitiateImportResultBody, AppError> {
235 auth.require_super_admin()?;
236 validate_algorithm(&body.algorithm)?;
237 enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
238
239 if body.expected_sha256.len() != 64
242 || !body.expected_sha256.chars().all(|c| c.is_ascii_hexdigit())
243 {
244 return Err(AppError::Validation(format!(
245 "expected_sha256 must be 64 lowercase hex chars; got `{}`",
246 body.expected_sha256
247 )));
248 }
249 if body.expected_size_bytes == 0 {
250 return Err(AppError::Validation(
251 "expected_size_bytes must be > 0".into(),
252 ));
253 }
254
255 let bundle_id = Uuid::new_v4();
256 let (token, token_hash) = mint_token()?;
257 let now = Utc::now();
258 let record = BundleRecord {
259 bundle_id,
260 kind: BundleKind::Import,
261 state: BundleState::ImportPending,
262 created_at: now,
263 expires_at: now + bundle_ttl(),
264 created_by: auth.did.clone(),
265 algorithm: body.algorithm,
266 expected_sha256: body.expected_sha256,
267 expected_size_bytes: body.expected_size_bytes,
268 token_hash,
269 blob_path: None,
271 };
272 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
273
274 info!(bundle_id = %bundle_id, "initiate-import: slot ready");
275 Ok(InitiateImportResultBody {
276 descriptor: build_descriptor(&record, token, deps.config).await?,
277 completion_hint: format!(
278 "Upload with: pnm backup restore --bundle-id {bundle_id} --input <path> --password <pw>"
279 ),
280 })
281}
282
283pub async fn finalize_import(
290 deps: &DescriptorDeps<'_>,
291 auth: &AuthClaims,
292 body: FinalizeImportBody,
293) -> Result<FinalizeImportResultBody, AppError> {
294 auth.require_super_admin()?;
295 let bundle_id = parse_bundle_id(&body.bundle_id)?;
296
297 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
298 enforce_kind(&record, BundleKind::Import)?;
299
300 match record.state {
304 BundleState::ImportReceived | BundleState::ImportPreviewed => {}
305 BundleState::ImportPending => {
306 return Err(AppError::Conflict(format!(
307 "bundle {bundle_id} has no uploaded bytes yet; \
308 POST to /backup/blob/{bundle_id} first"
309 )));
310 }
311 BundleState::ImportCommitted => {
312 return Err(AppError::Conflict(format!(
313 "bundle {bundle_id} already committed"
314 )));
315 }
316 BundleState::Aborted | BundleState::Expired => {
317 return Err(AppError::Conflict(format!(
318 "bundle {bundle_id} in terminal state {:?}",
319 record.state
320 )));
321 }
322 _ => {
323 return Err(AppError::Internal(format!(
324 "unexpected state for import bundle {bundle_id}: {:?}",
325 record.state
326 )));
327 }
328 }
329
330 let blob_path = record.blob_path.clone().ok_or_else(|| {
331 AppError::Internal(format!("bundle {bundle_id} has no blob_path on disk"))
332 })?;
333 let bytes = tokio::fs::read(&blob_path).await.map_err(AppError::Io)?;
334
335 let envelope: BackupEnvelope = serde_json::from_slice(&bytes).map_err(|e| {
336 AppError::Validation(format!("uploaded bytes are not a BackupEnvelope: {e}"))
337 })?;
338
339 if body.confirm {
340 let result = super::apply_import(
342 &super::preview_import(&envelope, &body.password).await?.0,
343 &deps.keyspaces,
344 deps.seed_store,
345 deps.config,
346 deps.store,
347 #[cfg(feature = "tee")]
348 deps.re_encryptor,
349 )
350 .await?;
351
352 if let Err(e) = tokio::fs::remove_file(&blob_path).await {
354 warn!(
355 bundle_id = %bundle_id,
356 path = %blob_path.display(),
357 error = %e,
358 "finalize-import: failed to delete blob after commit"
359 );
360 }
361 record.state = BundleState::ImportCommitted;
362 record.blob_path = None;
363 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
364
365 info!(bundle_id = %bundle_id, "finalize-import: committed");
366 Ok(FinalizeImportResultBody {
367 bundle_id: bundle_id.to_string(),
368 status: "committed".into(),
369 source_did: result.source_did,
370 key_count: result.key_count,
371 acl_count: result.acl_count,
372 context_count: result.context_count,
373 audit_count: result.audit_count,
374 imported_secret_count: result.imported_secret_count,
375 message: result.message,
376 })
377 } else {
378 let (_payload, result) = super::preview_import(&envelope, &body.password).await?;
380 record.state = BundleState::ImportPreviewed;
381 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
382
383 info!(bundle_id = %bundle_id, "finalize-import: preview");
384 Ok(FinalizeImportResultBody {
385 bundle_id: bundle_id.to_string(),
386 status: "preview".into(),
387 source_did: result.source_did,
388 key_count: result.key_count,
389 acl_count: result.acl_count,
390 context_count: result.context_count,
391 audit_count: result.audit_count,
392 imported_secret_count: result.imported_secret_count,
393 message: result.message,
394 })
395 }
396}
397
398pub async fn abort_bundle(
404 deps: &DescriptorDeps<'_>,
405 auth: &AuthClaims,
406 body: AbortBundleBody,
407) -> Result<AbortBundleResultBody, AppError> {
408 auth.require_super_admin()?;
409 let bundle_id = parse_bundle_id(&body.bundle_id)?;
410
411 let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
412
413 if record.state.is_terminal() {
414 info!(bundle_id = %bundle_id, state = ?record.state, "abort: bundle already terminal");
415 return Ok(AbortBundleResultBody {
416 bundle_id: bundle_id.to_string(),
417 aborted: false,
418 });
419 }
420
421 if let Some(path) = record.blob_path.clone()
424 && let Err(e) = tokio::fs::remove_file(&path).await
425 {
426 if e.kind() != std::io::ErrorKind::NotFound {
429 warn!(
430 bundle_id = %bundle_id,
431 path = %path.display(),
432 error = %e,
433 "abort: failed to delete staged bytes; sweeper will retry"
434 );
435 }
436 }
437
438 record.state = BundleState::Aborted;
439 record.blob_path = None;
440 backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
441
442 info!(bundle_id = %bundle_id, "abort: bundle cancelled");
443 Ok(AbortBundleResultBody {
444 bundle_id: bundle_id.to_string(),
445 aborted: true,
446 })
447}
448
449fn bundle_ttl() -> Duration {
452 Duration::seconds(DEFAULT_BUNDLE_TTL_SECS as i64)
453}
454
455fn validate_algorithm(algorithm: &str) -> Result<(), AppError> {
456 if algorithm != "stream" {
457 return Err(AppError::Validation(format!(
458 "unsupported transport algorithm: `{algorithm}`; this VTA supports: stream"
459 )));
460 }
461 Ok(())
462}
463
464async fn enforce_open_bundle_cap(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
465 let all = backup_bundle_store::list_bundles(ks).await?;
466 let open = all
467 .iter()
468 .filter(|r| r.created_by == did && !r.state.is_terminal())
469 .count();
470 if open >= MAX_OPEN_BUNDLES_PER_DID {
471 return Err(AppError::Conflict(format!(
472 "operator `{did}` has {open} open backup bundles; \
473 abort or wait for expiry before initiating another \
474 (cap: {MAX_OPEN_BUNDLES_PER_DID})"
475 )));
476 }
477 Ok(())
478}
479
480fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
481 Uuid::parse_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
482}
483
484async fn require_owned(
488 ks: &KeyspaceHandle,
489 id: &Uuid,
490 caller_did: &str,
491) -> Result<BundleRecord, AppError> {
492 let record = backup_bundle_store::get_bundle(ks, id)
493 .await?
494 .ok_or_else(|| AppError::NotFound(format!("bundle not found: {id}")))?;
495 if record.created_by != caller_did {
496 warn!(
498 bundle_id = %id,
499 caller = %caller_did,
500 owner = %record.created_by,
501 "bundle owned by a different super-admin; treating as not-found"
502 );
503 return Err(AppError::NotFound(format!("bundle not found: {id}")));
504 }
505 Ok(record)
506}
507
508fn enforce_kind(record: &BundleRecord, expected: BundleKind) -> Result<(), AppError> {
509 if record.kind != expected {
510 return Err(AppError::NotFound(format!(
513 "bundle not found: {}",
514 record.bundle_id
515 )));
516 }
517 Ok(())
518}
519
520async fn build_descriptor(
521 record: &BundleRecord,
522 token: BundleToken,
523 config: &tokio::sync::RwLock<AppConfig>,
524) -> Result<BundleDescriptor, AppError> {
525 let public_url = config.read().await.public_url.clone().ok_or_else(|| {
526 AppError::Internal(
527 "VTA `public_url` is not configured; cannot build backup bundle URL. \
528 Set `public_url` in config (or VTA_PUBLIC_URL env var) and restart."
529 .into(),
530 )
531 })?;
532 let transport_url = build_blob_url(&public_url, &record.bundle_id);
533 Ok(BundleDescriptor {
534 bundle_id: record.bundle_id.to_string(),
535 algorithm: record.algorithm.clone(),
536 transport_url,
537 transport_token: token.as_str().to_string(),
538 expected_sha256: record.expected_sha256.clone(),
539 expected_size_bytes: record.expected_size_bytes,
540 expires_at: record.expires_at,
541 })
542}
543
544fn build_blob_url(public_url: &str, bundle_id: &Uuid) -> String {
545 let base = public_url.trim_end_matches('/');
546 format!("{base}/backup/blob/{bundle_id}")
547}
548
549fn sha256_hex(bytes: &[u8]) -> String {
550 use sha2::{Digest, Sha256};
551 let mut hasher = Sha256::new();
552 hasher.update(bytes);
553 let out = hasher.finalize();
554 let mut s = String::with_capacity(out.len() * 2);
555 for b in out {
556 s.push_str(&format!("{b:02x}"));
557 }
558 s
559}
560
561#[cfg(unix)]
562async fn set_dir_mode_700(path: &Path) -> Result<(), AppError> {
563 use std::os::unix::fs::PermissionsExt;
564 let perms = std::fs::Permissions::from_mode(0o700);
565 tokio::fs::set_permissions(path, perms)
566 .await
567 .map_err(AppError::Io)
568}
569
570#[cfg(unix)]
571async fn set_file_mode_600(path: &Path) -> Result<(), AppError> {
572 use std::os::unix::fs::PermissionsExt;
573 let perms = std::fs::Permissions::from_mode(0o600);
574 tokio::fs::set_permissions(path, perms)
575 .await
576 .map_err(AppError::Io)
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
593 use crate::backup_bundle_store::{BundleKind, BundleRecord, BundleState};
594 use chrono::Duration;
595 use std::sync::Arc;
596 use tokio::sync::RwLock;
597 use vti_common::acl::Role;
598 use vti_common::config::StoreConfig as VtiStoreConfig;
599
600 fn super_admin(did: &str) -> AuthClaims {
601 AuthClaims {
602 did: did.into(),
603 role: Role::Admin,
604 allowed_contexts: Vec::new(),
605 session_id: "test-session".into(),
606 access_expires_at: 0,
607 issued_at: 0,
608 amr: Vec::new(),
609 acr: String::new(),
610 }
611 }
612
613 fn context_admin(did: &str) -> AuthClaims {
614 AuthClaims {
615 did: did.into(),
616 role: Role::Admin,
617 allowed_contexts: vec!["ctx1".into()],
618 session_id: "test-session".into(),
619 access_expires_at: 0,
620 issued_at: 0,
621 amr: Vec::new(),
622 acr: String::new(),
623 }
624 }
625
626 async fn open_bundles_ks() -> (tempfile::TempDir, KeyspaceHandle) {
627 let dir = tempfile::tempdir().unwrap();
628 let store = Store::open(&VtiStoreConfig {
629 data_dir: dir.path().into(),
630 })
631 .unwrap();
632 let ks = store.keyspace(vta_keyspaces::BACKUP_BUNDLES).unwrap();
633 (dir, ks)
634 }
635
636 fn config_with_public_url(url: &str) -> Arc<RwLock<AppConfig>> {
637 let mut config: AppConfig = toml::from_str(&format!(
638 r#"
639 vta_did = "did:key:zTestVTA"
640 public_url = "{url}"
641 [store]
642 data_dir = "/tmp/does-not-matter-for-this-test"
643 [auth]
644 "#
645 ))
646 .expect("parse config");
647 config.config_path = std::path::PathBuf::from("/tmp/does-not-matter");
651 Arc::new(RwLock::new(config))
652 }
653
654 fn seed_export_ready(bundle_id: Uuid, owner: &str, token_hash: [u8; 32]) -> BundleRecord {
655 let now = Utc::now();
656 BundleRecord {
657 bundle_id,
658 kind: BundleKind::Export,
659 state: BundleState::ExportReady,
660 created_at: now,
661 expires_at: now + Duration::minutes(5),
662 created_by: owner.into(),
663 algorithm: "stream".into(),
664 expected_sha256: "deadbeef".into(),
665 expected_size_bytes: 1024,
666 token_hash,
667 blob_path: None,
668 }
669 }
670
671 #[test]
672 fn validate_algorithm_accepts_stream_only() {
673 assert!(validate_algorithm("stream").is_ok());
674 let err = validate_algorithm("s3-presigned").unwrap_err();
675 assert!(
676 matches!(err, AppError::Validation(_)),
677 "unknown algorithm must surface as Validation: {err:?}"
678 );
679 assert!(validate_algorithm("").is_err());
681 assert!(validate_algorithm("Stream").is_err());
683 }
684
685 #[test]
686 fn parse_bundle_id_rejects_malformed() {
687 assert!(parse_bundle_id("00000000-0000-0000-0000-000000000000").is_ok());
688 assert!(parse_bundle_id("not-a-uuid").is_err());
689 assert!(parse_bundle_id("").is_err());
690 }
691
692 #[test]
693 fn build_blob_url_strips_trailing_slash() {
694 let id = Uuid::nil();
695 let url = build_blob_url("https://vta.example/", &id);
697 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
698 let url = build_blob_url("https://vta.example", &id);
700 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
701 }
702
703 #[tokio::test]
704 async fn require_owned_returns_record_for_owner() {
705 let (_dir, ks) = open_bundles_ks().await;
706 let id = Uuid::new_v4();
707 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
708 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
709 let restored = require_owned(&ks, &id, "did:example:alice").await.unwrap();
710 assert_eq!(restored.bundle_id, id);
711 }
712
713 #[tokio::test]
714 async fn require_owned_treats_cross_did_as_not_found() {
715 let (_dir, ks) = open_bundles_ks().await;
719 let id = Uuid::new_v4();
720 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
721 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
722 let err = require_owned(&ks, &id, "did:example:bob")
723 .await
724 .unwrap_err();
725 assert!(
726 matches!(err, AppError::NotFound(_)),
727 "cross-DID lookup must report NotFound (don't leak existence): {err:?}"
728 );
729 }
730
731 #[tokio::test]
732 async fn require_owned_404_for_unknown_bundle() {
733 let (_dir, ks) = open_bundles_ks().await;
734 let err = require_owned(&ks, &Uuid::new_v4(), "did:example:alice")
735 .await
736 .unwrap_err();
737 assert!(matches!(err, AppError::NotFound(_)));
738 }
739
740 #[tokio::test]
741 async fn enforce_kind_rejects_wrong_kind_as_not_found() {
742 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
743 let err = enforce_kind(&r, BundleKind::Import).unwrap_err();
744 assert!(
745 matches!(err, AppError::NotFound(_)),
746 "wrong-kind must report NotFound (don't leak the kind): {err:?}"
747 );
748 assert!(enforce_kind(&r, BundleKind::Export).is_ok());
749 }
750
751 #[tokio::test]
752 async fn enforce_open_bundle_cap_allows_under_limit() {
753 let (_dir, ks) = open_bundles_ks().await;
754 for _ in 0..2 {
756 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
757 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
758 }
759 assert!(
760 enforce_open_bundle_cap(&ks, "did:example:alice")
761 .await
762 .is_ok()
763 );
764 }
765
766 #[tokio::test]
767 async fn enforce_open_bundle_cap_rejects_at_limit() {
768 let (_dir, ks) = open_bundles_ks().await;
769 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
770 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
771 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
772 }
773 let err = enforce_open_bundle_cap(&ks, "did:example:alice")
774 .await
775 .unwrap_err();
776 assert!(matches!(err, AppError::Conflict(_)));
777 }
778
779 #[tokio::test]
780 async fn enforce_open_bundle_cap_ignores_terminal_states() {
781 let (_dir, ks) = open_bundles_ks().await;
783 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
784 let mut r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
785 r.state = BundleState::Aborted;
786 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
787 }
788 assert!(
789 enforce_open_bundle_cap(&ks, "did:example:alice")
790 .await
791 .is_ok()
792 );
793 }
794
795 #[tokio::test]
796 async fn enforce_open_bundle_cap_scopes_to_did() {
797 let (_dir, ks) = open_bundles_ks().await;
799 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
800 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
801 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
802 }
803 assert!(
804 enforce_open_bundle_cap(&ks, "did:example:bob")
805 .await
806 .is_ok()
807 );
808 }
809
810 #[tokio::test]
813 async fn initiate_import_then_abort_round_trip() {
814 let (dir, bundles_ks) = open_bundles_ks().await;
815 let config = config_with_public_url("https://vta.example");
816 let blob_dir = dir.path().join("backups");
817
818 let _ = config; let auth = super_admin("did:example:alice");
828 validate_algorithm("stream").unwrap();
829 enforce_open_bundle_cap(&bundles_ks, &auth.did)
830 .await
831 .unwrap();
832 let (token, token_hash) = mint_token().unwrap();
833 let id = Uuid::new_v4();
834 let now = Utc::now();
835 let record = BundleRecord {
836 bundle_id: id,
837 kind: BundleKind::Import,
838 state: BundleState::ImportPending,
839 created_at: now,
840 expires_at: now + Duration::minutes(5),
841 created_by: auth.did.clone(),
842 algorithm: "stream".into(),
843 expected_sha256: "a".repeat(64),
844 expected_size_bytes: 100,
845 token_hash,
846 blob_path: None,
847 };
848 backup_bundle_store::store_bundle(&bundles_ks, &record)
849 .await
850 .unwrap();
851 assert!(!token.as_str().is_empty());
853
854 let mut r = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
860 assert_eq!(r.state, BundleState::ImportPending);
861 r.state = BundleState::Aborted;
862 backup_bundle_store::store_bundle(&bundles_ks, &r)
863 .await
864 .unwrap();
865
866 let r2 = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
868 assert!(r2.state.is_terminal());
869 let _ = blob_dir;
870 }
871
872 #[test]
873 fn context_admin_is_not_super_admin() {
874 let auth = context_admin("did:example:ctx-admin");
880 assert!(
881 auth.require_super_admin().is_err(),
882 "context-admin must NOT pass require_super_admin"
883 );
884 }
885}