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
87#[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 ports.http = Some(Arc::new(crate::fakes::FakeHttpClient::ok_json("{}")));
126 ports.clock = Some(Arc::new(crate::fakes::FixedClock(
127 time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
128 )));
129 ports.id_gen = Some(Arc::new(UlidIdGen));
130 ports.defer = Some(Arc::new(crate::fakes::FakeDefer::new()));
131 ports
132}
133
134pub fn conformance(module: Box<dyn Module>) {
161 let inner: Arc<dyn Module> = Arc::from(module);
162 conformance_inner(&inner, true);
163}
164
165pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
174 assert!(
175 !reason.trim().is_empty(),
176 "conformance_in_process_only needs a reason: it is the only record of why \
177 `{}` is not checked for sidecar parity",
178 module.name()
179 );
180 eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
181 let inner: Arc<dyn Module> = Arc::from(module);
182 conformance_inner(&inner, false);
183}
184
185const UNDECLARED_TABLES: &[(&str, &[&str])] = &[];
208
209enum Verdict {
211 Declared,
213 Exempt(String),
216 Failed(String),
218}
219
220fn personal_data_verdict(
228 name: &str,
229 undeclared: &[&'static str],
230 exemptions: &[(&str, &[&str])],
231) -> Verdict {
232 let exempt = exemptions
233 .iter()
234 .find(|(listed, _)| *listed == name)
235 .map(|(_, tables)| *tables);
236
237 if let Some(tables) = exempt {
238 if undeclared != tables {
239 return Verdict::Failed(format!(
240 "[{name}] is exempt from the personal-data rule for {tables:?} (issue #265), but \
241 its undeclared tables are now {undeclared:?}. Correct the entry in \
242 UNDECLARED_TABLES in cratefield-testing, or remove it: an exemption that no \
243 longer describes the module is how the next omission hides."
244 ));
245 }
246 return Verdict::Exempt(format!(
247 "[{name}] personal-data declarations missing for {tables:?}; exempt under issue #265"
248 ));
249 }
250
251 if undeclared.is_empty() {
252 return Verdict::Declared;
253 }
254 Verdict::Failed(format!(
255 "[{name}] owns {undeclared:?} and declares nothing personal about them. \
256 `cratefield-module-privacy` plans an export and an erasure from \
257 `Module::personal_data()`, so a table missing from it is never exported and never \
258 erased. Declare each one with a `PersonalDataSet`, or with `PersonalDataSet::none(table, \
259 reason)` if it holds nobody — \"no declaration\" and \"nothing here\" must not look the \
260 same."
261 ))
262}
263
264fn check_personal_data(module: &dyn Module) {
272 match personal_data_verdict(
273 module.name(),
274 &cratefield_core::undeclared_tables(module),
275 UNDECLARED_TABLES,
276 ) {
277 Verdict::Declared => {}
278 Verdict::Exempt(note) => eprintln!("{note}"),
279 Verdict::Failed(message) => panic!("{message}"),
280 }
281}
282
283fn unlisted_verdict(
304 name: &str,
305 migration_count: usize,
306 listed: &[&'static str],
307 found: &[String],
308) -> Result<(), String> {
309 if migration_count > 0 {
310 let missed: Vec<&str> = listed
311 .iter()
312 .copied()
313 .filter(|table| !found.iter().any(|seen| seen.eq_ignore_ascii_case(table)))
314 .collect();
315 if !missed.is_empty() {
316 return Err(format!(
317 "[{name}] lists {missed:?} in `Module::tables()`, but the `CREATE TABLE` scan \
318 over its {migration_count} migrations did not find them — it found {found:?}. \
319 Either the module does not create those tables, or the scan has stopped \
320 matching; the second is why this is checked at all, because \"no unlisted \
321 table\" is an absence assertion and a scan that sees nothing satisfies it \
322 forever (issue #272)."
323 ));
324 }
325 }
326
327 let unlisted: Vec<&String> = found
328 .iter()
329 .filter(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)))
330 .collect();
331 if unlisted.is_empty() {
332 return Ok(());
333 }
334 Err(format!(
335 "[{name}] creates {unlisted:?} in its migrations and does not list them in \
336 `Module::tables()`. A table in neither list is outside three things at once: \
337 `fz data export` walks `tables()`, subject access and erasure walk \
338 `personal_data()`, and the rule that compares them can only read the two lists it \
339 is handed. `auth-core` held `deletion_jobs` — and a person's identifier at their \
340 identity provider with it — in exactly that position (issue #272). Add each table \
341 to `tables()` and give it a `PersonalDataSet`; note that `tables()` is also what a \
342 whole-database `fz data export`/`import` carries, so adding one changes what a \
343 move of this venture takes with it."
344 ))
345}
346
347fn check_module_tables(module: &dyn Module) {
361 if let Err(message) = unlisted_verdict(
362 module.name(),
363 module.migrations().sqlite.len() + module.migrations().postgres.len(),
364 module.tables(),
365 &cratefield_core::migration_tables(module),
366 ) {
367 panic!("{message}");
368 }
369}
370
371fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
372 check_module_tables(inner.as_ref());
373 check_personal_data(inner.as_ref());
374 let name = inner.name().to_owned();
375 let version = inner.version().to_owned();
376 let has_well_known = inner.well_known().is_some();
377 let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
378 inner: inner.clone(),
379 });
380
381 for dialect in crate::dialect::Dialect::available() {
382 conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
383 }
384
385 if parity {
386 parity_on(inner, &name);
389 }
390}
391
392fn conformance_on_dialect(
393 dialect: &crate::dialect::Dialect,
394 module: Arc<dyn Module>,
395 name: &str,
396 version: &str,
397 has_well_known: bool,
398) {
399 let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
400 let module = kit
401 .modules
402 .iter()
403 .find(|m| m.name() == name)
404 .unwrap_or_else(|| panic!("module {name} missing from kit"))
405 .clone();
406
407 let health = pollster::block_on(request(
409 &kit.router,
410 axum::http::Method::GET,
411 "/__health",
412 None,
413 ));
414 let body = health.json();
415 let listed = body["modules"]
416 .as_array()
417 .unwrap_or_else(|| panic!("health modules array missing: {body}"));
418 let entry = listed
419 .iter()
420 .find(|entry| entry["name"] == *name)
421 .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
422 assert_eq!(
423 entry["version"],
424 version,
425 "[{}] health lists the module version",
426 dialect.name()
427 );
428
429 let probe = pollster::block_on(request(
431 &kit.router,
432 axum::http::Method::GET,
433 &format!("/v1/{name}/"),
434 None,
435 ));
436 let _ = probe.status;
438
439 #[cfg(feature = "postgres")]
441 if let crate::dialect::Dialect::Postgres { .. } = &dialect {
442 crate::pg::migrations_apply_twice(&kit.modules)
443 .unwrap_or_else(|message| panic!("[postgres] {message}"));
444 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
445 return;
446 }
447 for round in 1..=2 {
448 let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
449 .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
450 for module in &kit.modules {
451 fresh
452 .apply_migrations(module.name(), module.migrations().sqlite)
453 .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
454 }
455 }
456 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
457}
458
459fn check_visibility_and_scope(
463 kit: &TestHarness,
464 module: &dyn Module,
465 name: &str,
466 has_well_known: bool,
467) {
468 let view = full_fake_ports().view_for(module);
472 for port in Port::ALL {
473 let declared = module.requires().contains(port) || module.optional().contains(port);
474 assert_eq!(
475 view.has(*port),
476 declared,
477 "{name}: port {} must be visible only when declared",
478 port.name()
479 );
480 }
481
482 let router_a = kit.router.clone();
484 let router_b = kit.router.clone();
485 let id_a = "conformance-AAAAAAAA";
486 let id_b = "conformance-BBBBBBBB";
487 let make = |router: axum::Router, id: &'static str| {
488 std::thread::spawn(move || {
489 use tower::ServiceExt;
490 let request = axum::http::Request::builder()
491 .uri("/__health")
492 .header("x-request-id", id)
493 .body(axum::body::Body::empty())
494 .expect("request builds");
495 let response = pollster::block_on(router.oneshot(request)).expect("router answers");
496 response
497 .headers()
498 .get("x-request-id")
499 .and_then(|value| value.to_str().ok())
500 .expect("request id echoed")
501 .to_string()
502 })
503 };
504 let handle_a = make(router_a, id_a);
505 let handle_b = make(router_b, id_b);
506 assert_eq!(handle_a.join().expect("a completes"), id_a);
507 assert_eq!(handle_b.join().expect("b completes"), id_b);
508
509 check_well_known_mount(kit, name, has_well_known);
510}
511
512fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
516 let probe = pollster::block_on(request(
517 &kit.router,
518 axum::http::Method::GET,
519 &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
520 None,
521 ));
522 if has_well_known {
523 assert_eq!(
524 probe.status,
525 axum::http::StatusCode::OK,
526 "{name}: well-known router must serve at the root"
527 );
528 let under_v1 = pollster::block_on(request(
529 &kit.router,
530 axum::http::Method::GET,
531 &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
532 None,
533 ));
534 assert_eq!(
535 under_v1.status,
536 axum::http::StatusCode::NOT_FOUND,
537 "{name}: well-known routes must not be nested under /v1"
538 );
539 } else {
540 assert_eq!(
541 probe.status,
542 axum::http::StatusCode::NOT_FOUND,
543 "{name}: nothing must mount at /.well-known without a well-known router"
544 );
545 }
546}
547
548struct Probe {
552 what: &'static str,
553 method: axum::http::Method,
554 path: &'static str,
556 body: Option<&'static str>,
557}
558
559const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
563
564async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
566 use axum::http::{HeaderValue, Request, header};
567 use tower::ServiceExt;
568
569 let uri = format!("/v1/{name}{}", probe.path);
570 let mut builder = Request::builder()
571 .method(probe.method.clone())
572 .uri(uri)
573 .header(
574 cratefield_core::X_REQUEST_ID,
575 HeaderValue::from_static(PARITY_REQUEST_ID),
576 )
577 .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
578 let body = match probe.body {
579 Some(json) => {
580 builder = builder.header(header::CONTENT_TYPE, "application/json");
581 axum::body::Body::from(json)
582 }
583 None => axum::body::Body::empty(),
584 };
585 let response = router
586 .clone()
587 .oneshot(builder.body(body).expect("probe request builds"))
588 .await
589 .expect("router is infallible");
590 crate::TestResponse::of(response).await
591}
592
593pub fn sidecar_parity(module: Box<dyn Module>) {
610 let name = module.name().to_owned();
611 parity_on(&Arc::from(module), &name);
612}
613
614fn parity_on(shared: &Arc<dyn Module>, name: &str) {
615 let in_process = TestHarness::from_arcs(
617 vec![shared.clone()],
618 crate::dialect::Dialect::Sqlite,
619 |_| {},
620 );
621
622 let remote = TestHarness::from_arcs(
625 vec![shared.clone()],
626 crate::dialect::Dialect::Sqlite,
627 |_| {},
628 );
629 let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
630 "PARITY",
631 remote.router.clone(),
632 ));
633 let table = format!("{{\"{name}\":\"PARITY\"}}");
634 let host = TestHarness::with_builder(
635 Vec::new(),
636 |builder| builder,
637 |ports| {
638 ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
639 ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
640 (cratefield_core::HARNESS_SIDECARS, table.as_str()),
641 ]));
642 ports.dispatcher = Some(dispatcher);
643 },
644 );
645
646 let probes = [
647 Probe {
648 what: "an unknown path under the module prefix",
649 method: axum::http::Method::GET,
650 path: "/__parity_no_such_route",
651 body: None,
652 },
653 Probe {
654 what: "a POST to an unknown path",
655 method: axum::http::Method::POST,
656 path: "/__parity_no_such_route",
657 body: Some(r#"{"parity":true}"#),
658 },
659 Probe {
660 what: "the module root",
661 method: axum::http::Method::GET,
662 path: "",
663 body: None,
664 },
665 Probe {
666 what: "a malformed JSON body at the module root",
667 method: axum::http::Method::POST,
668 path: "",
669 body: Some("{not json"),
670 },
671 ];
672
673 for (sent, probe) in probes.iter().enumerate() {
674 let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
675 let hopped = pollster::block_on(probe_once(&host.router, name, probe));
676 assert_eq!(
681 sidecar.calls(),
682 sent + 1,
683 "[{name}] {} never reached the sidecar: the mount is not forwarding, \
684 so this comparison proves nothing",
685 probe.what
686 );
687 compare(name, probe.what, &direct, &hopped);
688 }
689
690 check_oversized_body_stops_at_the_host(&host, &sidecar, name);
691}
692
693fn check_oversized_body_stops_at_the_host(
697 host: &TestHarness,
698 sidecar: &Arc<crate::sidecar::FakeSidecar>,
699 name: &str,
700) {
701 use axum::http::{Request, StatusCode, header};
702 use tower::ServiceExt;
703
704 let before = sidecar.calls();
705 let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
706 let response = pollster::block_on(async {
707 let request = Request::builder()
708 .method(axum::http::Method::POST)
709 .uri(format!("/v1/{name}"))
710 .header(header::CONTENT_TYPE, "application/json")
711 .body(axum::body::Body::from(big))
712 .expect("oversized request builds");
713 let response = host
714 .router
715 .clone()
716 .oneshot(request)
717 .await
718 .expect("router is infallible");
719 crate::TestResponse::of(response).await
720 });
721 assert_eq!(
722 response.status,
723 StatusCode::PAYLOAD_TOO_LARGE,
724 "[{name}] a body over {} bytes must be refused by the host",
725 cratefield_core::MAX_BODY_BYTES
726 );
727 assert_eq!(
728 sidecar.calls(),
729 before,
730 "[{name}] an oversized body must never be forwarded to the sidecar"
731 );
732}
733
734fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
738 assert_eq!(
739 direct.status, hopped.status,
740 "[{name}] status differs over the sidecar hop for {what}"
741 );
742 assert_eq!(
743 direct.body(),
744 hopped.body(),
745 "[{name}] body differs over the sidecar hop for {what}"
746 );
747 for header in [
748 axum::http::header::CONTENT_TYPE.as_str(),
749 axum::http::header::LOCATION.as_str(),
750 ] {
751 assert_eq!(
752 direct.headers.get(header),
753 hopped.headers.get(header),
754 "[{name}] `{header}` differs over the sidecar hop for {what}"
755 );
756 }
757 for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
758 let ids: Vec<_> = response
759 .headers
760 .get_all(cratefield_core::X_REQUEST_ID)
761 .iter()
762 .collect();
763 assert_eq!(
764 ids.len(),
765 1,
766 "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
767 ids.len()
768 );
769 assert_eq!(
770 ids[0], PARITY_REQUEST_ID,
771 "[{name}] {label} did not echo the caller's request id for {what}"
772 );
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::{UNDECLARED_TABLES, Verdict, personal_data_verdict, unlisted_verdict};
779
780 const FIXTURE: &[(&str, &[&str])] = &[("ledger", &["ledger_entries", "ledger_lines"])];
783
784 fn message(verdict: Verdict) -> String {
785 match verdict {
786 Verdict::Failed(message) | Verdict::Exempt(message) => message,
787 Verdict::Declared => String::new(),
788 }
789 }
790
791 #[test]
792 fn no_module_is_exempt_from_the_personal_data_rule() {
793 assert!(
798 UNDECLARED_TABLES.is_empty(),
799 "a module is exempt from the personal-data rule again: {UNDECLARED_TABLES:?}. \
800 An exemption means a venture composing that module answers an erasure request \
801 without the rows it lists — declare them instead, and open the issue the entry \
802 would have tracked."
803 );
804 }
805
806 #[test]
807 fn a_declared_module_passes() {
808 assert!(matches!(
809 personal_data_verdict("notes", &[], FIXTURE),
810 Verdict::Declared
811 ));
812 }
813
814 #[test]
815 fn an_exempt_module_that_declared_everything_is_told_to_remove_its_entry() {
816 let message = message(personal_data_verdict("ledger", &[], FIXTURE));
821 assert!(message.contains("or remove it"), "{message}");
822 assert!(message.contains("#265"), "{message}");
823 }
824
825 #[test]
826 fn an_undeclared_table_fails_and_says_what_to_write() {
827 let message = message(personal_data_verdict("notes", &["note_tags"], FIXTURE));
828 assert!(message.contains("note_tags"), "{message}");
829 assert!(message.contains("PersonalDataSet::none"), "{message}");
830 }
831
832 #[test]
833 fn an_exemption_that_still_describes_its_module_is_printed_not_failed() {
834 let verdict = personal_data_verdict("ledger", &["ledger_entries", "ledger_lines"], FIXTURE);
835 assert!(matches!(verdict, Verdict::Exempt(_)));
836 assert!(message(verdict).contains("exempt under issue #265"));
837 }
838
839 #[test]
840 fn an_exemption_that_no_longer_describes_its_module_fails() {
841 let message = message(personal_data_verdict("ledger", &["ledger_lines"], FIXTURE));
846 assert!(message.contains("#265"), "{message}");
847 assert!(message.contains("ledger_entries"), "{message}");
848 }
849
850 #[test]
851 fn an_exempt_module_that_grows_a_new_undeclared_table_fails() {
852 let message = message(personal_data_verdict(
853 "ledger",
854 &["ledger_entries", "ledger_lines", "ledger_fx"],
855 FIXTURE,
856 ));
857 assert!(message.contains("ledger_fx"), "{message}");
858 }
859
860 fn problem(verdict: Result<(), String>) -> String {
861 verdict.err().unwrap_or_default()
862 }
863
864 #[test]
865 fn a_module_that_lists_every_table_it_creates_passes() {
866 assert!(
867 unlisted_verdict(
868 "auth-core",
869 6,
870 &["users", "sessions"],
871 &["users".to_owned(), "sessions".to_owned()],
872 )
873 .is_ok()
874 );
875 }
876
877 #[test]
878 fn a_table_the_migrations_create_and_tables_omits_fails() {
879 let message = problem(unlisted_verdict(
882 "auth-core",
883 6,
884 &["users"],
885 &["users".to_owned(), "deletion_jobs".to_owned()],
886 ));
887 assert!(message.contains("deletion_jobs"), "{message}");
888 assert!(message.contains("#272"), "{message}");
889 assert!(message.contains("PersonalDataSet"), "{message}");
891 assert!(message.contains("fz data export"), "{message}");
892 }
893
894 #[test]
895 fn a_scan_that_found_nothing_fails_rather_than_passing_vacuously() {
896 let message = problem(unlisted_verdict("auth-core", 6, &["users"], &[]));
901 assert!(message.contains("stopped matching"), "{message}");
902 assert!(message.contains("users"), "{message}");
903 }
904
905 #[test]
906 fn a_scan_that_found_only_some_of_the_listed_tables_fails() {
907 let message = problem(unlisted_verdict(
910 "auth-core",
911 6,
912 &["users", "sessions"],
913 &["users".to_owned()],
914 ));
915 assert!(message.contains(r#"lists ["sessions"]"#), "{message}");
919 assert!(message.contains(r#"it found ["users"]"#), "{message}");
920 }
921
922 #[test]
923 fn a_module_with_no_migrations_is_not_asked_to_have_found_anything() {
924 assert!(unlisted_verdict("auth-passkeys", 0, &[], &[]).is_ok());
928 }
929}