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 amr: Vec::new(),
608 acr: String::new(),
609 }
610 }
611
612 fn context_admin(did: &str) -> AuthClaims {
613 AuthClaims {
614 did: did.into(),
615 role: Role::Admin,
616 allowed_contexts: vec!["ctx1".into()],
617 session_id: "test-session".into(),
618 access_expires_at: 0,
619 amr: Vec::new(),
620 acr: String::new(),
621 }
622 }
623
624 async fn open_bundles_ks() -> (tempfile::TempDir, KeyspaceHandle) {
625 let dir = tempfile::tempdir().unwrap();
626 let store = Store::open(&VtiStoreConfig {
627 data_dir: dir.path().into(),
628 })
629 .unwrap();
630 let ks = store.keyspace(vta_keyspaces::BACKUP_BUNDLES).unwrap();
631 (dir, ks)
632 }
633
634 fn config_with_public_url(url: &str) -> Arc<RwLock<AppConfig>> {
635 let mut config: AppConfig = toml::from_str(&format!(
636 r#"
637 vta_did = "did:key:zTestVTA"
638 public_url = "{url}"
639 [store]
640 data_dir = "/tmp/does-not-matter-for-this-test"
641 [auth]
642 "#
643 ))
644 .expect("parse config");
645 config.config_path = std::path::PathBuf::from("/tmp/does-not-matter");
649 Arc::new(RwLock::new(config))
650 }
651
652 fn seed_export_ready(bundle_id: Uuid, owner: &str, token_hash: [u8; 32]) -> BundleRecord {
653 let now = Utc::now();
654 BundleRecord {
655 bundle_id,
656 kind: BundleKind::Export,
657 state: BundleState::ExportReady,
658 created_at: now,
659 expires_at: now + Duration::minutes(5),
660 created_by: owner.into(),
661 algorithm: "stream".into(),
662 expected_sha256: "deadbeef".into(),
663 expected_size_bytes: 1024,
664 token_hash,
665 blob_path: None,
666 }
667 }
668
669 #[test]
670 fn validate_algorithm_accepts_stream_only() {
671 assert!(validate_algorithm("stream").is_ok());
672 let err = validate_algorithm("s3-presigned").unwrap_err();
673 assert!(
674 matches!(err, AppError::Validation(_)),
675 "unknown algorithm must surface as Validation: {err:?}"
676 );
677 assert!(validate_algorithm("").is_err());
679 assert!(validate_algorithm("Stream").is_err());
681 }
682
683 #[test]
684 fn parse_bundle_id_rejects_malformed() {
685 assert!(parse_bundle_id("00000000-0000-0000-0000-000000000000").is_ok());
686 assert!(parse_bundle_id("not-a-uuid").is_err());
687 assert!(parse_bundle_id("").is_err());
688 }
689
690 #[test]
691 fn build_blob_url_strips_trailing_slash() {
692 let id = Uuid::nil();
693 let url = build_blob_url("https://vta.example/", &id);
695 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
696 let url = build_blob_url("https://vta.example", &id);
698 assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
699 }
700
701 #[tokio::test]
702 async fn require_owned_returns_record_for_owner() {
703 let (_dir, ks) = open_bundles_ks().await;
704 let id = Uuid::new_v4();
705 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
706 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
707 let restored = require_owned(&ks, &id, "did:example:alice").await.unwrap();
708 assert_eq!(restored.bundle_id, id);
709 }
710
711 #[tokio::test]
712 async fn require_owned_treats_cross_did_as_not_found() {
713 let (_dir, ks) = open_bundles_ks().await;
717 let id = Uuid::new_v4();
718 let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
719 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
720 let err = require_owned(&ks, &id, "did:example:bob")
721 .await
722 .unwrap_err();
723 assert!(
724 matches!(err, AppError::NotFound(_)),
725 "cross-DID lookup must report NotFound (don't leak existence): {err:?}"
726 );
727 }
728
729 #[tokio::test]
730 async fn require_owned_404_for_unknown_bundle() {
731 let (_dir, ks) = open_bundles_ks().await;
732 let err = require_owned(&ks, &Uuid::new_v4(), "did:example:alice")
733 .await
734 .unwrap_err();
735 assert!(matches!(err, AppError::NotFound(_)));
736 }
737
738 #[tokio::test]
739 async fn enforce_kind_rejects_wrong_kind_as_not_found() {
740 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
741 let err = enforce_kind(&r, BundleKind::Import).unwrap_err();
742 assert!(
743 matches!(err, AppError::NotFound(_)),
744 "wrong-kind must report NotFound (don't leak the kind): {err:?}"
745 );
746 assert!(enforce_kind(&r, BundleKind::Export).is_ok());
747 }
748
749 #[tokio::test]
750 async fn enforce_open_bundle_cap_allows_under_limit() {
751 let (_dir, ks) = open_bundles_ks().await;
752 for _ in 0..2 {
754 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
755 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
756 }
757 assert!(
758 enforce_open_bundle_cap(&ks, "did:example:alice")
759 .await
760 .is_ok()
761 );
762 }
763
764 #[tokio::test]
765 async fn enforce_open_bundle_cap_rejects_at_limit() {
766 let (_dir, ks) = open_bundles_ks().await;
767 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
768 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
769 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
770 }
771 let err = enforce_open_bundle_cap(&ks, "did:example:alice")
772 .await
773 .unwrap_err();
774 assert!(matches!(err, AppError::Conflict(_)));
775 }
776
777 #[tokio::test]
778 async fn enforce_open_bundle_cap_ignores_terminal_states() {
779 let (_dir, ks) = open_bundles_ks().await;
781 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
782 let mut r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
783 r.state = BundleState::Aborted;
784 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
785 }
786 assert!(
787 enforce_open_bundle_cap(&ks, "did:example:alice")
788 .await
789 .is_ok()
790 );
791 }
792
793 #[tokio::test]
794 async fn enforce_open_bundle_cap_scopes_to_did() {
795 let (_dir, ks) = open_bundles_ks().await;
797 for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
798 let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
799 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
800 }
801 assert!(
802 enforce_open_bundle_cap(&ks, "did:example:bob")
803 .await
804 .is_ok()
805 );
806 }
807
808 #[tokio::test]
811 async fn initiate_import_then_abort_round_trip() {
812 let (dir, bundles_ks) = open_bundles_ks().await;
813 let config = config_with_public_url("https://vta.example");
814 let blob_dir = dir.path().join("backups");
815
816 let _ = config; let auth = super_admin("did:example:alice");
826 validate_algorithm("stream").unwrap();
827 enforce_open_bundle_cap(&bundles_ks, &auth.did)
828 .await
829 .unwrap();
830 let (token, token_hash) = mint_token().unwrap();
831 let id = Uuid::new_v4();
832 let now = Utc::now();
833 let record = BundleRecord {
834 bundle_id: id,
835 kind: BundleKind::Import,
836 state: BundleState::ImportPending,
837 created_at: now,
838 expires_at: now + Duration::minutes(5),
839 created_by: auth.did.clone(),
840 algorithm: "stream".into(),
841 expected_sha256: "a".repeat(64),
842 expected_size_bytes: 100,
843 token_hash,
844 blob_path: None,
845 };
846 backup_bundle_store::store_bundle(&bundles_ks, &record)
847 .await
848 .unwrap();
849 assert!(!token.as_str().is_empty());
851
852 let mut r = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
858 assert_eq!(r.state, BundleState::ImportPending);
859 r.state = BundleState::Aborted;
860 backup_bundle_store::store_bundle(&bundles_ks, &r)
861 .await
862 .unwrap();
863
864 let r2 = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
866 assert!(r2.state.is_terminal());
867 let _ = blob_dir;
868 }
869
870 #[test]
871 fn context_admin_is_not_super_admin() {
872 let auth = context_admin("did:example:ctx-admin");
878 assert!(
879 auth.require_super_admin().is_err(),
880 "context-admin must NOT pass require_super_admin"
881 );
882 }
883}