1use cratefield_core::{
4 AnyError, BoxFuture, Config, ConfigError, EventHandler, EventName, HmacSigner, Migrations,
5 Module, ModuleContext, Port, Ports, UlidIdGen,
6};
7use std::sync::Arc;
8
9use crate::{TestHarness, request};
10
11struct WellKnownProbe {
15 inner: Arc<dyn Module>,
16}
17
18impl Module for WellKnownProbe {
19 fn name(&self) -> &'static str {
20 self.inner.name()
21 }
22 fn version(&self) -> &'static str {
23 self.inner.version()
24 }
25 fn harness_api(&self) -> u32 {
26 self.inner.harness_api()
27 }
28 fn requires(&self) -> &'static [Port] {
29 self.inner.requires()
30 }
31 fn optional(&self) -> &'static [Port] {
32 self.inner.optional()
33 }
34 fn tables(&self) -> &'static [&'static str] {
35 self.inner.tables()
36 }
37 fn emits(&self) -> &'static [&'static str] {
38 self.inner.emits()
39 }
40 fn public_writes(&self) -> bool {
41 self.inner.public_writes()
42 }
43 fn migrations(&self) -> Migrations {
44 self.inner.migrations()
45 }
46 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
47 self.inner.validate_config(cfg)
48 }
49 fn router(&self, ctx: ModuleContext) -> axum::Router {
50 self.inner.router(ctx)
51 }
52 fn well_known(&self) -> Option<axum::Router> {
53 self.inner.well_known().map(|router| {
54 router.route(
55 WELL_KNOWN_PROBE_PATH,
56 axum::routing::get(|| async { "well-known" }),
57 )
58 })
59 }
60 fn events(&self) -> Vec<(EventName, EventHandler)> {
61 self.inner.events()
62 }
63 fn scheduled<'a>(
64 &'a self,
65 ctx: &'a ModuleContext,
66 cron: &'a str,
67 ) -> BoxFuture<'a, Result<(), AnyError>> {
68 self.inner.scheduled(ctx, cron)
69 }
70}
71
72const WELL_KNOWN_PROBE_PATH: &str = "/conformance-probe";
75
76fn full_fake_ports() -> Ports {
79 let mut ports = Ports::empty();
80 ports.db = Some(Arc::new(crate::fakes::EmptyDatabase));
81 ports.mailer = Some(Arc::new(crate::fakes::FakeMailer::new(
82 crate::fakes::MailerMode::SendOk,
83 )));
84 ports.captcha = Some(Arc::new(crate::fakes::FakeCaptcha::allow_all()));
85 ports.rate_limiter = Some(Arc::new(crate::fakes::FakeRateLimiter::always_allow()));
86 ports.signer = Some(Arc::new(
87 HmacSigner::new(crate::TEST_HARNESS_SECRET, None).expect("test secret is long enough"),
88 ));
89 ports.kv = Some(Arc::new(crate::fakes::MemoryKeyValue::new()));
90 ports.blob = Some(Arc::new(crate::fakes::MemoryBlob::new()));
91 ports.http = Some(Arc::new(crate::fakes::FakeHttpClient::ok_json("{}")));
92 ports.clock = Some(Arc::new(crate::fakes::FixedClock(
93 time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
94 )));
95 ports.id_gen = Some(Arc::new(UlidIdGen));
96 ports.defer = Some(Arc::new(crate::fakes::FakeDefer::new()));
97 ports
98}
99
100pub fn conformance(module: Box<dyn Module>) {
120 let inner: Arc<dyn Module> = Arc::from(module);
121 conformance_inner(&inner, true);
122}
123
124pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
133 assert!(
134 !reason.trim().is_empty(),
135 "conformance_in_process_only needs a reason: it is the only record of why \
136 `{}` is not checked for sidecar parity",
137 module.name()
138 );
139 eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
140 let inner: Arc<dyn Module> = Arc::from(module);
141 conformance_inner(&inner, false);
142}
143
144fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
145 let name = inner.name().to_owned();
146 let version = inner.version().to_owned();
147 let has_well_known = inner.well_known().is_some();
148 let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
149 inner: inner.clone(),
150 });
151
152 for dialect in crate::dialect::Dialect::available() {
153 conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
154 }
155
156 if parity {
157 parity_on(inner, &name);
160 }
161}
162
163fn conformance_on_dialect(
164 dialect: &crate::dialect::Dialect,
165 module: Arc<dyn Module>,
166 name: &str,
167 version: &str,
168 has_well_known: bool,
169) {
170 let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
171 let module = kit
172 .modules
173 .iter()
174 .find(|m| m.name() == name)
175 .unwrap_or_else(|| panic!("module {name} missing from kit"))
176 .clone();
177
178 let health = pollster::block_on(request(
180 &kit.router,
181 axum::http::Method::GET,
182 "/__health",
183 None,
184 ));
185 let body = health.json();
186 let listed = body["modules"]
187 .as_array()
188 .unwrap_or_else(|| panic!("health modules array missing: {body}"));
189 let entry = listed
190 .iter()
191 .find(|entry| entry["name"] == *name)
192 .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
193 assert_eq!(
194 entry["version"],
195 version,
196 "[{}] health lists the module version",
197 dialect.name()
198 );
199
200 let probe = pollster::block_on(request(
202 &kit.router,
203 axum::http::Method::GET,
204 &format!("/v1/{name}/"),
205 None,
206 ));
207 let _ = probe.status;
209
210 #[cfg(feature = "postgres")]
212 if let crate::dialect::Dialect::Postgres { .. } = &dialect {
213 crate::pg::migrations_apply_twice(&kit.modules)
214 .unwrap_or_else(|message| panic!("[postgres] {message}"));
215 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
216 return;
217 }
218 for round in 1..=2 {
219 let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
220 .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
221 for module in &kit.modules {
222 fresh
223 .apply_migrations(module.name(), module.migrations().sqlite)
224 .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
225 }
226 }
227 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
228}
229
230fn check_visibility_and_scope(
234 kit: &TestHarness,
235 module: &dyn Module,
236 name: &str,
237 has_well_known: bool,
238) {
239 let view = full_fake_ports().view_for(module);
241 for (port, provided) in [
242 (Port::Db, view.db.is_some()),
243 (Port::Mailer, view.mailer.is_some()),
244 (Port::Captcha, view.captcha.is_some()),
245 (Port::RateLimiter, view.rate_limiter.is_some()),
246 (Port::Signer, view.signer.is_some()),
247 (Port::KeyValue, view.kv.is_some()),
248 (Port::Blob, view.blob.is_some()),
249 (Port::HttpClient, view.http.is_some()),
250 (Port::Clock, view.clock.is_some()),
251 (Port::IdGen, view.id_gen.is_some()),
252 (Port::Defer, view.defer.is_some()),
253 ] {
254 let declared = module.requires().contains(&port) || module.optional().contains(&port);
255 assert_eq!(
256 provided,
257 declared,
258 "{name}: port {} must be visible only when declared",
259 port.name()
260 );
261 }
262
263 let router_a = kit.router.clone();
265 let router_b = kit.router.clone();
266 let id_a = "conformance-AAAAAAAA";
267 let id_b = "conformance-BBBBBBBB";
268 let make = |router: axum::Router, id: &'static str| {
269 std::thread::spawn(move || {
270 use tower::ServiceExt;
271 let request = axum::http::Request::builder()
272 .uri("/__health")
273 .header("x-request-id", id)
274 .body(axum::body::Body::empty())
275 .expect("request builds");
276 let response = pollster::block_on(router.oneshot(request)).expect("router answers");
277 response
278 .headers()
279 .get("x-request-id")
280 .and_then(|value| value.to_str().ok())
281 .expect("request id echoed")
282 .to_string()
283 })
284 };
285 let handle_a = make(router_a, id_a);
286 let handle_b = make(router_b, id_b);
287 assert_eq!(handle_a.join().expect("a completes"), id_a);
288 assert_eq!(handle_b.join().expect("b completes"), id_b);
289
290 check_well_known_mount(kit, name, has_well_known);
291}
292
293fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
297 let probe = pollster::block_on(request(
298 &kit.router,
299 axum::http::Method::GET,
300 &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
301 None,
302 ));
303 if has_well_known {
304 assert_eq!(
305 probe.status,
306 axum::http::StatusCode::OK,
307 "{name}: well-known router must serve at the root"
308 );
309 let under_v1 = pollster::block_on(request(
310 &kit.router,
311 axum::http::Method::GET,
312 &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
313 None,
314 ));
315 assert_eq!(
316 under_v1.status,
317 axum::http::StatusCode::NOT_FOUND,
318 "{name}: well-known routes must not be nested under /v1"
319 );
320 } else {
321 assert_eq!(
322 probe.status,
323 axum::http::StatusCode::NOT_FOUND,
324 "{name}: nothing must mount at /.well-known without a well-known router"
325 );
326 }
327}
328
329pub fn assert_wasm_safe_deps(module_crate: &str) {
338 let output =
339 std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
340 .args(["tree", "-p", module_crate, "--edges", "normal"])
341 .output()
342 .unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
343 assert!(
344 output.status.success(),
345 "cargo tree failed for {module_crate}"
346 );
347 let tree = String::from_utf8_lossy(&output.stdout);
348 for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
349 assert!(
350 !tree.contains(forbidden),
351 "{module_crate} pulls forbidden dependency `{}`:\n{tree}",
352 forbidden.trim_end_matches(" v")
353 );
354 }
355}
356
357struct Probe {
361 what: &'static str,
362 method: axum::http::Method,
363 path: &'static str,
365 body: Option<&'static str>,
366}
367
368const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
372
373async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
375 use axum::http::{HeaderValue, Request, header};
376 use tower::ServiceExt;
377
378 let uri = format!("/v1/{name}{}", probe.path);
379 let mut builder = Request::builder()
380 .method(probe.method.clone())
381 .uri(uri)
382 .header(
383 cratefield_core::X_REQUEST_ID,
384 HeaderValue::from_static(PARITY_REQUEST_ID),
385 )
386 .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
387 let body = match probe.body {
388 Some(json) => {
389 builder = builder.header(header::CONTENT_TYPE, "application/json");
390 axum::body::Body::from(json)
391 }
392 None => axum::body::Body::empty(),
393 };
394 let response = router
395 .clone()
396 .oneshot(builder.body(body).expect("probe request builds"))
397 .await
398 .expect("router is infallible");
399 crate::TestResponse::of(response).await
400}
401
402pub fn sidecar_parity(module: Box<dyn Module>) {
419 let name = module.name().to_owned();
420 parity_on(&Arc::from(module), &name);
421}
422
423fn parity_on(shared: &Arc<dyn Module>, name: &str) {
424 let in_process = TestHarness::from_arcs(
426 vec![shared.clone()],
427 crate::dialect::Dialect::Sqlite,
428 |_| {},
429 );
430
431 let remote = TestHarness::from_arcs(
434 vec![shared.clone()],
435 crate::dialect::Dialect::Sqlite,
436 |_| {},
437 );
438 let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
439 "PARITY",
440 remote.router.clone(),
441 ));
442 let table = format!("{{\"{name}\":\"PARITY\"}}");
443 let host = TestHarness::with_builder(
444 Vec::new(),
445 |builder| builder,
446 |ports| {
447 ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
448 ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
449 (cratefield_core::HARNESS_SIDECARS, table.as_str()),
450 ]));
451 ports.dispatcher = Some(dispatcher);
452 },
453 );
454
455 let probes = [
456 Probe {
457 what: "an unknown path under the module prefix",
458 method: axum::http::Method::GET,
459 path: "/__parity_no_such_route",
460 body: None,
461 },
462 Probe {
463 what: "a POST to an unknown path",
464 method: axum::http::Method::POST,
465 path: "/__parity_no_such_route",
466 body: Some(r#"{"parity":true}"#),
467 },
468 Probe {
469 what: "the module root",
470 method: axum::http::Method::GET,
471 path: "",
472 body: None,
473 },
474 Probe {
475 what: "a malformed JSON body at the module root",
476 method: axum::http::Method::POST,
477 path: "",
478 body: Some("{not json"),
479 },
480 ];
481
482 for (sent, probe) in probes.iter().enumerate() {
483 let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
484 let hopped = pollster::block_on(probe_once(&host.router, name, probe));
485 assert_eq!(
490 sidecar.calls(),
491 sent + 1,
492 "[{name}] {} never reached the sidecar: the mount is not forwarding, \
493 so this comparison proves nothing",
494 probe.what
495 );
496 compare(name, probe.what, &direct, &hopped);
497 }
498
499 check_oversized_body_stops_at_the_host(&host, &sidecar, name);
500}
501
502fn check_oversized_body_stops_at_the_host(
506 host: &TestHarness,
507 sidecar: &Arc<crate::sidecar::FakeSidecar>,
508 name: &str,
509) {
510 use axum::http::{Request, StatusCode, header};
511 use tower::ServiceExt;
512
513 let before = sidecar.calls();
514 let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
515 let response = pollster::block_on(async {
516 let request = Request::builder()
517 .method(axum::http::Method::POST)
518 .uri(format!("/v1/{name}"))
519 .header(header::CONTENT_TYPE, "application/json")
520 .body(axum::body::Body::from(big))
521 .expect("oversized request builds");
522 let response = host
523 .router
524 .clone()
525 .oneshot(request)
526 .await
527 .expect("router is infallible");
528 crate::TestResponse::of(response).await
529 });
530 assert_eq!(
531 response.status,
532 StatusCode::PAYLOAD_TOO_LARGE,
533 "[{name}] a body over {} bytes must be refused by the host",
534 cratefield_core::MAX_BODY_BYTES
535 );
536 assert_eq!(
537 sidecar.calls(),
538 before,
539 "[{name}] an oversized body must never be forwarded to the sidecar"
540 );
541}
542
543fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
547 assert_eq!(
548 direct.status, hopped.status,
549 "[{name}] status differs over the sidecar hop for {what}"
550 );
551 assert_eq!(
552 direct.body(),
553 hopped.body(),
554 "[{name}] body differs over the sidecar hop for {what}"
555 );
556 for header in [
557 axum::http::header::CONTENT_TYPE.as_str(),
558 axum::http::header::LOCATION.as_str(),
559 ] {
560 assert_eq!(
561 direct.headers.get(header),
562 hopped.headers.get(header),
563 "[{name}] `{header}` differs over the sidecar hop for {what}"
564 );
565 }
566 for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
567 let ids: Vec<_> = response
568 .headers
569 .get_all(cratefield_core::X_REQUEST_ID)
570 .iter()
571 .collect();
572 assert_eq!(
573 ids.len(),
574 1,
575 "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
576 ids.len()
577 );
578 assert_eq!(
579 ids[0], PARITY_REQUEST_ID,
580 "[{name}] {label} did not echo the caller's request id for {what}"
581 );
582 }
583}