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.push = Some(Arc::new(crate::fakes::FakePush::new(
92 crate::fakes::PushMode::DeliverOk,
93 )));
94 ports.payments = Some(Arc::new(crate::fakes::FakePayments::new(
95 crate::fakes::PaymentsMode::Ok,
96 )));
97 ports.http = Some(Arc::new(crate::fakes::FakeHttpClient::ok_json("{}")));
98 ports.clock = Some(Arc::new(crate::fakes::FixedClock(
99 time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
100 )));
101 ports.id_gen = Some(Arc::new(UlidIdGen));
102 ports.defer = Some(Arc::new(crate::fakes::FakeDefer::new()));
103 ports
104}
105
106pub fn conformance(module: Box<dyn Module>) {
126 let inner: Arc<dyn Module> = Arc::from(module);
127 conformance_inner(&inner, true);
128}
129
130pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
139 assert!(
140 !reason.trim().is_empty(),
141 "conformance_in_process_only needs a reason: it is the only record of why \
142 `{}` is not checked for sidecar parity",
143 module.name()
144 );
145 eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
146 let inner: Arc<dyn Module> = Arc::from(module);
147 conformance_inner(&inner, false);
148}
149
150fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
151 let name = inner.name().to_owned();
152 let version = inner.version().to_owned();
153 let has_well_known = inner.well_known().is_some();
154 let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
155 inner: inner.clone(),
156 });
157
158 for dialect in crate::dialect::Dialect::available() {
159 conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
160 }
161
162 if parity {
163 parity_on(inner, &name);
166 }
167}
168
169fn conformance_on_dialect(
170 dialect: &crate::dialect::Dialect,
171 module: Arc<dyn Module>,
172 name: &str,
173 version: &str,
174 has_well_known: bool,
175) {
176 let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
177 let module = kit
178 .modules
179 .iter()
180 .find(|m| m.name() == name)
181 .unwrap_or_else(|| panic!("module {name} missing from kit"))
182 .clone();
183
184 let health = pollster::block_on(request(
186 &kit.router,
187 axum::http::Method::GET,
188 "/__health",
189 None,
190 ));
191 let body = health.json();
192 let listed = body["modules"]
193 .as_array()
194 .unwrap_or_else(|| panic!("health modules array missing: {body}"));
195 let entry = listed
196 .iter()
197 .find(|entry| entry["name"] == *name)
198 .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
199 assert_eq!(
200 entry["version"],
201 version,
202 "[{}] health lists the module version",
203 dialect.name()
204 );
205
206 let probe = pollster::block_on(request(
208 &kit.router,
209 axum::http::Method::GET,
210 &format!("/v1/{name}/"),
211 None,
212 ));
213 let _ = probe.status;
215
216 #[cfg(feature = "postgres")]
218 if let crate::dialect::Dialect::Postgres { .. } = &dialect {
219 crate::pg::migrations_apply_twice(&kit.modules)
220 .unwrap_or_else(|message| panic!("[postgres] {message}"));
221 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
222 return;
223 }
224 for round in 1..=2 {
225 let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
226 .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
227 for module in &kit.modules {
228 fresh
229 .apply_migrations(module.name(), module.migrations().sqlite)
230 .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
231 }
232 }
233 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
234}
235
236fn check_visibility_and_scope(
240 kit: &TestHarness,
241 module: &dyn Module,
242 name: &str,
243 has_well_known: bool,
244) {
245 let view = full_fake_ports().view_for(module);
247 for (port, provided) in [
248 (Port::Db, view.db.is_some()),
249 (Port::Mailer, view.mailer.is_some()),
250 (Port::Captcha, view.captcha.is_some()),
251 (Port::RateLimiter, view.rate_limiter.is_some()),
252 (Port::Signer, view.signer.is_some()),
253 (Port::KeyValue, view.kv.is_some()),
254 (Port::Blob, view.blob.is_some()),
255 (Port::Push, view.push.is_some()),
256 (Port::Payments, view.payments.is_some()),
257 (Port::HttpClient, view.http.is_some()),
258 (Port::Clock, view.clock.is_some()),
259 (Port::IdGen, view.id_gen.is_some()),
260 (Port::Defer, view.defer.is_some()),
261 ] {
262 let declared = module.requires().contains(&port) || module.optional().contains(&port);
263 assert_eq!(
264 provided,
265 declared,
266 "{name}: port {} must be visible only when declared",
267 port.name()
268 );
269 }
270
271 let router_a = kit.router.clone();
273 let router_b = kit.router.clone();
274 let id_a = "conformance-AAAAAAAA";
275 let id_b = "conformance-BBBBBBBB";
276 let make = |router: axum::Router, id: &'static str| {
277 std::thread::spawn(move || {
278 use tower::ServiceExt;
279 let request = axum::http::Request::builder()
280 .uri("/__health")
281 .header("x-request-id", id)
282 .body(axum::body::Body::empty())
283 .expect("request builds");
284 let response = pollster::block_on(router.oneshot(request)).expect("router answers");
285 response
286 .headers()
287 .get("x-request-id")
288 .and_then(|value| value.to_str().ok())
289 .expect("request id echoed")
290 .to_string()
291 })
292 };
293 let handle_a = make(router_a, id_a);
294 let handle_b = make(router_b, id_b);
295 assert_eq!(handle_a.join().expect("a completes"), id_a);
296 assert_eq!(handle_b.join().expect("b completes"), id_b);
297
298 check_well_known_mount(kit, name, has_well_known);
299}
300
301fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
305 let probe = pollster::block_on(request(
306 &kit.router,
307 axum::http::Method::GET,
308 &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
309 None,
310 ));
311 if has_well_known {
312 assert_eq!(
313 probe.status,
314 axum::http::StatusCode::OK,
315 "{name}: well-known router must serve at the root"
316 );
317 let under_v1 = pollster::block_on(request(
318 &kit.router,
319 axum::http::Method::GET,
320 &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
321 None,
322 ));
323 assert_eq!(
324 under_v1.status,
325 axum::http::StatusCode::NOT_FOUND,
326 "{name}: well-known routes must not be nested under /v1"
327 );
328 } else {
329 assert_eq!(
330 probe.status,
331 axum::http::StatusCode::NOT_FOUND,
332 "{name}: nothing must mount at /.well-known without a well-known router"
333 );
334 }
335}
336
337pub fn assert_wasm_safe_deps(module_crate: &str) {
346 let output =
347 std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
348 .args(["tree", "-p", module_crate, "--edges", "normal"])
349 .output()
350 .unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
351 assert!(
352 output.status.success(),
353 "cargo tree failed for {module_crate}"
354 );
355 let tree = String::from_utf8_lossy(&output.stdout);
356 for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
357 assert!(
358 !tree.contains(forbidden),
359 "{module_crate} pulls forbidden dependency `{}`:\n{tree}",
360 forbidden.trim_end_matches(" v")
361 );
362 }
363}
364
365struct Probe {
369 what: &'static str,
370 method: axum::http::Method,
371 path: &'static str,
373 body: Option<&'static str>,
374}
375
376const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
380
381async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
383 use axum::http::{HeaderValue, Request, header};
384 use tower::ServiceExt;
385
386 let uri = format!("/v1/{name}{}", probe.path);
387 let mut builder = Request::builder()
388 .method(probe.method.clone())
389 .uri(uri)
390 .header(
391 cratefield_core::X_REQUEST_ID,
392 HeaderValue::from_static(PARITY_REQUEST_ID),
393 )
394 .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
395 let body = match probe.body {
396 Some(json) => {
397 builder = builder.header(header::CONTENT_TYPE, "application/json");
398 axum::body::Body::from(json)
399 }
400 None => axum::body::Body::empty(),
401 };
402 let response = router
403 .clone()
404 .oneshot(builder.body(body).expect("probe request builds"))
405 .await
406 .expect("router is infallible");
407 crate::TestResponse::of(response).await
408}
409
410pub fn sidecar_parity(module: Box<dyn Module>) {
427 let name = module.name().to_owned();
428 parity_on(&Arc::from(module), &name);
429}
430
431fn parity_on(shared: &Arc<dyn Module>, name: &str) {
432 let in_process = TestHarness::from_arcs(
434 vec![shared.clone()],
435 crate::dialect::Dialect::Sqlite,
436 |_| {},
437 );
438
439 let remote = TestHarness::from_arcs(
442 vec![shared.clone()],
443 crate::dialect::Dialect::Sqlite,
444 |_| {},
445 );
446 let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
447 "PARITY",
448 remote.router.clone(),
449 ));
450 let table = format!("{{\"{name}\":\"PARITY\"}}");
451 let host = TestHarness::with_builder(
452 Vec::new(),
453 |builder| builder,
454 |ports| {
455 ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
456 ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
457 (cratefield_core::HARNESS_SIDECARS, table.as_str()),
458 ]));
459 ports.dispatcher = Some(dispatcher);
460 },
461 );
462
463 let probes = [
464 Probe {
465 what: "an unknown path under the module prefix",
466 method: axum::http::Method::GET,
467 path: "/__parity_no_such_route",
468 body: None,
469 },
470 Probe {
471 what: "a POST to an unknown path",
472 method: axum::http::Method::POST,
473 path: "/__parity_no_such_route",
474 body: Some(r#"{"parity":true}"#),
475 },
476 Probe {
477 what: "the module root",
478 method: axum::http::Method::GET,
479 path: "",
480 body: None,
481 },
482 Probe {
483 what: "a malformed JSON body at the module root",
484 method: axum::http::Method::POST,
485 path: "",
486 body: Some("{not json"),
487 },
488 ];
489
490 for (sent, probe) in probes.iter().enumerate() {
491 let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
492 let hopped = pollster::block_on(probe_once(&host.router, name, probe));
493 assert_eq!(
498 sidecar.calls(),
499 sent + 1,
500 "[{name}] {} never reached the sidecar: the mount is not forwarding, \
501 so this comparison proves nothing",
502 probe.what
503 );
504 compare(name, probe.what, &direct, &hopped);
505 }
506
507 check_oversized_body_stops_at_the_host(&host, &sidecar, name);
508}
509
510fn check_oversized_body_stops_at_the_host(
514 host: &TestHarness,
515 sidecar: &Arc<crate::sidecar::FakeSidecar>,
516 name: &str,
517) {
518 use axum::http::{Request, StatusCode, header};
519 use tower::ServiceExt;
520
521 let before = sidecar.calls();
522 let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
523 let response = pollster::block_on(async {
524 let request = Request::builder()
525 .method(axum::http::Method::POST)
526 .uri(format!("/v1/{name}"))
527 .header(header::CONTENT_TYPE, "application/json")
528 .body(axum::body::Body::from(big))
529 .expect("oversized request builds");
530 let response = host
531 .router
532 .clone()
533 .oneshot(request)
534 .await
535 .expect("router is infallible");
536 crate::TestResponse::of(response).await
537 });
538 assert_eq!(
539 response.status,
540 StatusCode::PAYLOAD_TOO_LARGE,
541 "[{name}] a body over {} bytes must be refused by the host",
542 cratefield_core::MAX_BODY_BYTES
543 );
544 assert_eq!(
545 sidecar.calls(),
546 before,
547 "[{name}] an oversized body must never be forwarded to the sidecar"
548 );
549}
550
551fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
555 assert_eq!(
556 direct.status, hopped.status,
557 "[{name}] status differs over the sidecar hop for {what}"
558 );
559 assert_eq!(
560 direct.body(),
561 hopped.body(),
562 "[{name}] body differs over the sidecar hop for {what}"
563 );
564 for header in [
565 axum::http::header::CONTENT_TYPE.as_str(),
566 axum::http::header::LOCATION.as_str(),
567 ] {
568 assert_eq!(
569 direct.headers.get(header),
570 hopped.headers.get(header),
571 "[{name}] `{header}` differs over the sidecar hop for {what}"
572 );
573 }
574 for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
575 let ids: Vec<_> = response
576 .headers
577 .get_all(cratefield_core::X_REQUEST_ID)
578 .iter()
579 .collect();
580 assert_eq!(
581 ids.len(),
582 1,
583 "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
584 ids.len()
585 );
586 assert_eq!(
587 ids[0], PARITY_REQUEST_ID,
588 "[{name}] {label} did not echo the caller's request id for {what}"
589 );
590 }
591}