mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
445 lines
15 KiB
Rust
445 lines
15 KiB
Rust
use assert_cmd::cargo::cargo_bin_cmd;
|
|
use coding_agent_search::model::types::{Agent, AgentKind};
|
|
use coding_agent_search::pages::bundle::{BundleBuilder, BundleResult};
|
|
use coding_agent_search::pages::encrypt::{DecryptionEngine, EncryptionEngine, load_config};
|
|
use coding_agent_search::pages::export::{ExportEngine, ExportFilter, PathMode};
|
|
use coding_agent_search::pages::key_management::{key_add_password, key_list, key_revoke};
|
|
use coding_agent_search::pages::verify::verify_bundle;
|
|
use coding_agent_search::storage::sqlite::SqliteStorage;
|
|
use serde_json::Value;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
#[path = "util/mod.rs"]
|
|
mod util;
|
|
|
|
use util::ConversationFixtureBuilder;
|
|
|
|
const TEST_PASSWORD: &str = "test-password";
|
|
const TEST_PASSWORD_2: &str = "second-password";
|
|
const TEST_RECOVERY_SECRET: &[u8] = b"recovery-secret-bytes-32-padding!";
|
|
|
|
struct PipelineArtifacts {
|
|
export_db_path: std::path::PathBuf,
|
|
bundle: BundleResult,
|
|
}
|
|
|
|
fn build_pipeline(temp_dir: &TempDir) -> PipelineArtifacts {
|
|
let data_dir = temp_dir.path().join("data");
|
|
fs::create_dir_all(&data_dir).unwrap();
|
|
|
|
// 1. Setup: Create fixtures and populate DB
|
|
setup_db(&data_dir);
|
|
|
|
// 2. Export (simulating `cass pages --export-only`)
|
|
let export_staging = temp_dir.path().join("export_staging");
|
|
fs::create_dir_all(&export_staging).unwrap();
|
|
let export_db_path = export_staging.join("export.db");
|
|
let source_db_path = data_dir.join("agent_search.db");
|
|
|
|
let filter = ExportFilter {
|
|
agents: None,
|
|
workspaces: None,
|
|
since: None,
|
|
until: None,
|
|
path_mode: PathMode::Relative,
|
|
};
|
|
|
|
let export_engine = ExportEngine::new(&source_db_path, &export_db_path, filter);
|
|
let stats = export_engine
|
|
.execute(|_, _| {}, None)
|
|
.expect("ExportEngine execution failed");
|
|
assert_eq!(
|
|
stats.conversations_processed, 1,
|
|
"Should export 1 conversation"
|
|
);
|
|
assert!(export_db_path.exists(), "Export database should exist");
|
|
|
|
// 3. Encrypt (simulating Wizard/Encrypt Step)
|
|
let encrypt_dir = temp_dir.path().join("encrypt_staging");
|
|
let mut enc_engine = EncryptionEngine::new(1024 * 1024).expect("valid chunk size"); // 1MB chunks
|
|
enc_engine
|
|
.add_password_slot(TEST_PASSWORD)
|
|
.expect("Failed to add password slot");
|
|
enc_engine
|
|
.add_recovery_slot(TEST_RECOVERY_SECRET)
|
|
.expect("Failed to add recovery slot");
|
|
|
|
let _enc_config = enc_engine
|
|
.encrypt_file(&export_db_path, &encrypt_dir, |_, _| {})
|
|
.expect("Encryption failed");
|
|
|
|
assert!(encrypt_dir.join("config.json").exists());
|
|
assert!(encrypt_dir.join("payload").exists());
|
|
|
|
// 4. Bundle (simulating Bundle Step)
|
|
let bundle_dir = temp_dir.path().join("bundle");
|
|
let builder = BundleBuilder::new()
|
|
.title("E2E Test Archive")
|
|
.description("Test archive for E2E pipeline")
|
|
.generate_qr(false) // Skip QR generation to avoid dependency issues
|
|
.recovery_secret(Some(TEST_RECOVERY_SECRET.to_vec()));
|
|
|
|
let bundle = builder
|
|
.build(&encrypt_dir, &bundle_dir, |_, _| {})
|
|
.expect("Bundle failed");
|
|
|
|
assert!(bundle.site_dir.join("index.html").exists());
|
|
assert!(bundle.private_dir.join("recovery-secret.txt").exists());
|
|
|
|
PipelineArtifacts {
|
|
export_db_path,
|
|
bundle,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_export_pipeline_e2e() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let artifacts = build_pipeline(&temp_dir);
|
|
let bundle_root = artifacts.bundle.site_dir.parent().expect("bundle root");
|
|
|
|
// Verify (CLI)
|
|
// Run `cass pages --verify <bundle_root>` to validate the bundle integrity and structure
|
|
let mut cmd = cargo_bin_cmd!("cass");
|
|
let assert = cmd
|
|
.arg("pages")
|
|
.arg("--verify")
|
|
.arg(bundle_root)
|
|
.arg("--json")
|
|
.assert();
|
|
|
|
assert.success();
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_pipeline_decrypt_roundtrip() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let artifacts = build_pipeline(&temp_dir);
|
|
let bundle_root = artifacts.bundle.site_dir.parent().expect("bundle root");
|
|
|
|
// Unlock with password and decrypt
|
|
let config = load_config(bundle_root).expect("load config");
|
|
let decryptor =
|
|
DecryptionEngine::unlock_with_password(config, TEST_PASSWORD).expect("unlock password");
|
|
let decrypted_path = temp_dir.path().join("decrypted.db");
|
|
decryptor
|
|
.decrypt_to_file(bundle_root, &decrypted_path, |_, _| {})
|
|
.expect("decrypt with password");
|
|
|
|
assert_eq!(
|
|
fs::read(&artifacts.export_db_path).unwrap(),
|
|
fs::read(&decrypted_path).unwrap()
|
|
);
|
|
|
|
// Unlock with recovery secret and decrypt
|
|
let config = load_config(bundle_root).expect("load config");
|
|
let decryptor = DecryptionEngine::unlock_with_recovery(config, TEST_RECOVERY_SECRET)
|
|
.expect("unlock recovery");
|
|
let decrypted_recovery_path = temp_dir.path().join("decrypted_recovery.db");
|
|
decryptor
|
|
.decrypt_to_file(bundle_root, &decrypted_recovery_path, |_, _| {})
|
|
.expect("decrypt with recovery");
|
|
|
|
assert_eq!(
|
|
fs::read(&artifacts.export_db_path).unwrap(),
|
|
fs::read(&decrypted_recovery_path).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_bundle_excludes_raw_mirror_artifacts_by_default() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let input_path = temp_dir.path().join("export.db");
|
|
fs::write(&input_path, b"export database bytes").expect("write export db fixture");
|
|
|
|
let encrypt_dir = temp_dir.path().join("encrypt_staging");
|
|
let mut enc_engine = EncryptionEngine::new(1024 * 1024).expect("valid chunk size");
|
|
enc_engine
|
|
.add_password_slot(TEST_PASSWORD)
|
|
.expect("add password slot");
|
|
enc_engine
|
|
.encrypt_file(&input_path, &encrypt_dir, |_, _| {})
|
|
.expect("encrypt fixture");
|
|
|
|
let raw_secret = b"PAGES_RAW_MIRROR_SECRET_SHOULD_NOT_LEAK";
|
|
let raw_mirror_root = encrypt_dir.join("raw-mirror").join("v1");
|
|
let raw_mirror_blob = raw_mirror_root
|
|
.join("blobs")
|
|
.join("blake3")
|
|
.join("aa")
|
|
.join("secret.raw");
|
|
fs::create_dir_all(raw_mirror_blob.parent().expect("raw mirror blob parent"))
|
|
.expect("create raw mirror blob dir");
|
|
fs::write(&raw_mirror_blob, raw_secret).expect("write raw mirror blob fixture");
|
|
|
|
let raw_mirror_manifest = raw_mirror_root.join("manifests").join("secret.json");
|
|
fs::create_dir_all(
|
|
raw_mirror_manifest
|
|
.parent()
|
|
.expect("raw mirror manifest parent"),
|
|
)
|
|
.expect("create raw mirror manifest dir");
|
|
fs::write(
|
|
&raw_mirror_manifest,
|
|
br#"{"original_path":"/secret/project/session.jsonl","content":"PAGES_RAW_MIRROR_SECRET_SHOULD_NOT_LEAK"}"#,
|
|
)
|
|
.expect("write raw mirror manifest fixture");
|
|
|
|
let bundle_dir = temp_dir.path().join("bundle");
|
|
let bundle = BundleBuilder::new()
|
|
.title("Raw Mirror Privacy")
|
|
.description("Raw mirror privacy regression")
|
|
.generate_qr(false)
|
|
.build(&encrypt_dir, &bundle_dir, |_, _| {})
|
|
.expect("build pages bundle");
|
|
|
|
assert!(
|
|
!bundle.site_dir.join("raw-mirror").exists(),
|
|
"public Pages site must not copy raw mirror directories"
|
|
);
|
|
assert!(
|
|
!bundle.private_dir.join("raw-mirror").exists(),
|
|
"private Pages helper artifacts must not copy raw mirror directories by default"
|
|
);
|
|
|
|
for entry in walkdir::WalkDir::new(&bundle_dir)
|
|
.follow_links(false)
|
|
.into_iter()
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| entry.file_type().is_file())
|
|
{
|
|
let bytes = fs::read(entry.path()).expect("read generated bundle file");
|
|
assert!(
|
|
!bytes
|
|
.windows(raw_secret.len())
|
|
.any(|window| window == raw_secret),
|
|
"raw mirror bytes leaked into {}",
|
|
entry.path().display()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_config_validate_cli() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("pages-config.json");
|
|
let config = r#"{
|
|
"filters": {
|
|
"agents": ["claude-code"],
|
|
"path_mode": "relative"
|
|
},
|
|
"encryption": {
|
|
"password": "test-password-123",
|
|
"no_encryption": false,
|
|
"i_understand_risks": false,
|
|
"generate_recovery": true,
|
|
"generate_qr": false,
|
|
"compression": "deflate",
|
|
"chunk_size": 8388608
|
|
},
|
|
"bundle": {
|
|
"title": "Test Archive",
|
|
"description": "CLI config validation test",
|
|
"hide_metadata": false
|
|
},
|
|
"deployment": {
|
|
"target": "local",
|
|
"output_dir": "./cass-export",
|
|
"repo": null,
|
|
"branch": null
|
|
}
|
|
}"#;
|
|
|
|
fs::write(&config_path, config).unwrap();
|
|
|
|
let mut cmd = cargo_bin_cmd!("cass");
|
|
let assert = cmd
|
|
.arg("pages")
|
|
.arg("--config")
|
|
.arg(&config_path)
|
|
.arg("--validate-config")
|
|
.arg("--json")
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
|
|
let payload: Value = serde_json::from_str(&stdout).unwrap();
|
|
assert_eq!(payload["valid"].as_bool(), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_bundle_key_add_revoke_cycle() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let artifacts = build_pipeline(&temp_dir);
|
|
let bundle_root = artifacts.bundle.site_dir.parent().expect("bundle root");
|
|
|
|
// Add second password slot
|
|
let slot_id = key_add_password(bundle_root, TEST_PASSWORD, TEST_PASSWORD_2).unwrap();
|
|
assert_eq!(
|
|
slot_id, 2,
|
|
"Expected slot id 2 after password+recovery slots"
|
|
);
|
|
|
|
let list = key_list(bundle_root).unwrap();
|
|
assert_eq!(list.active_slots, 3);
|
|
|
|
// Revoke original password slot using second password
|
|
let revoke = key_revoke(bundle_root, TEST_PASSWORD_2, 0).unwrap();
|
|
assert_eq!(revoke.revoked_slot_id, 0);
|
|
assert_eq!(revoke.remaining_slots, 2);
|
|
|
|
// Original password should fail
|
|
let config = load_config(bundle_root).unwrap();
|
|
assert!(DecryptionEngine::unlock_with_password(config, TEST_PASSWORD).is_err());
|
|
|
|
// Second password should still work
|
|
let config = load_config(bundle_root).unwrap();
|
|
assert!(DecryptionEngine::unlock_with_password(config, TEST_PASSWORD_2).is_ok());
|
|
|
|
let verified = verify_bundle(bundle_root, false).expect("verify after key mutation");
|
|
assert_eq!(verified.status, "valid");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pages_bundle_verify_detects_corruption() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let artifacts = build_pipeline(&temp_dir);
|
|
let site_dir = &artifacts.bundle.site_dir;
|
|
|
|
// Baseline: verify passes
|
|
let baseline = verify_bundle(site_dir, false).expect("verify baseline");
|
|
assert_eq!(baseline.status, "valid");
|
|
|
|
// Corrupt a payload chunk
|
|
let payload_dir = site_dir.join("payload");
|
|
let chunk = fs::read_dir(&payload_dir)
|
|
.unwrap()
|
|
.filter_map(|entry| entry.ok())
|
|
.map(|entry| entry.path())
|
|
.find(|path| path.extension().map(|e| e == "bin").unwrap_or(false))
|
|
.expect("payload chunk");
|
|
fs::write(&chunk, b"corrupted payload").unwrap();
|
|
|
|
let result = verify_bundle(site_dir, false).expect("verify after corruption");
|
|
assert_eq!(result.status, "invalid");
|
|
}
|
|
|
|
#[test]
|
|
fn test_secret_scan_gating() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
// Setup XDG_DATA_HOME structure
|
|
let xdg_data_home = temp_dir.path().join("xdg_data");
|
|
let cass_data_dir = xdg_data_home.join("coding-agent-search");
|
|
fs::create_dir_all(&cass_data_dir).unwrap();
|
|
|
|
setup_db_with_secret(&cass_data_dir);
|
|
|
|
// 1. Scan secrets (report only)
|
|
let mut cmd = cargo_bin_cmd!("cass");
|
|
let output = cmd
|
|
.env("XDG_DATA_HOME", &xdg_data_home)
|
|
.arg("pages")
|
|
.arg("--scan-secrets")
|
|
.arg("--json")
|
|
.output()
|
|
.unwrap();
|
|
|
|
if !output.status.success() {
|
|
eprintln!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
|
|
}
|
|
assert!(
|
|
output.status.success(),
|
|
"Scan should succeed in report mode"
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid json output");
|
|
|
|
let findings = json.get("findings").expect("findings field in output");
|
|
let findings_array = findings.as_array().expect("findings should be array");
|
|
assert!(!findings_array.is_empty(), "Should detect inserted secret");
|
|
|
|
// Check that we found the specific type of secret (openai_key pattern)
|
|
let found_api_key = findings_array.iter().any(|f| {
|
|
f.get("kind")
|
|
.and_then(|k| k.as_str())
|
|
.map(|s| s == "openai_key")
|
|
.unwrap_or(false)
|
|
});
|
|
assert!(
|
|
found_api_key,
|
|
"Should detect the test API key (openai_key pattern)"
|
|
);
|
|
|
|
// 2. Fail on secrets
|
|
let mut cmd_fail = cargo_bin_cmd!("cass");
|
|
cmd_fail
|
|
.env("XDG_DATA_HOME", &xdg_data_home)
|
|
.arg("pages")
|
|
.arg("--scan-secrets")
|
|
.arg("--fail-on-secrets")
|
|
.assert()
|
|
.failure(); // Should exit with non-zero code
|
|
}
|
|
|
|
fn setup_db(data_dir: &Path) {
|
|
setup_db_internal(data_dir, false);
|
|
}
|
|
|
|
fn setup_db_with_secret(data_dir: &Path) {
|
|
setup_db_internal(data_dir, true);
|
|
}
|
|
|
|
fn setup_db_internal(data_dir: &Path, include_secret: bool) {
|
|
let db_path = data_dir.join("agent_search.db");
|
|
if let Some(p) = db_path.parent() {
|
|
fs::create_dir_all(p).unwrap();
|
|
}
|
|
|
|
// Initialize DB with schema
|
|
let storage = SqliteStorage::open(&db_path).expect("Failed to open storage");
|
|
|
|
// Create Agent
|
|
let agent = Agent {
|
|
id: None,
|
|
slug: "claude_code".to_string(),
|
|
name: "Claude Code".to_string(),
|
|
version: None,
|
|
kind: AgentKind::Cli,
|
|
};
|
|
let agent_id = storage.ensure_agent(&agent).expect("ensure agent");
|
|
|
|
// Create Workspace
|
|
let workspace_path = Path::new("/home/user/projects/test");
|
|
let workspace_id = Some(
|
|
storage
|
|
.ensure_workspace(workspace_path, None)
|
|
.expect("ensure workspace"),
|
|
);
|
|
|
|
let content = if include_secret {
|
|
// Use a valid OpenAI key format (sk- followed by 20+ alphanumeric chars, no extra hyphens)
|
|
"I accidentally pasted my key: sk-TESTabcdefghijklmnopqrstuvwxyz012345"
|
|
} else {
|
|
"Agent response 1"
|
|
};
|
|
|
|
// Create a fixture conversation
|
|
let conversation = ConversationFixtureBuilder::new("claude_code")
|
|
.title("Test Conversation")
|
|
.workspace(workspace_path)
|
|
.source_path("/home/user/.claude/projects/test/session.jsonl")
|
|
.messages(5)
|
|
.with_content(0, "User message 1")
|
|
.with_content(1, content)
|
|
.build_conversation();
|
|
|
|
// Insert into DB
|
|
storage
|
|
.insert_conversation_tree(agent_id, workspace_id, &conversation)
|
|
.expect("Failed to insert conversation");
|
|
}
|