Skip to main content

cratefield_testing/
conformance.rs

1//! The shared conformance suite (issue #9): every module must pass it.
2
3use 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
11/// Wraps a module so its `well_known` router (when present) carries a
12/// probe route the kit can request, proving where `Harness::router`
13/// mounted it (issue #46). Everything else delegates unchanged.
14struct 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
72/// Route the probe wrapper registers inside the module's well-known
73/// router; reachable at `/.well-known{WELL_KNOWN_PROBE_PATH}`.
74const WELL_KNOWN_PROBE_PATH: &str = "/conformance-probe";
75
76/// A `Ports` with every port faked, for the visibility check: declared
77/// ports must survive `view_for`, undeclared ones must be hidden.
78fn 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
100/// Runs the shared conformance suite against one module, once per
101/// dialect available in the environment (issue #20 — SQLite always,
102/// Postgres when `FZ_TEST_POSTGRES_URL` names a server):
103///
104/// 1. mounts under `/v1/<name>` and `/__health` lists it with its version;
105/// 2. a request under the module prefix is answered (no harness-level
106///    crash — module-specific routes are covered by the module's own
107///    tests via [`crate::request`]);
108/// 3. migrations apply from scratch **twice** on fresh databases
109///    (idempotence);
110/// 4. no undeclared port access: `Ports::view_for` hides every port the
111///    module did not declare;
112/// 5. two concurrent requests keep their own request ids (ADR 0007);
113/// 6. a well-known router, when provided, serves at the root under
114///    `/.well-known` and never under `/v1` (issue #46).
115///
116/// # Panics
117///
118/// Panics with a message naming the failed check and the dialect.
119pub fn conformance(module: Box<dyn Module>) {
120    let inner: Arc<dyn Module> = Arc::from(module);
121    conformance_inner(&inner, true);
122}
123
124/// [`conformance`] without the sidecar parity axis (issue #64). `reason`
125/// is recorded in code and printed by the run, so a module that opts out
126/// says why in the same place it opts out. Use it only for a module that
127/// genuinely cannot be sidecar-mounted; "it fails" is not a reason.
128///
129/// # Panics
130///
131/// Panics when `reason` is empty, and on any conformance failure.
132pub 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        // One instance across both mounts, as the dialect axis above
158        // already does: the probes never reach a module's parked context.
159        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    // 1. health lists it.
179    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    // 2. a request under the prefix is answered without panicking.
201    let probe = pollster::block_on(request(
202        &kit.router,
203        axum::http::Method::GET,
204        &format!("/v1/{name}/"),
205        None,
206    ));
207    // Any status is the module's business; the point is no crash.
208    let _ = probe.status;
209
210    // 3. migrations apply twice on fresh databases.
211    #[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
230/// Conformance checks 4-6: undeclared ports stay hidden, concurrent
231/// requests keep their request ids, and a well-known router mounts at
232/// the root only.
233fn check_visibility_and_scope(
234    kit: &TestHarness,
235    module: &dyn Module,
236    name: &str,
237    has_well_known: bool,
238) {
239    // 4. undeclared ports are hidden (declared ones stay visible).
240    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    // 5. two concurrent requests keep their own request ids (ADR 0007).
264    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
293/// Conformance check 6 (issue #46): the module's well-known router, when
294/// present, serves at the root under `/.well-known` and never under
295/// `/v1`; when absent, nothing mounts at `/.well-known`.
296fn 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
329/// Asserts the named crate's normal dependency tree is wasm-safe: no
330/// `worker`, `wasm-bindgen`, `tokio`, `reqwest` (ADR 0001). Runs `cargo
331/// tree -p <crate> --edges normal` in the caller's workspace — call it
332/// with `env!("CARGO_PKG_NAME")` from a module test.
333///
334/// # Panics
335///
336/// Panics when a forbidden dependency is found or cargo fails.
337pub 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
357/// One probe of the [`sidecar_parity`] battery: a request whose answer
358/// must be identical whether the module is linked in or reached over a
359/// service binding.
360struct Probe {
361    what: &'static str,
362    method: axum::http::Method,
363    /// Appended to `/v1/<module>`.
364    path: &'static str,
365    body: Option<&'static str>,
366}
367
368/// The request id both mounts are given, so `instance` in a problem body
369/// and the `x-request-id` header can be compared byte for byte. The
370/// harness accepts a client-supplied id that matches its pattern.
371const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
372
373/// Sends one probe through `router`, with the fixed request id.
374async 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
402/// Asserts that a module answers identically in-process and behind a
403/// sidecar mount (issue #64, ADR 0009: a caller cannot tell which).
404///
405/// Both mounts are given the same client-supplied request id, so the
406/// `instance` of a problem body and the `x-request-id` header are
407/// comparable byte for byte. Probes are deliberately module-agnostic —
408/// unknown paths, a malformed body, a wrong method — because the kit
409/// does not know the module's routes and because those are exactly the
410/// paths where the hop could quietly rewrite something.
411///
412/// Also asserted: a body over the harness's 64 KiB cap is refused by the
413/// **host** and never forwarded.
414///
415/// # Panics
416///
417/// Panics naming the probe and the field that diverged.
418pub 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    // In-process: the module is linked into the harness under test.
425    let in_process = TestHarness::from_arcs(
426        vec![shared.clone()],
427        crate::dialect::Dialect::Sqlite,
428        |_| {},
429    );
430
431    // Sidecar: a second harness holds the module, and the host holds a
432    // mount table pointing at it over a fake service binding.
433    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        // Without this the axis could pass vacuously: if the mount stopped
486        // forwarding, the host would answer its own 404 and a module that
487        // also answers 404 would compare equal. Every probe must have
488        // crossed the hop.
489        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
502/// A body over the harness cap is refused by the **host** and never
503/// forwarded: the forwarder buffers, so a body it accepted would be held
504/// in the isolate twice (issue #64, amended).
505fn 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
543/// Compares one probe's two answers: status, the problem body byte for
544/// byte, and the request id (present exactly once, and the one the
545/// caller sent).
546fn 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}