test(extensions): 完成 D-03 恢复验收

This commit is contained in:
2026-09-09 19:08:07 +08:00
parent 8adbd51e9d
commit 576c365573
4 changed files with 385 additions and 1 deletions
@@ -213,6 +213,12 @@ pub fn recover(db: &mut Connection) -> Result<usize> {
#[cfg(test)]
mod tests {
use super::*;
use std::{
path::Path,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
fn open(path: &std::path::Path) -> Connection {
let db = Connection::open(path).unwrap();
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
@@ -232,6 +238,41 @@ mod tests {
expected_revision: previous,
}
}
fn seeded(path: &Path) -> (Vec<Change>, Vec<Change>) {
let mut db = open(path);
let old = vec![change('a', 1, None), change('b', 1, None)];
let id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &id, &old).unwrap();
finish(&mut db, &id, true).unwrap();
let next = old
.iter()
.map(|current| {
change(
current.target.slot.chars().next().unwrap(),
2,
Some(active(&db, &current.target.slot).unwrap().unwrap().revision),
)
})
.collect();
(old, next)
}
fn assert_complete_generation(db: &Connection) {
let active: Vec<_> = ['a', 'b']
.into_iter()
.map(|slot| active(db, &slot.to_string().repeat(64)).unwrap().unwrap())
.collect();
let versions: BTreeSet<_> = active
.iter()
.map(|item| item.target.configuration["version"].as_u64().unwrap())
.collect();
assert_eq!(versions.len(), 1, "package/config generations were mixed");
let version = *versions.first().unwrap();
assert!(matches!(version, 1 | 2));
for item in active {
assert_eq!(item.target.package_key, format!("{version:x}").repeat(64));
assert!(item.pending_operation.is_none());
}
}
#[test]
fn group_switch_crashes_recover_matching_packages_and_configuration() {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
@@ -275,6 +316,176 @@ mod tests {
}
}
#[test]
#[ignore = "parent acceptance oracle hard-terminates this helper at a durable boundary"]
fn power_cut_worker() {
let Some(path) = std::env::var_os("OPENNEXUS_D03_DATABASE") else {
return;
};
let boundary = std::env::var("OPENNEXUS_D03_BOUNDARY").unwrap();
let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_D03_MARKER").unwrap());
let mut db = open(Path::new(&path));
let old: Vec<_> = ['a', 'b']
.into_iter()
.map(|slot| {
let current = active(&db, &slot.to_string().repeat(64)).unwrap().unwrap();
Change {
target: current.target.clone(),
expected_revision: Some(current.revision),
}
})
.collect();
let next: Vec<_> = old
.iter()
.map(|current| {
change(
current.target.slot.chars().next().unwrap(),
2,
current.expected_revision.clone(),
)
})
.collect();
let operation = uuid::Uuid::new_v4().to_string();
let _ = switch_inner(&mut db, &operation, &next, |at| {
if at == boundary {
let file = std::fs::File::create(&marker).unwrap();
file.sync_all().unwrap();
loop {
thread::sleep(Duration::from_secs(60));
}
}
Ok(())
});
panic!("power-cut helper passed the requested boundary");
}
#[test]
fn group_switch_survives_hard_termination_twenty_times_per_boundary() {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
for round in 0..20 {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
seeded(&path);
let marker = temp.path().join(format!("{boundary}-{round}.ready"));
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"extension_transaction::tests::power_cut_worker",
"--nocapture",
])
.env("OPENNEXUS_D03_DATABASE", &path)
.env("OPENNEXUS_D03_BOUNDARY", boundary)
.env("OPENNEXUS_D03_MARKER", &marker)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let started = Instant::now();
while !marker.is_file() {
assert!(
child.try_wait().unwrap().is_none(),
"helper exited before {boundary}"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"helper did not reach {boundary}"
);
thread::sleep(Duration::from_millis(5));
}
child.kill().unwrap();
assert!(!child.wait().unwrap().success());
let mut db = open(&path);
recover(&mut db).unwrap();
assert_complete_generation(&db);
assert_eq!(recover(&mut db).unwrap(), 0);
}
}
}
#[test]
fn disk_full_and_configuration_migration_failures_cover_every_boundary() {
let mapped: HostError = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
None,
)
.into();
assert_eq!(mapped.code, "QUOTA_EXCEEDED");
for failure in ["QUOTA_EXCEEDED", "EXTENSION_CONFIG_MIGRATION_FAILED"] {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
for _ in 0..20 {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
let (_, next) = seeded(&path);
let mut db = open(&path);
let update = uuid::Uuid::new_v4().to_string();
let error = switch_inner(&mut db, &update, &next, |at| {
if at == boundary {
Err(HostError::new(failure))
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error.code, failure);
drop(db);
let mut db = open(&path);
recover(&mut db).unwrap();
assert_complete_generation(&db);
assert_eq!(recover(&mut db).unwrap(), 0);
}
}
}
}
#[test]
fn failed_switch_cannot_expand_an_existing_execution_permit() {
use crate::extension_permit::{Authority, Claims, Environment, ExecutionKind};
use std::collections::BTreeMap;
let authority = Authority::default();
let mut claims = Claims {
kind: ExecutionKind::Mcp,
source: "https://catalog.example/".into(),
namespace: "examples".into(),
package_id: "note-reviewer".into(),
version: "1.0.0".into(),
archive_sha256: "a".repeat(64),
tree_sha256: "b".repeat(64),
signer_sha256: "c".repeat(64),
entry: "entry.exe".into(),
arguments: vec!["--stdio".into()],
environment: BTreeMap::from([(
"MODE".into(),
Environment::Literal("production".into()),
)]),
permissions: BTreeSet::from(["notes.read".into()]),
vault_id: uuid::Uuid::new_v4().to_string(),
platform: "windows".into(),
policy_version: "1".into(),
expires_at_ms: 10_000,
};
let permit = authority.issue(&claims, 1).unwrap();
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
let (_, next) = seeded(&path);
let mut db = open(&path);
let error = switch_inner(&mut db, &uuid::Uuid::new_v4().to_string(), &next, |at| {
if at == "switch_committed" {
Err(HostError::new("EXTENSION_CONFIG_MIGRATION_FAILED"))
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error.code, "EXTENSION_CONFIG_MIGRATION_FAILED");
recover(&mut db).unwrap();
authority.verify(&permit, &claims, 2).unwrap();
claims.permissions.insert("notes.write".into());
assert_eq!(
authority.verify(&permit, &claims, 2).unwrap_err().code,
"PERMISSION_CHANGED"
);
assert_complete_generation(&db);
}
#[test]
fn cas_busy_replay_and_failed_first_install() {
let mut db = Connection::open_in_memory().unwrap();
schema(&db).unwrap();
+13 -1
View File
@@ -33,7 +33,19 @@ impl From<std::io::Error> for HostError {
}
}
impl From<rusqlite::Error> for HostError {
fn from(_: rusqlite::Error) -> Self {
fn from(error: rusqlite::Error) -> Self {
if matches!(
error,
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DiskFull,
..
},
_
)
) {
return Self::new("QUOTA_EXCEEDED");
}
Self::new("DATABASE_ERROR")
}
}
@@ -0,0 +1,149 @@
"""D-03 crash, storage, configuration, dependency, and permission acceptance."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml"
TESTS = (
(
"extension_transaction::tests::group_switch_survives_hard_termination_twenty_times_per_boundary",
False,
),
(
"extension_transaction::tests::disk_full_and_configuration_migration_failures_cover_every_boundary",
False,
),
(
"extension_transaction::tests::failed_switch_cannot_expand_an_existing_execution_permit",
False,
),
(
"extension_dependencies::tests::cycles_missing_versions_and_incompatible_platforms_fail",
False,
),
(
"extension_store::tests::prepared_switch_rechecks_content_vault_binding_and_recovers_on_open",
False,
),
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run_exact(cargo: str, test: str, ignored: bool) -> bool:
arguments = [
cargo,
"test",
"--manifest-path",
str(MANIFEST),
"--locked",
"--features",
"desktop",
"--lib",
test,
"--",
]
if ignored:
arguments.append("--ignored")
arguments.extend(("--exact", "--nocapture", "--test-threads=1"))
completed = subprocess.run(
arguments,
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
print(completed.stdout, end="")
print(completed.stderr, end="")
return completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
assertions = [
{
"name": "three durable switch boundaries survive 20 actual hard terminations each",
"evidence": "60 child test processes were killed only after the requested SQLite boundary",
},
{
"name": "disk-full and configuration-migration failures cover every boundary for 20 rounds",
"evidence": "120 injected failures reopened and recovered to one complete package/config generation",
},
{
"name": "dependency cycle, missing package, and version conflict fail during immutable planning",
"evidence": "three exact dependency error codes are returned before any active pointer exists",
},
{
"name": "failed installation cannot expand a previously issued execution permit",
"evidence": "old permit remains bound to notes.read and rejects added notes.write as PERMISSION_CHANGED",
},
{
"name": "prepared switch rejects invalid configuration and recovers pending state on reopen",
"evidence": "configuration rejection creates zero transactions; reopen rolls pending switch back idempotently",
},
]
cargo = shutil.which("cargo")
passed = case_id == "D-03" and cargo is not None and os.name == "nt"
if passed:
passed = all(run_exact(cargo, test, ignored) for test, ignored in TESTS)
status = "PASSED" if passed else "FAILED"
for assertion in assertions:
assertion["status"] = status
files = []
for relative in (
"frontend/src-tauri/src/extension_transaction.rs",
"frontend/src-tauri/src/extension_dependencies.rs",
"frontend/src-tauri/src/extension_store.rs",
"frontend/src-tauri/src/extension_permit.rs",
"frontend/src-tauri/src/workspace.rs",
"scripts/acceptance_cases/d03_extension_transactions.py",
):
files.append({"path": relative, "sha256": sha256(ROOT / relative)})
payload = {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": "" if passed else "A D-03 exact transaction oracle failed.",
"assertions": assertions,
"metrics": {
"power_cut_rounds": 60 if passed else 0,
"disk_full_rounds": 60 if passed else 0,
"configuration_failure_rounds": 60 if passed else 0,
"dependency_preflight_rejections": 3 if passed else 0,
"permission_expansions": 0,
"peak_rss_bytes": None,
"max_process_count": None,
"denied_access_count": None,
},
"files": files,
"revisions": [
{"scope": "transaction boundaries", "values": ["journal_recorded", "pointer_recorded", "switch_committed"]},
{"scope": "recovered generations", "values": ["complete-old", "complete-new"]},
],
}
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -68,6 +68,18 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
"timeout_seconds": 900,
"required_metrics": (),
},
"D-03": {
"driver": "scripts/acceptance_cases/d03_extension_transactions.py",
"timeout_seconds": 900,
"required_metrics": (
"power_cut_rounds",
"disk_full_rounds",
"configuration_failure_rounds",
"dependency_preflight_rejections",
"permission_expansions",
),
"platform_profiles": ("windows-11-x64",),
},
"S-01": {
"driver": "scripts/acceptance_cases/s01_sync_client.py",
"timeout_seconds": 900,