Skip to main content

max / balanced_breakfast

9.3 KB · 290 lines History Blame Raw
1 //! End-to-end tests for the fetch-encrypt-store pipeline.
2 //!
3 //! These tests exercise the full path from plugin fetch through HTML
4 //! sanitization and database storage, as well as encryption of Secret
5 //! config fields and error classification on fetch failure.
6
7 mod common;
8
9 use bb_core::crypto;
10 use bb_db::{BusserId, CreateFeed};
11
12 // ── Helpers ─────────────────────────────────────────────────────────
13
14 /// Write a test Rhai plugin that returns static items (no network).
15 fn write_test_plugin(dir: &std::path::Path) -> std::path::PathBuf {
16 let code = r#"
17 fn id() { "test_pipeline" }
18 fn name() { "Test Pipeline Plugin" }
19
20 fn config_schema() {
21 #{
22 description: "Pipeline test plugin",
23 fields: [
24 #{
25 key: "api_key",
26 label: "API Key",
27 field_type: "secret",
28 required: true
29 },
30 #{
31 key: "tag",
32 label: "Tag filter",
33 field_type: "text",
34 required: false
35 }
36 ]
37 }
38 }
39
40 fn fetch(config, cursor) {
41 #{
42 items: [
43 #{
44 id: "pipe-1",
45 bite: #{
46 author: "Alice",
47 text: "First item from pipeline"
48 },
49 content: #{
50 title: "Pipeline Item 1",
51 body: "<p>Clean body</p>",
52 url: "https://example.com/1"
53 },
54 meta: #{
55 source_name: "test_pipeline",
56 published_at: 1700000000,
57 score: 10,
58 tags: ["rust", "test"]
59 }
60 },
61 #{
62 id: "pipe-2",
63 bite: #{
64 author: "Bob",
65 text: "Second item with script tag"
66 },
67 content: #{
68 title: "Pipeline Item 2",
69 body: "<p>Safe</p><script>alert('xss')</script><p>Also safe</p>",
70 url: "https://example.com/2?utm_source=test&ref=campaign"
71 },
72 meta: #{
73 source_name: "test_pipeline",
74 published_at: 1700001000,
75 tags: ["security"]
76 }
77 }
78 ],
79 has_more: false
80 }
81 }
82 "#;
83 let path = dir.join("test_pipeline.rhai");
84 std::fs::write(&path, code).unwrap();
85 path
86 }
87
88 /// Write a Rhai plugin that always throws a structured error on fetch.
89 fn write_failing_plugin(dir: &std::path::Path) -> std::path::PathBuf {
90 let code = r#"
91 fn id() { "test_failing" }
92 fn name() { "Failing Plugin" }
93
94 fn config_schema() {
95 #{
96 description: "Always fails on fetch",
97 fields: []
98 }
99 }
100
101 fn fetch(config, cursor) {
102 throw "BB_ERR:auth:HTTP 401: Invalid API key";
103 }
104 "#;
105 let path = dir.join("test_failing.rhai");
106 std::fs::write(&path, code).unwrap();
107 path
108 }
109
110 // ── Tests ───────────────────────────────────────────────────────────
111
112 /// End-to-end: load plugin, create feed with encrypted secret, fetch
113 /// items, verify storage and sanitization, verify secret round-trip.
114 #[tokio::test]
115 async fn test_fetch_encrypt_store_pipeline() {
116 let mut orch = common::setup("pipeline_e2e").await;
117
118 // Write the test plugin into the orchestrator's plugins dir
119 let plugins_dir = {
120 let pm = orch.plugins();
121 let pm = pm.read().await;
122 pm.plugins_dir().to_path_buf()
123 };
124 let plugin_path = write_test_plugin(&plugins_dir);
125
126 // Set an encryption key before borrowing db
127 let key: crypto::EncryptionKey = [42u8; 32].into();
128 orch.set_encryption_key(key.clone());
129
130 let db = orch.database();
131
132 // Create a feed for the test plugin with a plaintext secret
133 let feed = db
134 .feeds()
135 .create(CreateFeed {
136 busser_id: BusserId::new("test_pipeline"),
137 name: "Pipeline Test Feed".to_string(),
138 config: serde_json::json!({
139 "api_key": "sk-secret-12345",
140 "tag": "rust"
141 }),
142 })
143 .await
144 .unwrap();
145
146 // Load the plugin
147 {
148 let pm = orch.plugins();
149 let mut pm = pm.write().await;
150 pm.load_plugin(&plugin_path).unwrap();
151 }
152
153 // Encrypt existing plaintext secrets in the DB
154 orch.encrypt_existing_secrets().await.unwrap();
155
156 // Verify the secret is now encrypted in the DB
157 let updated_feed = db.feeds().get(feed.id).await.unwrap().unwrap();
158 let stored_config = updated_feed.config_json();
159 let stored_api_key = stored_config["api_key"].as_str().unwrap();
160 assert!(
161 stored_api_key.starts_with("bb_enc:v1:"),
162 "Secret should be encrypted in DB, got: {}",
163 stored_api_key
164 );
165
166 // The non-secret field should remain plaintext
167 assert_eq!(stored_config["tag"], "rust");
168
169 // Verify the encrypted value decrypts to the original
170 let decrypted = crypto::decrypt_field(stored_api_key, &key).unwrap();
171 assert_eq!(decrypted, "sk-secret-12345");
172
173 // Initialize the plugin from DB config (triggers decryption)
174 orch.init_plugin_from_db("test_pipeline").await.unwrap();
175
176 // Fetch items through the full pipeline
177 let count = orch.fetch_plugin("test_pipeline").await.unwrap();
178 assert_eq!(count, 2, "Should store 2 items");
179
180 // Verify items are persisted in the database
181 let items = db.items().list_by_feed(feed.id, 10, 0).await.unwrap();
182 assert_eq!(items.len(), 2);
183
184 // Check first item fields round-trip
185 let item1 = items.iter().find(|i| i.external_id == "test_pipeline:pipe-1").unwrap();
186 assert_eq!(item1.bite_author, "Alice");
187 assert_eq!(item1.bite_text, "First item from pipeline");
188 assert_eq!(item1.title, Some("Pipeline Item 1".to_string()));
189 assert_eq!(item1.body, Some("<p>Clean body</p>".to_string()));
190 assert_eq!(item1.url, Some("https://example.com/1".to_string()));
191 assert_eq!(item1.score, Some(10));
192 assert_eq!(item1.tags_vec(), vec!["rust", "test"]);
193 assert!(!item1.is_read);
194 assert!(!item1.is_starred);
195
196 // Check second item: script tag should be sanitized, tracking params stripped
197 let item2 = items.iter().find(|i| i.external_id == "test_pipeline:pipe-2").unwrap();
198 assert_eq!(item2.bite_author, "Bob");
199 let body = item2.body.as_deref().unwrap();
200 assert!(
201 !body.contains("<script>"),
202 "Script tags should be stripped by sanitizer, got: {}",
203 body
204 );
205 assert!(body.contains("Safe"), "Non-script content should be preserved");
206 assert!(body.contains("Also safe"), "Content after script should be preserved");
207
208 // URL tracking params should be stripped
209 let url = item2.url.as_deref().unwrap();
210 assert!(
211 !url.contains("utm_source"),
212 "Tracking params should be stripped from URL, got: {}",
213 url
214 );
215
216 // Feed should have recorded a successful fetch (failure counter = 0)
217 let feed_after = db.feeds().get(feed.id).await.unwrap().unwrap();
218 assert_eq!(feed_after.consecutive_failures, 0);
219 assert!(!feed_after.circuit_broken);
220 }
221
222 /// Fetch failure should record a structured error with correct classification.
223 #[tokio::test]
224 async fn test_fetch_failure_records_error() {
225 let orch = common::setup("pipeline_fail").await;
226 let db = orch.database();
227
228 // Write the failing plugin
229 let plugins_dir = {
230 let pm = orch.plugins();
231 let pm = pm.read().await;
232 pm.plugins_dir().to_path_buf()
233 };
234 let plugin_path = write_failing_plugin(&plugins_dir);
235
236 // Create a feed for the failing plugin
237 let feed = db
238 .feeds()
239 .create(CreateFeed {
240 busser_id: BusserId::new("test_failing"),
241 name: "Failing Feed".to_string(),
242 config: serde_json::json!({}),
243 })
244 .await
245 .unwrap();
246
247 // Load and initialize the plugin
248 {
249 let pm = orch.plugins();
250 let mut pm = pm.write().await;
251 pm.load_plugin(&plugin_path).unwrap();
252 pm.initialize_plugin(
253 "test_failing",
254 bb_interface::BusserConfig::new(),
255 )
256 .unwrap();
257 }
258
259 // Fetch should fail
260 let result = orch.fetch_plugin("test_failing").await;
261 assert!(result.is_err(), "Fetch should return an error");
262
263 // Verify the error was recorded in the database
264 let feed_after = db.feeds().get(feed.id).await.unwrap().unwrap();
265 assert!(
266 feed_after.last_error.is_some(),
267 "Error should be recorded on the feed"
268 );
269
270 // Auth errors immediately trip the circuit breaker
271 assert!(
272 feed_after.circuit_broken,
273 "Auth error should immediately trip the circuit breaker"
274 );
275
276 // Parse the structured error from the stored last_error
277 let stored_error = bb_interface::StructuredError::from_last_error(
278 feed_after.last_error.as_ref().unwrap(),
279 );
280 assert_eq!(
281 stored_error.category,
282 bb_interface::ErrorCategory::Auth,
283 "Error should be classified as Auth"
284 );
285
286 // No items should have been stored
287 let items = db.items().list_by_feed(feed.id, 10, 0).await.unwrap();
288 assert!(items.is_empty(), "No items should be stored on fetch failure");
289 }
290