From a74e228140521c271c45735f53c3e6f10e789fb6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 19:18:46 +0800 Subject: [PATCH] =?UTF-8?q?test(credentials):=20=E5=AE=8C=E6=88=90=20B-03?= =?UTF-8?q?=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src-tauri/src/credentials.rs | 36 ++++++ frontend/src-tauri/src/main.rs | 3 +- frontend/src-tauri/src/session_lock.rs | 40 +++++++ scripts/acceptance_cases/b03_credentials.py | 123 ++++++++++++++++++++ scripts/phase3_acceptance.py | 12 ++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 scripts/acceptance_cases/b03_credentials.py diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index 3c262ea..975f3b0 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -1118,4 +1118,40 @@ mod tests { assert!(broker.unlock(password()).is_err()); assert_eq!(fs::read(path).unwrap(), b"corrupt"); } + #[test] + fn b03_unrecoverable_store_does_not_block_local_editing() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("credentials.v1"); + fs::write(&path, b"unrecoverable-credential-store").unwrap(); + let mut broker = CredentialBroker::new(path.clone()); + assert_eq!( + broker.unlock(password()).unwrap_err(), + "SCHEMA_INCOMPATIBLE" + ); + assert!(broker.is_locked()); + assert_eq!(fs::read(&path).unwrap(), b"unrecoverable-credential-store"); + + let vault = tempfile::tempdir().unwrap(); + let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap(); + let saved = workspace + .write( + "still-editable.md", + "", + b"local notes remain available", + "local", + ) + .unwrap(); + assert_eq!( + workspace.read("still-editable.md").unwrap().content, + "local notes remain available" + ); + assert_eq!( + fs::read(vault.path().join("still-editable.md")).unwrap(), + b"local notes remain available" + ); + assert_eq!( + saved.hash, + crate::workspace::hash(b"local notes remain available") + ); + } } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 88b23fa..ccd1f6a 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -1081,7 +1081,7 @@ mod lifecycle_tests { let worker = scope.spawn(|| host.lock_credentials()); let start = std::time::Instant::now(); while credentials.load(Ordering::SeqCst) == 0 - && start.elapsed() < Duration::from_secs(2) + && start.elapsed() < Duration::from_secs(1) { std::thread::sleep(Duration::from_millis(1)); } @@ -1092,6 +1092,7 @@ mod lifecycle_tests { worker.join().unwrap().unwrap(); assert!(observed > 0); assert!(revoked > 0); + assert!(start.elapsed() < Duration::from_secs(1)); }); } } diff --git a/frontend/src-tauri/src/session_lock.rs b/frontend/src-tauri/src/session_lock.rs index 1d9a960..1223306 100644 --- a/frontend/src-tauri/src/session_lock.rs +++ b/frontend/src-tauri/src/session_lock.rs @@ -141,6 +141,13 @@ impl Drop for SessionMonitor { #[cfg(test)] mod tests { use super::*; + use crate::credentials::{CredentialBroker, CredentialId, Scope}; + use std::time::{Duration, Instant}; + use zeroize::Zeroizing; + + fn password() -> Zeroizing> { + Zeroizing::new(b"b03-test-password-123".to_vec()) + } #[test] fn native_message_revokes_without_unlocking_on_session_return() { let signal = Arc::new(AtomicU64::new(0)); @@ -166,6 +173,39 @@ mod tests { assert_eq!(signal.load(Ordering::SeqCst), 1); } #[test] + fn b03_native_and_manual_lock_reject_new_resolves_within_one_second() { + let temp = tempfile::tempdir().unwrap(); + let mut broker = CredentialBroker::new(temp.path().join("credentials.v1")); + broker.unlock(password()).unwrap(); + let id = CredentialId::legacy("b03-provider"); + broker + .put(&id, Zeroizing::new(b"b03-lock-fixture".to_vec())) + .unwrap(); + let signal = broker.lock_signal(); + let monitor = SessionMonitor::start(signal).unwrap(); + + let started = Instant::now(); + unsafe { + SendMessageW( + monitor.window as HWND, + WM_WTSSESSION_CHANGE, + WTS_SESSION_LOCK as usize, + 0, + ); + } + let error = broker.resolve(&Scope::Provider, &id).unwrap_err(); + assert_eq!(error, "CREDENTIALS_LOCKED"); + assert!(started.elapsed() < Duration::from_secs(1)); + + broker.unlock(password()).unwrap(); + let started = Instant::now(); + broker.lock(); + let error = broker.resolve(&Scope::Provider, &id).unwrap_err(); + assert_eq!(error, "CREDENTIALS_LOCKED"); + assert!(started.elapsed() < Duration::from_secs(1)); + drop(monitor); + } + #[test] fn native_lock_logoff_and_disconnect_revoke_every_bound_domain() { let credential = Arc::new(AtomicU64::new(0)); let extension = Arc::new(AtomicU64::new(0)); diff --git a/scripts/acceptance_cases/b03_credentials.py b/scripts/acceptance_cases/b03_credentials.py new file mode 100644 index 0000000..cf2448c --- /dev/null +++ b/scripts/acceptance_cases/b03_credentials.py @@ -0,0 +1,123 @@ +"""B-03 credential locking, password rotation, recovery, and edit availability.""" + +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 = ( + ("lib", "session_lock::tests::b03_native_and_manual_lock_reject_new_resolves_within_one_second"), + ("bin", "lifecycle_tests::manual_lock_revokes_before_waiting_for_credential_mutex"), + ("lib", "credentials::tests::stronghold_roundtrip_scope_lock_and_password_rotation"), + ("lib", "credentials::tests::encrypted_backup_restores_corrupt_store_without_overwrite_on_failure"), + ("lib", "credentials::tests::b03_unrecoverable_store_does_not_block_local_editing"), +) + + +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, target: str, test: str) -> bool: + command = [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + ] + command.extend(("--lib",) if target == "lib" else ("--bin", "notesagent-desktop")) + command.extend((test, "--", "--exact", "--nocapture", "--test-threads=1")) + completed = subprocess.run(command, 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": "Windows session-lock and manual-lock events reject a new resolve within one second", + "evidence": "native hidden-window WTS_SESSION_LOCK and direct broker lock use the production epoch signal", + }, + { + "name": "Host manual lock revokes credentials before waiting for a contended broker mutex", + "evidence": "desktop Host exact oracle observes both credential and extension generations within one second", + }, + { + "name": "wrong password fails and password rotation preserves the encrypted record", + "evidence": "old password fails after rotation; new password reopens the same one-record Stronghold", + }, + { + "name": "a corrupt store restores from an encrypted backup without destructive failed attempts", + "evidence": "wrong backup password preserves corrupt bytes; valid restore recovers the exact record and remains locked", + }, + { + "name": "an unrecoverable credential store leaves local note editing available", + "evidence": "corrupt credential bytes remain untouched while a separate Workspace writes and reads a note", + }, + ] + cargo = shutil.which("cargo") + passed = case_id == "B-03" and cargo is not None and os.name == "nt" + if passed: + passed = all(run_exact(cargo, target, test) for target, test in TESTS) + status = "PASSED" if passed else "FAILED" + for assertion in assertions: + assertion["status"] = status + files = [] + for relative in ( + "frontend/src-tauri/src/credentials.rs", + "frontend/src-tauri/src/session_lock.rs", + "frontend/src-tauri/src/main.rs", + "frontend/src-tauri/src/workspace.rs", + "scripts/acceptance_cases/b03_credentials.py", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + result = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "A B-03 exact credential lifecycle oracle failed.", + "assertions": assertions, + "metrics": { + "lock_deadline_ms": 1000, + "native_lock_events": 1 if passed else 0, + "manual_lock_events": 1 if passed else 0, + "restored_records": 1 if passed else 0, + "local_edit_successes": 1 if passed else 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [ + {"scope": "credential states", "values": ["unlocked", "locked", "restored-locked"]}, + {"scope": "password generations", "values": ["old-rejected", "new-accepted"]}, + ], + } + output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 531160e..978797a 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -53,6 +53,18 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { "timeout_seconds": 900, "required_metrics": (), }, + "B-03": { + "driver": "scripts/acceptance_cases/b03_credentials.py", + "timeout_seconds": 900, + "required_metrics": ( + "lock_deadline_ms", + "native_lock_events", + "manual_lock_events", + "restored_records", + "local_edit_successes", + ), + "platform_profiles": ("windows-11-x64",), + }, "C-03": { "driver": "scripts/acceptance_cases/c03_permission_binding.py", "timeout_seconds": 900,