1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7use std::time::Duration as StdDuration;
8
9use serde_json::{json, Value as JsonValue};
10use time::OffsetDateTime;
11use tokio::sync::mpsc;
12use uuid::Uuid;
13
14pub use crate::http::framing::{
15 http_content_length_from_header_lines, HttpContentLengthLimitError, TEST_HTTP_MAX_BODY_BYTES,
16};
17pub use crate::http::{HttpMockCallSnapshot, HttpMockResponse};
18pub use crate::triggers::test_util::clock::{
19 active_mock_clock, install_override as install_clock_override, instant_now, now_ms, now_utc,
20 ClockInstant, ClockOverrideGuard, MockClock,
21};
22
23use crate::connectors::{
24 ConnectorCtx, MetricsRegistry, RateLimiterFactory, RawInbound, TriggerBinding,
25};
26use crate::event_log::{AnyEventLog, MemoryEventLog};
27pub use crate::secrets::MemorySecretProvider;
28use crate::secrets::SecretId;
29use crate::triggers::{InboxIndex, ProviderId, TenantId};
30
31impl MemorySecretProvider {
32 pub fn with_scoped_secret(
33 self,
34 namespace: impl Into<String>,
35 tenant_id: impl AsRef<str>,
36 binding_id: impl AsRef<str>,
37 name: impl AsRef<str>,
38 value: impl AsRef<[u8]>,
39 ) -> Self {
40 let id = scoped_secret_id(namespace, tenant_id, binding_id, name);
41 self.with_secret(id, value)
42 }
43
44 pub fn insert_scoped(
45 &mut self,
46 namespace: impl Into<String>,
47 tenant_id: impl AsRef<str>,
48 binding_id: impl AsRef<str>,
49 name: impl AsRef<str>,
50 value: impl AsRef<[u8]>,
51 ) -> SecretId {
52 let id = scoped_secret_id(namespace, tenant_id, binding_id, name);
53 self.insert(id.clone(), value);
54 id
55 }
56}
57
58pub fn scoped_secret_id(
59 namespace: impl Into<String>,
60 tenant_id: impl AsRef<str>,
61 binding_id: impl AsRef<str>,
62 name: impl AsRef<str>,
63) -> SecretId {
64 SecretId::new(
65 namespace,
66 format!(
67 "tenants/{}/bindings/{}/{}",
68 tenant_id.as_ref(),
69 binding_id.as_ref(),
70 name.as_ref()
71 ),
72 )
73}
74
75#[derive(Clone)]
76pub struct ConnectorTestkit {
77 pub clock: Arc<MockClock>,
78 pub event_log: Arc<AnyEventLog>,
79 pub inbox: Arc<InboxIndex>,
80 pub metrics: Arc<MetricsRegistry>,
81 pub rate_limiter: Arc<RateLimiterFactory>,
82 pub secrets: Arc<MemorySecretProvider>,
83}
84
85impl ConnectorTestkit {
86 pub async fn new(start: OffsetDateTime) -> Self {
87 Self::with_secrets(start, MemorySecretProvider::empty()).await
88 }
89
90 pub async fn with_secrets(start: OffsetDateTime, secrets: MemorySecretProvider) -> Self {
91 let clock = MockClock::new(start);
92 let event_log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(64)));
93 let metrics = Arc::new(MetricsRegistry::default());
94 let inbox = Arc::new(
95 InboxIndex::new(event_log.clone(), metrics.clone())
96 .await
97 .expect("connector testkit inbox should initialize"),
98 );
99 Self {
100 clock,
101 event_log,
102 inbox,
103 metrics,
104 rate_limiter: Arc::new(RateLimiterFactory::default()),
105 secrets: Arc::new(secrets),
106 }
107 }
108
109 pub fn ctx(&self) -> ConnectorCtx {
110 ConnectorCtx {
111 event_log: self.event_log.clone(),
112 secrets: self.secrets.clone(),
113 inbox: self.inbox.clone(),
114 metrics: self.metrics.clone(),
115 rate_limiter: self.rate_limiter.clone(),
116 }
117 }
118
119 pub fn install_clock(&self) -> ClockOverrideGuard {
120 install_clock_override(self.clock.clone())
121 }
122}
123
124#[derive(Debug)]
125pub struct TempPackageWorkspace {
126 root: PathBuf,
127}
128
129impl TempPackageWorkspace {
130 pub fn new(prefix: impl AsRef<str>) -> io::Result<Self> {
131 let root = std::env::temp_dir().join(format!(
132 "{}-{}",
133 prefix.as_ref().trim_matches('-'),
134 Uuid::new_v4()
135 ));
136 fs::create_dir_all(&root)?;
137 Ok(Self { root })
138 }
139
140 pub fn path(&self) -> &Path {
141 &self.root
142 }
143
144 pub fn write_file(
145 &self,
146 relative: impl AsRef<Path>,
147 contents: impl AsRef<[u8]>,
148 ) -> io::Result<PathBuf> {
149 let path = self.root.join(relative.as_ref());
150 if let Some(parent) = path.parent() {
151 fs::create_dir_all(parent)?;
152 }
153 fs::write(&path, contents)?;
154 Ok(path)
155 }
156
157 pub fn write_harn_package(&self, name: &str) -> io::Result<PathBuf> {
158 self.write_file(
159 "Harn.toml",
160 format!("[package]\nname = \"{name}\"\nversion = \"0.0.0-test\"\n"),
161 )
162 }
163
164 pub fn write_cargo_package(&self, name: &str) -> io::Result<PathBuf> {
165 self.write_file(
166 "Cargo.toml",
167 format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
168 )
169 }
170
171 pub fn write_npm_package(&self, name: &str) -> io::Result<PathBuf> {
172 self.write_file(
173 "package.json",
174 format!("{{\"name\":\"{name}\",\"version\":\"0.0.0-test\"}}\n"),
175 )
176 }
177}
178
179impl Drop for TempPackageWorkspace {
180 fn drop(&mut self) {
181 let _ = fs::remove_dir_all(&self.root);
182 }
183}
184
185pub struct HttpMockGuard;
186
187impl HttpMockGuard {
188 pub fn new() -> Self {
189 crate::http::reset_http_state();
190 Self
191 }
192
193 pub fn push(
194 &self,
195 method: impl Into<String>,
196 url_pattern: impl Into<String>,
197 responses: Vec<HttpMockResponse>,
198 ) {
199 crate::http::push_http_mock(method, url_pattern, responses);
200 }
201
202 pub fn calls(&self) -> Vec<HttpMockCallSnapshot> {
203 crate::http::http_mock_calls_snapshot()
204 }
205}
206
207impl Default for HttpMockGuard {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213impl Drop for HttpMockGuard {
214 fn drop(&mut self) {
215 crate::http::reset_http_state();
216 }
217}
218
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub enum MockStreamEvent {
221 Json(JsonValue),
222 Bytes(Vec<u8>),
223 Cancelled,
224}
225
226#[derive(Clone, Debug)]
227pub struct MockStreamHandle {
228 tx: mpsc::UnboundedSender<MockStreamEvent>,
229 cancelled: Arc<AtomicBool>,
230}
231
232#[derive(Debug)]
233pub struct MockStreamReader {
234 rx: mpsc::UnboundedReceiver<MockStreamEvent>,
235 cancelled: Arc<AtomicBool>,
236}
237
238pub fn mock_stream() -> (MockStreamHandle, MockStreamReader) {
239 let (tx, rx) = mpsc::unbounded_channel();
240 let cancelled = Arc::new(AtomicBool::new(false));
241 (
242 MockStreamHandle {
243 tx,
244 cancelled: cancelled.clone(),
245 },
246 MockStreamReader { rx, cancelled },
247 )
248}
249
250impl MockStreamHandle {
251 pub fn send_json(
252 &self,
253 value: JsonValue,
254 ) -> Result<(), mpsc::error::SendError<MockStreamEvent>> {
255 self.tx.send(MockStreamEvent::Json(value))
256 }
257
258 pub fn send_bytes(
259 &self,
260 value: impl Into<Vec<u8>>,
261 ) -> Result<(), mpsc::error::SendError<MockStreamEvent>> {
262 self.tx.send(MockStreamEvent::Bytes(value.into()))
263 }
264
265 pub fn cancel(&self) {
266 self.cancelled.store(true, Ordering::SeqCst);
267 let _ = self.tx.send(MockStreamEvent::Cancelled);
268 }
269
270 pub fn is_cancelled(&self) -> bool {
271 self.cancelled.load(Ordering::SeqCst)
272 }
273}
274
275impl MockStreamReader {
276 pub async fn next(&mut self) -> Option<MockStreamEvent> {
277 self.rx.recv().await
278 }
279
280 pub fn is_cancelled(&self) -> bool {
281 self.cancelled.load(Ordering::SeqCst)
282 }
283}
284
285#[allow(clippy::derive_partial_eq_without_eq)]
287#[derive(Clone, Debug, PartialEq)]
288pub struct WebhookFixture {
289 pub raw: RawInbound,
290 pub body: Vec<u8>,
291}
292
293impl WebhookFixture {
294 pub fn with_binding(mut self, binding: &TriggerBinding) -> Self {
295 self.raw.metadata = json!({
296 "binding_id": binding.binding_id,
297 "binding_version": 1,
298 });
299 self
300 }
301
302 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
303 self.raw.tenant_id = Some(TenantId(tenant_id.into()));
304 self
305 }
306}
307
308pub fn github_ping_fixture(secret: &str, received_at: OffsetDateTime) -> WebhookFixture {
309 let body = br#"{"zen":"Keep it logically awesome.","hook_id":42}"#.to_vec();
310 let mut raw = RawInbound::new(
311 "webhook",
312 BTreeMap::from([
313 ("content-type".to_string(), "application/json".to_string()),
314 ("x-github-event".to_string(), "ping".to_string()),
315 ("x-github-delivery".to_string(), "delivery-1".to_string()),
316 (
317 "x-hub-signature-256".to_string(),
318 format!("sha256={}", hmac_sha256_hex(secret.as_bytes(), &body)),
319 ),
320 ]),
321 body.clone(),
322 );
323 raw.received_at = received_at;
324 WebhookFixture { raw, body }
325}
326
327pub fn slack_message_fixture(
328 secret: &str,
329 timestamp: i64,
330 received_at: OffsetDateTime,
331) -> WebhookFixture {
332 let body = br#"{"type":"event_callback","event_id":"Ev1","team_id":"T1","event":{"type":"message","channel_type":"channel","channel":"C1","user":"U1","text":"hello","event_ts":"1710000000.000100"}}"#.to_vec();
333 let signed = format!("v0:{timestamp}:{}", String::from_utf8_lossy(&body));
334 let mut raw = RawInbound::new(
335 "webhook",
336 BTreeMap::from([
337 ("content-type".to_string(), "application/json".to_string()),
338 (
339 "x-slack-request-timestamp".to_string(),
340 timestamp.to_string(),
341 ),
342 (
343 "x-slack-signature".to_string(),
344 format!(
345 "v0={}",
346 hmac_sha256_hex(secret.as_bytes(), signed.as_bytes())
347 ),
348 ),
349 ]),
350 body.clone(),
351 );
352 raw.received_at = received_at;
353 WebhookFixture { raw, body }
354}
355
356pub fn linear_issue_update_fixture(secret: &str, received_at: OffsetDateTime) -> WebhookFixture {
357 let body = br#"{"type":"Issue","action":"update","createdAt":"2026-04-19T00:00:00Z","data":{"id":"issue-1","identifier":"ENG-1","title":"connector"},"updatedFrom":{"title":"old"}}"#.to_vec();
358 let mut raw = RawInbound::new(
359 "webhook",
360 BTreeMap::from([
361 ("content-type".to_string(), "application/json".to_string()),
362 (
363 "linear-signature".to_string(),
364 hmac_sha256_hex(secret.as_bytes(), &body),
365 ),
366 ]),
367 body.clone(),
368 );
369 raw.received_at = received_at;
370 WebhookFixture { raw, body }
371}
372
373pub fn notion_page_content_updated_fixture(
374 secret: &str,
375 received_at: OffsetDateTime,
376) -> WebhookFixture {
377 let body = br#"{"id":"evt_1","type":"page.content_updated","workspace_id":"ws_1","subscription_id":"sub_1","integration_id":"int_1","entity":{"id":"page_1","type":"page"},"api_version":"2022-06-28"}"#.to_vec();
378 let mut raw = RawInbound::new(
379 "webhook",
380 BTreeMap::from([
381 ("content-type".to_string(), "application/json".to_string()),
382 (
383 "x-notion-signature".to_string(),
384 format!("sha256={}", hmac_sha256_hex(secret.as_bytes(), &body)),
385 ),
386 ("request-id".to_string(), "req_1".to_string()),
387 ]),
388 body.clone(),
389 );
390 raw.received_at = received_at;
391 WebhookFixture { raw, body }
392}
393
394pub fn webhook_binding(
395 provider: impl Into<String>,
396 binding_id: impl Into<String>,
397 signing_secret: Option<SecretId>,
398) -> TriggerBinding {
399 let provider = provider.into();
400 let mut binding =
401 TriggerBinding::new(ProviderId::from(provider.clone()), "webhook", binding_id);
402 let mut secrets = serde_json::Map::new();
403 if let Some(secret) = signing_secret {
404 secrets.insert(
405 "signing_secret".to_string(),
406 JsonValue::String(secret.to_string()),
407 );
408 }
409 binding.config = json!({
410 "path": format!("/hooks/{provider}"),
411 "match": {"events": ["*"]},
412 "secrets": secrets,
413 });
414 binding
415}
416
417fn hmac_sha256_hex(secret: &[u8], data: &[u8]) -> String {
418 hex::encode(crate::connectors::hmac::hmac_sha256(secret, data))
419}
420
421pub async fn advance_until<F>(
422 clock: &MockClock,
423 timeout: StdDuration,
424 tick: StdDuration,
425 mut predicate: F,
426) -> bool
427where
428 F: FnMut() -> bool,
429{
430 let mut elapsed = StdDuration::ZERO;
431 while elapsed <= timeout {
432 if predicate() {
433 return true;
434 }
435 clock.advance_std(tick).await;
436 elapsed += tick;
437 }
438 predicate()
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::secrets::{SecretBytes, SecretProvider, SecretVersion};
445
446 fn parse_ts(value: &str) -> OffsetDateTime {
447 OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap()
448 }
449
450 #[tokio::test]
451 async fn memory_secret_provider_scopes_and_versions_secrets() {
452 let mut provider = MemorySecretProvider::new("test");
453 let scoped = provider.insert_scoped("github", "tenant-a", "binding-a", "token", "v1");
454 provider
455 .put(&scoped, SecretBytes::from("v2"))
456 .await
457 .expect("put latest");
458
459 let latest = provider.get(&scoped).await.expect("latest");
460 assert_eq!(latest.with_exposed(|bytes| bytes.to_vec()), b"v2".to_vec());
461 let first = provider
462 .get(&scoped.clone().with_version(SecretVersion::Exact(1)))
463 .await
464 .expect("v1");
465 assert_eq!(first.with_exposed(|bytes| bytes.to_vec()), b"v1".to_vec());
466 assert!(provider
467 .get(&scoped_secret_id(
468 "github",
469 "tenant-b",
470 "binding-a",
471 "token"
472 ))
473 .await
474 .is_err());
475 }
476
477 #[tokio::test]
478 async fn connector_testkit_controls_clock_and_deadlines() {
479 let kit = ConnectorTestkit::new(parse_ts("2026-04-19T00:00:00Z")).await;
480 let _guard = kit.install_clock();
481 let mut fired = false;
482 assert!(
483 !advance_until(
484 &kit.clock,
485 StdDuration::from_millis(20),
486 StdDuration::from_millis(10),
487 || fired,
488 )
489 .await
490 );
491 fired = true;
492 assert!(
493 advance_until(
494 &kit.clock,
495 StdDuration::from_millis(20),
496 StdDuration::from_millis(10),
497 || fired,
498 )
499 .await
500 );
501 assert_eq!(instant_now().as_millis(), 30);
502 }
503
504 #[tokio::test]
505 async fn mock_stream_cancels_reader_without_wall_clock_sleep() {
506 let (handle, mut reader) = mock_stream();
507 handle.send_json(json!({"event": "one"})).expect("send");
508 assert_eq!(
509 reader.next().await,
510 Some(MockStreamEvent::Json(json!({"event": "one"})))
511 );
512 handle.cancel();
513 assert_eq!(reader.next().await, Some(MockStreamEvent::Cancelled));
514 assert!(reader.is_cancelled());
515 }
516
517 #[test]
518 fn temp_workspace_writes_package_markers() {
519 let workspace = TempPackageWorkspace::new("harn-testkit").expect("workspace");
520 workspace.write_harn_package("demo").expect("harn package");
521 workspace
522 .write_cargo_package("demo")
523 .expect("cargo package");
524 workspace.write_npm_package("demo").expect("npm package");
525 assert!(workspace.path().join("Harn.toml").exists());
526 assert!(workspace.path().join("Cargo.toml").exists());
527 assert!(workspace.path().join("package.json").exists());
528 }
529
530 #[test]
531 fn webhook_fixtures_include_provider_signatures() {
532 let received_at = parse_ts("2026-04-19T00:00:00Z");
533 let github = github_ping_fixture("topsecret", received_at);
534 assert!(github.raw.headers["x-hub-signature-256"].starts_with("sha256="));
535 let slack = slack_message_fixture("topsecret", received_at.unix_timestamp(), received_at);
536 assert!(slack.raw.headers["x-slack-signature"].starts_with("v0="));
537 let linear = linear_issue_update_fixture("topsecret", received_at);
538 assert_eq!(linear.raw.headers["linear-signature"].len(), 64);
539 let notion = notion_page_content_updated_fixture("topsecret", received_at);
540 assert!(notion.raw.headers["x-notion-signature"].starts_with("sha256="));
541 }
542}