authored_draft.rs (29829B)
1 //! Immutable authored-draft revisions stored before outbound side effects. 2 3 use core::num::NonZeroU64; 4 use radroots_transport::BoxFuture; 5 use sha2::{Digest, Sha256}; 6 use std::{string::String, vec::Vec}; 7 8 use crate::{ 9 Error, 10 authored_draft_query::{AuthoredDraftPage, AuthoredDraftQuery, AuthoredDraftScope}, 11 journal::OperationInstanceId, 12 }; 13 14 pub const AUTHORED_DRAFT_PAYLOAD_MAX_BYTES: usize = 4 * 1024 * 1024; 15 pub const AUTHORED_DRAFT_SCHEMA_MAX_BYTES: usize = 128; 16 pub const AUTHORED_DRAFT_QUERY_LIMIT_MAX: u16 = 256; 17 18 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 19 #[cfg_attr(feature = "serde", serde(try_from = "[u8; 16]", into = "[u8; 16]"))] 20 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 21 pub struct AuthoredDraftId([u8; 16]); 22 23 impl AuthoredDraftId { 24 pub const fn new(value: [u8; 16]) -> Result<Self, Error> { 25 if bytes_are_zero(&value) { 26 Err(Error::InvalidAuthoredDraft) 27 } else { 28 Ok(Self(value)) 29 } 30 } 31 32 pub const fn as_bytes(&self) -> &[u8; 16] { 33 &self.0 34 } 35 } 36 37 impl TryFrom<[u8; 16]> for AuthoredDraftId { 38 type Error = Error; 39 40 fn try_from(value: [u8; 16]) -> Result<Self, Self::Error> { 41 Self::new(value) 42 } 43 } 44 45 impl From<AuthoredDraftId> for [u8; 16] { 46 fn from(value: AuthoredDraftId) -> Self { 47 value.0 48 } 49 } 50 51 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 52 #[cfg_attr(feature = "serde", serde(try_from = "u64", into = "u64"))] 53 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 54 pub struct AuthoredDraftRevision(NonZeroU64); 55 56 impl AuthoredDraftRevision { 57 pub const INITIAL: Self = Self(NonZeroU64::MIN); 58 59 pub const fn new(value: u64) -> Result<Self, Error> { 60 match NonZeroU64::new(value) { 61 Some(value) => Ok(Self(value)), 62 None => Err(Error::InvalidAuthoredDraft), 63 } 64 } 65 66 pub const fn get(self) -> u64 { 67 self.0.get() 68 } 69 70 pub fn next(self) -> Result<Self, Error> { 71 self.get() 72 .checked_add(1) 73 .ok_or(Error::InvalidAuthoredDraft) 74 .and_then(Self::new) 75 } 76 } 77 78 impl TryFrom<u64> for AuthoredDraftRevision { 79 type Error = Error; 80 81 fn try_from(value: u64) -> Result<Self, Self::Error> { 82 Self::new(value) 83 } 84 } 85 86 impl From<AuthoredDraftRevision> for u64 { 87 fn from(value: AuthoredDraftRevision) -> Self { 88 value.get() 89 } 90 } 91 92 /// Product-independent persistence phases for one local authored draft. 93 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 94 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 95 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 96 pub enum AuthoredDraftStage { 97 Draft, 98 MediaPreparing, 99 MediaUploading, 100 ReadyToSign, 101 Queued, 102 Cancelled, 103 } 104 105 impl AuthoredDraftStage { 106 pub const fn is_terminal(self) -> bool { 107 matches!(self, Self::Cancelled) 108 } 109 } 110 111 /// One immutable, integrity-bound draft revision. 112 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 113 #[cfg_attr( 114 feature = "serde", 115 serde( 116 try_from = "AuthoredDraftRevisionWire", 117 into = "AuthoredDraftRevisionWire" 118 ) 119 )] 120 #[derive(Clone, Debug, Eq, PartialEq)] 121 pub struct AuthoredDraft { 122 draft_id: AuthoredDraftId, 123 revision: AuthoredDraftRevision, 124 author: [u8; 32], 125 payload_schema: String, 126 scope: Option<AuthoredDraftScope>, 127 payload: Vec<u8>, 128 payload_sha256: [u8; 32], 129 stage: AuthoredDraftStage, 130 operation_id: Option<OperationInstanceId>, 131 created_at_unix_ms: u64, 132 updated_at_unix_ms: u64, 133 } 134 135 #[cfg(feature = "serde")] 136 #[derive(serde::Serialize, serde::Deserialize)] 137 struct AuthoredDraftRevisionWire { 138 draft_id: AuthoredDraftId, 139 revision: AuthoredDraftRevision, 140 author: [u8; 32], 141 payload_schema: String, 142 #[serde(default, skip_serializing_if = "Option::is_none")] 143 scope: Option<AuthoredDraftScope>, 144 payload: Vec<u8>, 145 payload_sha256: [u8; 32], 146 stage: AuthoredDraftStage, 147 operation_id: Option<OperationInstanceId>, 148 created_at_unix_ms: u64, 149 updated_at_unix_ms: u64, 150 } 151 152 #[cfg(feature = "serde")] 153 impl TryFrom<AuthoredDraftRevisionWire> for AuthoredDraft { 154 type Error = Error; 155 156 fn try_from(value: AuthoredDraftRevisionWire) -> Result<Self, Self::Error> { 157 Self::reconstruct_scoped( 158 value.draft_id, 159 value.revision, 160 value.author, 161 value.payload_schema, 162 value.scope, 163 value.payload, 164 value.payload_sha256, 165 value.stage, 166 value.operation_id, 167 value.created_at_unix_ms, 168 value.updated_at_unix_ms, 169 ) 170 } 171 } 172 173 #[cfg(feature = "serde")] 174 impl From<AuthoredDraft> for AuthoredDraftRevisionWire { 175 fn from(value: AuthoredDraft) -> Self { 176 Self { 177 draft_id: value.draft_id, 178 revision: value.revision, 179 author: value.author, 180 payload_schema: value.payload_schema, 181 scope: value.scope, 182 payload: value.payload, 183 payload_sha256: value.payload_sha256, 184 stage: value.stage, 185 operation_id: value.operation_id, 186 created_at_unix_ms: value.created_at_unix_ms, 187 updated_at_unix_ms: value.updated_at_unix_ms, 188 } 189 } 190 } 191 192 impl AuthoredDraft { 193 #[allow(clippy::too_many_arguments)] 194 pub fn initial( 195 draft_id: AuthoredDraftId, 196 author: [u8; 32], 197 payload_schema: impl Into<String>, 198 payload: Vec<u8>, 199 stage: AuthoredDraftStage, 200 operation_id: Option<OperationInstanceId>, 201 created_at_unix_ms: u64, 202 ) -> Result<Self, Error> { 203 if matches!( 204 stage, 205 AuthoredDraftStage::ReadyToSign 206 | AuthoredDraftStage::Queued 207 | AuthoredDraftStage::Cancelled 208 ) { 209 return Err(Error::InvalidAuthoredDraft); 210 } 211 let payload_sha256 = Sha256::digest(payload.as_slice()).into(); 212 Self::reconstruct( 213 draft_id, 214 AuthoredDraftRevision::INITIAL, 215 author, 216 payload_schema.into(), 217 payload, 218 payload_sha256, 219 stage, 220 operation_id, 221 created_at_unix_ms, 222 created_at_unix_ms, 223 ) 224 } 225 226 pub fn successor( 227 &self, 228 payload: Vec<u8>, 229 stage: AuthoredDraftStage, 230 operation_id: Option<OperationInstanceId>, 231 updated_at_unix_ms: u64, 232 ) -> Result<Self, Error> { 233 let payload_sha256 = Sha256::digest(payload.as_slice()).into(); 234 let next = Self::reconstruct_scoped( 235 self.draft_id, 236 self.revision.next()?, 237 self.author, 238 self.payload_schema.clone(), 239 self.scope, 240 payload, 241 payload_sha256, 242 stage, 243 operation_id, 244 self.created_at_unix_ms, 245 updated_at_unix_ms, 246 )?; 247 next.validate_successor_of(self)?; 248 Ok(next) 249 } 250 251 #[allow(clippy::too_many_arguments)] 252 pub fn reconstruct( 253 draft_id: AuthoredDraftId, 254 revision: AuthoredDraftRevision, 255 author: [u8; 32], 256 payload_schema: impl Into<String>, 257 payload: Vec<u8>, 258 payload_sha256: [u8; 32], 259 stage: AuthoredDraftStage, 260 operation_id: Option<OperationInstanceId>, 261 created_at_unix_ms: u64, 262 updated_at_unix_ms: u64, 263 ) -> Result<Self, Error> { 264 Self::reconstruct_scoped( 265 draft_id, 266 revision, 267 author, 268 payload_schema, 269 None, 270 payload, 271 payload_sha256, 272 stage, 273 operation_id, 274 created_at_unix_ms, 275 updated_at_unix_ms, 276 ) 277 } 278 279 #[allow(clippy::too_many_arguments)] 280 fn reconstruct_scoped( 281 draft_id: AuthoredDraftId, 282 revision: AuthoredDraftRevision, 283 author: [u8; 32], 284 payload_schema: impl Into<String>, 285 scope: Option<AuthoredDraftScope>, 286 payload: Vec<u8>, 287 payload_sha256: [u8; 32], 288 stage: AuthoredDraftStage, 289 operation_id: Option<OperationInstanceId>, 290 created_at_unix_ms: u64, 291 updated_at_unix_ms: u64, 292 ) -> Result<Self, Error> { 293 let value = Self { 294 draft_id, 295 revision, 296 author, 297 payload_schema: payload_schema.into(), 298 scope, 299 payload, 300 payload_sha256, 301 stage, 302 operation_id, 303 created_at_unix_ms, 304 updated_at_unix_ms, 305 }; 306 value.validate()?; 307 Ok(value) 308 } 309 310 pub fn validate(&self) -> Result<(), Error> { 311 let schema = self.payload_schema.as_str(); 312 let requires_operation = matches!( 313 self.stage, 314 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued 315 ); 316 if bytes_are_zero(&self.author) 317 || schema.is_empty() 318 || schema.len() > AUTHORED_DRAFT_SCHEMA_MAX_BYTES 319 || schema != schema.trim() 320 || schema.chars().any(char::is_control) 321 || self.payload.is_empty() 322 || self.payload.len() > AUTHORED_DRAFT_PAYLOAD_MAX_BYTES 323 || Sha256::digest(self.payload.as_slice()).as_slice() != self.payload_sha256 324 || self.created_at_unix_ms == 0 325 || self.updated_at_unix_ms < self.created_at_unix_ms 326 || (requires_operation && self.operation_id.is_none()) 327 || (!requires_operation 328 && self.stage != AuthoredDraftStage::Cancelled 329 && self.operation_id.is_some()) 330 { 331 return Err(Error::InvalidAuthoredDraft); 332 } 333 Ok(()) 334 } 335 336 pub fn validate_successor_of(&self, previous: &Self) -> Result<(), Error> { 337 let identity_matches = self.draft_id == previous.draft_id 338 && self.author == previous.author 339 && self.payload_schema == previous.payload_schema 340 && self.scope == previous.scope 341 && self.created_at_unix_ms == previous.created_at_unix_ms 342 && self.revision == previous.revision.next()? 343 && self.updated_at_unix_ms >= previous.updated_at_unix_ms; 344 let operation_matches = match (previous.operation_id, self.operation_id) { 345 (Some(previous), Some(next)) => previous == next, 346 (None, _) => true, 347 (Some(_), None) => false, 348 }; 349 let frozen_queue_payload = !matches!( 350 (previous.stage, self.stage), 351 ( 352 AuthoredDraftStage::ReadyToSign, 353 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued 354 ) | (AuthoredDraftStage::Queued, AuthoredDraftStage::Queued) 355 ) || self.payload_sha256 == previous.payload_sha256; 356 let stage_allowed = match previous.stage { 357 AuthoredDraftStage::Draft => matches!( 358 self.stage, 359 AuthoredDraftStage::Draft 360 | AuthoredDraftStage::MediaPreparing 361 | AuthoredDraftStage::ReadyToSign 362 | AuthoredDraftStage::Cancelled 363 ), 364 AuthoredDraftStage::MediaPreparing => matches!( 365 self.stage, 366 AuthoredDraftStage::MediaPreparing 367 | AuthoredDraftStage::MediaUploading 368 | AuthoredDraftStage::ReadyToSign 369 | AuthoredDraftStage::Cancelled 370 ), 371 AuthoredDraftStage::MediaUploading => matches!( 372 self.stage, 373 AuthoredDraftStage::MediaPreparing 374 | AuthoredDraftStage::MediaUploading 375 | AuthoredDraftStage::ReadyToSign 376 | AuthoredDraftStage::Cancelled 377 ), 378 AuthoredDraftStage::ReadyToSign => matches!( 379 self.stage, 380 AuthoredDraftStage::ReadyToSign 381 | AuthoredDraftStage::Queued 382 | AuthoredDraftStage::Cancelled 383 ), 384 AuthoredDraftStage::Queued => matches!( 385 self.stage, 386 AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled 387 ), 388 AuthoredDraftStage::Cancelled => false, 389 }; 390 if !identity_matches || !operation_matches || !frozen_queue_payload || !stage_allowed { 391 return Err(Error::DraftRevisionConflict); 392 } 393 Ok(()) 394 } 395 396 pub const fn draft_id(&self) -> AuthoredDraftId { 397 self.draft_id 398 } 399 pub const fn revision(&self) -> AuthoredDraftRevision { 400 self.revision 401 } 402 pub const fn author(&self) -> &[u8; 32] { 403 &self.author 404 } 405 pub fn payload_schema(&self) -> &str { 406 self.payload_schema.as_str() 407 } 408 /// Selects an immutable scope while constructing an initial local record. 409 pub fn with_scope(mut self, scope: AuthoredDraftScope) -> Result<Self, Error> { 410 if self.revision != AuthoredDraftRevision::INITIAL || self.scope.is_some() { 411 return Err(Error::InvalidAuthoredDraft); 412 } 413 self.scope = Some(scope); 414 Ok(self) 415 } 416 pub const fn scope(&self) -> Option<AuthoredDraftScope> { 417 self.scope 418 } 419 420 pub fn payload(&self) -> &[u8] { 421 self.payload.as_slice() 422 } 423 pub const fn payload_sha256(&self) -> &[u8; 32] { 424 &self.payload_sha256 425 } 426 pub const fn stage(&self) -> AuthoredDraftStage { 427 self.stage 428 } 429 pub const fn operation_id(&self) -> Option<OperationInstanceId> { 430 self.operation_id 431 } 432 pub const fn created_at_unix_ms(&self) -> u64 { 433 self.created_at_unix_ms 434 } 435 pub const fn updated_at_unix_ms(&self) -> u64 { 436 self.updated_at_unix_ms 437 } 438 } 439 440 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 441 pub enum DraftAppendDisposition { 442 Inserted, 443 Replay, 444 } 445 446 #[derive(Clone, Debug, Eq, PartialEq)] 447 pub struct DraftAppendReceipt { 448 draft: AuthoredDraft, 449 disposition: DraftAppendDisposition, 450 } 451 452 impl DraftAppendReceipt { 453 pub const fn new(draft: AuthoredDraft, disposition: DraftAppendDisposition) -> Self { 454 Self { draft, disposition } 455 } 456 pub const fn draft(&self) -> &AuthoredDraft { 457 &self.draft 458 } 459 pub const fn disposition(&self) -> DraftAppendDisposition { 460 self.disposition 461 } 462 } 463 464 pub trait AuthoredDraftStore: Send + Sync { 465 /// Atomically appends two opaque revisions or replays both exact snapshots. 466 /// 467 /// Unsupported backends fail closed; sequential single-row calls are not an 468 /// implementation. Caller cancellation after commit may lose the receipt 469 /// without rolling back either row. Replay is historical, not a head lease. 470 fn append_authored_draft_pair( 471 &self, 472 _pair: crate::authored_draft_pair::AuthoredDraftPair, 473 ) -> BoxFuture<'_, Result<[DraftAppendReceipt; 2], Error>> { 474 Box::pin(async { Err(Error::BackendUnavailable) }) 475 } 476 477 fn query_authored_drafts( 478 &self, 479 query: AuthoredDraftQuery, 480 ) -> BoxFuture<'_, Result<AuthoredDraftPage, Error>>; 481 482 fn append_authored_draft( 483 &self, 484 draft: AuthoredDraft, 485 expected_head: Option<AuthoredDraftRevision>, 486 ) -> BoxFuture<'_, Result<DraftAppendReceipt, Error>>; 487 488 fn authored_draft_head( 489 &self, 490 draft_id: AuthoredDraftId, 491 ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>>; 492 493 fn authored_draft_revision( 494 &self, 495 draft_id: AuthoredDraftId, 496 revision: AuthoredDraftRevision, 497 ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>>; 498 499 fn authored_draft_heads( 500 &self, 501 author: [u8; 32], 502 limit: u16, 503 ) -> BoxFuture<'_, Result<Vec<AuthoredDraft>, Error>>; 504 } 505 506 const fn bytes_are_zero<const N: usize>(bytes: &[u8; N]) -> bool { 507 let mut index = 0; 508 while index < bytes.len() { 509 if bytes[index] != 0 { 510 return false; 511 } 512 index += 1; 513 } 514 true 515 } 516 517 #[cfg(test)] 518 mod tests { 519 use super::*; 520 521 fn draft() -> AuthoredDraft { 522 AuthoredDraft::initial( 523 AuthoredDraftId::new([1; 16]).unwrap(), 524 [2; 32], 525 "radroots.phase1-draft.v1", 526 b"draft".to_vec(), 527 AuthoredDraftStage::Draft, 528 None, 529 10, 530 ) 531 .unwrap() 532 } 533 534 #[test] 535 fn revisions_are_immutable_and_phase_ordered() { 536 let first = draft(); 537 let media = first 538 .successor( 539 b"media".to_vec(), 540 AuthoredDraftStage::MediaPreparing, 541 None, 542 11, 543 ) 544 .unwrap(); 545 let operation = OperationInstanceId::new([3; 16]).unwrap(); 546 let ready = media 547 .successor( 548 b"ready".to_vec(), 549 AuthoredDraftStage::ReadyToSign, 550 Some(operation), 551 12, 552 ) 553 .unwrap(); 554 assert!( 555 ready 556 .successor( 557 b"changed".to_vec(), 558 AuthoredDraftStage::Queued, 559 Some(operation), 560 13, 561 ) 562 .is_err() 563 ); 564 let queued = ready 565 .successor( 566 b"ready".to_vec(), 567 AuthoredDraftStage::Queued, 568 Some(operation), 569 13, 570 ) 571 .unwrap(); 572 assert_eq!(queued.revision().get(), 4); 573 assert!( 574 queued 575 .successor(b"x".to_vec(), AuthoredDraftStage::Draft, None, 14) 576 .is_err() 577 ); 578 } 579 580 #[test] 581 fn reconstruction_rejects_tampering_and_invalid_contracts() { 582 let value = draft(); 583 assert!(AuthoredDraftId::new([0; 16]).is_err()); 584 assert!(AuthoredDraftRevision::new(0).is_err()); 585 assert!( 586 AuthoredDraft::reconstruct( 587 value.draft_id(), 588 value.revision(), 589 *value.author(), 590 value.payload_schema(), 591 value.payload().to_vec(), 592 [9; 32], 593 value.stage(), 594 None, 595 value.created_at_unix_ms(), 596 value.updated_at_unix_ms(), 597 ) 598 .is_err() 599 ); 600 assert!( 601 AuthoredDraft::initial( 602 value.draft_id(), 603 [0; 32], 604 "schema", 605 Vec::new(), 606 AuthoredDraftStage::ReadyToSign, 607 None, 608 0, 609 ) 610 .is_err() 611 ); 612 } 613 614 #[test] 615 fn validation_rejects_every_independent_invalid_field() { 616 let value = draft(); 617 let digest = |payload: &[u8]| -> [u8; 32] { Sha256::digest(payload).into() }; 618 let reconstruct = |author: [u8; 32], 619 schema: String, 620 payload: Vec<u8>, 621 payload_sha256: [u8; 32], 622 stage: AuthoredDraftStage, 623 operation_id: Option<OperationInstanceId>, 624 created_at_unix_ms: u64, 625 updated_at_unix_ms: u64| { 626 AuthoredDraft::reconstruct( 627 value.draft_id(), 628 value.revision(), 629 author, 630 schema, 631 payload, 632 payload_sha256, 633 stage, 634 operation_id, 635 created_at_unix_ms, 636 updated_at_unix_ms, 637 ) 638 }; 639 640 let operation = OperationInstanceId::new([3; 16]).unwrap(); 641 let valid_payload = b"draft".to_vec(); 642 let valid_digest = digest(&valid_payload); 643 let invalid = [ 644 reconstruct( 645 [0; 32], 646 "schema".into(), 647 valid_payload.clone(), 648 valid_digest, 649 AuthoredDraftStage::Draft, 650 None, 651 10, 652 10, 653 ), 654 reconstruct( 655 [2; 32], 656 String::new(), 657 valid_payload.clone(), 658 valid_digest, 659 AuthoredDraftStage::Draft, 660 None, 661 10, 662 10, 663 ), 664 reconstruct( 665 [2; 32], 666 "x".repeat(AUTHORED_DRAFT_SCHEMA_MAX_BYTES + 1), 667 valid_payload.clone(), 668 valid_digest, 669 AuthoredDraftStage::Draft, 670 None, 671 10, 672 10, 673 ), 674 reconstruct( 675 [2; 32], 676 " schema".into(), 677 valid_payload.clone(), 678 valid_digest, 679 AuthoredDraftStage::Draft, 680 None, 681 10, 682 10, 683 ), 684 reconstruct( 685 [2; 32], 686 "bad\nschema".into(), 687 valid_payload.clone(), 688 valid_digest, 689 AuthoredDraftStage::Draft, 690 None, 691 10, 692 10, 693 ), 694 reconstruct( 695 [2; 32], 696 "schema".into(), 697 Vec::new(), 698 digest(&[]), 699 AuthoredDraftStage::Draft, 700 None, 701 10, 702 10, 703 ), 704 reconstruct( 705 [2; 32], 706 "schema".into(), 707 valid_payload.clone(), 708 [9; 32], 709 AuthoredDraftStage::Draft, 710 None, 711 10, 712 10, 713 ), 714 reconstruct( 715 [2; 32], 716 "schema".into(), 717 valid_payload.clone(), 718 valid_digest, 719 AuthoredDraftStage::Draft, 720 None, 721 0, 722 10, 723 ), 724 reconstruct( 725 [2; 32], 726 "schema".into(), 727 valid_payload.clone(), 728 valid_digest, 729 AuthoredDraftStage::Draft, 730 None, 731 10, 732 9, 733 ), 734 reconstruct( 735 [2; 32], 736 "schema".into(), 737 valid_payload.clone(), 738 valid_digest, 739 AuthoredDraftStage::ReadyToSign, 740 None, 741 10, 742 10, 743 ), 744 reconstruct( 745 [2; 32], 746 "schema".into(), 747 valid_payload, 748 valid_digest, 749 AuthoredDraftStage::Draft, 750 Some(operation), 751 10, 752 10, 753 ), 754 ]; 755 assert!(invalid.into_iter().all(|result| result.is_err())); 756 757 let oversized = vec![0; AUTHORED_DRAFT_PAYLOAD_MAX_BYTES + 1]; 758 assert!( 759 reconstruct( 760 [2; 32], 761 "schema".into(), 762 oversized.clone(), 763 digest(&oversized), 764 AuthoredDraftStage::Draft, 765 None, 766 10, 767 10, 768 ) 769 .is_err() 770 ); 771 assert!( 772 AuthoredDraftRevision::new(u64::MAX) 773 .unwrap() 774 .next() 775 .is_err() 776 ); 777 assert!(AuthoredDraftStage::Cancelled.is_terminal()); 778 assert!(!AuthoredDraftStage::Draft.is_terminal()); 779 for forbidden_initial_stage in [ 780 AuthoredDraftStage::ReadyToSign, 781 AuthoredDraftStage::Queued, 782 AuthoredDraftStage::Cancelled, 783 ] { 784 assert!( 785 AuthoredDraft::initial( 786 AuthoredDraftId::new([4; 16]).unwrap(), 787 [5; 32], 788 "schema", 789 b"payload".to_vec(), 790 forbidden_initial_stage, 791 None, 792 10, 793 ) 794 .is_err() 795 ); 796 } 797 } 798 799 #[test] 800 fn every_stage_pair_obeys_the_transition_matrix() { 801 let stages = [ 802 AuthoredDraftStage::Draft, 803 AuthoredDraftStage::MediaPreparing, 804 AuthoredDraftStage::MediaUploading, 805 AuthoredDraftStage::ReadyToSign, 806 AuthoredDraftStage::Queued, 807 AuthoredDraftStage::Cancelled, 808 ]; 809 let operation = OperationInstanceId::new([3; 16]).unwrap(); 810 811 for previous_stage in stages { 812 for next_stage in stages { 813 let previous_operation = matches!( 814 previous_stage, 815 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued 816 ) 817 .then_some(operation); 818 let previous = AuthoredDraft::reconstruct( 819 AuthoredDraftId::new([1; 16]).unwrap(), 820 AuthoredDraftRevision::INITIAL, 821 [2; 32], 822 "schema", 823 b"payload".to_vec(), 824 Sha256::digest(b"payload").into(), 825 previous_stage, 826 previous_operation, 827 10, 828 10, 829 ) 830 .unwrap(); 831 let next_operation = match next_stage { 832 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued => Some(operation), 833 AuthoredDraftStage::Cancelled => previous_operation, 834 _ => None, 835 }; 836 let payload = if matches!( 837 (previous_stage, next_stage), 838 ( 839 AuthoredDraftStage::ReadyToSign, 840 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued 841 ) | (AuthoredDraftStage::Queued, AuthoredDraftStage::Queued) 842 ) { 843 b"payload".to_vec() 844 } else { 845 b"next".to_vec() 846 }; 847 let allowed = match previous_stage { 848 AuthoredDraftStage::Draft => matches!( 849 next_stage, 850 AuthoredDraftStage::Draft 851 | AuthoredDraftStage::MediaPreparing 852 | AuthoredDraftStage::ReadyToSign 853 | AuthoredDraftStage::Cancelled 854 ), 855 AuthoredDraftStage::MediaPreparing => matches!( 856 next_stage, 857 AuthoredDraftStage::MediaPreparing 858 | AuthoredDraftStage::MediaUploading 859 | AuthoredDraftStage::ReadyToSign 860 | AuthoredDraftStage::Cancelled 861 ), 862 AuthoredDraftStage::MediaUploading => matches!( 863 next_stage, 864 AuthoredDraftStage::MediaPreparing 865 | AuthoredDraftStage::MediaUploading 866 | AuthoredDraftStage::ReadyToSign 867 | AuthoredDraftStage::Cancelled 868 ), 869 AuthoredDraftStage::ReadyToSign => matches!( 870 next_stage, 871 AuthoredDraftStage::ReadyToSign 872 | AuthoredDraftStage::Queued 873 | AuthoredDraftStage::Cancelled 874 ), 875 AuthoredDraftStage::Queued => matches!( 876 next_stage, 877 AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled 878 ), 879 AuthoredDraftStage::Cancelled => false, 880 }; 881 assert_eq!( 882 previous 883 .successor(payload, next_stage, next_operation, 11) 884 .is_ok(), 885 allowed, 886 "{previous_stage:?} -> {next_stage:?}" 887 ); 888 } 889 } 890 891 let previous = AuthoredDraft::reconstruct( 892 AuthoredDraftId::new([1; 16]).unwrap(), 893 AuthoredDraftRevision::INITIAL, 894 [2; 32], 895 "schema", 896 b"payload".to_vec(), 897 Sha256::digest(b"payload").into(), 898 AuthoredDraftStage::ReadyToSign, 899 Some(operation), 900 10, 901 10, 902 ) 903 .unwrap(); 904 assert!( 905 previous 906 .successor( 907 b"payload".to_vec(), 908 AuthoredDraftStage::ReadyToSign, 909 Some(OperationInstanceId::new([4; 16]).unwrap()), 910 11, 911 ) 912 .is_err() 913 ); 914 assert!( 915 previous 916 .successor(b"payload".to_vec(), AuthoredDraftStage::Cancelled, None, 11,) 917 .is_err() 918 ); 919 let rebound_identity = AuthoredDraft::reconstruct( 920 AuthoredDraftId::new([9; 16]).unwrap(), 921 previous.revision().next().unwrap(), 922 *previous.author(), 923 previous.payload_schema(), 924 previous.payload().to_vec(), 925 *previous.payload_sha256(), 926 AuthoredDraftStage::ReadyToSign, 927 previous.operation_id(), 928 previous.created_at_unix_ms(), 929 11, 930 ) 931 .unwrap(); 932 assert_eq!( 933 rebound_identity.validate_successor_of(&previous), 934 Err(Error::DraftRevisionConflict) 935 ); 936 } 937 }