fix: harden identity publication recovery
This commit is contained in:
@@ -237,10 +237,45 @@ fn workspace_create_fingerprint(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
||||||
|
use crate::workspace_signing_identity::{
|
||||||
|
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
|
||||||
|
WorkspaceSigningPrivateMaterial, identity_error,
|
||||||
|
};
|
||||||
use workspace_api::RepositorySourceKind;
|
use workspace_api::RepositorySourceKind;
|
||||||
|
|
||||||
|
struct FailFirstMaterialWrite {
|
||||||
|
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||||
|
fail: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
|
||||||
|
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||||
|
self.inner.load(material_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_if_absent(
|
||||||
|
&self,
|
||||||
|
material_ref: &str,
|
||||||
|
material: &WorkspaceSigningPrivateMaterial,
|
||||||
|
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||||
|
if self.fail.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(identity_error(
|
||||||
|
"workspace_signing_identity_material_io_failed",
|
||||||
|
"injected private material write failure",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inner.put_if_absent(material_ref, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||||
|
self.inner.delete(material_ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn git_repository() -> tempfile::TempDir {
|
fn git_repository() -> tempfile::TempDir {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||||
@@ -319,6 +354,127 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let database_path = temp.path().join("server.db");
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
|
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||||
|
let service = WorkspaceCatalogService::new(
|
||||||
|
store.clone(),
|
||||||
|
Arc::new(FailFirstMaterialWrite {
|
||||||
|
inner: materials.clone(),
|
||||||
|
fail: AtomicBool::new(true),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let repository = git_repository();
|
||||||
|
let request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "material-failure".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository.path().display().to_string(),
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
|
let reserved_key = store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
drop(service);
|
||||||
|
drop(store);
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||||
|
let created = restarted.create(request, owner_account_id).unwrap();
|
||||||
|
let identity = store
|
||||||
|
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(identity.key_id, reserved_key);
|
||||||
|
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let database_path = temp.path().join("server.db");
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
|
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||||
|
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
||||||
|
let repository = git_repository();
|
||||||
|
let request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "db-failure".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository.path().display().to_string(),
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
||||||
|
BEFORE INSERT ON workspace_signing_identity_audit
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
|
let (reserved_key, material_ref) = store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(materials.load(&material_ref).unwrap().is_some());
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
drop(service);
|
||||||
|
drop(store);
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||||
|
let created = restarted.create(request, owner_account_id).unwrap();
|
||||||
|
let identity = store
|
||||||
|
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(identity.key_id, reserved_key);
|
||||||
|
assert_eq!(identity.state, "active");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
|||||||
@@ -451,14 +451,19 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
|
|||||||
.and_then(|()| file.sync_all())
|
.and_then(|()| file.sync_all())
|
||||||
.map_err(|error| material_io_error("write", error))?;
|
.map_err(|error| material_io_error("write", error))?;
|
||||||
match fs::hard_link(&temporary, &path) {
|
match fs::hard_link(&temporary, &path) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => sync_directory(parent),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||||
Err(error) => Err(material_io_error("publish", error)),
|
Err(error) => Err(material_io_error("publish", error)),
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
bytes.zeroize();
|
bytes.zeroize();
|
||||||
let _ = fs::remove_file(&temporary);
|
let cleanup_result = match fs::remove_file(&temporary) {
|
||||||
|
Ok(()) => sync_directory(parent),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(material_io_error("remove temporary", error)),
|
||||||
|
};
|
||||||
write_result?;
|
write_result?;
|
||||||
|
cleanup_result?;
|
||||||
self.load(material_ref)?.ok_or_else(|| {
|
self.load(material_ref)?.ok_or_else(|| {
|
||||||
identity_error(
|
identity_error(
|
||||||
"workspace_signing_identity_material_missing",
|
"workspace_signing_identity_material_missing",
|
||||||
@@ -469,8 +474,16 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
|
|||||||
|
|
||||||
fn delete(&self, material_ref: &str) -> Result<()> {
|
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||||
let path = self.material_path(material_ref)?;
|
let path = self.material_path(material_ref)?;
|
||||||
match fs::remove_file(path) {
|
match fs::remove_file(&path) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => {
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
identity_error(
|
||||||
|
"workspace_signing_identity_material_ref_invalid",
|
||||||
|
"Workspace signing private material reference has no parent",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
sync_directory(parent)
|
||||||
|
}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
Err(error) => Err(material_io_error("delete", error)),
|
Err(error) => Err(material_io_error("delete", error)),
|
||||||
}
|
}
|
||||||
@@ -479,6 +492,9 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
|
|||||||
|
|
||||||
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
|
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
|
||||||
ensure_private_directory(root)?;
|
ensure_private_directory(root)?;
|
||||||
|
if let Some(parent) = root.parent() {
|
||||||
|
sync_directory(parent)?;
|
||||||
|
}
|
||||||
let relative = leaf.strip_prefix(root).map_err(|_| {
|
let relative = leaf.strip_prefix(root).map_err(|_| {
|
||||||
identity_error(
|
identity_error(
|
||||||
"workspace_signing_identity_material_ref_invalid",
|
"workspace_signing_identity_material_ref_invalid",
|
||||||
@@ -487,12 +503,29 @@ fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
let mut current = root.to_path_buf();
|
let mut current = root.to_path_buf();
|
||||||
for component in relative.components() {
|
for component in relative.components() {
|
||||||
|
let parent = current.clone();
|
||||||
current.push(component);
|
current.push(component);
|
||||||
ensure_private_directory(¤t)?;
|
ensure_private_directory(¤t)?;
|
||||||
|
sync_directory(&parent)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn sync_directory(path: &Path) -> Result<()> {
|
||||||
|
std::fs::File::open(path)
|
||||||
|
.and_then(|directory| directory.sync_all())
|
||||||
|
.map_err(|error| material_io_error("synchronize directory", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn sync_directory(_path: &Path) -> Result<()> {
|
||||||
|
Err(identity_error(
|
||||||
|
"workspace_signing_identity_durable_publish_unsupported",
|
||||||
|
"Workspace signing private material durable publication is unsupported on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_private_directory(path: &Path) -> Result<()> {
|
fn ensure_private_directory(path: &Path) -> Result<()> {
|
||||||
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
|
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -548,14 +581,46 @@ pub fn identity_error(code: impl Into<String>, message: impl Into<String>) -> Er
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
struct FailFirstMaterialWrite {
|
||||||
|
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||||
|
fail: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
|
||||||
|
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||||
|
self.inner.load(material_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_if_absent(
|
||||||
|
&self,
|
||||||
|
material_ref: &str,
|
||||||
|
material: &WorkspaceSigningPrivateMaterial,
|
||||||
|
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||||
|
if self.fail.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(identity_error(
|
||||||
|
"workspace_signing_identity_material_io_failed",
|
||||||
|
"injected private material write failure",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inner.put_if_absent(material_ref, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||||
|
self.inner.delete(material_ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
|
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
|
||||||
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
|
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&temp.path().join("server.db")).unwrap());
|
let database_path = temp.path().join("server.db");
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
account_id: "account-1".to_string(),
|
account_id: "account-1".to_string(),
|
||||||
@@ -578,12 +643,38 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||||
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
let failing_service = WorkspaceSigningIdentityService::new(
|
||||||
|
store.clone(),
|
||||||
|
Arc::new(FailFirstMaterialWrite {
|
||||||
|
inner: materials.clone(),
|
||||||
|
fail: AtomicBool::new(true),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
service.get_validated("workspace-1").unwrap().state,
|
failing_service.get_validated("workspace-1").unwrap().state,
|
||||||
|
"pending_provisioning"
|
||||||
|
);
|
||||||
|
let error = failing_service
|
||||||
|
.provision_existing("workspace-1", "account-1")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
Error::WorkspaceSigningIdentity { ref code, .. }
|
||||||
|
if code == "workspace_signing_identity_material_io_failed"
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workspace_signing_identity("workspace-1")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
"pending_provisioning"
|
"pending_provisioning"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
drop(failing_service);
|
||||||
|
drop(store);
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
||||||
let provisioned = service
|
let provisioned = service
|
||||||
.provision_existing("workspace-1", "account-1")
|
.provision_existing("workspace-1", "account-1")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -617,8 +708,91 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
store
|
||||||
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
|
workspace_id: "workspace-2".to_string(),
|
||||||
|
owner_account_id: "account-1".to_string(),
|
||||||
|
display_name: "Workspace 2".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pending = store
|
||||||
|
.get_workspace_signing_identity("workspace-2")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let operation = store
|
||||||
|
.reserve_workspace_signing_identity_provisioning(
|
||||||
|
&WorkspaceSigningIdentityProvisioningOperation {
|
||||||
|
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
|
||||||
|
request_fingerprint: provisioning_fingerprint(
|
||||||
|
"workspace-2",
|
||||||
|
&pending.key_id,
|
||||||
|
pending.revision,
|
||||||
|
),
|
||||||
|
operation_kind: "existing_workspace".to_string(),
|
||||||
|
workspace_id: "workspace-2".to_string(),
|
||||||
|
key_id: pending.key_id.clone(),
|
||||||
|
private_material_ref: pending.private_material_ref.clone(),
|
||||||
|
revision: pending.revision,
|
||||||
|
actor_account_id: "account-1".to_string(),
|
||||||
|
state: "pending".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
completed_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let activation = service.prepare_material(&operation).unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
|
||||||
|
BEFORE INSERT ON workspace_signing_identity_audit
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.activate_workspace_signing_identity(
|
||||||
|
&activation,
|
||||||
|
&operation.operation_key,
|
||||||
|
"account-1",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workspace_signing_identity("workspace-2")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
"pending_provisioning"
|
||||||
|
);
|
||||||
|
drop(service);
|
||||||
|
drop(store);
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
|
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
||||||
|
let recovered = restarted
|
||||||
|
.provision_existing("workspace-2", "account-1")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recovered.key_id, activation.key_id);
|
||||||
|
assert_eq!(
|
||||||
|
recovered.public_key_fingerprint.as_deref(),
|
||||||
|
Some(activation.public_key_fingerprint.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
materials.delete(&provisioned.private_material_ref).unwrap();
|
materials.delete(&provisioned.private_material_ref).unwrap();
|
||||||
let error = service.get_validated("workspace-1").unwrap_err();
|
let error = restarted.get_validated("workspace-1").unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
error,
|
error,
|
||||||
Error::WorkspaceSigningIdentity { ref code, .. }
|
Error::WorkspaceSigningIdentity { ref code, .. }
|
||||||
|
|||||||
Reference in New Issue
Block a user