fix: validate owner adoption clients publication and storage lifecycle

This commit is contained in:
2026-11-04 19:34:58 +09:00
parent 9f8aad79a3
commit e6c95894c5
19 changed files with 737 additions and 149 deletions
@@ -107,6 +107,32 @@ fn has_identity(connection: &Connection) -> bool {
.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(&registration).unwrap();
assert!(!root.exists());
let path = legacy(&root, SCOPE);
assert_identity_error(scoped.verify_owned_if_present(&registration));
assert!(!has_identity(&Connection::open(&path).unwrap()));
let database = scoped
.adopt_existing(&registration, REQUEST, EVIDENCE, validate_legacy)
.unwrap();
let identity = database.identity().clone();
scoped.verify_owned_if_present(&registration).unwrap();
assert_eq!(values(&database), vec!["preserved"]);
drop(database);
fs::remove_file(&path).unwrap();
assert!(matches!(scoped.verify_owned_if_present(&registration),
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
assert!(!path.exists());
assert!(matches!(scoped.open_existing(&registration, &identity),
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
}
#[test]
fn provision_then_reopen_retains_physical_identity_and_committed_data() {
let temp = tempfile::tempdir().unwrap();
+25
View File
@@ -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, &registration.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
/// provision missing optional storage. An existing legacy DB still requires
/// explicit adoption authority and persisted attribution validation.
@@ -182,7 +182,7 @@ pub fn configuration_typescript() -> String {
ConfigurationReconciliation::decl(&config),
];
format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse }} from \"./legacy-server-api.ts\";\nimport type {{ ScopedPermission, SubjectPermissions }} from \"./memory-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
@@ -1,6 +1,7 @@
//! 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
//! 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::{
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
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 db = SqliteWorkspaceStore::open_for_test(&path).unwrap();
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');")?;
Ok(())
}).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() {
let db = SqliteWorkspaceStore::in_memory_for_test().unwrap();
db.with_immediate_transaction(|tx| {
tx.execute_batch(content_history_store::SCHEMA)?;
// 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');")?;
let s=scoped(tx);
+46 -20
View File
@@ -71993,33 +71993,58 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
#[tokio::test]
async fn generated_config_source_routes_preserve_nested_entry_paths() {
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 initial = get_json(app.clone(), &source_tree_path).await;
let digest = initial["snapshot"]["digest"].as_str().unwrap();
let committed = request_json(
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let initial = get_json_authenticated(app.clone(), &adoption_path, &owner).await;
let 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(),
"POST",
&format!("{source_tree_path}/commit"),
Some(json!({
"base_digest": digest,
"changes": [{
"kind": "create",
"path": "notes/readme.txt",
"content_type": "text",
"content": "nested entry"
}],
"entrypoints": ["main.dcdl"]
})),
StatusCode::CREATED,
&format!("{adoption_path}/preview"),
Some(request.clone()),
&owner,
StatusCode::OK,
)
.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(),
&format!("{source_tree_path}/history/{committed_digest}"),
&owner,
)
.await;
assert_eq!(config_snapshot["digest"], committed_digest);
@@ -72028,9 +72053,10 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
"nested entry"
);
let entry = get_json(
let entry = get_json_authenticated(
app,
&format!("{source_tree_path}/entries/notes%2Freadme.txt"),
&owner,
)
.await;
assert_eq!(entry["path"], "notes/readme.txt");
@@ -472,6 +472,24 @@ impl ConfigurationAdoptionApi for WorkspaceServerApi {
))
})
.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))
})
.await
@@ -252,32 +252,7 @@ mod plugin_controller_tests {
crate::plugin_settings::render_value(&plugins).unwrap(),
crate::plugin_settings::render_value(&selection).unwrap()
);
let state = self
.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;
super::plugin_tests::adopt_plugin_config(self.api.clone(), &self.owner, content).await;
}
async fn revoke(&self, installed: &server_api::PluginOperationResponse) {
@@ -1373,25 +1373,24 @@ fn assert_check_physical_initializations(
.subjektiv_registration
.open_observation()
.since(before);
let directory = f
.api
.config
.database_path
.parent()
.unwrap()
.join("feature-storage")
.join(TEST_WORKSPACE_ID)
.join("features");
let resolved = fs::canonicalize(&directory).unwrap();
let ancestors: std::collections::HashSet<_> =
directory.ancestors().chain(resolved.ancestors()).collect();
// Subject creation has already durably provisioned this physical DB. Cold
// connection opens must still initialize/read SQLite, but must not provision
// or sync new metadata ancestors. Initial publication sync is owned below
// FeatureStorage's identity boundary, not repeated per authorization check.
assert!(
before.directory_sync_attempts > 0,
"fixture must exercise initial publication I/O"
);
assert_eq!(
before.directory_sync_successes,
before.directory_sync_attempts
);
assert_eq!(observed.completed_initializations, expected, "{observed:?}");
assert_eq!(observed.physical_open_attempts, expected, "{observed:?}");
assert_eq!(observed.physical_opens, expected, "{observed:?}");
assert_eq!(
observed.directory_sync_attempts,
expected * ancestors.len() as u64,
"{observed:?}"
observed.directory_sync_attempts, 0,
"owned DB reopen must not publish directories: {observed:?}"
);
assert_eq!(
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(&selection).unwrap()
);
let state = f
.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}");
super::plugin_tests::adopt_plugin_config(f.api.clone(), &f.owner, content).await;
(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
}
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]
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
let dir = tempfile::tempdir().unwrap();
@@ -414,12 +461,23 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
}
#[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 api = test_api(dir.path()).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 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 upload = request(
app.clone(),
@@ -435,7 +493,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let state = get(
app.clone(),
Some(&owner),
&format!("{prefix}/config/source-tree"),
&format!("{prefix}/config/adoption"),
StatusCode::OK,
)
.await;
@@ -447,11 +505,15 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
a["package_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(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/commit"),
&format!("{prefix}/config/adoption/commit"),
change.clone(),
StatusCode::FORBIDDEN,
)
@@ -459,7 +521,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
json(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/preview"),
&format!("{prefix}/config/adoption/preview"),
change.clone(),
StatusCode::FORBIDDEN,
)
@@ -467,7 +529,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let preview = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/preview"),
&format!("{prefix}/config/adoption/preview"),
change.clone(),
StatusCode::OK,
)
@@ -475,26 +537,42 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let unchanged = get(
app.clone(),
Some(&owner),
&format!("{prefix}/config/source-tree"),
&format!("{prefix}/config/adoption"),
StatusCode::OK,
)
.await;
assert_eq!(unchanged["snapshot"]["digest"], state["snapshot"]["digest"]);
assert_eq!(unchanged, state);
let saved = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
&format!("{prefix}/config/adoption/commit"),
change.clone(),
StatusCode::CREATED,
StatusCode::OK,
)
.await;
assert_eq!(saved, preview);
assert_ne!(saved["snapshot"]["digest"], state["snapshot"]["digest"]);
assert_eq!(saved["state"], preview["state"]);
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(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
change,
&format!("{prefix}/config/adoption/commit"),
stale,
StatusCode::CONFLICT,
)
.await;
@@ -1316,13 +1316,28 @@ async fn drive_and_config_preflight_keep_live_authority_owned_and_report_overloa
}
#[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;
// 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
.api
.config_store
.load_workspace_config(WORKSPACE)
.unwrap()
.server
.configuration_source(context.clone(), WORKSPACE.into())
.await
.unwrap();
let mut reply = HeldRuntimeReply::new();
install_remote_fixture(
@@ -1331,31 +1346,29 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
&format!("http://{}", reply.address),
false,
);
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut commit = Box::pin(crate::db_executor::with_operation_lifetime(
Arc::new(CountJobLifetime(leases.clone())),
scoped_commit_workspace_config_tree(
State(fixture.api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: WORKSPACE.into(),
}),
Json(server_api::ConfigCommitRequest {
base_digest: current.snapshot.digest.clone(),
entrypoints: current
.contract
.entrypoints
.iter()
.map(ToString::to_string)
.collect(),
changes: vec![server_api::ConfigTreeChange::Create {
path: "notes/owned-publication.txt".into(),
content_type: server_api::ConfigContentType::Text,
content: "committed before prompt publication".into(),
}],
}),
),
));
assert!(futures::poll!(&mut commit).is_pending());
let server = fixture.server.clone();
let request = server_api::ConfigurationAdoptionRequest {
operation_id: "owned-publication".into(),
expected_head: current.history_head.clone(),
expected_catalog_digest: current.catalog_digest.clone(),
expected_legacy_export: current.expected_legacy_export.clone(),
source: server_api::ConfigCommitRequest {
base_digest: current.state.snapshot.digest.clone(),
entrypoints: current.state.contract.entrypoints.clone(),
changes: vec![server_api::ConfigTreeChange::Create {
path: "notes/owned-publication.txt".into(),
content_type: server_api::ConfigContentType::Text,
content: "committed before prompt publication".into(),
}],
},
};
let commit = tokio::spawn(async move {
server
.configuration_adopt(context, WORKSPACE.into(), request)
.await
});
let receipt = observed(commit).await.unwrap().unwrap();
assert!(receipt.source_stored && receipt.policy_committed);
let request = observed(reply.entered.take().unwrap()).await.unwrap();
assert!(
request.starts_with("POST "),
@@ -1371,7 +1384,7 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
.unwrap()
.unwrap();
assert_ne!(
state.snapshot.digest, current.snapshot.digest,
state.snapshot.digest, current.state.snapshot.digest,
"publication must observe the committed tree"
);
assert!(
@@ -1387,12 +1400,16 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
"control"
);
assert!(!reply.upstream_released.load(Ordering::SeqCst));
drop(commit);
assert_eq!(leases.load(Ordering::SeqCst), 1);
let admission = fixture.server.admission_for_workspace(WORKSPACE).await;
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();
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
assert_eq!(leases.load(Ordering::SeqCst), 0);
let _deletion = observed(deletion).await;
assert_eq!(
fixture
.api
@@ -41,6 +41,113 @@ fn legacy_subject(source: &Path, destination: &Path, attribution: &str) -> Strin
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(&registration).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, &registration, "store_scope");
if let Some(identity) = identity {
result.unwrap();
assert_eq!(
scope
.open_existing(&registration, &identity)
.unwrap()
.identity(),
&identity
);
std::fs::remove_file(&feature_path).unwrap();
assert!(
catalog
.adopt_workspace_feature_storage(&scope, &registration, "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(&registration).unwrap().identity().clone();
catalog
.with_conn(|conn| {
conn.execute("UPDATE workspaces SET state='deleting'", [])?;
Ok(())
})
.unwrap();
assert!(
catalog
.adopt_workspace_feature_storage(&scope, &registration, "store_scope")
.is_err()
);
assert_eq!(
scope
.open_existing(&registration, &identity)
.unwrap()
.identity(),
&identity
);
}
#[test]
fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() {
let temp = tempfile::tempdir().unwrap();
+19 -4
View File
@@ -2998,10 +2998,25 @@ impl SqliteWorkspaceStore {
}
};
self.with_immediate_transaction(|tx| {
let row: Option<(String,String)> = tx.query_row(
"SELECT owner_account_id,created_at FROM workspaces WHERE workspace_id=?1 AND state='active'",[storage.scope_id()],
|row| Ok((row.get(0)?,row.get(1)?))).optional()?;
let (owner,created) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?;
let row: Option<(String,String,String)> = tx.query_row(
"SELECT owner_account_id,created_at,state FROM workspaces WHERE workspace_id=?1",[storage.scope_id()],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).optional()?;
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 request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id());
storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| {
@@ -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));
}
+1 -1
View File
@@ -6,7 +6,7 @@
"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",
"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: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",
@@ -1,7 +1,8 @@
// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.
// Do not edit manually.
import type { ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions } from "./legacy-server-api.ts";
import type { ConfigCommitRequest, WorkspaceConfigTreeResponse } from "./legacy-server-api.ts";
import type { ScopedPermission, SubjectPermissions } from "./memory-api.ts";
import type { PermissionAssignment, PermissionCatalog } from "./feature-permissions.generated.ts";
export type ConfigurationAdoptionRequest = { operation_id: string, expected_head: string | null, expected_catalog_digest: string, expected_legacy_export: string | null, source: ConfigCommitRequest, };
@@ -374,7 +374,7 @@ async function prepareDraft() {
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) =>
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" };
toolchain.applyChanges.mockResolvedValue(tree.snapshot);
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.mockImplementationOnce(commitReceiptResponse(tree, "head:second"));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
@@ -40,6 +40,9 @@
let baseDigest = $state("");
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
let editingWorkspaceId = $state("");
// 結果不明の依頼はeditor instanceのメモリ内だけに保持する。画面離脱や
// browser reloadを跨ぐ復元は保証しない。Serverのreceipt永続化とは別の限界で、
// その場合も結果不明を失敗とせず、元の依頼全体を使った照合が必要になる。
let pendingAdoption = $state.raw<{
workspaceId: string;
request: ConfigurationAdoptionRequest;
@@ -10,9 +10,9 @@ import {
parseConfigurationSourceResponse,
previewConfigTree,
reconcileConfigTree,
} from "./api.ts";
} from "../../src/lib/workspace/config-source/api.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 = {
snapshot: { digest: "sha256:content-A", entries: {} },
@@ -117,7 +117,7 @@ Deno.test("not_observed and pending_transfer remain distinct unknown evidence, n
{ status: "not_observed" },
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
{ status: "adopted", receipt },
]) assertEquals(parseConfigurationReconciliation(evidence), evidence);
]) assertEquals<unknown>(parseConfigurationReconciliation(evidence), evidence);
for (const invalid of [
{ status: "failed" }, { status: "not_observed", receipt },
{ status: "pending_transfer", transfer_identity: "transfer:one" },