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    /// Delegated, like `tables`, and for the same reason: the kit builds a
38    /// harness out of this wrapper, and `HarnessBuilder::build` validates a
39    /// module's personal-data declarations against the tables it claims. A
40    /// wrapper that answered core's empty default would hand every module a
41    /// build that checked declarations it could not see.
42    fn personal_data(&self) -> &'static [cratefield_core::PersonalDataSet] {
43        self.inner.personal_data()
44    }
45    fn emits(&self) -> &'static [&'static str] {
46        self.inner.emits()
47    }
48    fn public_writes(&self) -> bool {
49        self.inner.public_writes()
50    }
51    fn migrations(&self) -> Migrations {
52        self.inner.migrations()
53    }
54    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
55        self.inner.validate_config(cfg)
56    }
57    fn self_check(&self) -> Vec<String> {
58        self.inner.self_check()
59    }
60    fn router(&self, ctx: ModuleContext) -> axum::Router {
61        self.inner.router(ctx)
62    }
63    fn well_known(&self) -> Option<axum::Router> {
64        self.inner.well_known().map(|router| {
65            router.route(
66                WELL_KNOWN_PROBE_PATH,
67                axum::routing::get(|| async { "well-known" }),
68            )
69        })
70    }
71    fn events(&self) -> Vec<(EventName, EventHandler)> {
72        self.inner.events()
73    }
74    fn scheduled<'a>(
75        &'a self,
76        ctx: &'a ModuleContext,
77        cron: &'a str,
78    ) -> BoxFuture<'a, Result<(), AnyError>> {
79        self.inner.scheduled(ctx, cron)
80    }
81}
82
83/// Route the probe wrapper registers inside the module's well-known
84/// router; reachable at `/.well-known{WELL_KNOWN_PROBE_PATH}`.
85const WELL_KNOWN_PROBE_PATH: &str = "/conformance-probe";
86
87/// A [`Ports`] bundle with every [`Port`] wired to a fake, for checks that
88/// need a full bundle: conformance's visibility check (declared ports must
89/// survive [`Ports::view_for`], undeclared ones must be hidden), and a
90/// runtime proving it carries every port through a copy of its bundle.
91/// `dispatcher` and `tenants` are not ports and stay `None`.
92///
93/// # Panics
94///
95/// Only if the kit's own fixed signer secret or clock epoch is invalid.
96#[must_use]
97pub fn full_fake_ports() -> Ports {
98    let mut ports = Ports::empty();
99    ports.db = Some(Arc::new(crate::fakes::EmptyDatabase));
100    ports.auth = Some(Arc::new(crate::fakes::FakeAuth::new(
101        crate::fakes::AuthMode::Anonymous,
102    )));
103    ports.mailer = Some(Arc::new(crate::fakes::FakeMailer::new(
104        crate::fakes::MailerMode::SendOk,
105    )));
106    ports.captcha = Some(Arc::new(crate::fakes::FakeCaptcha::allow_all()));
107    ports.rate_limiter = Some(Arc::new(crate::fakes::FakeRateLimiter::always_allow()));
108    ports.signer = Some(Arc::new(
109        HmacSigner::new(crate::TEST_HARNESS_SECRET, None).expect("test secret is long enough"),
110    ));
111    ports.kv = Some(Arc::new(crate::fakes::MemoryKeyValue::new()));
112    ports.blob = Some(Arc::new(crate::fakes::MemoryBlob::new()));
113    ports.push = Some(Arc::new(crate::fakes::FakePush::new(
114        crate::fakes::PushMode::DeliverOk,
115    )));
116    ports.payments = Some(Arc::new(crate::fakes::FakePayments::new(
117        crate::fakes::PaymentsMode::Ok,
118    )));
119    ports.tracker = Some(Arc::new(crate::fakes::FakeTracker::new(
120        crate::fakes::TrackerMode::FileOk,
121    )));
122    ports.realtime = Some(Arc::new(crate::fakes::FakeRealtime::new()));
123    ports.text_model = Some(Arc::new(crate::fakes::FakeTextModel::default()));
124    ports.classifier = Some(Arc::new(crate::fakes::FakeClassifier::default()));
125    // The index's dimensions are the fake embedder's width: an index and
126    // the embedder feeding it must agree (issue #561).
127    ports.vector_index = Some(Arc::new(cratefield_core::ExactVectorIndex::new(
128        crate::fakes::FAKE_EMBEDDER_DIMENSIONS,
129    )));
130    ports.embedder = Some(Arc::new(crate::fakes::FakeEmbedder));
131    ports.custom_hostnames = Some(Arc::new(crate::fakes::FakeCustomHostnames::new(
132        "cratefield.app",
133    )));
134    ports.http = Some(Arc::new(crate::fakes::FakeHttpClient::ok_json("{}")));
135    ports.clock = Some(Arc::new(crate::fakes::FixedClock(
136        time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
137    )));
138    ports.id_gen = Some(Arc::new(UlidIdGen));
139    ports.defer = Some(Arc::new(crate::fakes::FakeDefer::new()));
140    ports.url_safety = Some(Arc::new(crate::fakes::FakeUrlSafety));
141    // The actor host gets its own clock: the ports' `FixedClock` is
142    // immutable, while a test that fires an alarm advances this one. An
143    // empty handler registry is enough — a module only ever reaches a
144    // `ScopedActors` over it, and nothing here declares an actor kind.
145    ports.actors = Some(Arc::new(crate::fakes::MemoryActors::new(
146        cratefield_core::ActorHandlers::new(),
147        crate::fakes::ManualClock::new(
148            time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
149        ),
150    )));
151    ports
152}
153
154/// Runs the shared conformance suite against one module, once per
155/// dialect available in the environment (issue #20 — SQLite always,
156/// Postgres when `FZ_TEST_POSTGRES_URL` names a server):
157///
158/// 1. mounts under `/v1/<name>` and `/__health` lists it with its version;
159/// 2. a request under the module prefix is answered (no harness-level
160///    crash — module-specific routes are covered by the module's own
161///    tests via [`crate::request`]);
162/// 3. migrations apply from scratch **twice** on fresh databases
163///    (idempotence);
164/// 4. no undeclared port access: `Ports::view_for` hides every port the
165///    module did not declare;
166/// 5. two concurrent requests keep their own request ids (ADR 0007);
167/// 6. a well-known router, when provided, serves at the root under
168///    `/.well-known` and never under `/v1` (issue #46);
169/// 7. every table the module owns has a personal-data declaration, so a
170///    table cannot be added to `tables()` and left outside export and
171///    erasure (issue #244);
172/// 8. every table the module's **migrations** create is in `tables()`, so a
173///    table cannot come into being outside all three lists at once — and,
174///    the other way round, every table `tables()` names is one the scan
175///    actually found, so check 8 cannot pass by seeing nothing (issue #272).
176///
177/// # Panics
178///
179/// Panics with a message naming the failed check and the dialect.
180pub fn conformance(module: Box<dyn Module>) {
181    let inner: Arc<dyn Module> = Arc::from(module);
182    conformance_inner(&inner, true);
183}
184
185/// [`conformance`] without the sidecar parity axis (issue #64). `reason`
186/// is recorded in code and printed by the run, so a module that opts out
187/// says why in the same place it opts out. Use it only for a module that
188/// genuinely cannot be sidecar-mounted; "it fails" is not a reason.
189///
190/// # Panics
191///
192/// Panics when `reason` is empty, and on any conformance failure.
193pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
194    assert!(
195        !reason.trim().is_empty(),
196        "conformance_in_process_only needs a reason: it is the only record of why \
197         `{}` is not checked for sidecar parity",
198        module.name()
199    );
200    eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
201    let inner: Arc<dyn Module> = Arc::from(module);
202    conformance_inner(&inner, false);
203}
204
205/// The modules that own tables and have not said what is personal about
206/// them, with the tables still missing.
207///
208/// **Empty, and that is the deliverable** (issue #265). `auth-core`, `cms`,
209/// `email-signup`, `linkedin` and `waitlist` each sat here while they were
210/// being worked through, and each left as it was declared. The mechanism
211/// stays because the next module to grow a table before its declaration will
212/// want it — but an entry is a debt with a number on it, not a place to park
213/// one, and the list is checked empty by
214/// `no_module_is_exempt_from_the_personal_data_rule`.
215///
216/// An exemption is a loud silence rather than a quiet one. `HarnessBuilder::build`
217/// has always refused a declaration for a table a module does **not** own; the
218/// converse — a table with no declaration at all — is what actually happens,
219/// and what left `cratefield-module-notifications` holding addresses, device
220/// tokens and inbox rows outside the erasure catalogue through six migrations
221/// in two days (issue #244).
222///
223/// An entry names the tables, not just the module, so it cannot rot: a module
224/// that declares one of them fails until its entry is corrected or removed,
225/// and a module that grows a **new** undeclared table fails even while it is
226/// listed. Every exemption prints on the run that uses it.
227const UNDECLARED_TABLES: &[(&str, &[&str])] = &[];
228
229/// What [`personal_data_verdict`] found.
230enum Verdict {
231    /// Every table the module owns has a declaration.
232    Declared,
233    /// Listed in `UNDECLARED_TABLES`, and the entry still describes the
234    /// module exactly. Printed on the run rather than failing it.
235    Exempt(String),
236    /// The panic message.
237    Failed(String),
238}
239
240/// The personal-data rule, as a value rather than a panic (issue #244).
241///
242/// Split out from [`check_personal_data`] so the exemption's anti-rot half
243/// keeps a test after the list went empty: with nothing listed there is no
244/// module left to reach that branch through `conformance` itself, and a
245/// mechanism that only runs when somebody needs it is exactly the one that
246/// must not be allowed to quietly stop working in between.
247fn personal_data_verdict(
248    name: &str,
249    undeclared: &[&'static str],
250    exemptions: &[(&str, &[&str])],
251) -> Verdict {
252    let exempt = exemptions
253        .iter()
254        .find(|(listed, _)| *listed == name)
255        .map(|(_, tables)| *tables);
256
257    if let Some(tables) = exempt {
258        if undeclared != tables {
259            return Verdict::Failed(format!(
260                "[{name}] is exempt from the personal-data rule for {tables:?} (issue #265), but \
261                 its undeclared tables are now {undeclared:?}. Correct the entry in \
262                 UNDECLARED_TABLES in cratefield-testing, or remove it: an exemption that no \
263                 longer describes the module is how the next omission hides."
264            ));
265        }
266        return Verdict::Exempt(format!(
267            "[{name}] personal-data declarations missing for {tables:?}; exempt under issue #265"
268        ));
269    }
270
271    if undeclared.is_empty() {
272        return Verdict::Declared;
273    }
274    Verdict::Failed(format!(
275        "[{name}] owns {undeclared:?} and declares nothing personal about them. \
276         `cratefield-module-privacy` plans an export and an erasure from \
277         `Module::personal_data()`, so a table missing from it is never exported and never \
278         erased. Declare each one with a `PersonalDataSet`, or with `PersonalDataSet::none(table, \
279         reason)` if it holds nobody — \"no declaration\" and \"nothing here\" must not look the \
280         same."
281    ))
282}
283
284/// Fails a module that owns a table it declares nothing about (issue #244).
285///
286/// Export walks [`Module::tables`] and erasure plans from
287/// [`Module::personal_data`], so a table added to the first and forgotten in
288/// the second is invisible from both ends: the venture boots, the export runs,
289/// the erasure reports success, and the rows are never in either. Nothing
290/// catches it but this.
291fn check_personal_data(module: &dyn Module) {
292    match personal_data_verdict(
293        module.name(),
294        &cratefield_core::undeclared_tables(module),
295        UNDECLARED_TABLES,
296    ) {
297        Verdict::Declared => {}
298        Verdict::Exempt(note) => eprintln!("{note}"),
299        Verdict::Failed(message) => panic!("{message}"),
300    }
301}
302
303/// The migrations-versus-`tables()` rule, as a value rather than a panic
304/// (issue #272). `Err` is the panic message.
305///
306/// **No exemption list.** The rule it completes has one, emptied in #271, and
307/// an entry in it is a debt with a number on it. This one starts empty and
308/// stays that way: a module that cannot pass it has a table outside export and
309/// outside erasure right now, and the entry would be the record of deciding
310/// not to mind. #265 is what a list that grows looks like.
311///
312/// `found` is every table the module's migrations leave behind and `listed` is
313/// what `tables()` says, and **both directions are checked**, deliberately:
314///
315/// - a table in `found` and not in `listed` is the hole this exists for;
316/// - a table in `listed` that the scan did not find is the failure mode that
317///   makes the first check pass for the wrong reason. "No unlisted table" is
318///   an absence assertion, and a scan that has quietly stopped matching —
319///   a dialect that stops embedding its SQL, a `CREATE TABLE` written a way
320///   the walk does not read — satisfies it perfectly while seeing nothing.
321///   A module with migrations and a table list must be able to show the scan
322///   found what it listed.
323fn unlisted_verdict(
324    name: &str,
325    migration_count: usize,
326    listed: &[&'static str],
327    found: &[String],
328) -> Result<(), String> {
329    if migration_count > 0 {
330        let missed: Vec<&str> = listed
331            .iter()
332            .copied()
333            .filter(|table| !found.iter().any(|seen| seen.eq_ignore_ascii_case(table)))
334            .collect();
335        if !missed.is_empty() {
336            return Err(format!(
337                "[{name}] lists {missed:?} in `Module::tables()`, but the `CREATE TABLE` scan \
338                 over its {migration_count} migrations did not find them — it found {found:?}. \
339                 Either the module does not create those tables, or the scan has stopped \
340                 matching; the second is why this is checked at all, because \"no unlisted \
341                 table\" is an absence assertion and a scan that sees nothing satisfies it \
342                 forever (issue #272)."
343            ));
344        }
345    }
346
347    let unlisted: Vec<&String> = found
348        .iter()
349        .filter(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)))
350        .collect();
351    if unlisted.is_empty() {
352        return Ok(());
353    }
354    Err(format!(
355        "[{name}] creates {unlisted:?} in its migrations and does not list them in \
356         `Module::tables()`. A table in neither list is outside three things at once: \
357         `fz data export` walks `tables()`, subject access and erasure walk \
358         `personal_data()`, and the rule that compares them can only read the two lists it \
359         is handed. `auth-core` held `deletion_jobs` — and a person's identifier at their \
360         identity provider with it — in exactly that position (issue #272). Add each table \
361         to `tables()` and give it a `PersonalDataSet`; note that `tables()` is also what a \
362         whole-database `fz data export`/`import` carries, so adding one changes what a \
363         move of this venture takes with it."
364    ))
365}
366
367/// Fails a module that creates a table its `tables()` never mentions
368/// (issue #272).
369///
370/// The converse of [`check_personal_data`], one step further back: that rule
371/// reads `tables()`, so it cannot see a table that never reached the list.
372/// The migrations can, because a `CREATE TABLE` is where a table comes into
373/// being and is the one statement its author cannot forget to write.
374///
375/// It reaches a module through the module's own `tests/conformance.rs`, which
376/// is the cost of being a kit check rather than a build error: a module
377/// without that file is not checked by anything. The control plane's two
378/// modules are in exactly that position and own six unlisted tables between
379/// them (issue #280).
380fn check_module_tables(module: &dyn Module) {
381    if let Err(message) = unlisted_verdict(
382        module.name(),
383        module.migrations().sqlite.len() + module.migrations().postgres.len(),
384        module.tables(),
385        &cratefield_core::migration_tables(module),
386    ) {
387        panic!("{message}");
388    }
389}
390
391fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
392    check_module_tables(inner.as_ref());
393    check_personal_data(inner.as_ref());
394    let name = inner.name().to_owned();
395    let version = inner.version().to_owned();
396    let has_well_known = inner.well_known().is_some();
397    let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
398        inner: inner.clone(),
399    });
400
401    for dialect in crate::dialect::Dialect::available() {
402        conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
403    }
404
405    if parity {
406        // One instance across both mounts, as the dialect axis above
407        // already does: the probes never reach a module's parked context.
408        parity_on(inner, &name);
409    }
410}
411
412fn conformance_on_dialect(
413    dialect: &crate::dialect::Dialect,
414    module: Arc<dyn Module>,
415    name: &str,
416    version: &str,
417    has_well_known: bool,
418) {
419    let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
420    let module = kit
421        .modules
422        .iter()
423        .find(|m| m.name() == name)
424        .unwrap_or_else(|| panic!("module {name} missing from kit"))
425        .clone();
426
427    // 1. health lists it.
428    let health = pollster::block_on(request(
429        &kit.router,
430        axum::http::Method::GET,
431        "/__health",
432        None,
433    ));
434    let body = health.json();
435    let listed = body["modules"]
436        .as_array()
437        .unwrap_or_else(|| panic!("health modules array missing: {body}"));
438    let entry = listed
439        .iter()
440        .find(|entry| entry["name"] == *name)
441        .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
442    assert_eq!(
443        entry["version"],
444        version,
445        "[{}] health lists the module version",
446        dialect.name()
447    );
448
449    // 2. a request under the prefix is answered without panicking.
450    let probe = pollster::block_on(request(
451        &kit.router,
452        axum::http::Method::GET,
453        &format!("/v1/{name}/"),
454        None,
455    ));
456    // Any status is the module's business; the point is no crash.
457    let _ = probe.status;
458
459    // 3. migrations apply twice on fresh databases.
460    #[cfg(feature = "postgres")]
461    if let crate::dialect::Dialect::Postgres { .. } = &dialect {
462        crate::pg::migrations_apply_twice(&kit.modules)
463            .unwrap_or_else(|message| panic!("[postgres] {message}"));
464        check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
465        return;
466    }
467    for round in 1..=2 {
468        let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
469            .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
470        for module in &kit.modules {
471            fresh
472                .apply_migrations(module.name(), module.migrations().sqlite)
473                .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
474        }
475    }
476    check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
477}
478
479/// Conformance checks 4-6: undeclared ports stay hidden, concurrent
480/// requests keep their request ids, and a well-known router mounts at
481/// the root only.
482fn check_visibility_and_scope(
483    kit: &TestHarness,
484    module: &dyn Module,
485    name: &str,
486    has_well_known: bool,
487) {
488    // 4. undeclared ports are hidden (declared ones stay visible).
489    // Walks `Port::ALL` rather than a list of its own: a hand-written one
490    // had already lost `Port::Auth` (issue #450).
491    let view = full_fake_ports().view_for(module);
492    for port in Port::ALL {
493        let declared = module.requires().contains(port) || module.optional().contains(port);
494        assert_eq!(
495            view.has(*port),
496            declared,
497            "{name}: port {} must be visible only when declared",
498            port.name()
499        );
500    }
501
502    // 5. two concurrent requests keep their own request ids (ADR 0007).
503    let router_a = kit.router.clone();
504    let router_b = kit.router.clone();
505    let id_a = "conformance-AAAAAAAA";
506    let id_b = "conformance-BBBBBBBB";
507    let make = |router: axum::Router, id: &'static str| {
508        std::thread::spawn(move || {
509            use tower::ServiceExt;
510            let request = axum::http::Request::builder()
511                .uri("/__health")
512                .header("x-request-id", id)
513                .body(axum::body::Body::empty())
514                .expect("request builds");
515            let response = pollster::block_on(router.oneshot(request)).expect("router answers");
516            response
517                .headers()
518                .get("x-request-id")
519                .and_then(|value| value.to_str().ok())
520                .expect("request id echoed")
521                .to_string()
522        })
523    };
524    let handle_a = make(router_a, id_a);
525    let handle_b = make(router_b, id_b);
526    assert_eq!(handle_a.join().expect("a completes"), id_a);
527    assert_eq!(handle_b.join().expect("b completes"), id_b);
528
529    check_well_known_mount(kit, name, has_well_known);
530}
531
532/// Conformance check 6 (issue #46): the module's well-known router, when
533/// present, serves at the root under `/.well-known` and never under
534/// `/v1`; when absent, nothing mounts at `/.well-known`.
535fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
536    let probe = pollster::block_on(request(
537        &kit.router,
538        axum::http::Method::GET,
539        &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
540        None,
541    ));
542    if has_well_known {
543        assert_eq!(
544            probe.status,
545            axum::http::StatusCode::OK,
546            "{name}: well-known router must serve at the root"
547        );
548        let under_v1 = pollster::block_on(request(
549            &kit.router,
550            axum::http::Method::GET,
551            &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
552            None,
553        ));
554        assert_eq!(
555            under_v1.status,
556            axum::http::StatusCode::NOT_FOUND,
557            "{name}: well-known routes must not be nested under /v1"
558        );
559    } else {
560        assert_eq!(
561            probe.status,
562            axum::http::StatusCode::NOT_FOUND,
563            "{name}: nothing must mount at /.well-known without a well-known router"
564        );
565    }
566}
567
568/// One probe of the [`sidecar_parity`] battery: a request whose answer
569/// must be identical whether the module is linked in or reached over a
570/// service binding.
571struct Probe {
572    what: &'static str,
573    method: axum::http::Method,
574    /// Appended to `/v1/<module>`.
575    path: &'static str,
576    body: Option<&'static str>,
577}
578
579/// The request id both mounts are given, so `instance` in a problem body
580/// and the `x-request-id` header can be compared byte for byte. The
581/// harness accepts a client-supplied id that matches its pattern.
582const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
583
584/// Sends one probe through `router`, with the fixed request id.
585async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
586    use axum::http::{HeaderValue, Request, header};
587    use tower::ServiceExt;
588
589    let uri = format!("/v1/{name}{}", probe.path);
590    let mut builder = Request::builder()
591        .method(probe.method.clone())
592        .uri(uri)
593        .header(
594            cratefield_core::X_REQUEST_ID,
595            HeaderValue::from_static(PARITY_REQUEST_ID),
596        )
597        .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
598    let body = match probe.body {
599        Some(json) => {
600            builder = builder.header(header::CONTENT_TYPE, "application/json");
601            axum::body::Body::from(json)
602        }
603        None => axum::body::Body::empty(),
604    };
605    let response = router
606        .clone()
607        .oneshot(builder.body(body).expect("probe request builds"))
608        .await
609        .expect("router is infallible");
610    crate::TestResponse::of(response).await
611}
612
613/// Asserts that a module answers identically in-process and behind a
614/// sidecar mount (issue #64, ADR 0009: a caller cannot tell which).
615///
616/// Both mounts are given the same client-supplied request id, so the
617/// `instance` of a problem body and the `x-request-id` header are
618/// comparable byte for byte. Probes are deliberately module-agnostic —
619/// unknown paths, a malformed body, a wrong method — because the kit
620/// does not know the module's routes and because those are exactly the
621/// paths where the hop could quietly rewrite something.
622///
623/// Also asserted: a body over the harness's 64 KiB cap is refused by the
624/// **host** and never forwarded.
625///
626/// # Panics
627///
628/// Panics naming the probe and the field that diverged.
629pub fn sidecar_parity(module: Box<dyn Module>) {
630    let name = module.name().to_owned();
631    parity_on(&Arc::from(module), &name);
632}
633
634fn parity_on(shared: &Arc<dyn Module>, name: &str) {
635    // In-process: the module is linked into the harness under test.
636    let in_process = TestHarness::from_arcs(
637        vec![shared.clone()],
638        crate::dialect::Dialect::Sqlite,
639        |_| {},
640    );
641
642    // Sidecar: a second harness holds the module, and the host holds a
643    // mount table pointing at it over a fake service binding.
644    let remote = TestHarness::from_arcs(
645        vec![shared.clone()],
646        crate::dialect::Dialect::Sqlite,
647        |_| {},
648    );
649    let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
650        "PARITY",
651        remote.router.clone(),
652    ));
653    let table = format!("{{\"{name}\":\"PARITY\"}}");
654    let host = TestHarness::with_builder(
655        Vec::new(),
656        |builder| builder,
657        |ports| {
658            ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
659                ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
660                (cratefield_core::HARNESS_SIDECARS, table.as_str()),
661            ]));
662            ports.dispatcher = Some(dispatcher);
663        },
664    );
665
666    let probes = [
667        Probe {
668            what: "an unknown path under the module prefix",
669            method: axum::http::Method::GET,
670            path: "/__parity_no_such_route",
671            body: None,
672        },
673        Probe {
674            what: "a POST to an unknown path",
675            method: axum::http::Method::POST,
676            path: "/__parity_no_such_route",
677            body: Some(r#"{"parity":true}"#),
678        },
679        Probe {
680            what: "the module root",
681            method: axum::http::Method::GET,
682            path: "",
683            body: None,
684        },
685        Probe {
686            what: "a malformed JSON body at the module root",
687            method: axum::http::Method::POST,
688            path: "",
689            body: Some("{not json"),
690        },
691    ];
692
693    for (sent, probe) in probes.iter().enumerate() {
694        let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
695        let hopped = pollster::block_on(probe_once(&host.router, name, probe));
696        // Without this the axis could pass vacuously: if the mount stopped
697        // forwarding, the host would answer its own 404 and a module that
698        // also answers 404 would compare equal. Every probe must have
699        // crossed the hop.
700        assert_eq!(
701            sidecar.calls(),
702            sent + 1,
703            "[{name}] {} never reached the sidecar: the mount is not forwarding, \
704             so this comparison proves nothing",
705            probe.what
706        );
707        compare(name, probe.what, &direct, &hopped);
708    }
709
710    check_oversized_body_stops_at_the_host(&host, &sidecar, name);
711}
712
713/// A body over the harness cap is refused by the **host** and never
714/// forwarded: the forwarder buffers, so a body it accepted would be held
715/// in the isolate twice (issue #64, amended).
716fn check_oversized_body_stops_at_the_host(
717    host: &TestHarness,
718    sidecar: &Arc<crate::sidecar::FakeSidecar>,
719    name: &str,
720) {
721    use axum::http::{Request, StatusCode, header};
722    use tower::ServiceExt;
723
724    let before = sidecar.calls();
725    let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
726    let response = pollster::block_on(async {
727        let request = Request::builder()
728            .method(axum::http::Method::POST)
729            .uri(format!("/v1/{name}"))
730            .header(header::CONTENT_TYPE, "application/json")
731            .body(axum::body::Body::from(big))
732            .expect("oversized request builds");
733        let response = host
734            .router
735            .clone()
736            .oneshot(request)
737            .await
738            .expect("router is infallible");
739        crate::TestResponse::of(response).await
740    });
741    assert_eq!(
742        response.status,
743        StatusCode::PAYLOAD_TOO_LARGE,
744        "[{name}] a body over {} bytes must be refused by the host",
745        cratefield_core::MAX_BODY_BYTES
746    );
747    assert_eq!(
748        sidecar.calls(),
749        before,
750        "[{name}] an oversized body must never be forwarded to the sidecar"
751    );
752}
753
754/// Compares one probe's two answers: status, the problem body byte for
755/// byte, and the request id (present exactly once, and the one the
756/// caller sent).
757fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
758    assert_eq!(
759        direct.status, hopped.status,
760        "[{name}] status differs over the sidecar hop for {what}"
761    );
762    assert_eq!(
763        direct.body(),
764        hopped.body(),
765        "[{name}] body differs over the sidecar hop for {what}"
766    );
767    for header in [
768        axum::http::header::CONTENT_TYPE.as_str(),
769        axum::http::header::LOCATION.as_str(),
770    ] {
771        assert_eq!(
772            direct.headers.get(header),
773            hopped.headers.get(header),
774            "[{name}] `{header}` differs over the sidecar hop for {what}"
775        );
776    }
777    for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
778        let ids: Vec<_> = response
779            .headers
780            .get_all(cratefield_core::X_REQUEST_ID)
781            .iter()
782            .collect();
783        assert_eq!(
784            ids.len(),
785            1,
786            "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
787            ids.len()
788        );
789        assert_eq!(
790            ids[0], PARITY_REQUEST_ID,
791            "[{name}] {label} did not echo the caller's request id for {what}"
792        );
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::{UNDECLARED_TABLES, Verdict, personal_data_verdict, unlisted_verdict};
799
800    /// A fixture list, so these cover the mechanism rather than whatever
801    /// happens to be exempt on the day.
802    const FIXTURE: &[(&str, &[&str])] = &[("ledger", &["ledger_entries", "ledger_lines"])];
803
804    fn message(verdict: Verdict) -> String {
805        match verdict {
806            Verdict::Failed(message) | Verdict::Exempt(message) => message,
807            Verdict::Declared => String::new(),
808        }
809    }
810
811    #[test]
812    fn no_module_is_exempt_from_the_personal_data_rule() {
813        // Issue #265: the five modules that could not pass the rule when it
814        // landed are declared, and the list they sat in is empty. The
815        // mechanism stays for the next module that needs it; an entry in it
816        // is a debt, and this is the test that notices one being taken on.
817        assert!(
818            UNDECLARED_TABLES.is_empty(),
819            "a module is exempt from the personal-data rule again: {UNDECLARED_TABLES:?}. \
820             An exemption means a venture composing that module answers an erasure request \
821             without the rows it lists — declare them instead, and open the issue the entry \
822             would have tracked."
823        );
824    }
825
826    #[test]
827    fn a_declared_module_passes() {
828        assert!(matches!(
829            personal_data_verdict("notes", &[], FIXTURE),
830            Verdict::Declared
831        ));
832    }
833
834    #[test]
835    fn an_exempt_module_that_declared_everything_is_told_to_remove_its_entry() {
836        // The other end of the anti-rot rule, and the one this change ran
837        // into: a module that has done the work still fails while its
838        // exemption stands, because an entry left behind is how the next
839        // omission hides behind the last one.
840        let message = message(personal_data_verdict("ledger", &[], FIXTURE));
841        assert!(message.contains("or remove it"), "{message}");
842        assert!(message.contains("#265"), "{message}");
843    }
844
845    #[test]
846    fn an_undeclared_table_fails_and_says_what_to_write() {
847        let message = message(personal_data_verdict("notes", &["note_tags"], FIXTURE));
848        assert!(message.contains("note_tags"), "{message}");
849        assert!(message.contains("PersonalDataSet::none"), "{message}");
850    }
851
852    #[test]
853    fn an_exemption_that_still_describes_its_module_is_printed_not_failed() {
854        let verdict = personal_data_verdict("ledger", &["ledger_entries", "ledger_lines"], FIXTURE);
855        assert!(matches!(verdict, Verdict::Exempt(_)));
856        assert!(message(verdict).contains("exempt under issue #265"));
857    }
858
859    #[test]
860    fn an_exemption_that_no_longer_describes_its_module_fails() {
861        // The anti-rot half. A module that declared one of its listed tables
862        // is a different module from the one that was exempted, and letting
863        // the entry keep covering it is how the next omission hides behind
864        // the last one.
865        let message = message(personal_data_verdict("ledger", &["ledger_lines"], FIXTURE));
866        assert!(message.contains("#265"), "{message}");
867        assert!(message.contains("ledger_entries"), "{message}");
868    }
869
870    #[test]
871    fn an_exempt_module_that_grows_a_new_undeclared_table_fails() {
872        let message = message(personal_data_verdict(
873            "ledger",
874            &["ledger_entries", "ledger_lines", "ledger_fx"],
875            FIXTURE,
876        ));
877        assert!(message.contains("ledger_fx"), "{message}");
878    }
879
880    fn problem(verdict: Result<(), String>) -> String {
881        verdict.err().unwrap_or_default()
882    }
883
884    #[test]
885    fn a_module_that_lists_every_table_it_creates_passes() {
886        assert!(
887            unlisted_verdict(
888                "auth-core",
889                6,
890                &["users", "sessions"],
891                &["users".to_owned(), "sessions".to_owned()],
892            )
893            .is_ok()
894        );
895    }
896
897    #[test]
898    fn a_table_the_migrations_create_and_tables_omits_fails() {
899        // The live shape: `auth-core` creates `deletion_jobs` in migration
900        // 0005 and lists seven tables that do not include it (issue #272).
901        let message = problem(unlisted_verdict(
902            "auth-core",
903            6,
904            &["users"],
905            &["users".to_owned(), "deletion_jobs".to_owned()],
906        ));
907        assert!(message.contains("deletion_jobs"), "{message}");
908        assert!(message.contains("#272"), "{message}");
909        // It has to say what to write, and that the list is also the export's.
910        assert!(message.contains("PersonalDataSet"), "{message}");
911        assert!(message.contains("fz data export"), "{message}");
912    }
913
914    #[test]
915    fn a_scan_that_found_nothing_fails_rather_than_passing_vacuously() {
916        // The failure this epic has hit twice: "no unlisted table" is an
917        // absence assertion, and a scan that has stopped matching satisfies
918        // it forever. A module with migrations and a table list must be able
919        // to show the scan found what it listed.
920        let message = problem(unlisted_verdict("auth-core", 6, &["users"], &[]));
921        assert!(message.contains("stopped matching"), "{message}");
922        assert!(message.contains("users"), "{message}");
923    }
924
925    #[test]
926    fn a_scan_that_found_only_some_of_the_listed_tables_fails() {
927        // Not only the all-or-nothing case: a walk that stops reading after
928        // the first statement of a set would find one table and miss the rest.
929        let message = problem(unlisted_verdict(
930            "auth-core",
931            6,
932            &["users", "sessions"],
933            &["users".to_owned()],
934        ));
935        // The one it missed is named, and what it did find is shown beside
936        // it: a message that only said "something is missing" would send the
937        // reader back to the scan with nothing to go on.
938        assert!(message.contains(r#"lists ["sessions"]"#), "{message}");
939        assert!(message.contains(r#"it found ["users"]"#), "{message}");
940    }
941
942    #[test]
943    fn a_module_with_no_migrations_is_not_asked_to_have_found_anything() {
944        // `auth-passkeys` and its siblings write to `auth-core`'s schema and
945        // ship no migrations of their own; there is nothing for the scan to
946        // read and nothing it could have missed.
947        assert!(unlisted_verdict("auth-passkeys", 0, &[], &[]).is_ok());
948    }
949}