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 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
83const WELL_KNOWN_PROBE_PATH: &str = "/conformance-probe";
86
87fn full_fake_ports() -> Ports {
90 let mut ports = Ports::empty();
91 ports.db = Some(Arc::new(crate::fakes::EmptyDatabase));
92 ports.mailer = Some(Arc::new(crate::fakes::FakeMailer::new(
93 crate::fakes::MailerMode::SendOk,
94 )));
95 ports.captcha = Some(Arc::new(crate::fakes::FakeCaptcha::allow_all()));
96 ports.rate_limiter = Some(Arc::new(crate::fakes::FakeRateLimiter::always_allow()));
97 ports.signer = Some(Arc::new(
98 HmacSigner::new(crate::TEST_HARNESS_SECRET, None).expect("test secret is long enough"),
99 ));
100 ports.kv = Some(Arc::new(crate::fakes::MemoryKeyValue::new()));
101 ports.blob = Some(Arc::new(crate::fakes::MemoryBlob::new()));
102 ports.push = Some(Arc::new(crate::fakes::FakePush::new(
103 crate::fakes::PushMode::DeliverOk,
104 )));
105 ports.payments = Some(Arc::new(crate::fakes::FakePayments::new(
106 crate::fakes::PaymentsMode::Ok,
107 )));
108 ports.realtime = Some(Arc::new(crate::fakes::FakeRealtime::new()));
109 ports.http = Some(Arc::new(crate::fakes::FakeHttpClient::ok_json("{}")));
110 ports.clock = Some(Arc::new(crate::fakes::FixedClock(
111 time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
112 )));
113 ports.id_gen = Some(Arc::new(UlidIdGen));
114 ports.defer = Some(Arc::new(crate::fakes::FakeDefer::new()));
115 ports
116}
117
118pub fn conformance(module: Box<dyn Module>) {
145 let inner: Arc<dyn Module> = Arc::from(module);
146 conformance_inner(&inner, true);
147}
148
149pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
158 assert!(
159 !reason.trim().is_empty(),
160 "conformance_in_process_only needs a reason: it is the only record of why \
161 `{}` is not checked for sidecar parity",
162 module.name()
163 );
164 eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
165 let inner: Arc<dyn Module> = Arc::from(module);
166 conformance_inner(&inner, false);
167}
168
169const UNDECLARED_TABLES: &[(&str, &[&str])] = &[];
192
193enum Verdict {
195 Declared,
197 Exempt(String),
200 Failed(String),
202}
203
204fn personal_data_verdict(
212 name: &str,
213 undeclared: &[&'static str],
214 exemptions: &[(&str, &[&str])],
215) -> Verdict {
216 let exempt = exemptions
217 .iter()
218 .find(|(listed, _)| *listed == name)
219 .map(|(_, tables)| *tables);
220
221 if let Some(tables) = exempt {
222 if undeclared != tables {
223 return Verdict::Failed(format!(
224 "[{name}] is exempt from the personal-data rule for {tables:?} (issue #265), but \
225 its undeclared tables are now {undeclared:?}. Correct the entry in \
226 UNDECLARED_TABLES in cratefield-testing, or remove it: an exemption that no \
227 longer describes the module is how the next omission hides."
228 ));
229 }
230 return Verdict::Exempt(format!(
231 "[{name}] personal-data declarations missing for {tables:?}; exempt under issue #265"
232 ));
233 }
234
235 if undeclared.is_empty() {
236 return Verdict::Declared;
237 }
238 Verdict::Failed(format!(
239 "[{name}] owns {undeclared:?} and declares nothing personal about them. \
240 `cratefield-module-privacy` plans an export and an erasure from \
241 `Module::personal_data()`, so a table missing from it is never exported and never \
242 erased. Declare each one with a `PersonalDataSet`, or with `PersonalDataSet::none(table, \
243 reason)` if it holds nobody — \"no declaration\" and \"nothing here\" must not look the \
244 same."
245 ))
246}
247
248fn check_personal_data(module: &dyn Module) {
256 match personal_data_verdict(
257 module.name(),
258 &cratefield_core::undeclared_tables(module),
259 UNDECLARED_TABLES,
260 ) {
261 Verdict::Declared => {}
262 Verdict::Exempt(note) => eprintln!("{note}"),
263 Verdict::Failed(message) => panic!("{message}"),
264 }
265}
266
267fn unlisted_verdict(
288 name: &str,
289 migration_count: usize,
290 listed: &[&'static str],
291 found: &[String],
292) -> Result<(), String> {
293 if migration_count > 0 {
294 let missed: Vec<&str> = listed
295 .iter()
296 .copied()
297 .filter(|table| !found.iter().any(|seen| seen.eq_ignore_ascii_case(table)))
298 .collect();
299 if !missed.is_empty() {
300 return Err(format!(
301 "[{name}] lists {missed:?} in `Module::tables()`, but the `CREATE TABLE` scan \
302 over its {migration_count} migrations did not find them — it found {found:?}. \
303 Either the module does not create those tables, or the scan has stopped \
304 matching; the second is why this is checked at all, because \"no unlisted \
305 table\" is an absence assertion and a scan that sees nothing satisfies it \
306 forever (issue #272)."
307 ));
308 }
309 }
310
311 let unlisted: Vec<&String> = found
312 .iter()
313 .filter(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)))
314 .collect();
315 if unlisted.is_empty() {
316 return Ok(());
317 }
318 Err(format!(
319 "[{name}] creates {unlisted:?} in its migrations and does not list them in \
320 `Module::tables()`. A table in neither list is outside three things at once: \
321 `fz data export` walks `tables()`, subject access and erasure walk \
322 `personal_data()`, and the rule that compares them can only read the two lists it \
323 is handed. `auth-core` held `deletion_jobs` — and a person's identifier at their \
324 identity provider with it — in exactly that position (issue #272). Add each table \
325 to `tables()` and give it a `PersonalDataSet`; note that `tables()` is also what a \
326 whole-database `fz data export`/`import` carries, so adding one changes what a \
327 move of this venture takes with it."
328 ))
329}
330
331fn check_module_tables(module: &dyn Module) {
345 if let Err(message) = unlisted_verdict(
346 module.name(),
347 module.migrations().sqlite.len() + module.migrations().postgres.len(),
348 module.tables(),
349 &cratefield_core::migration_tables(module),
350 ) {
351 panic!("{message}");
352 }
353}
354
355fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
356 check_module_tables(inner.as_ref());
357 check_personal_data(inner.as_ref());
358 let name = inner.name().to_owned();
359 let version = inner.version().to_owned();
360 let has_well_known = inner.well_known().is_some();
361 let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
362 inner: inner.clone(),
363 });
364
365 for dialect in crate::dialect::Dialect::available() {
366 conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
367 }
368
369 if parity {
370 parity_on(inner, &name);
373 }
374}
375
376fn conformance_on_dialect(
377 dialect: &crate::dialect::Dialect,
378 module: Arc<dyn Module>,
379 name: &str,
380 version: &str,
381 has_well_known: bool,
382) {
383 let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
384 let module = kit
385 .modules
386 .iter()
387 .find(|m| m.name() == name)
388 .unwrap_or_else(|| panic!("module {name} missing from kit"))
389 .clone();
390
391 let health = pollster::block_on(request(
393 &kit.router,
394 axum::http::Method::GET,
395 "/__health",
396 None,
397 ));
398 let body = health.json();
399 let listed = body["modules"]
400 .as_array()
401 .unwrap_or_else(|| panic!("health modules array missing: {body}"));
402 let entry = listed
403 .iter()
404 .find(|entry| entry["name"] == *name)
405 .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
406 assert_eq!(
407 entry["version"],
408 version,
409 "[{}] health lists the module version",
410 dialect.name()
411 );
412
413 let probe = pollster::block_on(request(
415 &kit.router,
416 axum::http::Method::GET,
417 &format!("/v1/{name}/"),
418 None,
419 ));
420 let _ = probe.status;
422
423 #[cfg(feature = "postgres")]
425 if let crate::dialect::Dialect::Postgres { .. } = &dialect {
426 crate::pg::migrations_apply_twice(&kit.modules)
427 .unwrap_or_else(|message| panic!("[postgres] {message}"));
428 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
429 return;
430 }
431 for round in 1..=2 {
432 let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
433 .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
434 for module in &kit.modules {
435 fresh
436 .apply_migrations(module.name(), module.migrations().sqlite)
437 .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
438 }
439 }
440 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
441}
442
443fn check_visibility_and_scope(
447 kit: &TestHarness,
448 module: &dyn Module,
449 name: &str,
450 has_well_known: bool,
451) {
452 let view = full_fake_ports().view_for(module);
454 for (port, provided) in [
455 (Port::Db, view.db.is_some()),
456 (Port::Mailer, view.mailer.is_some()),
457 (Port::Captcha, view.captcha.is_some()),
458 (Port::RateLimiter, view.rate_limiter.is_some()),
459 (Port::Signer, view.signer.is_some()),
460 (Port::KeyValue, view.kv.is_some()),
461 (Port::Blob, view.blob.is_some()),
462 (Port::Push, view.push.is_some()),
463 (Port::Payments, view.payments.is_some()),
464 (Port::Realtime, view.realtime.is_some()),
465 (Port::HttpClient, view.http.is_some()),
466 (Port::Clock, view.clock.is_some()),
467 (Port::IdGen, view.id_gen.is_some()),
468 (Port::Defer, view.defer.is_some()),
469 ] {
470 let declared = module.requires().contains(&port) || module.optional().contains(&port);
471 assert_eq!(
472 provided,
473 declared,
474 "{name}: port {} must be visible only when declared",
475 port.name()
476 );
477 }
478
479 let router_a = kit.router.clone();
481 let router_b = kit.router.clone();
482 let id_a = "conformance-AAAAAAAA";
483 let id_b = "conformance-BBBBBBBB";
484 let make = |router: axum::Router, id: &'static str| {
485 std::thread::spawn(move || {
486 use tower::ServiceExt;
487 let request = axum::http::Request::builder()
488 .uri("/__health")
489 .header("x-request-id", id)
490 .body(axum::body::Body::empty())
491 .expect("request builds");
492 let response = pollster::block_on(router.oneshot(request)).expect("router answers");
493 response
494 .headers()
495 .get("x-request-id")
496 .and_then(|value| value.to_str().ok())
497 .expect("request id echoed")
498 .to_string()
499 })
500 };
501 let handle_a = make(router_a, id_a);
502 let handle_b = make(router_b, id_b);
503 assert_eq!(handle_a.join().expect("a completes"), id_a);
504 assert_eq!(handle_b.join().expect("b completes"), id_b);
505
506 check_well_known_mount(kit, name, has_well_known);
507}
508
509fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
513 let probe = pollster::block_on(request(
514 &kit.router,
515 axum::http::Method::GET,
516 &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
517 None,
518 ));
519 if has_well_known {
520 assert_eq!(
521 probe.status,
522 axum::http::StatusCode::OK,
523 "{name}: well-known router must serve at the root"
524 );
525 let under_v1 = pollster::block_on(request(
526 &kit.router,
527 axum::http::Method::GET,
528 &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
529 None,
530 ));
531 assert_eq!(
532 under_v1.status,
533 axum::http::StatusCode::NOT_FOUND,
534 "{name}: well-known routes must not be nested under /v1"
535 );
536 } else {
537 assert_eq!(
538 probe.status,
539 axum::http::StatusCode::NOT_FOUND,
540 "{name}: nothing must mount at /.well-known without a well-known router"
541 );
542 }
543}
544
545struct Probe {
549 what: &'static str,
550 method: axum::http::Method,
551 path: &'static str,
553 body: Option<&'static str>,
554}
555
556const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
560
561async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
563 use axum::http::{HeaderValue, Request, header};
564 use tower::ServiceExt;
565
566 let uri = format!("/v1/{name}{}", probe.path);
567 let mut builder = Request::builder()
568 .method(probe.method.clone())
569 .uri(uri)
570 .header(
571 cratefield_core::X_REQUEST_ID,
572 HeaderValue::from_static(PARITY_REQUEST_ID),
573 )
574 .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
575 let body = match probe.body {
576 Some(json) => {
577 builder = builder.header(header::CONTENT_TYPE, "application/json");
578 axum::body::Body::from(json)
579 }
580 None => axum::body::Body::empty(),
581 };
582 let response = router
583 .clone()
584 .oneshot(builder.body(body).expect("probe request builds"))
585 .await
586 .expect("router is infallible");
587 crate::TestResponse::of(response).await
588}
589
590pub fn sidecar_parity(module: Box<dyn Module>) {
607 let name = module.name().to_owned();
608 parity_on(&Arc::from(module), &name);
609}
610
611fn parity_on(shared: &Arc<dyn Module>, name: &str) {
612 let in_process = TestHarness::from_arcs(
614 vec![shared.clone()],
615 crate::dialect::Dialect::Sqlite,
616 |_| {},
617 );
618
619 let remote = TestHarness::from_arcs(
622 vec![shared.clone()],
623 crate::dialect::Dialect::Sqlite,
624 |_| {},
625 );
626 let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
627 "PARITY",
628 remote.router.clone(),
629 ));
630 let table = format!("{{\"{name}\":\"PARITY\"}}");
631 let host = TestHarness::with_builder(
632 Vec::new(),
633 |builder| builder,
634 |ports| {
635 ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
636 ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
637 (cratefield_core::HARNESS_SIDECARS, table.as_str()),
638 ]));
639 ports.dispatcher = Some(dispatcher);
640 },
641 );
642
643 let probes = [
644 Probe {
645 what: "an unknown path under the module prefix",
646 method: axum::http::Method::GET,
647 path: "/__parity_no_such_route",
648 body: None,
649 },
650 Probe {
651 what: "a POST to an unknown path",
652 method: axum::http::Method::POST,
653 path: "/__parity_no_such_route",
654 body: Some(r#"{"parity":true}"#),
655 },
656 Probe {
657 what: "the module root",
658 method: axum::http::Method::GET,
659 path: "",
660 body: None,
661 },
662 Probe {
663 what: "a malformed JSON body at the module root",
664 method: axum::http::Method::POST,
665 path: "",
666 body: Some("{not json"),
667 },
668 ];
669
670 for (sent, probe) in probes.iter().enumerate() {
671 let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
672 let hopped = pollster::block_on(probe_once(&host.router, name, probe));
673 assert_eq!(
678 sidecar.calls(),
679 sent + 1,
680 "[{name}] {} never reached the sidecar: the mount is not forwarding, \
681 so this comparison proves nothing",
682 probe.what
683 );
684 compare(name, probe.what, &direct, &hopped);
685 }
686
687 check_oversized_body_stops_at_the_host(&host, &sidecar, name);
688}
689
690fn check_oversized_body_stops_at_the_host(
694 host: &TestHarness,
695 sidecar: &Arc<crate::sidecar::FakeSidecar>,
696 name: &str,
697) {
698 use axum::http::{Request, StatusCode, header};
699 use tower::ServiceExt;
700
701 let before = sidecar.calls();
702 let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
703 let response = pollster::block_on(async {
704 let request = Request::builder()
705 .method(axum::http::Method::POST)
706 .uri(format!("/v1/{name}"))
707 .header(header::CONTENT_TYPE, "application/json")
708 .body(axum::body::Body::from(big))
709 .expect("oversized request builds");
710 let response = host
711 .router
712 .clone()
713 .oneshot(request)
714 .await
715 .expect("router is infallible");
716 crate::TestResponse::of(response).await
717 });
718 assert_eq!(
719 response.status,
720 StatusCode::PAYLOAD_TOO_LARGE,
721 "[{name}] a body over {} bytes must be refused by the host",
722 cratefield_core::MAX_BODY_BYTES
723 );
724 assert_eq!(
725 sidecar.calls(),
726 before,
727 "[{name}] an oversized body must never be forwarded to the sidecar"
728 );
729}
730
731fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
735 assert_eq!(
736 direct.status, hopped.status,
737 "[{name}] status differs over the sidecar hop for {what}"
738 );
739 assert_eq!(
740 direct.body(),
741 hopped.body(),
742 "[{name}] body differs over the sidecar hop for {what}"
743 );
744 for header in [
745 axum::http::header::CONTENT_TYPE.as_str(),
746 axum::http::header::LOCATION.as_str(),
747 ] {
748 assert_eq!(
749 direct.headers.get(header),
750 hopped.headers.get(header),
751 "[{name}] `{header}` differs over the sidecar hop for {what}"
752 );
753 }
754 for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
755 let ids: Vec<_> = response
756 .headers
757 .get_all(cratefield_core::X_REQUEST_ID)
758 .iter()
759 .collect();
760 assert_eq!(
761 ids.len(),
762 1,
763 "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
764 ids.len()
765 );
766 assert_eq!(
767 ids[0], PARITY_REQUEST_ID,
768 "[{name}] {label} did not echo the caller's request id for {what}"
769 );
770 }
771}
772
773#[cfg(test)]
774mod tests {
775 use super::{UNDECLARED_TABLES, Verdict, personal_data_verdict, unlisted_verdict};
776
777 const FIXTURE: &[(&str, &[&str])] = &[("ledger", &["ledger_entries", "ledger_lines"])];
780
781 fn message(verdict: Verdict) -> String {
782 match verdict {
783 Verdict::Failed(message) | Verdict::Exempt(message) => message,
784 Verdict::Declared => String::new(),
785 }
786 }
787
788 #[test]
789 fn no_module_is_exempt_from_the_personal_data_rule() {
790 assert!(
795 UNDECLARED_TABLES.is_empty(),
796 "a module is exempt from the personal-data rule again: {UNDECLARED_TABLES:?}. \
797 An exemption means a venture composing that module answers an erasure request \
798 without the rows it lists — declare them instead, and open the issue the entry \
799 would have tracked."
800 );
801 }
802
803 #[test]
804 fn a_declared_module_passes() {
805 assert!(matches!(
806 personal_data_verdict("notes", &[], FIXTURE),
807 Verdict::Declared
808 ));
809 }
810
811 #[test]
812 fn an_exempt_module_that_declared_everything_is_told_to_remove_its_entry() {
813 let message = message(personal_data_verdict("ledger", &[], FIXTURE));
818 assert!(message.contains("or remove it"), "{message}");
819 assert!(message.contains("#265"), "{message}");
820 }
821
822 #[test]
823 fn an_undeclared_table_fails_and_says_what_to_write() {
824 let message = message(personal_data_verdict("notes", &["note_tags"], FIXTURE));
825 assert!(message.contains("note_tags"), "{message}");
826 assert!(message.contains("PersonalDataSet::none"), "{message}");
827 }
828
829 #[test]
830 fn an_exemption_that_still_describes_its_module_is_printed_not_failed() {
831 let verdict = personal_data_verdict("ledger", &["ledger_entries", "ledger_lines"], FIXTURE);
832 assert!(matches!(verdict, Verdict::Exempt(_)));
833 assert!(message(verdict).contains("exempt under issue #265"));
834 }
835
836 #[test]
837 fn an_exemption_that_no_longer_describes_its_module_fails() {
838 let message = message(personal_data_verdict("ledger", &["ledger_lines"], FIXTURE));
843 assert!(message.contains("#265"), "{message}");
844 assert!(message.contains("ledger_entries"), "{message}");
845 }
846
847 #[test]
848 fn an_exempt_module_that_grows_a_new_undeclared_table_fails() {
849 let message = message(personal_data_verdict(
850 "ledger",
851 &["ledger_entries", "ledger_lines", "ledger_fx"],
852 FIXTURE,
853 ));
854 assert!(message.contains("ledger_fx"), "{message}");
855 }
856
857 fn problem(verdict: Result<(), String>) -> String {
858 verdict.err().unwrap_or_default()
859 }
860
861 #[test]
862 fn a_module_that_lists_every_table_it_creates_passes() {
863 assert!(
864 unlisted_verdict(
865 "auth-core",
866 6,
867 &["users", "sessions"],
868 &["users".to_owned(), "sessions".to_owned()],
869 )
870 .is_ok()
871 );
872 }
873
874 #[test]
875 fn a_table_the_migrations_create_and_tables_omits_fails() {
876 let message = problem(unlisted_verdict(
879 "auth-core",
880 6,
881 &["users"],
882 &["users".to_owned(), "deletion_jobs".to_owned()],
883 ));
884 assert!(message.contains("deletion_jobs"), "{message}");
885 assert!(message.contains("#272"), "{message}");
886 assert!(message.contains("PersonalDataSet"), "{message}");
888 assert!(message.contains("fz data export"), "{message}");
889 }
890
891 #[test]
892 fn a_scan_that_found_nothing_fails_rather_than_passing_vacuously() {
893 let message = problem(unlisted_verdict("auth-core", 6, &["users"], &[]));
898 assert!(message.contains("stopped matching"), "{message}");
899 assert!(message.contains("users"), "{message}");
900 }
901
902 #[test]
903 fn a_scan_that_found_only_some_of_the_listed_tables_fails() {
904 let message = problem(unlisted_verdict(
907 "auth-core",
908 6,
909 &["users", "sessions"],
910 &["users".to_owned()],
911 ));
912 assert!(message.contains(r#"lists ["sessions"]"#), "{message}");
916 assert!(message.contains(r#"it found ["users"]"#), "{message}");
917 }
918
919 #[test]
920 fn a_module_with_no_migrations_is_not_asked_to_have_found_anything() {
921 assert!(unlisted_verdict("auth-passkeys", 0, &[], &[]).is_ok());
925 }
926}