chore: merge approved optional workspace creation into develop

This commit is contained in:
2026-10-08 19:58:04 +09:00
38 changed files with 2407 additions and 354 deletions
+2 -15
View File
@@ -75,18 +75,6 @@ impl RepositoryRegistryReader {
}
pub fn list(&self) -> RepositoryListProjection {
if self.repositories.is_empty() {
return RepositoryListProjection {
items: Vec::new(),
diagnostics: vec![Diagnostic {
severity: DiagnosticSeverity::Warning,
code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend."
.to_string(),
}],
};
}
RepositoryListProjection {
items: self
.repositories
@@ -598,12 +586,11 @@ mod tests {
}
#[test]
fn empty_registry_reports_diagnostic_without_implicit_repository() {
fn empty_registry_is_valid_without_warning_or_implicit_repository() {
let projection = RepositoryRegistryReader::new(Vec::new()).list();
assert!(projection.items.is_empty());
assert_eq!(projection.diagnostics.len(), 1);
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
assert!(projection.diagnostics.is_empty());
}
#[test]
+272 -25
View File
@@ -355,14 +355,16 @@ impl ServerConfig {
workspace: &WorkspaceRecord,
repositories: Vec<RepositoryRecord>,
) -> Result<Self> {
if repositories.is_empty() {
return Err(Error::Config(format!(
"Workspace {} has no registered repository",
workspace.workspace_id
)));
}
let workspace_execution_root =
Self::default_workspace_backend_data_root(&workspace.workspace_id);
// Derive scoped storage from the configured authority database, not process-global
// defaults. This also keeps custom server roots and temporary API fixtures isolated.
let workspace_execution_root = self
.database_path
.parent()
.ok_or_else(|| {
Error::Config("Server database path must have a parent directory".to_string())
})?
.join("workspaces")
.join(&workspace.workspace_id);
let repositories = repositories
.into_iter()
.map(|repository| ConfiguredRepository {
@@ -386,9 +388,8 @@ impl ServerConfig {
scoped
.workspace_created_at
.clone_from(&workspace.created_at);
scoped.embedded_runtime_store_root = workspace_execution_root.join("embedded-runtime");
scoped.workspace_execution_root = workspace_execution_root;
scoped.embedded_runtime_store_root =
Self::default_embedded_runtime_store_root(&workspace.workspace_id);
scoped.repositories = repositories;
// Runtime trust is server-global. Only explicitly assigned sources enter
// this Workspace's registry and receive Workspace-scoped capabilities.
@@ -2549,7 +2550,7 @@ fn workspace_create_response(
) -> WorkspaceCreateResponse {
WorkspaceCreateResponse {
workspace: workspace_summary(created.workspace),
repository: workspace_repository_record(created.repository),
repository: created.repository.map(workspace_repository_record),
config_revision: created.config_revision,
request_fingerprint: created.request_fingerprint,
replayed: created.replayed,
@@ -42742,7 +42743,10 @@ mod tests {
);
assert_eq!(
scoped.workspace_execution_root,
ServerConfig::default_workspace_backend_data_root("remote-workspace")
base.database_path
.parent()
.unwrap()
.join("workspaces/remote-workspace")
);
}
@@ -45039,6 +45043,252 @@ mod tests {
assert!(auth["cookie_name"].is_string());
}
#[tokio::test]
async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() {
let temp = tempfile::tempdir().unwrap();
let config = test_server_config(temp.path());
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
let owner = seed_test_api_token(store.as_ref(), "empty-owner");
let foreign = seed_test_api_token(store.as_ref(), "empty-foreign");
let app = build_workspace_server_router(config.clone(), store.clone())
.await
.unwrap();
let wire =
json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"});
request_json(
app.clone(),
"POST",
"/api/workspaces",
Some(wire.clone()),
StatusCode::FORBIDDEN,
)
.await;
let created = request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(wire.clone()),
&owner,
StatusCode::CREATED,
)
.await;
assert!(created["repository"].is_null());
assert!(!created["replayed"].as_bool().unwrap());
let id = created["workspace"]["workspace_id"].as_str().unwrap();
assert!(store.list_repositories(id).unwrap().is_empty());
let scoped = format!("/api/w/{id}");
let replay = request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(wire.clone()),
&owner,
StatusCode::OK,
)
.await;
assert_eq!(replay["workspace"]["workspace_id"], id);
assert!(replay["replayed"].as_bool().unwrap());
let repositories = request_json_authenticated(
app.clone(),
"GET",
&format!("{scoped}/repositories"),
None,
&owner,
StatusCode::OK,
)
.await;
assert_eq!(repositories["items"], json!([]));
assert_eq!(repositories["diagnostics"], json!([]));
request_json_authenticated(
app.clone(),
"GET",
&format!("{scoped}/workspace"),
None,
&owner,
StatusCode::OK,
)
.await;
let non_owner_workspace = request_json_authenticated(
app.clone(),
"GET",
&format!("{scoped}/workspace"),
None,
&foreign,
StatusCode::OK,
)
.await;
assert_eq!(
non_owner_workspace["permissions"]["manage_repositories"],
false
);
assert_eq!(
non_owner_workspace["permissions"]["delete_workspace"],
false
);
request_json(
app.clone(),
"GET",
&format!("{scoped}/workspace"),
None,
StatusCode::UNAUTHORIZED,
)
.await;
let foreign_catalog = request_json_authenticated(
app.clone(),
"GET",
"/api/workspaces",
None,
&foreign,
StatusCode::OK,
)
.await;
assert_eq!(foreign_catalog, json!([]));
let deletion = request_json_authenticated(
app.clone(),
"GET",
&format!("/api/workspaces/{id}/deletion"),
None,
&owner,
StatusCode::OK,
)
.await;
assert_eq!(deletion["resources"]["repositories"], 0);
assert_eq!(
deletion["blockers"][0]["kind"], "last_accessible_workspace",
"{deletion}"
);
let worker = request_json_authenticated(app.clone(), "POST", &format!("{scoped}/workers"), Some(json!({"runtime_id": "embedded-worker-runtime", "display_name": "Worker", "initial_submit": [], "feature_connections": {}})), &owner, StatusCode::OK).await;
assert!(worker["worker_id"].is_string(), "{worker}");
let detail = request_json_authenticated(
app.clone(),
"GET",
&format!(
"{scoped}/runtimes/embedded-worker-runtime/workers/{}",
worker["worker_id"].as_str().unwrap()
),
None,
&owner,
StatusCode::OK,
)
.await;
assert_eq!(detail["profile"], "builtin:companion");
let worker_ref = RuntimeWorkerRef::new(
"embedded-worker-runtime",
worker["worker_id"].as_str().unwrap(),
);
assert!(
store
.list_worker_workdir_links(id, &worker_ref)
.unwrap()
.is_empty()
);
assert_eq!(detail["resource_key"], "W-1");
let repository = json!({"repository_key": "later", "source": "https://example.test/later.git", "default_ref": "main"});
request_json_authenticated(
app.clone(),
"POST",
&format!("{scoped}/repositories"),
Some(repository.clone()),
&foreign,
StatusCode::FORBIDDEN,
)
.await;
request_json_authenticated(
app.clone(),
"POST",
&format!("{scoped}/repositories"),
Some(repository),
&owner,
StatusCode::CREATED,
)
.await;
assert_eq!(store.list_repositories(id).unwrap().len(), 1);
let replay = request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(wire.clone()),
&owner,
StatusCode::OK,
)
.await;
assert!(
replay["repository"].is_null(),
"omitted initial repository never means a later asset"
);
let mut conflict = wire.clone();
conflict["repository"] =
json!({"repository_key": "later", "uri": "https://example.test/later.git"});
request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(conflict),
&owner,
StatusCode::CONFLICT,
)
.await;
for repository in [
json!({"repository_key": "main", "uri": "relative/repo"}),
json!({"repository_key": " main ", "uri": "/runtime/repo"}),
] {
let invalid = json!({"operation_key": "invalid-empty-repo", "display_name": "Workspace", "repository": repository});
request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(invalid),
&owner,
StatusCode::BAD_REQUEST,
)
.await;
}
assert_eq!(store.list_workspaces().unwrap().len(), 1);
// With another accessible Workspace, zero repositories are not a deletion blocker.
let second = request_json_authenticated(
app.clone(),
"POST",
"/api/workspaces",
Some(json!({"operation_key": "second-empty", "display_name": "Second Empty"})),
&owner,
StatusCode::CREATED,
)
.await;
let second_id = second["workspace"]["workspace_id"].as_str().unwrap();
let deletion_uri = format!("/api/workspaces/{second_id}/deletion");
let preflight = request_json_authenticated(
app.clone(),
"GET",
&deletion_uri,
None,
&owner,
StatusCode::OK,
)
.await;
assert_eq!(preflight["can_delete"], true, "{preflight}");
assert_eq!(preflight["resources"]["repositories"], 0);
let deletion_request = json!({"operation_id": "delete-second-empty", "expected_revision": preflight["expected_revision"], "confirmation": "Second Empty"});
request_json_authenticated(
app.clone(),
"POST",
&deletion_uri,
Some(deletion_request.clone()),
&foreign,
StatusCode::FORBIDDEN,
)
.await;
request_json_authenticated(
app,
"POST",
&deletion_uri,
Some(deletion_request),
&owner,
StatusCode::ACCEPTED,
)
.await;
}
#[tokio::test]
async fn workspace_server_router_requires_identity_for_scoped_rest() {
let temp = tempfile::tempdir().unwrap();
@@ -45081,11 +45331,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "create-auth".to_owned(),
display_name: "Auth Workspace".to_owned(),
repository: crate::workspace_catalog::InitialRepositoryIntent {
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository.display().to_string(),
default_ref: None,
},
}),
},
"account-auth".to_owned(),
)
@@ -45211,11 +45461,11 @@ mod tests {
let create_request = WorkspaceCreateRequest {
operation_key: "create-authenticated-workspace".to_owned(),
display_name: "Authenticated Workspace".to_owned(),
repository: crate::workspace_catalog::InitialRepositoryIntent {
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: created_repository.display().to_string(),
default_ref: Some("develop".to_owned()),
},
}),
};
let created_response = app
.clone()
@@ -46339,11 +46589,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "create-a".to_string(),
display_name: "Workspace A".to_string(),
repository: crate::workspace_catalog::InitialRepositoryIntent {
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository_a.display().to_string(),
default_ref: None,
},
}),
},
"account-two-workspaces".to_owned(),
)
@@ -46353,11 +46603,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "create-b".to_string(),
display_name: "Workspace B".to_string(),
repository: crate::workspace_catalog::InitialRepositoryIntent {
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository_b.display().to_string(),
default_ref: None,
},
}),
},
"account-two-workspaces".to_owned(),
)
@@ -57555,7 +57805,7 @@ mod tests {
}
#[tokio::test]
async fn empty_repository_config_returns_empty_list_with_warning() {
async fn empty_repository_config_returns_valid_empty_list_without_warning() {
let root = tempfile::tempdir().unwrap();
let mut config = test_server_config(root.path());
config.repositories.clear();
@@ -57572,10 +57822,7 @@ mod tests {
let repositories = get_json(app, "/api/repositories").await;
assert!(repositories["items"].as_array().unwrap().is_empty());
assert_eq!(
repositories["diagnostics"][0]["code"],
"repository_config_empty"
);
assert_eq!(repositories["diagnostics"], json!([]));
}
#[tokio::test]
+33 -24
View File
@@ -384,13 +384,13 @@ pub struct WorkspaceBootstrapRecord {
pub operation_key: String,
pub request_fingerprint: String,
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceBootstrapResult {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
pub config_revision: u64,
pub replayed: bool,
}
@@ -4431,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
signing_identity: &WorkspaceSigningIdentityActivation,
identity_provisioning_operation_key: &str,
) -> Result<WorkspaceBootstrapResult> {
validate_repository_record_identity(&record.repository)?;
if let Some(repository) = &record.repository {
validate_repository_record_identity(repository)?;
if repository.workspace_id != record.workspace.workspace_id {
return Err(Error::Store(
"Initial Repository does not belong to the Workspace bootstrap".to_string(),
));
}
}
if signing_identity.workspace_id != record.workspace.workspace_id {
return Err(Error::Store(
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
@@ -4472,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
params![workspace_id],
read_workspace_record,
)?;
let repository = tx.query_row(
let repository = if let Some(initial) = &record.repository { tx.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace.workspace_id, record.repository.repository_key],
params![workspace.workspace_id, initial.repository_key],
read_repository_record,
)?;
).optional()? } else { None };
let persisted_identity = tx.query_row(
r#"SELECT workspace_id, key_id, algorithm, public_key,
public_key_fingerprint, private_material_ref, revision, state,
@@ -4566,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.workspace.updated_at,
],
)?;
if let Some(repository) = &record.repository {
tx.execute(
r#"INSERT INTO repositories (
workspace_id, repository_id, repository_key, kind, provider, uri,
@@ -4573,23 +4581,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![
record.repository.workspace_id,
record.repository.repository_id,
record.repository.repository_key,
record.repository.kind,
record.repository.provider,
record.repository.source.uri,
record.repository.source.kind.as_str(),
record.repository.source.uri,
record.repository.default_ref,
record.repository.source_revision,
record.repository.source_fingerprint,
record.repository.observed_status.as_str(),
record.repository.observed_at,
record.repository.created_at,
record.repository.updated_at,
repository.workspace_id,
repository.repository_id,
repository.repository_key,
repository.kind,
repository.provider,
repository.source.uri,
repository.source.kind.as_str(),
repository.source.uri,
repository.default_ref,
repository.source_revision,
repository.source_fingerprint,
repository.observed_status.as_str(),
repository.observed_at,
repository.created_at,
repository.updated_at,
],
)?;
}
}
if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() {
let state = crate::config_source::initial_state()?;
@@ -21732,7 +21741,7 @@ INSERT INTO worker_registry (
operation_key: "invalid-key".to_string(),
request_fingerprint: "sha256:invalid-key".to_string(),
workspace: workspace.clone(),
repository,
repository: Some(repository),
},
&signing_identity,
"identity-store-test",
@@ -21771,7 +21780,7 @@ INSERT INTO worker_registry (
operation_key: "create-workspace".to_string(),
request_fingerprint: "sha256:create-workspace".to_string(),
workspace,
repository: valid_repository,
repository: Some(valid_repository),
};
store
.reserve_workspace_signing_identity_provisioning(
@@ -21798,7 +21807,7 @@ INSERT INTO worker_registry (
);
let mut duplicate = first;
duplicate.operation_key = "duplicate-workspace".to_string();
duplicate.repository.repository_id = Uuid::now_v7().to_string();
duplicate.repository.as_mut().unwrap().repository_id = Uuid::now_v7().to_string();
let error = store
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
.unwrap_err()
+345 -143
View File
@@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
@@ -94,19 +94,23 @@ impl WorkspaceCatalogService {
)?;
let display_name =
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_source = validate_repository_source(&request.repository.uri)?;
let repository_uri = repository_source.uri.clone();
server_api::validate_repository_key(&request.repository.repository_key)
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
let repository_key = request.repository.repository_key.clone();
let default_ref = request
let repository_intent = request
.repository
.default_ref
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("HEAD")
.to_string();
.map(|repository| {
let source = validate_repository_source(&repository.uri)?;
server_api::validate_repository_key(&repository.repository_key).map_err(
|error| Error::InvalidInput(format!("invalid Repository key: {error}")),
)?;
let default_ref = repository
.default_ref
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("HEAD")
.to_string();
Ok::<_, Error>((repository.repository_key, source, default_ref))
})
.transpose()?;
let requested_workspace_id = requested_workspace_id
.map(|value| {
Uuid::parse_str(value.trim())
@@ -121,9 +125,9 @@ impl WorkspaceCatalogService {
requested_workspace_id.as_deref(),
&display_name,
Some(&owner_account_id),
&repository_key,
&repository_uri,
&default_ref,
repository_intent.as_ref().map(|(key, source, selector)| {
(key.as_str(), source.uri.as_str(), selector.as_str())
}),
);
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let (signing_identity, identity_provisioning_operation_key) =
@@ -146,21 +150,23 @@ impl WorkspaceCatalogService {
created_at: now.clone(),
updated_at: now.clone(),
},
repository: RepositoryRecord {
workspace_id,
repository_id: Uuid::now_v7().to_string(),
repository_key: repository_key.clone(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: repository_source.clone(),
default_ref: Some(default_ref),
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
},
repository: repository_intent.map(
|(repository_key, repository_source, default_ref)| RepositoryRecord {
workspace_id,
repository_id: Uuid::now_v7().to_string(),
repository_key: repository_key.clone(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: repository_source.clone(),
default_ref: Some(default_ref),
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
},
),
},
&signing_identity,
&identity_provisioning_operation_key,
@@ -193,20 +199,19 @@ fn workspace_create_fingerprint(
requested_workspace_id: Option<&str>,
display_name: &str,
owner_account_id: Option<&str>,
repository_key: &str,
repository_uri: &str,
default_ref: &str,
repository: Option<(&str, &str, &str)>,
) -> String {
let payload = serde_json::json!({
"requested_workspace_id": requested_workspace_id,
"display_name": display_name,
"owner_account_id": owner_account_id,
"repository": {
"repository_key": repository_key,
"uri": repository_uri,
"default_ref": default_ref,
// Preserve repository-present fingerprint bytes for persisted create receipts.
"repository": repository.map(|(key, uri, selector)| serde_json::json!({
"repository_key": key,
"uri": uri,
"default_ref": selector,
"kind": "git",
}
}))
});
let mut hasher = Sha256::new();
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
@@ -294,11 +299,11 @@ mod tests {
let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "platform".to_string(),
default_ref: None,
},
}),
};
let owner_account_id = owner_account(store.as_ref());
@@ -354,11 +359,11 @@ mod tests {
let request = WorkspaceCreateRequest {
operation_key: operation_key.to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: repository_key.to_string(),
default_ref: None,
},
}),
};
let error = service
@@ -373,36 +378,37 @@ 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,
},
};
for with_repository in [false, true] {
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: with_repository.then(|| 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
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'",
@@ -413,55 +419,57 @@ mod tests {
})
.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);
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
for with_repository in [false, true] {
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: with_repository.then(|| 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();
)?;
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
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'",
@@ -471,25 +479,26 @@ mod tests {
.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();
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");
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]
@@ -506,11 +515,11 @@ mod tests {
let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
};
service
.create(request.clone(), owner_account_id.clone())
@@ -524,6 +533,199 @@ mod tests {
assert!(error.contains("different input"), "{error}");
}
#[test]
fn repository_free_create_initializes_authority_and_concurrent_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
);
let request = WorkspaceCreateRequest {
operation_key: "empty-concurrent".to_string(),
display_name: "No repositories".to_string(),
repository: None,
};
let barrier = Arc::new(std::sync::Barrier::new(4));
let handles = (0..4)
.map(|_| {
let (service, request, owner, barrier) = (
service.clone(),
request.clone(),
owner.clone(),
barrier.clone(),
);
std::thread::spawn(move || {
barrier.wait();
service.create(request, owner).unwrap()
})
})
.collect::<Vec<_>>();
let results = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>();
let id = &results[0].workspace.workspace_id;
assert_eq!(results.iter().filter(|result| !result.replayed).count(), 1);
assert!(
results
.iter()
.all(|result| &result.workspace.workspace_id == id && result.repository.is_none())
);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
assert!(store.list_repositories(id).unwrap().is_empty());
assert!(store.load_workspace_config(id).unwrap().is_some());
assert_eq!(
store
.get_workspace_signing_identity(id)
.unwrap()
.unwrap()
.state,
"active"
);
let counters = store.with_conn(|conn| {
let mut query = conn.prepare("SELECT resource_kind, next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 ORDER BY resource_kind")?;
Ok(query.query_map([id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?.collect::<std::result::Result<Vec<_>, _>>()?)
}).unwrap();
assert_eq!(
counters,
vec![
("objective".into(), 1),
("ticket".into(), 1),
("worker".into(), 1)
]
);
let mut different = request.clone();
different.repository = Some(InitialRepositoryIntent {
repository_key: "main".into(),
uri: "/runtime/repo".into(),
default_ref: None,
});
assert!(
service
.create(different, owner.clone())
.unwrap_err()
.to_string()
.contains("different input")
);
assert!(service.create(request, "missing-owner".into()).is_err());
assert!(store.list_repositories(id).unwrap().is_empty());
}
#[test]
fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
);
let request = WorkspaceCreateRequest {
operation_key: "current-repository".into(),
display_name: "Workspace".into(),
repository: Some(InitialRepositoryIntent {
repository_key: "main".into(),
uri: "/runtime/original".into(),
default_ref: None,
}),
};
let created = service.create(request.clone(), owner.clone()).unwrap();
let mut repository = created.repository.unwrap();
repository.source = validate_repository_source("https://example.test/changed.git").unwrap();
repository.source_revision += 1;
repository.source_fingerprint = repository_source_fingerprint(&repository.source);
store.with_conn(|conn| {
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'",
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?;
Ok(())
}).unwrap();
assert_eq!(
service
.create(request.clone(), owner.clone())
.unwrap()
.repository,
Some(repository)
);
store
.with_conn(|conn| {
conn.execute(
"DELETE FROM repositories WHERE workspace_id = ?1",
[&created.workspace.workspace_id],
)?;
Ok(())
})
.unwrap();
let replay = service.create(request, owner).unwrap();
assert!(replay.replayed);
assert!(replay.repository.is_none());
assert!(
store
.list_repositories(&replay.workspace.workspace_id)
.unwrap()
.is_empty()
);
}
#[test]
fn persisted_repository_free_receipt_replays_after_catalog_reopen() {
let temp = tempfile::tempdir().unwrap();
let database = temp.path().join("server.db");
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let request = WorkspaceCreateRequest {
operation_key: "persist-empty".into(),
display_name: "Workspace".into(),
repository: None,
};
let (owner, created) = {
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store, materials.clone());
let created = service.create(request.clone(), owner.clone()).unwrap();
(owner, created)
};
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
let reopened = WorkspaceCatalogService::new(store.clone(), materials);
let replay = reopened.create(request, owner).unwrap();
assert_eq!(replay.workspace, created.workspace);
assert!(replay.replayed);
assert!(replay.repository.is_none());
assert!(
store
.list_repositories(&replay.workspace.workspace_id)
.unwrap()
.is_empty()
);
}
#[test]
fn repository_present_fingerprint_preserves_saved_receipt_contract() {
let legacy_payload = serde_json::json!({
"requested_workspace_id": null, "display_name": "Workspace", "owner_account_id": "owner",
"repository": {"repository_key": "main", "uri": "/runtime/repo", "default_ref": "HEAD", "kind": "git"}
});
let legacy_digest = Sha256::digest(serde_json::to_vec(&legacy_payload).unwrap());
let legacy = format!(
"sha256:{}",
legacy_digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
assert_eq!(
workspace_create_fingerprint(
None,
"Workspace",
Some("owner"),
Some(("main", "/runtime/repo", "HEAD"))
),
legacy
);
assert_ne!(
workspace_create_fingerprint(None, "Workspace", Some("owner"), None),
legacy
);
}
#[test]
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
@@ -564,11 +766,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "organization-owner-create".to_string(),
display_name: "Organization Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
"organization-owner".to_string(),
)
@@ -605,11 +807,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "owner-a-create".to_string(),
display_name: "Owner A Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
owner_a.clone(),
)
@@ -619,11 +821,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "owner-b-create".to_string(),
display_name: "Owner B Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
owner_b.clone(),
)
@@ -660,11 +862,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
repository_key: "remote".to_string(),
default_ref: Some("main".to_string()),
},
}),
},
owner_account_id,
)
@@ -673,7 +875,7 @@ mod tests {
let persisted = store
.get_repository(
&result.workspace.workspace_id,
&result.repository.repository_id,
&result.repository.as_ref().unwrap().repository_id,
)
.unwrap()
.unwrap();