//! End-to-end tests for the fetch-encrypt-store pipeline. //! //! These tests exercise the full path from plugin fetch through HTML //! sanitization and database storage, as well as encryption of Secret //! config fields and error classification on fetch failure. mod common; use bb_core::crypto; use bb_db::{BusserId, CreateFeed}; // ── Helpers ───────────────────────────────────────────────────────── /// Write a test Rhai plugin that returns static items (no network). fn write_test_plugin(dir: &std::path::Path) -> std::path::PathBuf { let code = r#" fn id() { "test_pipeline" } fn name() { "Test Pipeline Plugin" } fn config_schema() { #{ description: "Pipeline test plugin", fields: [ #{ key: "api_key", label: "API Key", field_type: "secret", required: true }, #{ key: "tag", label: "Tag filter", field_type: "text", required: false } ] } } fn fetch(config, cursor) { #{ items: [ #{ id: "pipe-1", bite: #{ author: "Alice", text: "First item from pipeline" }, content: #{ title: "Pipeline Item 1", body: "

Clean body

", url: "https://example.com/1" }, meta: #{ source_name: "test_pipeline", published_at: 1700000000, score: 10, tags: ["rust", "test"] } }, #{ id: "pipe-2", bite: #{ author: "Bob", text: "Second item with script tag" }, content: #{ title: "Pipeline Item 2", body: "

Safe

Also safe

", url: "https://example.com/2?utm_source=test&ref=campaign" }, meta: #{ source_name: "test_pipeline", published_at: 1700001000, tags: ["security"] } } ], has_more: false } } "#; let path = dir.join("test_pipeline.rhai"); std::fs::write(&path, code).unwrap(); path } /// Write a Rhai plugin that always throws a structured error on fetch. fn write_failing_plugin(dir: &std::path::Path) -> std::path::PathBuf { let code = r#" fn id() { "test_failing" } fn name() { "Failing Plugin" } fn config_schema() { #{ description: "Always fails on fetch", fields: [] } } fn fetch(config, cursor) { throw "BB_ERR:auth:HTTP 401: Invalid API key"; } "#; let path = dir.join("test_failing.rhai"); std::fs::write(&path, code).unwrap(); path } // ── Tests ─────────────────────────────────────────────────────────── /// End-to-end: load plugin, create feed with encrypted secret, fetch /// items, verify storage and sanitization, verify secret round-trip. #[tokio::test] async fn test_fetch_encrypt_store_pipeline() { let mut orch = common::setup("pipeline_e2e").await; // Write the test plugin into the orchestrator's plugins dir let plugins_dir = { let pm = orch.plugins(); let pm = pm.read().await; pm.plugins_dir().to_path_buf() }; let plugin_path = write_test_plugin(&plugins_dir); // Set an encryption key before borrowing db let key: crypto::EncryptionKey = [42u8; 32].into(); orch.set_encryption_key(key.clone()); let db = orch.database(); // Create a feed for the test plugin with a plaintext secret let feed = db .feeds() .create(CreateFeed { busser_id: BusserId::new("test_pipeline"), name: "Pipeline Test Feed".to_string(), config: serde_json::json!({ "api_key": "sk-secret-12345", "tag": "rust" }), }) .await .unwrap(); // Load the plugin { let pm = orch.plugins(); let mut pm = pm.write().await; pm.load_plugin(&plugin_path).unwrap(); } // Encrypt existing plaintext secrets in the DB orch.encrypt_existing_secrets().await.unwrap(); // Verify the secret is now encrypted in the DB let updated_feed = db.feeds().get(feed.id).await.unwrap().unwrap(); let stored_config = updated_feed.config_json(); let stored_api_key = stored_config["api_key"].as_str().unwrap(); assert!( stored_api_key.starts_with("bb_enc:v1:"), "Secret should be encrypted in DB, got: {}", stored_api_key ); // The non-secret field should remain plaintext assert_eq!(stored_config["tag"], "rust"); // Verify the encrypted value decrypts to the original let decrypted = crypto::decrypt_field(stored_api_key, &key).unwrap(); assert_eq!(decrypted, "sk-secret-12345"); // Initialize the plugin from DB config (triggers decryption) orch.init_plugin_from_db("test_pipeline").await.unwrap(); // Fetch items through the full pipeline let count = orch.fetch_plugin("test_pipeline").await.unwrap(); assert_eq!(count, 2, "Should store 2 items"); // Verify items are persisted in the database let items = db.items().list_by_feed(feed.id, 10, 0).await.unwrap(); assert_eq!(items.len(), 2); // Check first item fields round-trip let item1 = items.iter().find(|i| i.external_id == "test_pipeline:pipe-1").unwrap(); assert_eq!(item1.bite_author, "Alice"); assert_eq!(item1.bite_text, "First item from pipeline"); assert_eq!(item1.title, Some("Pipeline Item 1".to_string())); assert_eq!(item1.body, Some("

Clean body

".to_string())); assert_eq!(item1.url, Some("https://example.com/1".to_string())); assert_eq!(item1.score, Some(10)); assert_eq!(item1.tags_vec(), vec!["rust", "test"]); assert!(!item1.is_read); assert!(!item1.is_starred); // Check second item: script tag should be sanitized, tracking params stripped let item2 = items.iter().find(|i| i.external_id == "test_pipeline:pipe-2").unwrap(); assert_eq!(item2.bite_author, "Bob"); let body = item2.body.as_deref().unwrap(); assert!( !body.contains("