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.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
106/// Runs the shared conformance suite against one module, once per
107/// dialect available in the environment (issue #20 — SQLite always,
108/// Postgres when `FZ_TEST_POSTGRES_URL` names a server):
109///
110/// 1. mounts under `/v1/<name>` and `/__health` lists it with its version;
111/// 2. a request under the module prefix is answered (no harness-level
112///    crash — module-specific routes are covered by the module's own
113///    tests via [`crate::request`]);
114/// 3. migrations apply from scratch **twice** on fresh databases
115///    (idempotence);
116/// 4. no undeclared port access: `Ports::view_for` hides every port the
117///    module did not declare;
118/// 5. two concurrent requests keep their own request ids (ADR 0007);
119/// 6. a well-known router, when provided, serves at the root under
120///    `/.well-known` and never under `/v1` (issue #46).
121///
122/// # Panics
123///
124/// Panics with a message naming the failed check and the dialect.
125pub fn conformance(module: Box<dyn Module>) {
126    let inner: Arc<dyn Module> = Arc::from(module);
127    conformance_inner(&inner, true);
128}
129
130/// [`conformance`] without the sidecar parity axis (issue #64). `reason`
131/// is recorded in code and printed by the run, so a module that opts out
132/// says why in the same place it opts out. Use it only for a module that
133/// genuinely cannot be sidecar-mounted; "it fails" is not a reason.
134///
135/// # Panics
136///
137/// Panics when `reason` is empty, and on any conformance failure.
138pub 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        // One instance across both mounts, as the dialect axis above
164        // already does: the probes never reach a module's parked context.
165        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    // 1. health lists it.
185    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    // 2. a request under the prefix is answered without panicking.
207    let probe = pollster::block_on(request(
208        &kit.router,
209        axum::http::Method::GET,
210        &format!("/v1/{name}/"),
211        None,
212    ));
213    // Any status is the module's business; the point is no crash.
214    let _ = probe.status;
215
216    // 3. migrations apply twice on fresh databases.
217    #[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
236/// Conformance checks 4-6: undeclared ports stay hidden, concurrent
237/// requests keep their request ids, and a well-known router mounts at
238/// the root only.
239fn check_visibility_and_scope(
240    kit: &TestHarness,
241    module: &dyn Module,
242    name: &str,
243    has_well_known: bool,
244) {
245    // 4. undeclared ports are hidden (declared ones stay visible).
246    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    // 5. two concurrent requests keep their own request ids (ADR 0007).
272    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
301/// Conformance check 6 (issue #46): the module's well-known router, when
302/// present, serves at the root under `/.well-known` and never under
303/// `/v1`; when absent, nothing mounts at `/.well-known`.
304fn 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
337/// Asserts the named crate's normal dependency tree is wasm-safe: no
338/// `worker`, `wasm-bindgen`, `tokio`, `reqwest` (ADR 0001). Runs `cargo
339/// tree -p <crate> --edges normal` in the caller's workspace — call it
340/// with `env!("CARGO_PKG_NAME")` from a module test.
341///
342/// # Panics
343///
344/// Panics when a forbidden dependency is found or cargo fails.
345pub 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
365/// One probe of the [`sidecar_parity`] battery: a request whose answer
366/// must be identical whether the module is linked in or reached over a
367/// service binding.
368struct Probe {
369    what: &'static str,
370    method: axum::http::Method,
371    /// Appended to `/v1/<module>`.
372    path: &'static str,
373    body: Option<&'static str>,
374}
375
376/// The request id both mounts are given, so `instance` in a problem body
377/// and the `x-request-id` header can be compared byte for byte. The
378/// harness accepts a client-supplied id that matches its pattern.
379const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
380
381/// Sends one probe through `router`, with the fixed request id.
382async 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
410/// Asserts that a module answers identically in-process and behind a
411/// sidecar mount (issue #64, ADR 0009: a caller cannot tell which).
412///
413/// Both mounts are given the same client-supplied request id, so the
414/// `instance` of a problem body and the `x-request-id` header are
415/// comparable byte for byte. Probes are deliberately module-agnostic —
416/// unknown paths, a malformed body, a wrong method — because the kit
417/// does not know the module's routes and because those are exactly the
418/// paths where the hop could quietly rewrite something.
419///
420/// Also asserted: a body over the harness's 64 KiB cap is refused by the
421/// **host** and never forwarded.
422///
423/// # Panics
424///
425/// Panics naming the probe and the field that diverged.
426pub 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    // In-process: the module is linked into the harness under test.
433    let in_process = TestHarness::from_arcs(
434        vec![shared.clone()],
435        crate::dialect::Dialect::Sqlite,
436        |_| {},
437    );
438
439    // Sidecar: a second harness holds the module, and the host holds a
440    // mount table pointing at it over a fake service binding.
441    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        // Without this the axis could pass vacuously: if the mount stopped
494        // forwarding, the host would answer its own 404 and a module that
495        // also answers 404 would compare equal. Every probe must have
496        // crossed the hop.
497        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
510/// A body over the harness cap is refused by the **host** and never
511/// forwarded: the forwarder buffers, so a body it accepted would be held
512/// in the isolate twice (issue #64, amended).
513fn 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
551/// Compares one probe's two answers: status, the problem body byte for
552/// byte, and the request id (present exactly once, and the one the
553/// caller sent).
554fn 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}