Implement transactional draft editing and client creation vertical slice

This commit is contained in:
2026-10-29 23:15:55 +02:00
parent 2766c6254a
commit c1d4f935c8
6 changed files with 396 additions and 149 deletions
+244 -130
View File
@@ -4,6 +4,10 @@
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
//! explicit runtime-restart confirmation boundary.
mod draft;
use draft::DraftChanges;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
@@ -78,9 +82,7 @@ pub struct SessionDisplay {
pub struct Application {
store: SavedStore,
saved: SavedBytes,
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
draft: DraftChanges,
historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>,
@@ -466,9 +468,9 @@ impl fmt::Debug for Application {
formatter
.debug_struct("Application")
.field("saved", &self.saved)
.field("document", &self.document)
.field("states", &self.states)
.field("vault_present", &self.vault.is_some())
.field("document", &self.draft.document)
.field("states", &self.draft.states)
.field("vault_present", &self.draft.vault.is_some())
.field("historical_apply", &self.historical_apply)
.field(
"restart_attempt_active",
@@ -477,6 +479,7 @@ impl fmt::Debug for Application {
.field(
"pending_reservation_count",
&self
.draft
.states
.address_reservations()
.pending_host_routes()
@@ -531,9 +534,7 @@ impl Application {
Ok(Self {
store,
saved,
document,
states,
vault,
draft: DraftChanges::new(document, states, vault),
historical_apply,
verified_saved_projection: None,
active_restart_attempt: None,
@@ -548,24 +549,25 @@ impl Application {
}
pub fn document(&self) -> &ConfigDocument {
&self.document
&self.draft.document
}
pub fn states(&self) -> &ClientStates {
&self.states
&self.draft.states
}
/// Return a secret-free projection that includes unsupported and
/// ambiguous peers instead of silently omitting them from the client list.
pub fn display_projection(&self) -> SessionDisplay {
let clients = self
.draft
.document
.peers()
.iter()
.map(|peer| {
let projected = peer
.identity
.and_then(|identity| self.document.project_client(&identity).ok());
.and_then(|identity| self.draft.document.project_client(&identity).ok());
let metadata_label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
@@ -588,14 +590,14 @@ impl Application {
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: projected
.filter(|client| self.states.draft().get(&client.identity).is_some())
.filter(|client| self.draft.states.draft().get(&client.identity).is_some())
.map(|client| client.identity),
}
})
.collect();
SessionDisplay {
clients,
document_issues: self.document.issues().to_vec(),
document_issues: self.draft.document.issues().to_vec(),
}
}
@@ -606,10 +608,18 @@ impl Application {
client: Client,
created_utc: Option<&str>,
) -> Result<(), AppError> {
let edited = self.document.add_client(&client, created_utc)?;
self.states.draft_mut().insert(client)?;
let edited = ConfigDocument::parse(
self.draft
.document
.add_client(&client, created_utc)?
.render(),
)?;
ensure_document_client(&edited, &client)?;
let mut next_states = self.draft.states.clone();
next_states.stage_client(client)?;
let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations();
self.document = edited;
Ok(())
}
@@ -628,14 +638,20 @@ impl Application {
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
let identity = key.public_key();
let server_addresses = server_interface_addresses(&self.document)?;
let server_addresses = server_interface_addresses(&self.draft.document)?;
let mut occupied = self
.draft
.states
.addresses_in_use()
.into_iter()
.collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.document));
let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.draft.document));
let reserved = self
.draft
.states
.address_reservations()
.all()
.collect::<Vec<_>>();
let availability = AddressAvailability {
server: &server_addresses,
occupied: &occupied,
@@ -668,10 +684,11 @@ impl Application {
// Prepare every fallible replacement off to the side. The live app is
// mutated only after document editing, state insertion, and encryption
// have all succeeded.
let mut next_document = self.document.add_client(&client, created_utc)?;
let mut next_states = self.states.clone();
next_states.draft_mut().insert(client)?;
let next_document = self.draft.document.add_client(&client, created_utc)?;
let mut next_states = self.draft.states.clone();
next_states.stage_client(client.clone())?;
let existing_vault = self
.draft
.vault
.as_ref()
.ok_or(AppError::VaultMissingForCreation)?;
@@ -680,12 +697,14 @@ impl Application {
next_vault.encrypt_record(identity, &key)?;
// Parse once before publication so a malformed editor result cannot
// leave the three in-memory drafts out of sync.
next_document = ConfigDocument::parse(next_document.render())?;
let next_document = ConfigDocument::parse(next_document.render())?;
ensure_document_client(&next_document, &client)?;
next_vault.to_toml()?;
let context = issue_context(&next_document, &next_vault, identity)?;
let replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault));
self.invalidate_active_issue_authorizations();
self.document = next_document;
self.states = next_states;
self.vault = Some(next_vault);
self.draft.publish_replacement(replacement);
self.initial_issues.insert(
identity,
InitialIssue {
@@ -977,7 +996,7 @@ impl Application {
let Ok((projection, _)) = restart_projection_from_document(document) else {
return false;
};
self.states.applied() == &ApplyStatus::Verified
self.draft.states.applied() == &ApplyStatus::Verified
&& self.verified_saved_projection.as_ref() == Some(&projection)
}
@@ -1213,13 +1232,13 @@ impl Application {
}
pub fn vault(&self) -> Option<&VaultBundle> {
self.vault.as_ref()
self.draft.vault.as_ref()
}
/// Whether a vault is present in either saved state or the current draft.
/// This reveals only setup availability, never vault contents.
pub fn vault_initialized(&self) -> bool {
self.vault.is_some()
self.draft.vault.is_some()
}
/// Whether the in-memory client/document/vault draft differs from saved.
@@ -1235,24 +1254,44 @@ impl Application {
selected_name: &OsStr,
) -> Result<SelectedFileImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?;
let vault = self
let existing_vault = self
.draft
.vault
.as_mut()
.as_ref()
.ok_or(AppError::VaultMissingForCreation)?;
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
.map_err(AppError::SourceImport)
let encoded = existing_vault.to_toml()?;
let mut next_vault = VaultBundle::from_toml(&encoded)?;
let report = crate::vault::source::import_selected_file(
&mut next_vault,
&settings,
&self.draft.document,
selected_name,
)
.map_err(AppError::SourceImport)?;
next_vault.to_toml()?;
let replacement = DraftChanges::stage_vault(Some(next_vault));
self.draft.publish_replacement(replacement);
Ok(report)
}
/// Replace a client's alias through the span-preserving config editor.
/// A peer outside the narrower M1 state projection remains editable.
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
let edited = self.document.rename_client(&identity, alias)?;
if let Some(client) = self.states.draft().get(&identity).cloned() {
let edited = ConfigDocument::parse(
self.draft
.document
.rename_client(&identity, alias)?
.render(),
)?;
let mut next_states = self.draft.states.clone();
if let Some(client) = next_states.draft().get(&identity).cloned() {
let replacement = Client::new(identity, alias, client.addresses)?;
self.states.draft_mut().insert(replacement)?;
next_states.stage_client(replacement.clone())?;
ensure_document_client(&edited, &replacement)?;
}
let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations();
self.document = edited;
Ok(())
}
@@ -1264,8 +1303,8 @@ impl Application {
.peers()
.iter()
.any(|peer| peer.identity == Some(identity));
let mut next_states = self.states.clone();
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
let mut next_states = self.draft.states.clone();
if saved_peer_exists && self.draft.states.saved().get(&identity).is_none() {
let peer = saved_document
.peers()
.iter()
@@ -1275,33 +1314,42 @@ impl Application {
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
}
let edited = self.document.delete_client(&identity)?;
let removed_record = match self.vault.as_mut() {
let edited = ConfigDocument::parse(self.draft.document.delete_client(&identity)?.render())?;
if edited
.peers()
.iter()
.any(|peer| peer.identity == Some(identity))
{
return Err(AppError::InvalidSavedState);
}
let mut next_vault = match self.draft.vault.as_ref() {
Some(vault) => Some(VaultBundle::from_toml(&vault.to_toml()?)?),
None => None,
};
let removed_record = match next_vault.as_mut() {
Some(vault) => vault.remove_record(identity)?,
None => false,
};
next_states.draft_mut().remove(&identity);
self.states = next_states;
self.document = edited;
if let Some(vault) = &next_vault {
vault.to_toml()?;
}
next_states.remove_draft_client(&identity);
let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations();
self.initial_issues.remove(&identity);
Ok(removed_record)
}
/// Edit the in-memory vault draft without exposing or replacing its identity.
/// The next commit validates that an installed identity is not changed.
pub fn vault_draft_mut(&mut self) -> Option<&mut VaultBundle> {
self.vault.as_mut()
}
/// Stage an initial vault from a prepared read-only source import. It cannot
/// replace an installed vault and does not change files until Commit.
pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> {
if self.vault.is_some() || self.saved.vault.is_some() {
if self.draft.vault.is_some() || self.saved.vault.is_some() {
return Err(AppError::VaultAlreadyInstalled);
}
bundle.to_toml()?;
self.vault = Some(bundle);
let replacement = DraftChanges::stage_vault(Some(bundle));
self.draft.publish_replacement(replacement);
Ok(())
}
@@ -1324,9 +1372,12 @@ impl Application {
vault_password: &str,
) -> Result<SourceImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?;
let prepared =
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password)
.map_err(AppError::SourceImport)?;
let prepared = crate::vault::source::prepare_initial_setup(
&settings,
&self.draft.document,
vault_password,
)
.map_err(AppError::SourceImport)?;
self.prepare_initial_setup(prepared)
}
@@ -1334,13 +1385,8 @@ impl Application {
/// successfully installed session snapshot. No files are written.
pub fn discard_draft(&mut self) -> Result<(), AppError> {
let installed = self.store.read_saved()?;
if !same_saved_bytes(&self.saved, &installed) {
self.verified_saved_projection = None;
}
self.saved = installed;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.vault = self
.saved
let next_document = ConfigDocument::parse(&installed.server_config)?;
let next_vault = installed
.vault
.as_deref()
.map(std::str::from_utf8)
@@ -1348,19 +1394,29 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
self.states.discard_draft();
if let Some(bytes) = &self.saved.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
let mut next_states = self.draft.states.clone();
next_states.discard_draft();
if let Some(bytes) = &installed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut next_states)?;
} else {
self.states.runtime_knowledge_unknown();
next_states.runtime_knowledge_unknown();
}
self.historical_apply = historical_apply_from_saved(&self.saved)?;
let next_historical_apply = historical_apply_from_saved(&installed)?;
let replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, next_vault);
let saved_changed = !same_saved_bytes(&self.saved, &installed);
self.saved = installed;
self.draft.publish_replacement(replacement);
if saved_changed {
self.verified_saved_projection = None;
}
self.historical_apply = next_historical_apply;
self.initial_issues
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
retain_matching_issue_map(
&mut self.initial_issues,
&self.document,
self.vault.as_ref(),
&self.draft.document,
self.draft.vault.as_ref(),
);
Ok(())
}
@@ -1421,7 +1477,8 @@ impl Application {
}
let (projection, expected_clients) =
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
let expected_clients =
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
Ok(RestartPreview {
saved: clone_saved(&installed),
interface: settings.interface_name,
@@ -1434,13 +1491,14 @@ impl Application {
}
fn has_draft(&self) -> bool {
let vault_differs = match &self.vault {
let vault_differs = match &self.draft.vault {
Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault,
None => self.saved.vault.is_some(),
};
self.states.draft() != self.states.saved()
|| self.document.render() != self.saved.server_config
self.draft.states.draft() != self.draft.states.saved()
|| self.draft.document.render() != self.saved.server_config
|| self
.draft
.states
.address_reservations()
.pending_host_routes()
@@ -1622,7 +1680,8 @@ impl Application {
);
}
};
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
let expected_clients =
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
if settings.interface_name != preview.interface
|| settings.systemd_unit != preview.unit
|| settings.restart_timeout != preview.timeout
@@ -1696,7 +1755,7 @@ impl Application {
);
}
self.states.runtime_knowledge_unknown();
self.draft.states.runtime_knowledge_unknown();
let deadline = RestartDeadline::new(now(), settings.restart_timeout);
let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome;
@@ -1847,12 +1906,13 @@ impl Application {
let snapshot = snapshot_from_document(&document);
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
let mut archived_reservations = BTreeSet::new();
let mut reservations = self.states.reserved_addresses().clone();
let mut reservations = self.draft.states.reserved_addresses().clone();
// A restore can remove clients that are currently saved even when their
// addresses were not yet in the persisted reservation set. Runtime state
// remains unchanged, so carry those addresses across the replacement.
reservations.extend(
self.states
self.draft
.states
.saved()
.clients()
.flat_map(|client| client.addresses.iter()),
@@ -1938,9 +1998,9 @@ impl Application {
}
self.saved = preview.saved_bytes;
self.store.set_resolved_settings(resolved);
self.document = document;
self.vault = vault;
self.states = states;
self.draft.document = document;
self.draft.vault = vault;
self.draft.states = states;
self.initial_issues.clear();
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
@@ -1966,7 +2026,7 @@ impl Application {
{
return Err(AppError::SavedGenerationChanged);
}
let mut next_states = self.states.clone();
let mut next_states = self.draft.states.clone();
next_states.prepare_technically_verified_result(observed_management_snapshot)?;
let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?,
@@ -2015,8 +2075,9 @@ impl Application {
return Err(error.into());
}
self.saved = proposed;
self.states = next_states;
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
self.draft.states = next_states;
self.verified_saved_projection =
Some(SavedPeerProjection::from_document(&self.draft.document));
self.historical_apply = historical_apply;
self.active_restart_attempt = None;
Ok(())
@@ -2047,6 +2108,7 @@ impl Application {
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
let existing = VaultBundle::from_toml(existing_text)?;
if self
.draft
.vault
.as_ref()
.is_none_or(|vault| existing.recipient() != vault.recipient())
@@ -2055,7 +2117,7 @@ impl Application {
}
}
let mut next_states = self.states.clone();
let mut next_states = self.draft.states.clone();
next_states.commit_draft();
let reservations = next_states
.reserved_addresses()
@@ -2064,6 +2126,7 @@ impl Application {
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault_bytes = self
.draft
.vault
.as_ref()
.map(VaultBundle::to_toml)
@@ -2071,7 +2134,7 @@ impl Application {
.map(String::into_bytes);
let proposed = SavedBytes {
settings: installed.settings.clone(),
server_config: self.document.render().to_vec(),
server_config: self.draft.document.render().to_vec(),
internal_state,
vault: vault_bytes,
};
@@ -2108,13 +2171,13 @@ impl Application {
}
self.saved = proposed;
self.states = next_states;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.draft.states = next_states;
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
self.invalidate_active_issue_authorizations();
promote_matching_issue_map(
&mut self.initial_issues,
&self.document,
self.vault.as_ref(),
&self.draft.document,
self.draft.vault.as_ref(),
);
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
@@ -2415,6 +2478,13 @@ fn snapshot_from_document(document: &ConfigDocument) -> Snapshot {
snapshot
}
fn ensure_document_client(document: &ConfigDocument, client: &Client) -> Result<(), AppError> {
if document.project_client(&client.identity)? != *client {
return Err(AppError::InvalidSavedState);
}
Ok(())
}
#[derive(Debug, Error)]
pub enum AppError {
#[error("saved state is invalid or incompatible with this session")]
@@ -2476,6 +2546,11 @@ mod tests {
use std::path::Path;
use std::time::{Duration, Instant};
use super::{
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
RestartStage, clone_saved, snapshot_from_document,
};
use crate::backup::{ArchiveCheckpoint, create_archive_with_hook};
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
use crate::config::SettingsFile;
@@ -2492,13 +2567,6 @@ mod tests {
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
use crate::vault::{VaultBundle, WireGuardPrivateKey};
use crate::wireguard::ConfigDocument;
use zeroize::Zeroizing;
use super::{
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
RestartStage, clone_saved, snapshot_from_document,
};
const SETTINGS: &str = r#"
interface_name = "wg0"
@@ -2687,6 +2755,25 @@ restart_timeout_seconds = 30
));
}
#[test]
fn missing_vault_after_client_preparation_does_not_publish_any_draft_or_issue() {
let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false);
let mut app = Application::open(store, states).expect("application opens");
let before_document = app.document().render().to_vec();
let before_draft = app.states().draft().clone();
assert!(matches!(
app.prepare_new_client("missing vault", None, None),
Err(AppError::VaultMissingForCreation)
));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states().draft(), &before_draft);
assert!(app.vault().is_none());
assert!(app.initial_issues.is_empty());
}
#[test]
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
let root = tempfile::tempdir().expect("temp directory");
@@ -2717,6 +2804,36 @@ restart_timeout_seconds = 30
assert_eq!(app.saved_bytes().vault, saved_vault);
}
#[test]
fn selected_import_error_keeps_vault_and_pending_issue_unchanged() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let identity = app
.prepare_new_client("pending import", None, None)
.expect("client stages");
let before_document = app.document().render().to_vec();
let before_states = app.states().clone();
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
assert!(matches!(
app.import_selected_file(OsStr::new("../invalid.conf")),
Err(AppError::SourceImport(
crate::vault::source::SourceImportError::InvalidSelection
))
));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states(), &before_states);
assert_eq!(
app.vault()
.expect("vault remains present")
.to_toml()
.unwrap(),
before_vault
);
assert!(app.initial_issues.contains_key(&identity));
}
#[test]
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
let root = tempfile::tempdir().expect("temp directory");
@@ -3471,37 +3588,6 @@ restart_timeout_seconds = 30
);
}
#[test]
fn replacing_the_encrypted_record_invalidates_committed_first_issue() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let identity = app
.prepare_new_client("replacement", None, None)
.expect("client stages");
app.commit().expect("client commits");
let replacement_bytes = Zeroizing::new(
app.initial_issues
.get(&identity)
.expect("session key retained")
.private_key
.to_base64(),
);
let replacement_key = WireGuardPrivateKey::parse_base64(&replacement_bytes)
.expect("same synthetic key parses");
let vault = app.vault_draft_mut().expect("vault exists");
assert!(vault.remove_record(identity).expect("record removed"));
vault
.encrypt_record(identity, &replacement_key)
.expect("replacement record encrypted");
app.commit().expect("replacement commits");
assert!(matches!(
app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(
()
)),
Err(AppError::FirstIssueUnavailable)
));
}
#[test]
fn deletion_and_discard_revoke_only_session_bound_initial_material() {
let root = tempfile::tempdir().expect("temp directory");
@@ -3677,7 +3763,7 @@ restart_timeout_seconds = 30
let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false);
let mut app = Application::open(store, states).expect("application opens");
app.states.runtime_knowledge_unavailable();
app.draft.states.runtime_knowledge_unavailable();
app.commit().expect("unchanged commit completes");
@@ -3724,6 +3810,34 @@ restart_timeout_seconds = 30
assert_eq!(app.states().draft(), app.states().saved());
}
#[test]
fn discard_parse_failure_preserves_every_live_draft_component_and_issue() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let issue = app
.prepare_new_client("discard failure", None, None)
.expect("new client stages");
let before_saved = clone_saved(app.saved_bytes());
let before_document = app.document().render().to_vec();
let before_states = app.states().clone();
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
fs::write(root.path().join("server.conf"), [0xff]).expect("corrupt synthetic saved file");
assert!(app.discard_draft().is_err());
assert!(super::same_saved_bytes(app.saved_bytes(), &before_saved));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states(), &before_states);
assert_eq!(
app.vault()
.expect("vault remains present")
.to_toml()
.unwrap(),
before_vault
);
assert!(app.initial_issues.contains_key(&issue));
}
#[test]
fn initial_vault_is_prepared_then_installed_only_after_commit() {
let root = tempfile::tempdir().expect("temp directory");
@@ -4925,7 +5039,7 @@ restart_timeout_seconds = 30
.install(&saved, BackupComplete(()))
.expect("install synthetic unsafe generation");
let reopened_store = app.store.clone();
let states = app.states.clone();
let states = app.draft.states.clone();
app = Application::open(reopened_store, states).expect("reopen fixture");
app.rename_client(key, "draft")
.expect("stage a draft for preservation");