merge: reconcile integrated configuration adoption with Job recovery
This commit is contained in:
@@ -107,6 +107,32 @@ fn has_identity(connection: &Connection) -> bool {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_verification_never_provisions_or_adopts_optional_storage() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
let (manager, registration) = registered(&root);
|
||||||
|
let scoped = manager.scope(SCOPE).unwrap();
|
||||||
|
scoped.verify_owned_if_present(®istration).unwrap();
|
||||||
|
assert!(!root.exists());
|
||||||
|
let path = legacy(&root, SCOPE);
|
||||||
|
assert_identity_error(scoped.verify_owned_if_present(®istration));
|
||||||
|
assert!(!has_identity(&Connection::open(&path).unwrap()));
|
||||||
|
let database = scoped
|
||||||
|
.adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy)
|
||||||
|
.unwrap();
|
||||||
|
let identity = database.identity().clone();
|
||||||
|
scoped.verify_owned_if_present(®istration).unwrap();
|
||||||
|
assert_eq!(values(&database), vec!["preserved"]);
|
||||||
|
drop(database);
|
||||||
|
fs::remove_file(&path).unwrap();
|
||||||
|
assert!(matches!(scoped.verify_owned_if_present(®istration),
|
||||||
|
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
|
||||||
|
assert!(!path.exists());
|
||||||
|
assert!(matches!(scoped.open_existing(®istration, &identity),
|
||||||
|
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provision_then_reopen_retains_physical_identity_and_committed_data() {
|
fn provision_then_reopen_retains_physical_identity_and_committed_data() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1006,6 +1006,31 @@ impl ScopedFeatureStorage {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate optional storage for a lifecycle consumer without authorizing
|
||||||
|
/// adoption or provisioning. Existing files require completed physical
|
||||||
|
/// ownership; missing bound files fail closed. This is not an open permit:
|
||||||
|
/// subsequent database access must still validate stored identity and fences.
|
||||||
|
pub fn verify_owned_if_present(&self, registration: &RegisteredFeature) -> Result<()> {
|
||||||
|
self.manager.check_registration(registration)?;
|
||||||
|
if !self.manager.inner.root.try_exists()? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let lifecycle = self.manager.lifecycle(&self.scope_id);
|
||||||
|
let _lease = lifecycle.lock(false)?;
|
||||||
|
let path = self
|
||||||
|
.manager
|
||||||
|
.feature_dir(&self.scope_id)
|
||||||
|
.join(format!("{}.sqlite", registration.feature_id));
|
||||||
|
if let Some(binding) = lifecycle.lookup(&self.scope_id, ®istration.feature_id)? {
|
||||||
|
binding.check_file(&path)?;
|
||||||
|
} else if path.try_exists()? {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"lifecycle consumer cannot adopt unowned Feature storage".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Host startup compatibility for a registered optional Feature. Does not
|
/// Host startup compatibility for a registered optional Feature. Does not
|
||||||
/// provision missing optional storage. An existing legacy DB still requires
|
/// provision missing optional storage. An existing legacy DB still requires
|
||||||
/// explicit adoption authority and persisted attribution validation.
|
/// explicit adoption authority and persisted attribution validation.
|
||||||
|
|||||||
@@ -529,6 +529,7 @@ fn prepare(
|
|||||||
schema,
|
schema,
|
||||||
)?;
|
)?;
|
||||||
crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
|
crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
|
||||||
|
crate::repository_access::validate_repository_access_in_transaction(tx, workspace, &candidate)?;
|
||||||
let source = policy_config::source(&candidate.evaluation)?;
|
let source = policy_config::source(&candidate.evaluation)?;
|
||||||
policy_config::validate_source(host, &source, require_subject)?;
|
policy_config::validate_source(host, &source, require_subject)?;
|
||||||
let state = WorkspaceConfigState {
|
let state = WorkspaceConfigState {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Contract fixture for the actual Server database/transaction owner and the content adapter.
|
//! Contract fixture for the actual Server database/transaction owner and the content adapter.
|
||||||
//! Existing authorization is represented by an exact-target Host decision; this is not a route
|
//! Existing authorization is represented by an exact-target Host decision; this is not a route
|
||||||
//! or production policy cutover. Domain schema is installed only in this isolated fixture.
|
//! or production policy cutover. The Server lifecycle installs the content schema;
|
||||||
|
//! this fixture installs only its business data and exact-target Host decision.
|
||||||
use config_source::{
|
use config_source::{
|
||||||
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
|
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
|
||||||
VirtualPath,
|
VirtualPath,
|
||||||
@@ -88,7 +89,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai
|
|||||||
let path = dir.path().join("server.sqlite");
|
let path = dir.path().join("server.sqlite");
|
||||||
let db = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let db = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
db.with_immediate_transaction(|tx| {
|
db.with_immediate_transaction(|tx| {
|
||||||
tx.execute_batch(content_history_store::SCHEMA)?;
|
|
||||||
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('history-owner','user','history-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('history-ws','history-owner','History fixture','active','1','1');")?;
|
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('history-owner','user','history-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('history-ws','history-owner','History fixture','active','1','1');")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
@@ -127,7 +127,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai
|
|||||||
fn ticket_snapshot_and_history_restoration_do_not_rewind_separate_business_events() {
|
fn ticket_snapshot_and_history_restoration_do_not_rewind_separate_business_events() {
|
||||||
let db = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let db = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||||
db.with_immediate_transaction(|tx| {
|
db.with_immediate_transaction(|tx| {
|
||||||
tx.execute_batch(content_history_store::SCHEMA)?;
|
|
||||||
// Business state is deliberately not a field accepted by the content adapter.
|
// Business state is deliberately not a field accepted by the content adapter.
|
||||||
tx.execute_batch("CREATE TEMP TABLE business_events(kind TEXT,value TEXT); INSERT INTO business_events VALUES('state','closed'),('assignee','current-worker'),('relation','depends-on-current');")?;
|
tx.execute_batch("CREATE TEMP TABLE business_events(kind TEXT,value TEXT); INSERT INTO business_events VALUES('state','closed'),('assignee','current-worker'),('relation','depends-on-current');")?;
|
||||||
let s=scoped(tx);
|
let s=scoped(tx);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
|||||||
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
|
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
|
||||||
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
|
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
|
||||||
for version in [50, 105] {
|
for version in [50, 105] {
|
||||||
let path = dir.path().join(format!("v{version}.db"));
|
let root = dir.path().join(format!("v{version}"));
|
||||||
|
std::fs::create_dir(&root).unwrap();
|
||||||
|
let path = root.join("workspace.db");
|
||||||
if version == 50 {
|
if version == 50 {
|
||||||
tests::prepare_schema_v50(&path, Some("workspace-a"));
|
tests::prepare_schema_v50(&path, Some("workspace-a"));
|
||||||
} else {
|
} else {
|
||||||
@@ -30,7 +32,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
|||||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||||
assert_eq!(after.last().unwrap(), &(113, "Ticket work-decomposition parent links".into()));
|
assert_eq!(after.last().unwrap(), &(122, crate::config_adoption::MIGRATION_NAME.into()));
|
||||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||||
@@ -83,6 +85,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
|||||||
));
|
));
|
||||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
expected.push((113, "Ticket work-decomposition parent links".into()));
|
expected.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
|
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
|
||||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||||
crate::plugin_catalog::verify_with_data(c)?;
|
crate::plugin_catalog::verify_with_data(c)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -101,7 +104,13 @@ fn plugin_catalog_startup_rejects_drifted_reference_fence() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(store);
|
drop(store);
|
||||||
assert!(
|
match SqliteWorkspaceStore::open_for_test(path) {
|
||||||
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted"))
|
Err(Error::Store(message)) => assert!(
|
||||||
);
|
message.contains("Server storage format: physical Base mismatch")
|
||||||
|
&& message.contains("plugin_reference_live"),
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
Err(error) => panic!("unexpected rejection: {error}"),
|
||||||
|
Ok(_) => panic!("startup accepted a missing reference fence"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
|
|||||||
));
|
));
|
||||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
expected.push((113, "Ticket work-decomposition parent links".into()));
|
expected.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
|
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
|
||||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||||
subject_assignment_migration::verify(conn)?;
|
subject_assignment_migration::verify(conn)?;
|
||||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||||
|
|||||||
@@ -189,24 +189,65 @@ fn project_repository_access_evaluation(
|
|||||||
projection_digest: &str,
|
projection_digest: &str,
|
||||||
evaluation: &config_source::EvaluationResult,
|
evaluation: &config_source::EvaluationResult,
|
||||||
) -> Result<RepositoryAccessProjection> {
|
) -> Result<RepositoryAccessProjection> {
|
||||||
let projection = evaluation.projections.first().ok_or_else(|| {
|
let config = repository_access_source(evaluation)?;
|
||||||
Error::InvalidInput("Workspace config produced no active projection".to_string())
|
|
||||||
})?;
|
|
||||||
let config: VirtualWorkspaceConfig = serde_json::from_value(projection.data_json.clone())
|
|
||||||
.map_err(|error| {
|
|
||||||
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
|
||||||
})?;
|
|
||||||
if !config.repository_access.is_empty() {
|
if !config.repository_access.is_empty() {
|
||||||
secrets.ensure_workspace_default_credential(workspace_id)?;
|
secrets.ensure_workspace_default_credential(workspace_id)?;
|
||||||
}
|
}
|
||||||
|
project_repository_access_bindings(
|
||||||
|
workspace_id,
|
||||||
|
projection_digest,
|
||||||
|
config,
|
||||||
|
|key| store.get_repository_by_key(workspace_id, key),
|
||||||
|
|id| secrets.get_credential(workspace_id, id, &[]),
|
||||||
|
|id| secrets.get_host_trust(workspace_id, id, &[]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate against the same catalog transaction that will adopt the source.
|
||||||
|
/// This reads only public credential/trust metadata: no default key generation,
|
||||||
|
/// unsealing, filesystem writes or second connection under the writer lock.
|
||||||
|
/// Both preview and adoption use the owning projection's semantic checks.
|
||||||
|
pub(crate) fn validate_repository_access_in_transaction(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
workspace_id: &str,
|
||||||
|
candidate: &EvaluatedConfigCandidate,
|
||||||
|
) -> Result<()> {
|
||||||
|
project_repository_access_bindings(
|
||||||
|
workspace_id,
|
||||||
|
&candidate.evaluation.projection_digest,
|
||||||
|
repository_access_source(&candidate.evaluation)?,
|
||||||
|
|key| crate::store::read_repository_by_key(tx, workspace_id, key),
|
||||||
|
|id| read_credential(tx, workspace_id, id),
|
||||||
|
|id| read_host_trust(tx, workspace_id, id),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_access_source(
|
||||||
|
evaluation: &config_source::EvaluationResult,
|
||||||
|
) -> Result<VirtualWorkspaceConfig> {
|
||||||
|
let projection = evaluation.projections.first().ok_or_else(|| {
|
||||||
|
Error::InvalidInput("Workspace config produced no active projection".to_string())
|
||||||
|
})?;
|
||||||
|
serde_json::from_value(projection.data_json.clone())
|
||||||
|
.map_err(|error| Error::InvalidInput(format!("invalid Repository access config: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_repository_access_bindings(
|
||||||
|
workspace_id: &str,
|
||||||
|
projection_digest: &str,
|
||||||
|
config: VirtualWorkspaceConfig,
|
||||||
|
repository: impl Fn(&str) -> Result<Option<crate::store::RepositoryRecord>>,
|
||||||
|
credential: impl Fn(&str) -> Result<Option<RepositorySshCredential>>,
|
||||||
|
host_trust: impl Fn(&str) -> Result<Option<RepositorySshHostTrust>>,
|
||||||
|
) -> Result<RepositoryAccessProjection> {
|
||||||
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
||||||
for (repository_key, access) in config.repository_access {
|
for (repository_key, access) in config.repository_access {
|
||||||
server_api::validate_repository_key(&repository_key)
|
server_api::validate_repository_key(&repository_key)
|
||||||
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
|
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
|
||||||
validate_identifier("credential_id", &access.ssh.credential)?;
|
validate_identifier("credential_id", &access.ssh.credential)?;
|
||||||
validate_identifier("host_trust_id", &access.ssh.host_trust)?;
|
validate_identifier("host_trust_id", &access.ssh.host_trust)?;
|
||||||
let repository = store
|
let repository = repository(&repository_key)?
|
||||||
.get_repository_by_key(workspace_id, &repository_key)?
|
|
||||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
|
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
|
||||||
validate_repository_access_source(&repository_key, &repository.source)?;
|
validate_repository_access_source(&repository_key, &repository.source)?;
|
||||||
if repository.source.kind != server_api::RepositorySourceKind::Ssh {
|
if repository.source.kind != server_api::RepositorySourceKind::Ssh {
|
||||||
@@ -214,28 +255,24 @@ fn project_repository_access_evaluation(
|
|||||||
"Repository `{repository_key}` is not an ssh:// Repository"
|
"Repository `{repository_key}` is not an ssh:// Repository"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let credential = secrets
|
let credential = credential(&access.ssh.credential)?.ok_or_else(|| {
|
||||||
.get_credential(workspace_id, &access.ssh.credential, &[])?
|
Error::InvalidInput(format!(
|
||||||
.ok_or_else(|| {
|
"unknown Repository SSH credential `{}`",
|
||||||
Error::InvalidInput(format!(
|
access.ssh.credential
|
||||||
"unknown Repository SSH credential `{}`",
|
))
|
||||||
access.ssh.credential
|
})?;
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if credential.status != "active" {
|
if credential.status != "active" {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Repository SSH credential `{}` is not active",
|
"Repository SSH credential `{}` is not active",
|
||||||
access.ssh.credential
|
access.ssh.credential
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let host_trust = secrets
|
let host_trust = host_trust(&access.ssh.host_trust)?.ok_or_else(|| {
|
||||||
.get_host_trust(workspace_id, &access.ssh.host_trust, &[])?
|
Error::InvalidInput(format!(
|
||||||
.ok_or_else(|| {
|
"unknown Repository SSH host trust `{}`",
|
||||||
Error::InvalidInput(format!(
|
access.ssh.host_trust
|
||||||
"unknown Repository SSH host trust `{}`",
|
))
|
||||||
access.ssh.host_trust
|
})?;
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let (hostname, port) =
|
let (hostname, port) =
|
||||||
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|
||||||
|| {
|
|| {
|
||||||
|
|||||||
@@ -13242,7 +13242,9 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
&worker,
|
&worker,
|
||||||
&source.authentication_binding_id,
|
&source.authentication_binding_id,
|
||||||
)?;
|
)?;
|
||||||
recover_ticket_notifications(&api);
|
// The receipt certifies result/source commit, not notification acceptance.
|
||||||
|
// Do not make it wait for unrelated pending deliveries or their Runtime ACKs.
|
||||||
|
drop(enqueue_ticket_notification_recovery(&api));
|
||||||
Ok(receipt)
|
Ok(receipt)
|
||||||
})();
|
})();
|
||||||
result
|
result
|
||||||
@@ -17332,7 +17334,7 @@ async fn apply_set_ticket_assignment(
|
|||||||
// same best-effort notification completion as before offload.
|
// same best-effort notification completion as before offload.
|
||||||
Ok::<_, Error>((
|
Ok::<_, Error>((
|
||||||
assignment,
|
assignment,
|
||||||
enqueue_orchestrator_assignment_attention(¬ification_api),
|
enqueue_ticket_notification_recovery(¬ification_api),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
@@ -23264,7 +23266,7 @@ fn maybe_dispatch_orchestrator_turn_end(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enqueue_orchestrator_assignment_attention(
|
fn enqueue_ticket_notification_recovery(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
let api = api.clone();
|
let api = api.clone();
|
||||||
@@ -23277,7 +23279,7 @@ fn enqueue_orchestrator_assignment_attention(
|
|||||||
}) {
|
}) {
|
||||||
Ok(()) => Some(completion),
|
Ok(()) => Some(completion),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "Assign notification was not admitted; input remains durable");
|
tracing::warn!(%error, "Ticket notification recovery was not admitted; source remains durable");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70794,33 +70796,58 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generated_config_source_routes_preserve_nested_entry_paths() {
|
async fn generated_config_source_routes_preserve_nested_entry_paths() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
|
let owner =
|
||||||
|
seed_test_workspace_owner_token(api.store.as_ref(), "nested-config-owner").await;
|
||||||
|
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||||
|
catalog
|
||||||
|
.apis
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||||
|
catalog
|
||||||
|
.routers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api));
|
||||||
|
let app = workspace_server_router(catalog);
|
||||||
let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
||||||
let initial = get_json(app.clone(), &source_tree_path).await;
|
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
|
||||||
let digest = initial["snapshot"]["digest"].as_str().unwrap();
|
let initial = get_json_authenticated(app.clone(), &adoption_path, &owner).await;
|
||||||
|
let request = json!({
|
||||||
let committed = request_json(
|
"operation_id": "nested-config-adoption", "expected_head": initial["history_head"],
|
||||||
|
"expected_catalog_digest": initial["catalog_digest"], "expected_legacy_export": initial["expected_legacy_export"],
|
||||||
|
"source": {
|
||||||
|
"base_digest": initial["state"]["snapshot"]["digest"],
|
||||||
|
"changes": [{ "kind": "create", "path": "notes/readme.txt", "content_type": "text", "content": "nested entry" }],
|
||||||
|
"entrypoints": ["main.dcdl"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let preview = request_json_authenticated(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&format!("{source_tree_path}/commit"),
|
&format!("{adoption_path}/preview"),
|
||||||
Some(json!({
|
Some(request.clone()),
|
||||||
"base_digest": digest,
|
&owner,
|
||||||
"changes": [{
|
StatusCode::OK,
|
||||||
"kind": "create",
|
|
||||||
"path": "notes/readme.txt",
|
|
||||||
"content_type": "text",
|
|
||||||
"content": "nested entry"
|
|
||||||
}],
|
|
||||||
"entrypoints": ["main.dcdl"]
|
|
||||||
})),
|
|
||||||
StatusCode::CREATED,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let committed_digest = committed["snapshot"]["digest"].as_str().unwrap();
|
let committed = request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
&format!("{adoption_path}/commit"),
|
||||||
|
Some(request),
|
||||||
|
&owner,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(committed["state"], preview["state"]);
|
||||||
|
let committed_digest = committed["state"]["snapshot"]["digest"].as_str().unwrap();
|
||||||
|
|
||||||
let config_snapshot = get_json(
|
let config_snapshot = get_json_authenticated(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&format!("{source_tree_path}/history/{committed_digest}"),
|
&format!("{source_tree_path}/history/{committed_digest}"),
|
||||||
|
&owner,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(config_snapshot["digest"], committed_digest);
|
assert_eq!(config_snapshot["digest"], committed_digest);
|
||||||
@@ -70829,9 +70856,10 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
"nested entry"
|
"nested entry"
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = get_json(
|
let entry = get_json_authenticated(
|
||||||
app,
|
app,
|
||||||
&format!("{source_tree_path}/entries/notes%2Freadme.txt"),
|
&format!("{source_tree_path}/entries/notes%2Freadme.txt"),
|
||||||
|
&owner,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(entry["path"], "notes/readme.txt");
|
assert_eq!(entry["path"], "notes/readme.txt");
|
||||||
|
|||||||
@@ -47,33 +47,63 @@ fn source(value: &str) -> String {
|
|||||||
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
|
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
let token = seed_test_workspace_owner_token(api.store.as_ref(), "builtin-profile-owner").await;
|
||||||
let app = build_router(api.clone());
|
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||||
|
catalog
|
||||||
|
.apis
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||||
|
catalog
|
||||||
|
.routers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||||
|
let app = workspace_server_router(catalog);
|
||||||
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
||||||
let commit = format!("{tree}/commit");
|
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
|
||||||
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
|
let commit = format!("{adoption_path}/commit");
|
||||||
let saved = request_json_authenticated(
|
let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
|
||||||
|
let initial = current["state"].clone();
|
||||||
|
let adoption = |current: &Value, id: &str, source: Value| {
|
||||||
|
json!({
|
||||||
|
"operation_id": id, "expected_head": current["history_head"],
|
||||||
|
"expected_catalog_digest": current["catalog_digest"], "expected_legacy_export": current["expected_legacy_export"],
|
||||||
|
"source": source,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let source_request = json!({
|
||||||
|
"base_digest": current["state"]["snapshot"]["digest"],
|
||||||
|
"entrypoints": current["state"]["contract"]["entrypoints"],
|
||||||
|
"changes": [
|
||||||
|
{ "kind": "update", "path": "main.dcdl",
|
||||||
|
"expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],
|
||||||
|
"content": source(r#"import "$builtin/profiles/companion.dcdl""#) },
|
||||||
|
// A Workspace file at the matching suffix must not rescue an unknown builtin.
|
||||||
|
{ "kind": "create", "path": "profiles/private.dcdl", "content_type": "decodal", "content": PROFILE }
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let save = adoption(¤t, "builtin-profile-adopt", source_request);
|
||||||
|
let preview = request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
&format!("{adoption_path}/preview"),
|
||||||
|
Some(save.clone()),
|
||||||
|
&token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let receipt = request_json_authenticated(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&commit,
|
&commit,
|
||||||
Some(
|
Some(save),
|
||||||
serde_json::to_value(request(
|
|
||||||
&api,
|
|
||||||
&source(r#"import "$builtin/profiles/companion.dcdl""#),
|
|
||||||
vec![config_source::ConfigTreeChange::Create {
|
|
||||||
// An ordinary Workspace file at the matching suffix must not rescue
|
|
||||||
// an unknown builtin import.
|
|
||||||
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
|
|
||||||
content_type: config_source::ConfigContentType::Decodal,
|
|
||||||
content: PROFILE.into(),
|
|
||||||
}],
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
),
|
|
||||||
&token,
|
&token,
|
||||||
StatusCode::CREATED,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
assert_eq!(receipt["state"], preview["state"]);
|
||||||
|
let saved = receipt["state"].clone();
|
||||||
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
|
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
|
||||||
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
|
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||||
assert_eq!(listed, saved);
|
assert_eq!(listed, saved);
|
||||||
@@ -107,22 +137,23 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
|
|||||||
"base_digest": listed["snapshot"]["digest"],
|
"base_digest": listed["snapshot"]["digest"],
|
||||||
"entrypoints": listed["contract"]["entrypoints"],
|
"entrypoints": listed["contract"]["entrypoints"],
|
||||||
});
|
});
|
||||||
|
let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
|
||||||
let reserved = "$builtin/profiles/companion.dcdl";
|
let reserved = "$builtin/profiles/companion.dcdl";
|
||||||
for change in [
|
for (index, change) in [
|
||||||
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
|
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
|
||||||
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
|
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
|
||||||
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
|
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
|
||||||
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
|
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
|
||||||
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
|
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
|
||||||
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
|
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
|
||||||
] {
|
].into_iter().enumerate() {
|
||||||
let mut body = base.clone();
|
let mut body = base.clone();
|
||||||
body["changes"] = json!([change]);
|
body["changes"] = json!([change]);
|
||||||
let error = request_json_authenticated(
|
let error = request_json_authenticated(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&commit,
|
&commit,
|
||||||
Some(body),
|
Some(adoption(¤t, &format!("reserved-builtin-{index}"), body)),
|
||||||
&token,
|
&token,
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
@@ -148,7 +179,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&commit,
|
&commit,
|
||||||
Some(body),
|
Some(adoption(¤t, "unknown-builtin-import", body)),
|
||||||
&token,
|
&token,
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -472,6 +472,24 @@ impl ConfigurationAdoptionApi for WorkspaceServerApi {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
.map_err(transfer_error)??;
|
.map_err(transfer_error)??;
|
||||||
|
// Core and Feature guards are released before best-effort Runtime
|
||||||
|
// publication. Dispatch transfers the admitted lifetime without
|
||||||
|
// keeping the SQL lane occupied by an external Runtime response.
|
||||||
|
// Exact historical receipt replay above must not publish old state.
|
||||||
|
let publication_api = api.clone();
|
||||||
|
let state = result.state.clone();
|
||||||
|
let _ = api.host_executors.host().and_then(|executor| {
|
||||||
|
executor.dispatch(move || {
|
||||||
|
if let Ok(projection) = publication_api
|
||||||
|
.prompt_projection_cache
|
||||||
|
.resolve(&publication_api.config.workspace_id, &state)
|
||||||
|
{
|
||||||
|
let _ = publication_api
|
||||||
|
.runtime
|
||||||
|
.observe_workspace_prompt_projection((*projection).clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
Ok(receipt_to_api(result))
|
Ok(receipt_to_api(result))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -355,6 +355,270 @@ mod configuration_adoption_http_integration {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_http_repository_validation_is_read_only_and_precedes_seal() {
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let source = server_api::RepositorySource {
|
||||||
|
kind: server_api::RepositorySourceKind::Ssh,
|
||||||
|
uri: "ssh://git@repository.example.test:2222/team/repo.git".into(),
|
||||||
|
};
|
||||||
|
f.api
|
||||||
|
.store
|
||||||
|
.upsert_repository(&crate::store::RepositoryRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||||
|
repository_id: "ssh-repository".into(),
|
||||||
|
repository_key: "remote".into(),
|
||||||
|
kind: "git".into(),
|
||||||
|
provider: None,
|
||||||
|
source_fingerprint: crate::repository_source::repository_source_fingerprint(
|
||||||
|
&source,
|
||||||
|
),
|
||||||
|
source,
|
||||||
|
default_ref: None,
|
||||||
|
observed_status: server_api::RepositoryObservedStatus::Unverified,
|
||||||
|
observed_at: None,
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "1".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut non_ssh = f
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_repository_by_key(TEST_WORKSPACE_ID, "remote")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
non_ssh.repository_id = "https-repository".into();
|
||||||
|
non_ssh.repository_key = "nonssh".into();
|
||||||
|
non_ssh.source = server_api::RepositorySource {
|
||||||
|
kind: server_api::RepositorySourceKind::Https,
|
||||||
|
uri: "https://repository.example.test/team/repo.git".into(),
|
||||||
|
};
|
||||||
|
non_ssh.source_fingerprint =
|
||||||
|
crate::repository_source::repository_source_fingerprint(&non_ssh.source);
|
||||||
|
f.api.store.upsert_repository(&non_ssh).unwrap();
|
||||||
|
for id in ["deploy", "revoked"] {
|
||||||
|
f.api
|
||||||
|
.repository_secrets
|
||||||
|
.generate_credential(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: format!("generate-{id}"),
|
||||||
|
credential_id: id.into(),
|
||||||
|
name: id.into(),
|
||||||
|
},
|
||||||
|
&f.owner_account,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
f.api.config_store.with_conn(|conn| {
|
||||||
|
conn.execute("UPDATE repository_ssh_credentials SET status='revoked' WHERE workspace_id=?1 AND credential_id='revoked'", [TEST_WORKSPACE_ID])?;
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
let public_key =
|
||||||
|
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[37; 32]))
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.unwrap();
|
||||||
|
for (id, hostname, port) in [
|
||||||
|
("matching", "repository.example.test", 2222),
|
||||||
|
("wrong-host", "other.example.test", 2222),
|
||||||
|
("wrong-port", "repository.example.test", 22),
|
||||||
|
] {
|
||||||
|
f.api
|
||||||
|
.repository_secrets
|
||||||
|
.put_host_trust(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: format!("trust-{id}"),
|
||||||
|
host_trust_id: id.into(),
|
||||||
|
hostname: hostname.into(),
|
||||||
|
port,
|
||||||
|
host_key: public_key.clone(),
|
||||||
|
expected_fingerprint: None,
|
||||||
|
},
|
||||||
|
&f.owner_account,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let secret_counts = || {
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
[
|
||||||
|
"repository_ssh_credentials",
|
||||||
|
"repository_ssh_credential_keys",
|
||||||
|
"repository_ssh_host_trusts",
|
||||||
|
"repository_ssh_host_trust_keys",
|
||||||
|
"repository_secret_operations",
|
||||||
|
"repository_secret_audit_events",
|
||||||
|
"server_secret_objects",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|table| {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT COUNT(*) FROM {table} WHERE workspace_id=?1"),
|
||||||
|
[TEST_WORKSPACE_ID],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let secrets_before = secret_counts();
|
||||||
|
let content = |repo: &str, credential: &str, trust: &str, access: &str| {
|
||||||
|
format!(
|
||||||
|
"{{ repository_access = {{ {repo} = {{ ssh = {{ credential = {credential:?}; host_trust = {trust:?}; access = {access:?}; }}; }}; }}; }}"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for canonical in [false, true] {
|
||||||
|
let current = f.source().await;
|
||||||
|
assert_eq!(current.source_stored, canonical);
|
||||||
|
let before = f.durable();
|
||||||
|
for (case, repo, credential, trust, access, diagnostic) in [
|
||||||
|
(
|
||||||
|
"mode",
|
||||||
|
"remote",
|
||||||
|
"deploy",
|
||||||
|
"matching",
|
||||||
|
"not-a-mode",
|
||||||
|
"invalid Repository access config",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"repository",
|
||||||
|
"absent",
|
||||||
|
"deploy",
|
||||||
|
"matching",
|
||||||
|
"read_only",
|
||||||
|
"unknown Repository",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"non-ssh",
|
||||||
|
"nonssh",
|
||||||
|
"deploy",
|
||||||
|
"matching",
|
||||||
|
"read_only",
|
||||||
|
"not an ssh",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"credential",
|
||||||
|
"remote",
|
||||||
|
"absent",
|
||||||
|
"matching",
|
||||||
|
"read_only",
|
||||||
|
"unknown Repository SSH credential",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inactive",
|
||||||
|
"remote",
|
||||||
|
"revoked",
|
||||||
|
"matching",
|
||||||
|
"read_only",
|
||||||
|
"is not active",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trust",
|
||||||
|
"remote",
|
||||||
|
"deploy",
|
||||||
|
"absent",
|
||||||
|
"read_only",
|
||||||
|
"unknown Repository SSH host trust",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"host",
|
||||||
|
"remote",
|
||||||
|
"deploy",
|
||||||
|
"wrong-host",
|
||||||
|
"read_only",
|
||||||
|
"does not match host trust",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"port",
|
||||||
|
"remote",
|
||||||
|
"deploy",
|
||||||
|
"wrong-port",
|
||||||
|
"read_only",
|
||||||
|
"does not match host trust",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let adoption = request(
|
||||||
|
¤t,
|
||||||
|
&format!("repository-{canonical}-{case}"),
|
||||||
|
vec![update(
|
||||||
|
¤t,
|
||||||
|
"main.dcdl",
|
||||||
|
&content(repo, credential, trust, access),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
for endpoint in ["/preview", "/commit"] {
|
||||||
|
let rejected = f
|
||||||
|
.owner_call(endpoint, &adoption, StatusCode::BAD_REQUEST)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
rejected.to_string().contains(diagnostic),
|
||||||
|
"{case} {endpoint}: {rejected}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.durable(),
|
||||||
|
before,
|
||||||
|
"{case} {endpoint}: all old authorities must remain unchanged"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
secret_counts(),
|
||||||
|
secrets_before,
|
||||||
|
"validation must not generate keys or secret receipts"
|
||||||
|
);
|
||||||
|
if canonical {
|
||||||
|
f.assert_legacy_sealed();
|
||||||
|
} else {
|
||||||
|
f.assert_legacy_writable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let valid = request(
|
||||||
|
¤t,
|
||||||
|
&format!("valid-repository-{canonical}"),
|
||||||
|
vec![update(
|
||||||
|
¤t,
|
||||||
|
"main.dcdl",
|
||||||
|
&content(
|
||||||
|
"remote",
|
||||||
|
"deploy",
|
||||||
|
"matching",
|
||||||
|
if canonical { "read_write" } else { "read_only" },
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
let preview = f.owner_call("/preview", &valid, StatusCode::OK).await;
|
||||||
|
assert_eq!(
|
||||||
|
f.durable(),
|
||||||
|
before,
|
||||||
|
"valid preview must remain a read, before and after cutover"
|
||||||
|
);
|
||||||
|
assert_eq!(secret_counts(), secrets_before);
|
||||||
|
let saved = f.commit(&valid).await;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&saved.state).unwrap(),
|
||||||
|
preview["state"]
|
||||||
|
);
|
||||||
|
assert_eq!(f.source().await.state, saved.state);
|
||||||
|
f.assert_legacy_sealed();
|
||||||
|
assert_eq!(
|
||||||
|
secret_counts(),
|
||||||
|
secrets_before,
|
||||||
|
"adoption validates metadata without secret side effects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
f.api
|
||||||
|
.repository_secrets
|
||||||
|
.get_credential(TEST_WORKSPACE_ID, "workspace-default", &[])
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() {
|
async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() {
|
||||||
let fixture = Fixture::new().await;
|
let fixture = Fixture::new().await;
|
||||||
|
|||||||
@@ -46,6 +46,111 @@ async fn assert_result_diagnostics(
|
|||||||
assert_eq!(checks[0]["captured"], true);
|
assert_eq!(checks[0]["captured"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn implementation_result_receipt_and_replay_do_not_wait_for_notification_acceptance() {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
struct ReleaseOnDrop(Option<std::sync::mpsc::Sender<()>>);
|
||||||
|
impl Drop for ReleaseOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(release) = self.0.take() {
|
||||||
|
let _ = release.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (api, execution, orchestrator, identity) = delivery_recording_fixture(dir.path()).await;
|
||||||
|
let ticket = delivery_ticket(&api, "Receipt is independent of notification acceptance");
|
||||||
|
delivery_assign(&api, &ticket.id, "blocked-result-delegation").await;
|
||||||
|
let coder = spawn_ticket_check_source(&api, "blocked-result-coder");
|
||||||
|
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
|
||||||
|
let input = json!({"operation_key":"blocked-result", "kind":"result", "body":"Committed facts survive a blocked notification"});
|
||||||
|
|
||||||
|
let (entered, entered_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let (release, release_rx) = std::sync::mpsc::channel();
|
||||||
|
let release = ReleaseOnDrop(Some(release));
|
||||||
|
let recipient = orchestrator.worker_id.clone();
|
||||||
|
execution.before_input_returns(move |worker| {
|
||||||
|
assert_eq!(worker.worker_id.to_string(), recipient);
|
||||||
|
let _ = entered.send(());
|
||||||
|
release_rx.recv_timeout(Duration::from_secs(15)).unwrap();
|
||||||
|
});
|
||||||
|
let mut report = tokio::spawn({
|
||||||
|
let api = api.clone();
|
||||||
|
let identity = identity.clone();
|
||||||
|
let coder = coder.clone();
|
||||||
|
let ticket = ticket.id.clone();
|
||||||
|
let input = input.clone();
|
||||||
|
async move { result_signed_http(&api, &identity, &coder, &ticket, &input, StatusCode::OK).await }
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), entered_rx)
|
||||||
|
.await
|
||||||
|
.expect("notification reached the gated Runtime acceptance boundary")
|
||||||
|
.unwrap();
|
||||||
|
let receipt = tokio::time::timeout(Duration::from_secs(2), &mut report).await;
|
||||||
|
let replay = if receipt.is_ok() {
|
||||||
|
Some(
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
api.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT count(*) FROM ticket_implementation_results",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
)?,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
)?,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Release before asserting the response result, including when testing the old coupled handler.
|
||||||
|
drop(release);
|
||||||
|
let cleanup = if receipt.is_err() {
|
||||||
|
Some(tokio::time::timeout(Duration::from_secs(5), report).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
api.host_executors.host().unwrap().drain(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("owned recovery drained after release")
|
||||||
|
.unwrap();
|
||||||
|
let receipt = receipt
|
||||||
|
.expect("committed receipt must not wait for Runtime acceptance")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replay
|
||||||
|
.unwrap()
|
||||||
|
.expect("replay must not wait for Runtime acceptance"),
|
||||||
|
receipt
|
||||||
|
);
|
||||||
|
assert!(cleanup.is_none());
|
||||||
|
assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await;
|
||||||
|
delivery_stop(&api, &coder);
|
||||||
|
delivery_stop(&api, &orchestrator);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
|
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -252,32 +252,7 @@ mod plugin_controller_tests {
|
|||||||
crate::plugin_settings::render_value(&plugins).unwrap(),
|
crate::plugin_settings::render_value(&plugins).unwrap(),
|
||||||
crate::plugin_settings::render_value(&selection).unwrap()
|
crate::plugin_settings::render_value(&selection).unwrap()
|
||||||
);
|
);
|
||||||
let state = self
|
super::plugin_tests::adopt_plugin_config(self.api.clone(), &self.owner, content).await;
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let path = config_source::VirtualPath::parse("main.dcdl").unwrap();
|
|
||||||
let commit = ConfigCommitRequest {
|
|
||||||
base_digest: state.snapshot.digest.clone(),
|
|
||||||
entrypoints: state.contract.entrypoints.clone(),
|
|
||||||
changes: vec![config_source::ConfigTreeChange::Update {
|
|
||||||
path: path.clone(),
|
|
||||||
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
|
|
||||||
content,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
request(
|
|
||||||
build_router(self.api.clone()),
|
|
||||||
Some(&self.owner),
|
|
||||||
"POST",
|
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit"),
|
|
||||||
serde_json::to_vec(&commit).unwrap(),
|
|
||||||
false,
|
|
||||||
StatusCode::CREATED,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke(&self, installed: &server_api::PluginOperationResponse) {
|
async fn revoke(&self, installed: &server_api::PluginOperationResponse) {
|
||||||
|
|||||||
@@ -1373,25 +1373,24 @@ fn assert_check_physical_initializations(
|
|||||||
.subjektiv_registration
|
.subjektiv_registration
|
||||||
.open_observation()
|
.open_observation()
|
||||||
.since(before);
|
.since(before);
|
||||||
let directory = f
|
// Subject creation has already durably provisioned this physical DB. Cold
|
||||||
.api
|
// connection opens must still initialize/read SQLite, but must not provision
|
||||||
.config
|
// or sync new metadata ancestors. Initial publication sync is owned below
|
||||||
.database_path
|
// FeatureStorage's identity boundary, not repeated per authorization check.
|
||||||
.parent()
|
assert!(
|
||||||
.unwrap()
|
before.directory_sync_attempts > 0,
|
||||||
.join("feature-storage")
|
"fixture must exercise initial publication I/O"
|
||||||
.join(TEST_WORKSPACE_ID)
|
);
|
||||||
.join("features");
|
assert_eq!(
|
||||||
let resolved = fs::canonicalize(&directory).unwrap();
|
before.directory_sync_successes,
|
||||||
let ancestors: std::collections::HashSet<_> =
|
before.directory_sync_attempts
|
||||||
directory.ancestors().chain(resolved.ancestors()).collect();
|
);
|
||||||
assert_eq!(observed.completed_initializations, expected, "{observed:?}");
|
assert_eq!(observed.completed_initializations, expected, "{observed:?}");
|
||||||
assert_eq!(observed.physical_open_attempts, expected, "{observed:?}");
|
assert_eq!(observed.physical_open_attempts, expected, "{observed:?}");
|
||||||
assert_eq!(observed.physical_opens, expected, "{observed:?}");
|
assert_eq!(observed.physical_opens, expected, "{observed:?}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed.directory_sync_attempts,
|
observed.directory_sync_attempts, 0,
|
||||||
expected * ancestors.len() as u64,
|
"owned DB reopen must not publish directories: {observed:?}"
|
||||||
"{observed:?}"
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed.directory_sync_successes,
|
observed.directory_sync_successes,
|
||||||
|
|||||||
@@ -126,30 +126,7 @@ mod plugin_remote_controller_tests {
|
|||||||
crate::plugin_settings::render_value(&plugins).unwrap(),
|
crate::plugin_settings::render_value(&plugins).unwrap(),
|
||||||
crate::plugin_settings::render_value(&selection).unwrap()
|
crate::plugin_settings::render_value(&selection).unwrap()
|
||||||
);
|
);
|
||||||
let state = f
|
super::plugin_tests::adopt_plugin_config(f.api.clone(), &f.owner, content).await;
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let path = config_source::VirtualPath::parse("main.dcdl").unwrap();
|
|
||||||
let commit = ConfigCommitRequest {
|
|
||||||
base_digest: state.snapshot.digest.clone(),
|
|
||||||
entrypoints: state.contract.entrypoints.clone(),
|
|
||||||
changes: vec![config_source::ConfigTreeChange::Update {
|
|
||||||
path: path.clone(),
|
|
||||||
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
|
|
||||||
content,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
let (status, result) = f
|
|
||||||
.human(
|
|
||||||
reqwest::Method::POST,
|
|
||||||
&format!("{prefix}/config/source-tree/commit"),
|
|
||||||
serde_json::to_value(commit).unwrap(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(status, StatusCode::CREATED, "{result}");
|
|
||||||
(installed, package.bytes)
|
(installed, package.bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,53 @@ async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode)
|
|||||||
request(app, token, "GET", path, vec![], false, expected).await
|
request(app, token, "GET", path, vec![], false, expected).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn adopt_plugin_config(api: WorkspaceApi, owner: &str, content: String) {
|
||||||
|
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||||
|
catalog
|
||||||
|
.apis
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||||
|
catalog
|
||||||
|
.routers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api));
|
||||||
|
let app = workspace_server_router(catalog);
|
||||||
|
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
|
||||||
|
let current = get(app.clone(), Some(owner), &prefix, StatusCode::OK).await;
|
||||||
|
let adoption = serde_json::json!({
|
||||||
|
"operation_id": uuid::Uuid::now_v7().to_string(),
|
||||||
|
"expected_head": current["history_head"],
|
||||||
|
"expected_catalog_digest": current["catalog_digest"],
|
||||||
|
"expected_legacy_export": current["expected_legacy_export"],
|
||||||
|
"source": {
|
||||||
|
"base_digest": current["state"]["snapshot"]["digest"],
|
||||||
|
"entrypoints": ["main.dcdl"],
|
||||||
|
"changes": [{"kind":"update", "path":"main.dcdl",
|
||||||
|
"expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],
|
||||||
|
"content": content}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let preview = json(
|
||||||
|
app.clone(),
|
||||||
|
owner,
|
||||||
|
&format!("{prefix}/preview"),
|
||||||
|
adoption.clone(),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let saved = json(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
&format!("{prefix}/commit"),
|
||||||
|
adoption,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(saved["state"], preview["state"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
|
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -414,12 +461,23 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
|
async fn workspace_owner_without_hosting_admin_configures_plugin_through_owner_adoption() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-config-owner").await;
|
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-config-owner").await;
|
||||||
let reader = seed_test_api_token(api.store.as_ref(), "plugin-config-reader");
|
let reader = seed_test_api_token(api.store.as_ref(), "plugin-config-reader");
|
||||||
let app = build_router(api.clone());
|
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||||
|
catalog
|
||||||
|
.apis
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||||
|
catalog
|
||||||
|
.routers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||||
|
let app = workspace_server_router(catalog);
|
||||||
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||||
let upload = request(
|
let upload = request(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
@@ -435,7 +493,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
|
|||||||
let state = get(
|
let state = get(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
Some(&owner),
|
Some(&owner),
|
||||||
&format!("{prefix}/config/source-tree"),
|
&format!("{prefix}/config/adoption"),
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -447,11 +505,15 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
|
|||||||
a["package_digest"],
|
a["package_digest"],
|
||||||
a["artifact_digest"]
|
a["artifact_digest"]
|
||||||
);
|
);
|
||||||
let change = serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
|
let change = serde_json::json!({
|
||||||
|
"operation_id": "config-plugin-adopt", "expected_head":state["history_head"],
|
||||||
|
"expected_catalog_digest":state["catalog_digest"], "expected_legacy_export":state["expected_legacy_export"],
|
||||||
|
"source": {"base_digest":state["state"]["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]}
|
||||||
|
});
|
||||||
json(
|
json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&reader,
|
&reader,
|
||||||
&format!("{prefix}/config/source-tree/commit"),
|
&format!("{prefix}/config/adoption/commit"),
|
||||||
change.clone(),
|
change.clone(),
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
)
|
)
|
||||||
@@ -459,7 +521,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
|
|||||||
json(
|
json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&reader,
|
&reader,
|
||||||
&format!("{prefix}/config/source-tree/preview"),
|
&format!("{prefix}/config/adoption/preview"),
|
||||||
change.clone(),
|
change.clone(),
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
)
|
)
|
||||||
@@ -467,7 +529,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
|
|||||||
let preview = json(
|
let preview = json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&owner,
|
&owner,
|
||||||
&format!("{prefix}/config/source-tree/preview"),
|
&format!("{prefix}/config/adoption/preview"),
|
||||||
change.clone(),
|
change.clone(),
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
@@ -475,26 +537,42 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
|
|||||||
let unchanged = get(
|
let unchanged = get(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
Some(&owner),
|
Some(&owner),
|
||||||
&format!("{prefix}/config/source-tree"),
|
&format!("{prefix}/config/adoption"),
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(unchanged["snapshot"]["digest"], state["snapshot"]["digest"]);
|
assert_eq!(unchanged, state);
|
||||||
let saved = json(
|
let saved = json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&owner,
|
&owner,
|
||||||
&format!("{prefix}/config/source-tree/commit"),
|
&format!("{prefix}/config/adoption/commit"),
|
||||||
change.clone(),
|
change.clone(),
|
||||||
StatusCode::CREATED,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(saved, preview);
|
assert_eq!(saved["state"], preview["state"]);
|
||||||
assert_ne!(saved["snapshot"]["digest"], state["snapshot"]["digest"]);
|
assert_ne!(
|
||||||
|
saved["state"]["snapshot"]["digest"],
|
||||||
|
state["state"]["snapshot"]["digest"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
json(
|
||||||
|
app.clone(),
|
||||||
|
&owner,
|
||||||
|
&format!("{prefix}/config/adoption/commit"),
|
||||||
|
change.clone(),
|
||||||
|
StatusCode::OK
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
let mut stale = change;
|
||||||
|
stale["operation_id"] = "stale-plugin-adoption".into();
|
||||||
json(
|
json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&owner,
|
&owner,
|
||||||
&format!("{prefix}/config/source-tree/commit"),
|
&format!("{prefix}/config/adoption/commit"),
|
||||||
change,
|
stale,
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -1316,13 +1316,28 @@ async fn drive_and_config_preflight_keep_live_authority_owned_and_report_overloa
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() {
|
async fn owner_adoption_publishes_after_commit_without_holding_the_sql_lane() {
|
||||||
|
use server_api::ConfigurationAdoptionApi;
|
||||||
let fixture = fixture().await;
|
let fixture = fixture().await;
|
||||||
|
// This executor fixture supplies an authenticated owner context; full cookie
|
||||||
|
// admission belongs to configuration_adoption_http_integration.
|
||||||
|
let context = server_api::ServerRequestContext {
|
||||||
|
actor: Some(RequestActor {
|
||||||
|
user_id: "fixture-user".into(),
|
||||||
|
account_id: format!("account-{WORKSPACE}"),
|
||||||
|
handle: "fixture-user".into(),
|
||||||
|
display_name: "Fixture user".into(),
|
||||||
|
auth_method: ActorAuthMethod::BrowserSession,
|
||||||
|
}),
|
||||||
|
worker_source: None,
|
||||||
|
runtime_source: None,
|
||||||
|
origin: Some("http://localhost:5173".into()),
|
||||||
|
transport_headers: Vec::new(),
|
||||||
|
};
|
||||||
let current = fixture
|
let current = fixture
|
||||||
.api
|
.server
|
||||||
.config_store
|
.configuration_source(context.clone(), WORKSPACE.into())
|
||||||
.load_workspace_config(WORKSPACE)
|
.await
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut reply = HeldRuntimeReply::new();
|
let mut reply = HeldRuntimeReply::new();
|
||||||
install_remote_fixture(
|
install_remote_fixture(
|
||||||
@@ -1331,31 +1346,29 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
|
|||||||
&format!("http://{}", reply.address),
|
&format!("http://{}", reply.address),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
let server = fixture.server.clone();
|
||||||
let mut commit = Box::pin(crate::db_executor::with_operation_lifetime(
|
let request = server_api::ConfigurationAdoptionRequest {
|
||||||
Arc::new(CountJobLifetime(leases.clone())),
|
operation_id: "owned-publication".into(),
|
||||||
scoped_commit_workspace_config_tree(
|
expected_head: current.history_head.clone(),
|
||||||
State(fixture.api.clone()),
|
expected_catalog_digest: current.catalog_digest.clone(),
|
||||||
AxumPath(ScopedWorkspacePath {
|
expected_legacy_export: current.expected_legacy_export.clone(),
|
||||||
workspace_id: WORKSPACE.into(),
|
source: server_api::ConfigCommitRequest {
|
||||||
}),
|
base_digest: current.state.snapshot.digest.clone(),
|
||||||
Json(server_api::ConfigCommitRequest {
|
entrypoints: current.state.contract.entrypoints.clone(),
|
||||||
base_digest: current.snapshot.digest.clone(),
|
changes: vec![server_api::ConfigTreeChange::Create {
|
||||||
entrypoints: current
|
path: "notes/owned-publication.txt".into(),
|
||||||
.contract
|
content_type: server_api::ConfigContentType::Text,
|
||||||
.entrypoints
|
content: "committed before prompt publication".into(),
|
||||||
.iter()
|
}],
|
||||||
.map(ToString::to_string)
|
},
|
||||||
.collect(),
|
};
|
||||||
changes: vec![server_api::ConfigTreeChange::Create {
|
let commit = tokio::spawn(async move {
|
||||||
path: "notes/owned-publication.txt".into(),
|
server
|
||||||
content_type: server_api::ConfigContentType::Text,
|
.configuration_adopt(context, WORKSPACE.into(), request)
|
||||||
content: "committed before prompt publication".into(),
|
.await
|
||||||
}],
|
});
|
||||||
}),
|
let receipt = observed(commit).await.unwrap().unwrap();
|
||||||
),
|
assert!(receipt.source_stored && receipt.policy_committed);
|
||||||
));
|
|
||||||
assert!(futures::poll!(&mut commit).is_pending());
|
|
||||||
let request = observed(reply.entered.take().unwrap()).await.unwrap();
|
let request = observed(reply.entered.take().unwrap()).await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
request.starts_with("POST "),
|
request.starts_with("POST "),
|
||||||
@@ -1371,7 +1384,7 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
state.snapshot.digest, current.snapshot.digest,
|
state.snapshot.digest, current.state.snapshot.digest,
|
||||||
"publication must observe the committed tree"
|
"publication must observe the committed tree"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1387,12 +1400,16 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
|
|||||||
"control"
|
"control"
|
||||||
);
|
);
|
||||||
assert!(!reply.upstream_released.load(Ordering::SeqCst));
|
assert!(!reply.upstream_released.load(Ordering::SeqCst));
|
||||||
drop(commit);
|
let admission = fixture.server.admission_for_workspace(WORKSPACE).await;
|
||||||
assert_eq!(leases.load(Ordering::SeqCst), 1);
|
let mut deletion = Box::pin(admission.lock_deletion());
|
||||||
|
assert!(
|
||||||
|
futures::poll!(&mut deletion).is_pending(),
|
||||||
|
"publication must retain its admitted Workspace lifetime after the receipt returns"
|
||||||
|
);
|
||||||
reply.finish();
|
reply.finish();
|
||||||
observed(fixture.host_executor.drain()).await.unwrap();
|
observed(fixture.host_executor.drain()).await.unwrap();
|
||||||
observed(fixture.executor.drain()).await.unwrap();
|
observed(fixture.executor.drain()).await.unwrap();
|
||||||
assert_eq!(leases.load(Ordering::SeqCst), 0);
|
let _deletion = observed(deletion).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fixture
|
fixture
|
||||||
.api
|
.api
|
||||||
|
|||||||
@@ -633,9 +633,12 @@ pub(super) async fn commit(
|
|||||||
&persistence_api.config.workspace_id,
|
&persistence_api.config.workspace_id,
|
||||||
&candidate,
|
&candidate,
|
||||||
).map_err(|_| unknown())?;
|
).map_err(|_| unknown())?;
|
||||||
|
// Repository projection may already have auxiliary secret
|
||||||
|
// effects. A subsequent save error cannot promise that the
|
||||||
|
// whole operation had no durable effect, even on SQL rollback.
|
||||||
persistence_api.config_store.commit_evaluated_workspace_config(
|
persistence_api.config_store.commit_evaluated_workspace_config(
|
||||||
&persistence_api.config.workspace_id, &candidate,
|
&persistence_api.config.workspace_id, &candidate,
|
||||||
).map_err(pre_error)
|
).map_err(|_| unknown())
|
||||||
})())
|
})())
|
||||||
}).map_err(|error| match error {
|
}).map_err(|error| match error {
|
||||||
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
|
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
|
||||||
|
|||||||
@@ -390,8 +390,10 @@ mod workspace_config_integration {
|
|||||||
"the granted WIP path remains available"
|
"the granted WIP path remains available"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Real API-token authentication still permits the normal UI editor.
|
// Real owner API-token authentication still permits the normal editor,
|
||||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
// but only through the head/request-bound adoption boundary.
|
||||||
|
let token =
|
||||||
|
seed_test_workspace_owner_token(api.store.as_ref(), "logical-editor-owner").await;
|
||||||
for (method, path) in &routes {
|
for (method, path) in &routes {
|
||||||
if *method == "GET" {
|
if *method == "GET" {
|
||||||
request_json_authenticated(
|
request_json_authenticated(
|
||||||
@@ -409,11 +411,52 @@ mod workspace_config_integration {
|
|||||||
build_router(api.clone()),
|
build_router(api.clone()),
|
||||||
"POST",
|
"POST",
|
||||||
&format!("{tree}/commit"),
|
&format!("{tree}/commit"),
|
||||||
Some(serde_json::to_value(change).unwrap()),
|
Some(serde_json::to_value(&change).unwrap()),
|
||||||
&token,
|
&token,
|
||||||
StatusCode::CREATED,
|
StatusCode::CONFLICT,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||||
|
catalog
|
||||||
|
.apis
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||||
|
catalog
|
||||||
|
.routers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||||
|
let editor = workspace_server_router(catalog);
|
||||||
|
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
|
||||||
|
let current = get_json_authenticated(editor.clone(), &adoption_path, &token).await;
|
||||||
|
let adoption = json!({
|
||||||
|
"operation_id": "logical-editor-owner-adoption",
|
||||||
|
"expected_head": current["history_head"],
|
||||||
|
"expected_catalog_digest": current["catalog_digest"],
|
||||||
|
"expected_legacy_export": current["expected_legacy_export"],
|
||||||
|
"source": {"base_digest": current["state"]["snapshot"]["digest"],
|
||||||
|
"entrypoints": current["state"]["contract"]["entrypoints"], "changes": change.changes}
|
||||||
|
});
|
||||||
|
let preview = request_json_authenticated(
|
||||||
|
editor.clone(),
|
||||||
|
"POST",
|
||||||
|
&format!("{adoption_path}/preview"),
|
||||||
|
Some(adoption.clone()),
|
||||||
|
&token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let receipt = request_json_authenticated(
|
||||||
|
editor,
|
||||||
|
"POST",
|
||||||
|
&format!("{adoption_path}/commit"),
|
||||||
|
Some(adoption),
|
||||||
|
&token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(receipt["state"], preview["state"]);
|
||||||
let saved = api
|
let saved = api
|
||||||
.config_store
|
.config_store
|
||||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||||
|
|||||||
@@ -41,6 +41,113 @@ fn legacy_subject(source: &Path, destination: &Path, attribution: &str) -> Strin
|
|||||||
subject.id
|
subject.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_deletion_reuses_owned_storage_but_never_adopts_legacy_or_recreates_missing() {
|
||||||
|
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||||
|
for owned in [false, true] {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("server.db");
|
||||||
|
let catalog = catalog(&path);
|
||||||
|
let scope = FeatureStorage::for_server_database(&path)
|
||||||
|
.unwrap()
|
||||||
|
.scope("storage-workspace")
|
||||||
|
.unwrap();
|
||||||
|
let registration = SubjektivStore::register(&scope).unwrap();
|
||||||
|
let feature_path = temp
|
||||||
|
.path()
|
||||||
|
.join("feature-storage/storage-workspace/features/subjektiv.sqlite");
|
||||||
|
let identity = if owned {
|
||||||
|
Some(scope.open(®istration).unwrap().identity().clone())
|
||||||
|
} else {
|
||||||
|
legacy_subject(
|
||||||
|
&temp.path().join("source"),
|
||||||
|
&feature_path,
|
||||||
|
"storage-workspace",
|
||||||
|
);
|
||||||
|
None
|
||||||
|
};
|
||||||
|
catalog.with_conn(|conn| {
|
||||||
|
conn.execute_batch("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
|
||||||
|
VALUES('retained-workspace','storage-owner','Retain','active','1','1');")?;
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
let preflight = catalog
|
||||||
|
.workspace_deletion_preflight("storage-owner", "storage-workspace")
|
||||||
|
.unwrap();
|
||||||
|
catalog
|
||||||
|
.reserve_workspace_deletion(
|
||||||
|
"storage-owner",
|
||||||
|
"storage-workspace",
|
||||||
|
&server_api::WorkspaceDeletionRequest {
|
||||||
|
operation_id: "delete-storage".into(),
|
||||||
|
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||||
|
confirmation: preflight.display_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = catalog.adopt_workspace_feature_storage(&scope, ®istration, "store_scope");
|
||||||
|
if let Some(identity) = identity {
|
||||||
|
result.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
scope
|
||||||
|
.open_existing(®istration, &identity)
|
||||||
|
.unwrap()
|
||||||
|
.identity(),
|
||||||
|
&identity
|
||||||
|
);
|
||||||
|
std::fs::remove_file(&feature_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.adopt_workspace_feature_storage(&scope, ®istration, "store_scope")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(!feature_path.exists());
|
||||||
|
} else {
|
||||||
|
assert!(result.is_err());
|
||||||
|
let raw = Connection::open(&feature_path).unwrap();
|
||||||
|
let identity_count: i64 = raw
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_schema WHERE name='__yoi_storage_identity'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(identity_count, 0, "deletion is not new adoption authority");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_without_reservation_cannot_reuse_storage() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("server.db");
|
||||||
|
let catalog = catalog(&path);
|
||||||
|
let scope = FeatureStorage::for_server_database(&path)
|
||||||
|
.unwrap()
|
||||||
|
.scope("storage-workspace")
|
||||||
|
.unwrap();
|
||||||
|
let registration = SubjektivStore::register(&scope).unwrap();
|
||||||
|
let identity = scope.open(®istration).unwrap().identity().clone();
|
||||||
|
catalog
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute("UPDATE workspaces SET state='deleting'", [])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.adopt_workspace_feature_storage(&scope, ®istration, "store_scope")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scope
|
||||||
|
.open_existing(®istration, &identity)
|
||||||
|
.unwrap()
|
||||||
|
.identity(),
|
||||||
|
&identity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() {
|
fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -3076,10 +3076,25 @@ impl SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.with_immediate_transaction(|tx| {
|
self.with_immediate_transaction(|tx| {
|
||||||
let row: Option<(String,String)> = tx.query_row(
|
let row: Option<(String,String,String)> = tx.query_row(
|
||||||
"SELECT owner_account_id,created_at FROM workspaces WHERE workspace_id=?1 AND state='active'",[storage.scope_id()],
|
"SELECT owner_account_id,created_at,state FROM workspaces WHERE workspace_id=?1",[storage.scope_id()],
|
||||||
|row| Ok((row.get(0)?,row.get(1)?))).optional()?;
|
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).optional()?;
|
||||||
let (owner,created) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?;
|
let (owner,created,state) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?;
|
||||||
|
if state == "deleting" {
|
||||||
|
// A retained deletion consumer can validate completed ownership,
|
||||||
|
// but cannot authorize new legacy adoption or replacement storage.
|
||||||
|
let reserved: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM workspace_deletion_operations WHERE workspace_id=?1 AND owner_account_id=?2 AND state!='succeeded')",
|
||||||
|
params![storage.scope_id(),owner], |row| row.get(0))?;
|
||||||
|
if !reserved {
|
||||||
|
return Err(Error::Store("Workspace storage requires a retained deletion reservation".into()));
|
||||||
|
}
|
||||||
|
storage.verify_owned_if_present(registration)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if state != "active" {
|
||||||
|
return Err(Error::Store("Workspace storage adoption requires an active catalog owner".into()));
|
||||||
|
}
|
||||||
let evidence = format!("catalog:{}:{owner}:{created}",storage.scope_id());
|
let evidence = format!("catalog:{}:{owner}:{created}",storage.scope_id());
|
||||||
let request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id());
|
let request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id());
|
||||||
storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| {
|
storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| {
|
||||||
@@ -6711,19 +6726,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
repository_key: &str,
|
repository_key: &str,
|
||||||
) -> Result<Option<RepositoryRecord>> {
|
) -> Result<Option<RepositoryRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| read_repository_by_key(conn, workspace_id, repository_key))
|
||||||
conn.query_row(
|
|
||||||
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
|
|
||||||
source_kind, source_uri, default_ref,
|
|
||||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
|
||||||
FROM repositories
|
|
||||||
WHERE workspace_id = ?1 AND repository_key = ?2"#,
|
|
||||||
params![workspace_id, repository_key],
|
|
||||||
read_repository_record,
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(Error::from)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
|
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
|
||||||
@@ -12441,6 +12444,24 @@ fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_repository_by_key(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
repository_key: &str,
|
||||||
|
) -> Result<Option<RepositoryRecord>> {
|
||||||
|
conn.query_row(
|
||||||
|
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
|
||||||
|
source_kind, source_uri, default_ref,
|
||||||
|
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||||
|
FROM repositories
|
||||||
|
WHERE workspace_id = ?1 AND repository_key = ?2"#,
|
||||||
|
params![workspace_id, repository_key],
|
||||||
|
read_repository_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
|
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
|
||||||
let source_kind_value = row.get::<_, String>(5)?;
|
let source_kind_value = row.get::<_, String>(5)?;
|
||||||
let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value)
|
let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value)
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env -S deno run -A
|
||||||
|
/** 実Web/実WASMを合成HTTPで操作する。認証・保存の証拠はServer suiteが所有する。 */
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join, resolve } from "@std/path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { chromium, type Page } from "playwright";
|
||||||
|
import { sha256File } from "../src/artifacts.ts";
|
||||||
|
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||||
|
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
|
||||||
|
import init, {
|
||||||
|
apply_changes,
|
||||||
|
compose_schema_bundle,
|
||||||
|
set_snapshot,
|
||||||
|
} from "../../../web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js";
|
||||||
|
import type {
|
||||||
|
ConfigTreeChange,
|
||||||
|
ConfigTreeSnapshot,
|
||||||
|
WorkspaceConfigSchemaBundle,
|
||||||
|
WorkspaceConfigTreeResponse,
|
||||||
|
} from "../../../web/workspace/src/lib/workspace/config-source/types.ts";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build"));
|
||||||
|
const runId = Deno.args[1];
|
||||||
|
const before = Deno.args[2] === "before";
|
||||||
|
if (!runId || !/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("supply unique safe run id");
|
||||||
|
const output = join(root, "target/web-ux/t838", runId);
|
||||||
|
await Deno.mkdir(dirname(output), { recursive: true });
|
||||||
|
await Deno.mkdir(output);
|
||||||
|
await init({
|
||||||
|
module_or_path: await Deno.readFile(
|
||||||
|
join(
|
||||||
|
root,
|
||||||
|
"web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const hash = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`;
|
||||||
|
const schema = compose_schema_bundle([]) as WorkspaceConfigSchemaBundle;
|
||||||
|
const content =
|
||||||
|
"{\n permissions = { subjects = []; remove_subjects = []; };\n} as WorkspaceConfigSchema\n";
|
||||||
|
const treeHash = createHash("sha256").update("yoi-config-tree-v1\0").update(
|
||||||
|
"main.dcdl\0text/x-decodal\0",
|
||||||
|
).update(content).update("\0");
|
||||||
|
const initial: WorkspaceConfigTreeResponse = {
|
||||||
|
snapshot: {
|
||||||
|
digest: `sha256:${treeHash.digest("hex")}`,
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content,
|
||||||
|
content_digest: hash(content),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
contract_version: 2,
|
||||||
|
decodal_version: "0.4.0",
|
||||||
|
schema_version: 1,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
import_policy_version: 1,
|
||||||
|
schema_bundle: schema,
|
||||||
|
fingerprint: hash(JSON.stringify([2, "0.4.0", 1, ["main.dcdl"], 1, schema.fingerprint])),
|
||||||
|
},
|
||||||
|
projection_digest: hash("synthetic-empty-policy-projection"),
|
||||||
|
};
|
||||||
|
const port = await freePort();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const owned = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: "configuration-synthetic-http",
|
||||||
|
command: Deno.execPath(),
|
||||||
|
cwd: root,
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||||
|
String(port),
|
||||||
|
buildRoot,
|
||||||
|
],
|
||||||
|
readyUrl: `${base}/health`,
|
||||||
|
}],
|
||||||
|
import.meta.url,
|
||||||
|
join(output, "processes"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const source = await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root }).output();
|
||||||
|
const manifest = {
|
||||||
|
evidence: "actual-Web-and-WASM-with-synthetic-HTTP-NOT-real-Backend",
|
||||||
|
before,
|
||||||
|
source: new TextDecoder().decode(source.stdout).trim(),
|
||||||
|
buildRoot,
|
||||||
|
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
|
||||||
|
visualVerdict: "UNREVIEWED",
|
||||||
|
captures: [] as unknown[],
|
||||||
|
failures: [] as string[],
|
||||||
|
};
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const documents = new WeakMap<Page, number>();
|
||||||
|
async function capture(page: Page, id: string, errors: unknown[], expectedErrors: string[] = []) {
|
||||||
|
if (id.endsWith("-unknown")) {
|
||||||
|
await page.locator(".config-source-conflict").scrollIntoViewIfNeeded();
|
||||||
|
} else if (id.endsWith("-late-receipt")) {
|
||||||
|
await page.locator(".config-source-status").scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
const image = join(output, `${id}.png`);
|
||||||
|
await page.screenshot({ path: image });
|
||||||
|
const accessibilitySnapshot = `${id}.aria.txt`;
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(output, accessibilitySnapshot),
|
||||||
|
await page.locator("body").ariaSnapshot(),
|
||||||
|
);
|
||||||
|
manifest.captures.push({
|
||||||
|
id,
|
||||||
|
route: new URL(page.url()).pathname,
|
||||||
|
documentStatus: documents.get(page) ?? null,
|
||||||
|
viewport: page.viewportSize(),
|
||||||
|
image,
|
||||||
|
imageSha256: await sha256File(image),
|
||||||
|
accessibilitySnapshot,
|
||||||
|
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||||
|
errors: [...errors],
|
||||||
|
expectedErrors,
|
||||||
|
});
|
||||||
|
await assertNoPageWideOverflow(page);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await context.route("**/api/auth/whoami", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
authentication_reference: "fixture-authentication",
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: {
|
||||||
|
user_id: "fixture-user",
|
||||||
|
account_id: "fixture-owner",
|
||||||
|
handle: "reviewer",
|
||||||
|
display_name: "Configuration review",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await context.route(
|
||||||
|
"**/api/hosting/access",
|
||||||
|
(route) => route.fulfill({ json: { is_admin: false } }),
|
||||||
|
);
|
||||||
|
await context.route("**/api/w/*/workspace", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const data = await response.json();
|
||||||
|
data.permissions.delete_subjects = !route.request().url().includes("home-member");
|
||||||
|
await route.fulfill({ json: data });
|
||||||
|
});
|
||||||
|
await context.route("**/*.wasm", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
headers: { ...response.headers(), "content-type": "application/wasm" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: { kind: string; message: string }[] = [];
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.request().isNavigationRequest()) documents.set(page, r.status());
|
||||||
|
if (r.status() >= 400) {
|
||||||
|
errors.push({ kind: "http", message: `${r.status()} ${new URL(r.url()).pathname}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("pageerror", (e) => errors.push({ kind: "page", message: e.message }));
|
||||||
|
page.on("console", (e) => {
|
||||||
|
if (e.type() === "error" || e.type() === "warning") {
|
||||||
|
errors.push({ kind: "console", message: e.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on(
|
||||||
|
"requestfailed",
|
||||||
|
(r) =>
|
||||||
|
errors.push({
|
||||||
|
kind: "request",
|
||||||
|
message: `${r.method()} ${new URL(r.url()).pathname}: ${r.failure()?.errorText}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let state = structuredClone(initial);
|
||||||
|
let request: unknown = null;
|
||||||
|
let savedReceipt: unknown = null;
|
||||||
|
let head = "head:observed";
|
||||||
|
const prefix = `${colorScheme}-${width}`;
|
||||||
|
await page.route("**/api/w/*/config/**", async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (path.includes("/home-member/")) {
|
||||||
|
return await route.fulfill({ status: 403, body: "Workspace owner required" });
|
||||||
|
}
|
||||||
|
if (path.endsWith("/source-tree")) return await route.fulfill({ json: state });
|
||||||
|
if (path.endsWith("/adoption")) {
|
||||||
|
return await route.fulfill({
|
||||||
|
json: {
|
||||||
|
state,
|
||||||
|
history_head: head,
|
||||||
|
source_stored: true,
|
||||||
|
catalog: { workspace: "home-owner", namespaces: [] },
|
||||||
|
catalog_digest: hash("catalog"),
|
||||||
|
expected_legacy_export: null,
|
||||||
|
policies: [],
|
||||||
|
migration_diagnostics: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const input = route.request().postDataJSON();
|
||||||
|
if (path.endsWith("/preview")) {
|
||||||
|
set_snapshot(state.snapshot);
|
||||||
|
const candidate = apply_changes(
|
||||||
|
input.source.changes as ConfigTreeChange[],
|
||||||
|
) as ConfigTreeSnapshot;
|
||||||
|
return await route.fulfill({
|
||||||
|
json: {
|
||||||
|
expected_head: head,
|
||||||
|
state: { ...state, snapshot: candidate },
|
||||||
|
changed_paths: ["module.dcdl"],
|
||||||
|
changed_subjects: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.endsWith("/commit")) {
|
||||||
|
request = input;
|
||||||
|
set_snapshot(state.snapshot);
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
snapshot: apply_changes(input.source.changes) as ConfigTreeSnapshot,
|
||||||
|
};
|
||||||
|
head = "head:adopted";
|
||||||
|
savedReceipt = {
|
||||||
|
workspace_id: "home-owner",
|
||||||
|
operation_id: input.operation_id,
|
||||||
|
history_head: head,
|
||||||
|
state,
|
||||||
|
source_stored: true,
|
||||||
|
policy_committed: true,
|
||||||
|
runtime_application: "next_current_policy_check",
|
||||||
|
};
|
||||||
|
return await route.abort("failed");
|
||||||
|
}
|
||||||
|
if (path.endsWith("/reconcile")) {
|
||||||
|
assertEquals(input, request);
|
||||||
|
return await route.fulfill({ json: { status: "not_observed" } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected config request: ${path}`);
|
||||||
|
});
|
||||||
|
await page.goto(`${base}/w/home-owner/settings/configuration`);
|
||||||
|
await page.locator(".cm-content[contenteditable=true]").waitFor();
|
||||||
|
await page.locator(".config-source-shell").scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, `${prefix}-ready`, errors);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
if (!before) {
|
||||||
|
await page.getByRole("button", { name: "Create local source" }).click();
|
||||||
|
const commit = page.getByRole("button", { name: "Commit", exact: true });
|
||||||
|
await commit.click();
|
||||||
|
await page.getByRole("button", { name: "Reconcile adoption" }).waitFor();
|
||||||
|
await page.getByText(/not observed does not mean failed/).first().waitFor();
|
||||||
|
await capture(page, `${prefix}-unknown`, errors, [
|
||||||
|
"Intentional failed POST commit response",
|
||||||
|
]);
|
||||||
|
// 後続draftを編集してから古いreceiptを返し、実editorでも消えないことを確認する。
|
||||||
|
const editor = page.locator(".cm-content");
|
||||||
|
await editor.fill("{ later = true; }\n");
|
||||||
|
await page.unroute("**/api/w/*/config/**");
|
||||||
|
await page.route("**/api/w/*/config/adoption/reconcile", async (route) => {
|
||||||
|
assertEquals(route.request().postDataJSON(), request);
|
||||||
|
await route.fulfill({ json: { status: "adopted", receipt: savedReceipt } });
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Reconcile adoption" }).click();
|
||||||
|
await page.getByText(/Later local edits remain unsaved/).waitFor();
|
||||||
|
assert((await editor.innerText()).includes("later"));
|
||||||
|
await capture(page, `${prefix}-late-receipt`, errors, [
|
||||||
|
"Intentional failed POST commit response",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await page.route(
|
||||||
|
"**/api/w/home-member/config/**",
|
||||||
|
(route) => route.fulfill({ status: 403, body: "Workspace owner required" }),
|
||||||
|
);
|
||||||
|
await page.goto(`${base}/w/home-member/settings/configuration`);
|
||||||
|
await page.getByRole("alert").filter({ hasText: "Unable to load workspace configuration" })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(await page.locator(".cm-content").count(), 0);
|
||||||
|
await capture(page, `${prefix}-non-owner`, errors, [
|
||||||
|
"HTTP403 owner rejection",
|
||||||
|
...(!before ? ["Intentional failed POST commit response"] : []),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const page = context.pages()[0];
|
||||||
|
if (page) {
|
||||||
|
await page.screenshot({ path: join(output, `failure-${colorScheme}-${width}.png`) });
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(output, `failure-${colorScheme}-${width}.txt`),
|
||||||
|
await page.locator("body").innerText(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
manifest.failures.push(String(e));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await stopOwnedProcesses(owned);
|
||||||
|
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts src/lib/workspace/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
|
||||||
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
||||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ async function prepareDraft() {
|
|||||||
|
|
||||||
const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body);
|
const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body);
|
||||||
|
|
||||||
function commitReceiptResponse(state = candidateState, historyHead = "head:adopted") {
|
function commitReceiptResponse(state: WorkspaceConfigTreeResponse = candidateState, historyHead = "head:adopted") {
|
||||||
return async (_input: string, init: RequestInit) =>
|
return async (_input: string, init: RequestInit) =>
|
||||||
jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead));
|
jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead));
|
||||||
}
|
}
|
||||||
@@ -494,7 +494,10 @@ test("adopted reconciliation updates head and clears the legacy export for the n
|
|||||||
const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" };
|
const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" };
|
||||||
toolchain.applyChanges.mockResolvedValue(tree.snapshot);
|
toolchain.applyChanges.mockResolvedValue(tree.snapshot);
|
||||||
toolchain.changesBetween.mockResolvedValue([deletion]);
|
toolchain.changesBetween.mockResolvedValue([deletion]);
|
||||||
await fireEvent.click(view.getByRole("button", { name: "Delete" }));
|
const remove = view.getByRole("button", { name: "Delete" });
|
||||||
|
await waitFor(() => expect(remove).toHaveProperty("disabled", false));
|
||||||
|
await fireEvent.click(remove);
|
||||||
|
await waitFor(() => expect(view.container.textContent).toContain("Delete staged locally"));
|
||||||
fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted"));
|
fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted"));
|
||||||
fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second"));
|
fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second"));
|
||||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
let baseDigest = $state("");
|
let baseDigest = $state("");
|
||||||
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
|
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
|
||||||
let editingWorkspaceId = $state("");
|
let editingWorkspaceId = $state("");
|
||||||
|
// 結果不明の依頼はeditor instanceのメモリ内だけに保持する。画面離脱や
|
||||||
|
// browser reloadを跨ぐ復元は保証しない。Serverのreceipt永続化とは別の限界で、
|
||||||
|
// その場合も結果不明を失敗とせず、元の依頼全体を使った照合が必要になる。
|
||||||
let pendingAdoption = $state.raw<{
|
let pendingAdoption = $state.raw<{
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
request: ConfigurationAdoptionRequest;
|
request: ConfigurationAdoptionRequest;
|
||||||
|
|||||||
+5
-5
@@ -10,9 +10,9 @@ import {
|
|||||||
parseConfigurationSourceResponse,
|
parseConfigurationSourceResponse,
|
||||||
previewConfigTree,
|
previewConfigTree,
|
||||||
reconcileConfigTree,
|
reconcileConfigTree,
|
||||||
} from "./api.ts";
|
} from "../../src/lib/workspace/config-source/api.ts";
|
||||||
import type { ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest, ConfigurationReconciliation } from "#lib/generated/configuration-adoption.generated.ts";
|
import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts";
|
||||||
import type { WorkspaceConfigTreeResponse } from "./types.ts";
|
import type { WorkspaceConfigTreeResponse } from "../../src/lib/workspace/config-source/types.ts";
|
||||||
|
|
||||||
const state: WorkspaceConfigTreeResponse = {
|
const state: WorkspaceConfigTreeResponse = {
|
||||||
snapshot: { digest: "sha256:content-A", entries: {} },
|
snapshot: { digest: "sha256:content-A", entries: {} },
|
||||||
@@ -36,7 +36,7 @@ const request: ConfigurationAdoptionRequest = {
|
|||||||
expected_legacy_export: source.expected_legacy_export,
|
expected_legacy_export: source.expected_legacy_export,
|
||||||
source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] },
|
source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] },
|
||||||
};
|
};
|
||||||
const receipt: ConfigurationAdoptionReceipt = {
|
const receipt = {
|
||||||
workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted",
|
workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted",
|
||||||
state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
|
state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
|
||||||
};
|
};
|
||||||
@@ -117,7 +117,7 @@ Deno.test("not_observed and pending_transfer remain distinct unknown evidence, n
|
|||||||
{ status: "not_observed" },
|
{ status: "not_observed" },
|
||||||
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
|
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
|
||||||
{ status: "adopted", receipt },
|
{ status: "adopted", receipt },
|
||||||
] satisfies ConfigurationReconciliation[]) assertEquals(parseConfigurationReconciliation(evidence), evidence);
|
]) assertEquals<unknown>(parseConfigurationReconciliation(evidence), evidence);
|
||||||
for (const invalid of [
|
for (const invalid of [
|
||||||
{ status: "failed" }, { status: "not_observed", receipt },
|
{ status: "failed" }, { status: "not_observed", receipt },
|
||||||
{ status: "pending_transfer", transfer_identity: "transfer:one" },
|
{ status: "pending_transfer", transfer_identity: "transfer:one" },
|
||||||
Reference in New Issue
Block a user