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.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.actors = Some(Arc::new(crate::fakes::MemoryActors::new(
145 cratefield_core::ActorHandlers::new(),
146 crate::fakes::ManualClock::new(
147 time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
148 ),
149 )));
150 ports
151}
152
153pub fn conformance(module: Box<dyn Module>) {
180 let inner: Arc<dyn Module> = Arc::from(module);
181 conformance_inner(&inner, true);
182}
183
184pub fn conformance_in_process_only(module: Box<dyn Module>, reason: &str) {
193 assert!(
194 !reason.trim().is_empty(),
195 "conformance_in_process_only needs a reason: it is the only record of why \
196 `{}` is not checked for sidecar parity",
197 module.name()
198 );
199 eprintln!("[{}] sidecar parity axis skipped: {reason}", module.name());
200 let inner: Arc<dyn Module> = Arc::from(module);
201 conformance_inner(&inner, false);
202}
203
204const UNDECLARED_TABLES: &[(&str, &[&str])] = &[];
227
228enum Verdict {
230 Declared,
232 Exempt(String),
235 Failed(String),
237}
238
239fn personal_data_verdict(
247 name: &str,
248 undeclared: &[&'static str],
249 exemptions: &[(&str, &[&str])],
250) -> Verdict {
251 let exempt = exemptions
252 .iter()
253 .find(|(listed, _)| *listed == name)
254 .map(|(_, tables)| *tables);
255
256 if let Some(tables) = exempt {
257 if undeclared != tables {
258 return Verdict::Failed(format!(
259 "[{name}] is exempt from the personal-data rule for {tables:?} (issue #265), but \
260 its undeclared tables are now {undeclared:?}. Correct the entry in \
261 UNDECLARED_TABLES in cratefield-testing, or remove it: an exemption that no \
262 longer describes the module is how the next omission hides."
263 ));
264 }
265 return Verdict::Exempt(format!(
266 "[{name}] personal-data declarations missing for {tables:?}; exempt under issue #265"
267 ));
268 }
269
270 if undeclared.is_empty() {
271 return Verdict::Declared;
272 }
273 Verdict::Failed(format!(
274 "[{name}] owns {undeclared:?} and declares nothing personal about them. \
275 `cratefield-module-privacy` plans an export and an erasure from \
276 `Module::personal_data()`, so a table missing from it is never exported and never \
277 erased. Declare each one with a `PersonalDataSet`, or with `PersonalDataSet::none(table, \
278 reason)` if it holds nobody — \"no declaration\" and \"nothing here\" must not look the \
279 same."
280 ))
281}
282
283fn check_personal_data(module: &dyn Module) {
291 match personal_data_verdict(
292 module.name(),
293 &cratefield_core::undeclared_tables(module),
294 UNDECLARED_TABLES,
295 ) {
296 Verdict::Declared => {}
297 Verdict::Exempt(note) => eprintln!("{note}"),
298 Verdict::Failed(message) => panic!("{message}"),
299 }
300}
301
302fn unlisted_verdict(
323 name: &str,
324 migration_count: usize,
325 listed: &[&'static str],
326 found: &[String],
327) -> Result<(), String> {
328 if migration_count > 0 {
329 let missed: Vec<&str> = listed
330 .iter()
331 .copied()
332 .filter(|table| !found.iter().any(|seen| seen.eq_ignore_ascii_case(table)))
333 .collect();
334 if !missed.is_empty() {
335 return Err(format!(
336 "[{name}] lists {missed:?} in `Module::tables()`, but the `CREATE TABLE` scan \
337 over its {migration_count} migrations did not find them — it found {found:?}. \
338 Either the module does not create those tables, or the scan has stopped \
339 matching; the second is why this is checked at all, because \"no unlisted \
340 table\" is an absence assertion and a scan that sees nothing satisfies it \
341 forever (issue #272)."
342 ));
343 }
344 }
345
346 let unlisted: Vec<&String> = found
347 .iter()
348 .filter(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)))
349 .collect();
350 if unlisted.is_empty() {
351 return Ok(());
352 }
353 Err(format!(
354 "[{name}] creates {unlisted:?} in its migrations and does not list them in \
355 `Module::tables()`. A table in neither list is outside three things at once: \
356 `fz data export` walks `tables()`, subject access and erasure walk \
357 `personal_data()`, and the rule that compares them can only read the two lists it \
358 is handed. `auth-core` held `deletion_jobs` — and a person's identifier at their \
359 identity provider with it — in exactly that position (issue #272). Add each table \
360 to `tables()` and give it a `PersonalDataSet`; note that `tables()` is also what a \
361 whole-database `fz data export`/`import` carries, so adding one changes what a \
362 move of this venture takes with it."
363 ))
364}
365
366fn check_module_tables(module: &dyn Module) {
380 if let Err(message) = unlisted_verdict(
381 module.name(),
382 module.migrations().sqlite.len() + module.migrations().postgres.len(),
383 module.tables(),
384 &cratefield_core::migration_tables(module),
385 ) {
386 panic!("{message}");
387 }
388}
389
390fn conformance_inner(inner: &Arc<dyn Module>, parity: bool) {
391 check_module_tables(inner.as_ref());
392 check_personal_data(inner.as_ref());
393 let name = inner.name().to_owned();
394 let version = inner.version().to_owned();
395 let has_well_known = inner.well_known().is_some();
396 let module: Arc<dyn Module> = Arc::new(WellKnownProbe {
397 inner: inner.clone(),
398 });
399
400 for dialect in crate::dialect::Dialect::available() {
401 conformance_on_dialect(&dialect, module.clone(), &name, &version, has_well_known);
402 }
403
404 if parity {
405 parity_on(inner, &name);
408 }
409}
410
411fn conformance_on_dialect(
412 dialect: &crate::dialect::Dialect,
413 module: Arc<dyn Module>,
414 name: &str,
415 version: &str,
416 has_well_known: bool,
417) {
418 let kit = TestHarness::from_arcs(vec![module], dialect.clone(), |_| {});
419 let module = kit
420 .modules
421 .iter()
422 .find(|m| m.name() == name)
423 .unwrap_or_else(|| panic!("module {name} missing from kit"))
424 .clone();
425
426 let health = pollster::block_on(request(
428 &kit.router,
429 axum::http::Method::GET,
430 "/__health",
431 None,
432 ));
433 let body = health.json();
434 let listed = body["modules"]
435 .as_array()
436 .unwrap_or_else(|| panic!("health modules array missing: {body}"));
437 let entry = listed
438 .iter()
439 .find(|entry| entry["name"] == *name)
440 .unwrap_or_else(|| panic!("health does not list {name}: {body}"));
441 assert_eq!(
442 entry["version"],
443 version,
444 "[{}] health lists the module version",
445 dialect.name()
446 );
447
448 let probe = pollster::block_on(request(
450 &kit.router,
451 axum::http::Method::GET,
452 &format!("/v1/{name}/"),
453 None,
454 ));
455 let _ = probe.status;
457
458 #[cfg(feature = "postgres")]
460 if let crate::dialect::Dialect::Postgres { .. } = &dialect {
461 crate::pg::migrations_apply_twice(&kit.modules)
462 .unwrap_or_else(|message| panic!("[postgres] {message}"));
463 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
464 return;
465 }
466 for round in 1..=2 {
467 let fresh = cratefield_adapter_sqlite::SqliteDatabase::in_memory()
468 .unwrap_or_else(|err| panic!("[sqlite] fresh db {round}: {err}"));
469 for module in &kit.modules {
470 fresh
471 .apply_migrations(module.name(), module.migrations().sqlite)
472 .unwrap_or_else(|err| panic!("[sqlite] round {round}, {}: {err}", module.name()));
473 }
474 }
475 check_visibility_and_scope(&kit, module.as_ref(), name, has_well_known);
476}
477
478fn check_visibility_and_scope(
482 kit: &TestHarness,
483 module: &dyn Module,
484 name: &str,
485 has_well_known: bool,
486) {
487 let view = full_fake_ports().view_for(module);
491 for port in Port::ALL {
492 let declared = module.requires().contains(port) || module.optional().contains(port);
493 assert_eq!(
494 view.has(*port),
495 declared,
496 "{name}: port {} must be visible only when declared",
497 port.name()
498 );
499 }
500
501 let router_a = kit.router.clone();
503 let router_b = kit.router.clone();
504 let id_a = "conformance-AAAAAAAA";
505 let id_b = "conformance-BBBBBBBB";
506 let make = |router: axum::Router, id: &'static str| {
507 std::thread::spawn(move || {
508 use tower::ServiceExt;
509 let request = axum::http::Request::builder()
510 .uri("/__health")
511 .header("x-request-id", id)
512 .body(axum::body::Body::empty())
513 .expect("request builds");
514 let response = pollster::block_on(router.oneshot(request)).expect("router answers");
515 response
516 .headers()
517 .get("x-request-id")
518 .and_then(|value| value.to_str().ok())
519 .expect("request id echoed")
520 .to_string()
521 })
522 };
523 let handle_a = make(router_a, id_a);
524 let handle_b = make(router_b, id_b);
525 assert_eq!(handle_a.join().expect("a completes"), id_a);
526 assert_eq!(handle_b.join().expect("b completes"), id_b);
527
528 check_well_known_mount(kit, name, has_well_known);
529}
530
531fn check_well_known_mount(kit: &TestHarness, name: &str, has_well_known: bool) {
535 let probe = pollster::block_on(request(
536 &kit.router,
537 axum::http::Method::GET,
538 &format!("/.well-known{WELL_KNOWN_PROBE_PATH}"),
539 None,
540 ));
541 if has_well_known {
542 assert_eq!(
543 probe.status,
544 axum::http::StatusCode::OK,
545 "{name}: well-known router must serve at the root"
546 );
547 let under_v1 = pollster::block_on(request(
548 &kit.router,
549 axum::http::Method::GET,
550 &format!("/v1/{name}/.well-known{WELL_KNOWN_PROBE_PATH}"),
551 None,
552 ));
553 assert_eq!(
554 under_v1.status,
555 axum::http::StatusCode::NOT_FOUND,
556 "{name}: well-known routes must not be nested under /v1"
557 );
558 } else {
559 assert_eq!(
560 probe.status,
561 axum::http::StatusCode::NOT_FOUND,
562 "{name}: nothing must mount at /.well-known without a well-known router"
563 );
564 }
565}
566
567struct Probe {
571 what: &'static str,
572 method: axum::http::Method,
573 path: &'static str,
575 body: Option<&'static str>,
576}
577
578const PARITY_REQUEST_ID: &str = "parity-0123456789abcdef";
582
583async fn probe_once(router: &axum::Router, name: &str, probe: &Probe) -> crate::TestResponse {
585 use axum::http::{HeaderValue, Request, header};
586 use tower::ServiceExt;
587
588 let uri = format!("/v1/{name}{}", probe.path);
589 let mut builder = Request::builder()
590 .method(probe.method.clone())
591 .uri(uri)
592 .header(
593 cratefield_core::X_REQUEST_ID,
594 HeaderValue::from_static(PARITY_REQUEST_ID),
595 )
596 .header("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
597 let body = match probe.body {
598 Some(json) => {
599 builder = builder.header(header::CONTENT_TYPE, "application/json");
600 axum::body::Body::from(json)
601 }
602 None => axum::body::Body::empty(),
603 };
604 let response = router
605 .clone()
606 .oneshot(builder.body(body).expect("probe request builds"))
607 .await
608 .expect("router is infallible");
609 crate::TestResponse::of(response).await
610}
611
612pub fn sidecar_parity(module: Box<dyn Module>) {
629 let name = module.name().to_owned();
630 parity_on(&Arc::from(module), &name);
631}
632
633fn parity_on(shared: &Arc<dyn Module>, name: &str) {
634 let in_process = TestHarness::from_arcs(
636 vec![shared.clone()],
637 crate::dialect::Dialect::Sqlite,
638 |_| {},
639 );
640
641 let remote = TestHarness::from_arcs(
644 vec![shared.clone()],
645 crate::dialect::Dialect::Sqlite,
646 |_| {},
647 );
648 let (sidecar, dispatcher) = crate::sidecar::shared(crate::sidecar::FakeSidecar::new(
649 "PARITY",
650 remote.router.clone(),
651 ));
652 let table = format!("{{\"{name}\":\"PARITY\"}}");
653 let host = TestHarness::with_builder(
654 Vec::new(),
655 |builder| builder,
656 |ports| {
657 ports.config = Arc::new(cratefield_core::MapConfig::from_pairs([
658 ("HARNESS_SECRET", crate::TEST_HARNESS_SECRET),
659 (cratefield_core::HARNESS_SIDECARS, table.as_str()),
660 ]));
661 ports.dispatcher = Some(dispatcher);
662 },
663 );
664
665 let probes = [
666 Probe {
667 what: "an unknown path under the module prefix",
668 method: axum::http::Method::GET,
669 path: "/__parity_no_such_route",
670 body: None,
671 },
672 Probe {
673 what: "a POST to an unknown path",
674 method: axum::http::Method::POST,
675 path: "/__parity_no_such_route",
676 body: Some(r#"{"parity":true}"#),
677 },
678 Probe {
679 what: "the module root",
680 method: axum::http::Method::GET,
681 path: "",
682 body: None,
683 },
684 Probe {
685 what: "a malformed JSON body at the module root",
686 method: axum::http::Method::POST,
687 path: "",
688 body: Some("{not json"),
689 },
690 ];
691
692 for (sent, probe) in probes.iter().enumerate() {
693 let direct = pollster::block_on(probe_once(&in_process.router, name, probe));
694 let hopped = pollster::block_on(probe_once(&host.router, name, probe));
695 assert_eq!(
700 sidecar.calls(),
701 sent + 1,
702 "[{name}] {} never reached the sidecar: the mount is not forwarding, \
703 so this comparison proves nothing",
704 probe.what
705 );
706 compare(name, probe.what, &direct, &hopped);
707 }
708
709 check_oversized_body_stops_at_the_host(&host, &sidecar, name);
710}
711
712fn check_oversized_body_stops_at_the_host(
716 host: &TestHarness,
717 sidecar: &Arc<crate::sidecar::FakeSidecar>,
718 name: &str,
719) {
720 use axum::http::{Request, StatusCode, header};
721 use tower::ServiceExt;
722
723 let before = sidecar.calls();
724 let big = "x".repeat(cratefield_core::MAX_BODY_BYTES + 1);
725 let response = pollster::block_on(async {
726 let request = Request::builder()
727 .method(axum::http::Method::POST)
728 .uri(format!("/v1/{name}"))
729 .header(header::CONTENT_TYPE, "application/json")
730 .body(axum::body::Body::from(big))
731 .expect("oversized request builds");
732 let response = host
733 .router
734 .clone()
735 .oneshot(request)
736 .await
737 .expect("router is infallible");
738 crate::TestResponse::of(response).await
739 });
740 assert_eq!(
741 response.status,
742 StatusCode::PAYLOAD_TOO_LARGE,
743 "[{name}] a body over {} bytes must be refused by the host",
744 cratefield_core::MAX_BODY_BYTES
745 );
746 assert_eq!(
747 sidecar.calls(),
748 before,
749 "[{name}] an oversized body must never be forwarded to the sidecar"
750 );
751}
752
753fn compare(name: &str, what: &str, direct: &crate::TestResponse, hopped: &crate::TestResponse) {
757 assert_eq!(
758 direct.status, hopped.status,
759 "[{name}] status differs over the sidecar hop for {what}"
760 );
761 assert_eq!(
762 direct.body(),
763 hopped.body(),
764 "[{name}] body differs over the sidecar hop for {what}"
765 );
766 for header in [
767 axum::http::header::CONTENT_TYPE.as_str(),
768 axum::http::header::LOCATION.as_str(),
769 ] {
770 assert_eq!(
771 direct.headers.get(header),
772 hopped.headers.get(header),
773 "[{name}] `{header}` differs over the sidecar hop for {what}"
774 );
775 }
776 for (label, response) in [("in-process", direct), ("sidecar", hopped)] {
777 let ids: Vec<_> = response
778 .headers
779 .get_all(cratefield_core::X_REQUEST_ID)
780 .iter()
781 .collect();
782 assert_eq!(
783 ids.len(),
784 1,
785 "[{name}] {label} answered {} request ids for {what}; exactly one is the contract",
786 ids.len()
787 );
788 assert_eq!(
789 ids[0], PARITY_REQUEST_ID,
790 "[{name}] {label} did not echo the caller's request id for {what}"
791 );
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::{UNDECLARED_TABLES, Verdict, personal_data_verdict, unlisted_verdict};
798
799 const FIXTURE: &[(&str, &[&str])] = &[("ledger", &["ledger_entries", "ledger_lines"])];
802
803 fn message(verdict: Verdict) -> String {
804 match verdict {
805 Verdict::Failed(message) | Verdict::Exempt(message) => message,
806 Verdict::Declared => String::new(),
807 }
808 }
809
810 #[test]
811 fn no_module_is_exempt_from_the_personal_data_rule() {
812 assert!(
817 UNDECLARED_TABLES.is_empty(),
818 "a module is exempt from the personal-data rule again: {UNDECLARED_TABLES:?}. \
819 An exemption means a venture composing that module answers an erasure request \
820 without the rows it lists — declare them instead, and open the issue the entry \
821 would have tracked."
822 );
823 }
824
825 #[test]
826 fn a_declared_module_passes() {
827 assert!(matches!(
828 personal_data_verdict("notes", &[], FIXTURE),
829 Verdict::Declared
830 ));
831 }
832
833 #[test]
834 fn an_exempt_module_that_declared_everything_is_told_to_remove_its_entry() {
835 let message = message(personal_data_verdict("ledger", &[], FIXTURE));
840 assert!(message.contains("or remove it"), "{message}");
841 assert!(message.contains("#265"), "{message}");
842 }
843
844 #[test]
845 fn an_undeclared_table_fails_and_says_what_to_write() {
846 let message = message(personal_data_verdict("notes", &["note_tags"], FIXTURE));
847 assert!(message.contains("note_tags"), "{message}");
848 assert!(message.contains("PersonalDataSet::none"), "{message}");
849 }
850
851 #[test]
852 fn an_exemption_that_still_describes_its_module_is_printed_not_failed() {
853 let verdict = personal_data_verdict("ledger", &["ledger_entries", "ledger_lines"], FIXTURE);
854 assert!(matches!(verdict, Verdict::Exempt(_)));
855 assert!(message(verdict).contains("exempt under issue #265"));
856 }
857
858 #[test]
859 fn an_exemption_that_no_longer_describes_its_module_fails() {
860 let message = message(personal_data_verdict("ledger", &["ledger_lines"], FIXTURE));
865 assert!(message.contains("#265"), "{message}");
866 assert!(message.contains("ledger_entries"), "{message}");
867 }
868
869 #[test]
870 fn an_exempt_module_that_grows_a_new_undeclared_table_fails() {
871 let message = message(personal_data_verdict(
872 "ledger",
873 &["ledger_entries", "ledger_lines", "ledger_fx"],
874 FIXTURE,
875 ));
876 assert!(message.contains("ledger_fx"), "{message}");
877 }
878
879 fn problem(verdict: Result<(), String>) -> String {
880 verdict.err().unwrap_or_default()
881 }
882
883 #[test]
884 fn a_module_that_lists_every_table_it_creates_passes() {
885 assert!(
886 unlisted_verdict(
887 "auth-core",
888 6,
889 &["users", "sessions"],
890 &["users".to_owned(), "sessions".to_owned()],
891 )
892 .is_ok()
893 );
894 }
895
896 #[test]
897 fn a_table_the_migrations_create_and_tables_omits_fails() {
898 let message = problem(unlisted_verdict(
901 "auth-core",
902 6,
903 &["users"],
904 &["users".to_owned(), "deletion_jobs".to_owned()],
905 ));
906 assert!(message.contains("deletion_jobs"), "{message}");
907 assert!(message.contains("#272"), "{message}");
908 assert!(message.contains("PersonalDataSet"), "{message}");
910 assert!(message.contains("fz data export"), "{message}");
911 }
912
913 #[test]
914 fn a_scan_that_found_nothing_fails_rather_than_passing_vacuously() {
915 let message = problem(unlisted_verdict("auth-core", 6, &["users"], &[]));
920 assert!(message.contains("stopped matching"), "{message}");
921 assert!(message.contains("users"), "{message}");
922 }
923
924 #[test]
925 fn a_scan_that_found_only_some_of_the_listed_tables_fails() {
926 let message = problem(unlisted_verdict(
929 "auth-core",
930 6,
931 &["users", "sessions"],
932 &["users".to_owned()],
933 ));
934 assert!(message.contains(r#"lists ["sessions"]"#), "{message}");
938 assert!(message.contains(r#"it found ["users"]"#), "{message}");
939 }
940
941 #[test]
942 fn a_module_with_no_migrations_is_not_asked_to_have_found_anything() {
943 assert!(unlisted_verdict("auth-passkeys", 0, &[], &[]).is_ok());
947 }
948}