1use crate::EnvVars;
15use crate::codes::{CODES, DoctorCodeDef};
16use crate::collect::{LockedMigrationError, verify_locked};
17use crate::lint::banned_tokens;
18use crate::lock::{Lock, read_lock};
19use cratefield_core::lint_card_data;
20use cratefield_core::{
21 HARNESS_API, HARNESS_SIDECARS, Harness, MIN_SECRET_BYTES, Port, SIDECAR_GATEWAY_SECRET,
22 VentureEnv, deployed_env, env_disagreement, harness_api_mismatch,
23};
24use cratefield_push_wiring::PushWiring;
25use std::path::{Path, PathBuf};
26use std::process::ExitCode;
27
28pub const REPORT_SCHEMA: u32 = 1;
31
32#[derive(Debug)]
36pub struct DoctorFailure {
37 pub code: &'static DoctorCodeDef,
39 pub message: String,
41}
42
43#[derive(Debug)]
45pub struct DoctorReport {
46 pub failures: Vec<DoctorFailure>,
48}
49
50impl DoctorReport {
51 #[must_use]
53 pub fn ok(&self) -> bool {
54 self.failures.is_empty()
55 }
56
57 pub fn render_json(&self) -> Result<String, serde_json::Error> {
66 #[derive(serde::Serialize)]
67 struct Report<'a> {
68 schema: u32,
69 ok: bool,
70 failures: Vec<Failure<'a>>,
71 }
72 #[derive(serde::Serialize)]
73 struct Failure<'a> {
74 code: &'a str,
75 message: &'a str,
76 }
77 serde_json::to_string(&Report {
78 schema: REPORT_SCHEMA,
79 ok: self.ok(),
80 failures: self
81 .failures
82 .iter()
83 .map(|failure| Failure {
84 code: failure.code.code,
85 message: &failure.message,
86 })
87 .collect(),
88 })
89 }
90}
91
92#[derive(Clone, Copy, PartialEq, Eq)]
94enum Output {
95 Human,
98 Json,
101}
102
103pub fn doctor(
112 harness: &Harness,
113 migrations_dir: &Path,
114 allow_no_captcha: Option<&str>,
115 sidecars: Option<&str>,
116) -> Result<(), String> {
117 let report = run_checks(
118 harness,
119 migrations_dir,
120 allow_no_captcha,
121 sidecars,
122 Output::Human,
123 Configured::from_env(),
124 );
125 if report.ok() {
126 return Ok(());
127 }
128 let messages: Vec<String> = report
129 .failures
130 .into_iter()
131 .map(|failure| failure.message)
132 .collect();
133 Err(messages.join("\n - "))
134}
135
136#[must_use = "call process::exit with the returned ExitCode"]
141pub fn doctor_json(
142 harness: &Harness,
143 migrations_dir: &Path,
144 allow_no_captcha: Option<&str>,
145 sidecars: Option<&str>,
146) -> ExitCode {
147 let report = run_checks(
148 harness,
149 migrations_dir,
150 allow_no_captcha,
151 sidecars,
152 Output::Json,
153 Configured::from_env(),
154 );
155 match report.render_json() {
156 Ok(payload) => println!("{payload}"),
157 Err(err) => {
158 eprintln!("fz: cannot serialize the doctor report: {err}");
159 return ExitCode::FAILURE;
160 }
161 }
162 if report.ok() {
163 ExitCode::SUCCESS
164 } else {
165 ExitCode::FAILURE
166 }
167}
168
169#[must_use]
173pub fn doctor_report_json(
174 harness: &Harness,
175 migrations_dir: &Path,
176 allow_no_captcha: Option<&str>,
177 sidecars: Option<&str>,
178) -> DoctorReport {
179 run_checks(
180 harness,
181 migrations_dir,
182 allow_no_captcha,
183 sidecars,
184 Output::Json,
185 Configured::from_env(),
186 )
187}
188
189fn run_checks(
190 harness: &Harness,
191 migrations_dir: &Path,
192 allow_no_captcha: Option<&str>,
193 sidecars: Option<&str>,
194 output: Output,
195 configured: Configured,
196) -> DoctorReport {
197 let mut failures: Vec<DoctorFailure> = Vec::new();
198
199 contract_failures(harness, &mut failures);
200 self_check_failures(harness, &mut failures);
201
202 let env = deployed_env(harness.venture().env, &EnvVars);
210 if let Some(note) = env_disagreement(harness.venture().env, env)
211 && output == Output::Human
212 {
213 eprintln!("fz: warning: {note}");
214 }
215 if env == VentureEnv::Production {
216 production_port_checks(
217 harness,
218 configured,
219 &EnvVars,
220 allow_no_captcha,
221 output,
222 &mut failures,
223 );
224 }
225
226 if let Some(message) = admin_token_failure(configured.admin_token_bytes) {
233 failures.push(DoctorFailure {
234 code: &CODES.admin_token_too_short,
235 message,
236 });
237 }
238
239 push_checks(harness, env, output, &mut failures);
240 lockfile_failures(harness, migrations_dir, &mut failures);
241 undeclared_migration_failures(harness, migrations_dir, &mut failures);
242 sidecar_failures(harness, sidecars, output, &mut failures);
243 lint_failures(harness, &mut failures);
244
245 DoctorReport { failures }
246}
247
248fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
253 for module in harness.modules() {
254 if module.harness_api() != HARNESS_API {
255 failures.push(DoctorFailure {
256 code: &CODES.harness_api_mismatch,
257 message: harness_api_mismatch(module.as_ref()),
258 });
259 }
260 }
261}
262
263fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
277 for module in harness.modules() {
278 for message in module.self_check() {
279 failures.push(DoctorFailure {
280 code: &CODES.module_self_check,
281 message,
282 });
283 }
284 }
285}
286
287fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
290 let lock = match read_lock(migrations_dir) {
291 Ok(lock) => lock,
292 Err(err) => {
293 failures.push(DoctorFailure {
294 code: &CODES.lockfile_unreadable,
295 message: err,
296 });
297 Lock::default()
298 }
299 };
300 for module in harness.modules() {
301 for migration in module.migrations().sqlite {
302 let key = format!("{}/{}", module.name(), migration.id);
303 match lock.get(&key) {
304 Some(entry) => {
305 if let Err(err) = verify_locked(migrations_dir, &key, entry) {
306 let code = match &err {
307 LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
308 LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
309 };
310 failures.push(DoctorFailure {
311 code,
312 message: err.to_string(),
313 });
314 }
315 }
316 None => failures.push(DoctorFailure {
317 code: &CODES.migration_not_collected,
318 message: format!(
319 "migration {key} is not collected yet — run `fz migrations collect`"
320 ),
321 }),
322 }
323 }
324 }
325}
326
327fn undeclared_migration_failures(
337 harness: &Harness,
338 migrations_dir: &Path,
339 failures: &mut Vec<DoctorFailure>,
340) {
341 let declared: std::collections::BTreeSet<&str> = harness
342 .modules()
343 .iter()
344 .flat_map(|module| module.migrations().sqlite)
345 .map(|migration| migration.sql.trim())
346 .collect();
347 for dir in venture_dependency_dirs(migrations_dir) {
348 let dep = dir.file_name().unwrap_or(dir.as_os_str()).to_string_lossy();
349 for file in shipped_sql_files(&dir) {
350 let message = match std::fs::read_to_string(&file) {
351 Ok(body) if declared.contains(body.trim()) => continue,
353 Ok(_) => format!(
354 "dependency `{dep}` ships `{}` but no module declares its SQL — it is \
355 never collected and its tables never exist on D1. Return it from the \
356 module's `Module::migrations()` (e.g. `include_str!`) so \
357 `fz migrations collect` ships it, or drop the file",
358 file.display()
359 ),
360 Err(err) => format!(
363 "dependency `{dep}` ships `{}` but it cannot be read ({err}), so it \
364 cannot be checked against any module's declared SQL",
365 file.display()
366 ),
367 };
368 failures.push(DoctorFailure {
369 code: &CODES.migration_not_declared,
370 message,
371 });
372 }
373 }
374}
375
376#[must_use]
392pub fn venture_dependency_dirs(migrations_dir: &Path) -> Vec<PathBuf> {
393 let venture = match migrations_dir.parent() {
397 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
398 _ => PathBuf::from("."),
399 };
400 let venture = std::fs::canonicalize(&venture).unwrap_or(venture);
401 let manifest = std::fs::read_to_string(venture.join("Cargo.toml"))
402 .ok()
403 .and_then(|text| text.parse::<toml::Table>().ok());
404 let Some(manifest) = manifest else {
405 return Vec::new();
406 };
407 let mut workspace: Option<PathBuf> = None;
409 let mut dirs = std::collections::BTreeSet::new();
410 for deps in dependency_tables(&manifest) {
411 for (name, dep) in deps {
412 if let Some(path) = dep.get("path").and_then(toml::Value::as_str) {
413 dirs.insert(venture.join(path));
414 } else if dep.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
415 if workspace.is_none() {
416 workspace = workspace_root(&venture);
417 }
418 if let Some(path) = workspace
419 .as_deref()
420 .and_then(|root| workspace_dep_path(root, name))
421 {
422 dirs.insert(path);
423 }
424 }
425 }
426 }
427 dirs.into_iter().collect()
428}
429
430fn dependency_tables(manifest: &toml::Table) -> Vec<&toml::Table> {
433 let mut tables = Vec::new();
434 if let Some(deps) = manifest.get("dependencies").and_then(toml::Value::as_table) {
435 tables.push(deps);
436 }
437 if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
438 for target in targets.values() {
439 if let Some(deps) = target.get("dependencies").and_then(toml::Value::as_table) {
440 tables.push(deps);
441 }
442 }
443 }
444 tables
445}
446
447fn workspace_root(venture: &Path) -> Option<PathBuf> {
451 let mut dir = venture.to_path_buf();
452 loop {
453 let is_workspace = std::fs::read_to_string(dir.join("Cargo.toml"))
454 .ok()
455 .and_then(|text| text.parse::<toml::Table>().ok())
456 .is_some_and(|table| table.contains_key("workspace"));
457 if is_workspace {
458 return Some(dir);
459 }
460 if !dir.pop() || dir.as_os_str().is_empty() {
461 return None;
462 }
463 }
464}
465
466fn workspace_dep_path(root: &Path, name: &str) -> Option<PathBuf> {
469 let table = std::fs::read_to_string(root.join("Cargo.toml"))
470 .ok()
471 .and_then(|text| text.parse::<toml::Table>().ok())?;
472 table
473 .get("workspace")?
474 .get("dependencies")?
475 .get(name)?
476 .get("path")?
477 .as_str()
478 .map(|path| root.join(path))
479}
480
481#[must_use]
492pub fn shipped_sql_files(dir: &Path) -> Vec<PathBuf> {
493 let migrations = dir.join("migrations");
494 let set = if migrations.join("sqlite").is_dir() {
495 migrations.join("sqlite")
496 } else if migrations.is_dir() && !migrations.join(crate::lock::LOCK_FILE).exists() {
497 migrations
498 } else {
499 return Vec::new();
500 };
501 let mut files: Vec<PathBuf> = std::fs::read_dir(&set)
502 .map(|entries| {
503 entries
504 .flatten()
505 .map(|entry| entry.path())
506 .filter(|path| {
507 path.is_file()
508 && path
509 .extension()
510 .is_some_and(|ext| ext.eq_ignore_ascii_case("sql"))
511 })
512 .collect()
513 })
514 .unwrap_or_default();
515 files.sort();
516 files
517}
518
519fn sidecar_failures(
526 harness: &Harness,
527 sidecars: Option<&str>,
528 output: Output,
529 failures: &mut Vec<DoctorFailure>,
530) {
531 match crate::sidecars::parse(sidecars) {
532 Ok(mounts) if !mounts.is_empty() => {
533 for mount in crate::sidecars::shadowing(harness, &mounts) {
534 failures.push(DoctorFailure {
535 code: &CODES.sidecar_shadows_module,
536 message: format!(
537 "sidecar mount `{}` names a module compiled into this venture; the runtime \
538 ignores the mount and serves the compiled-in module. Remove it from \
539 {HARNESS_SIDECARS} or remove the module from the composition",
540 mount.name
541 ),
542 });
543 }
544 if output == Output::Human {
545 eprintln!(
546 "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
547 checked here — they live in its own repository (docs/MOUNTING.md)",
548 crate::sidecars::listed(&mounts)
549 );
550 if std::env::var(SIDECAR_GATEWAY_SECRET)
555 .ok()
556 .is_none_or(|value| value.trim().is_empty())
557 {
558 eprintln!(
559 "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
560 gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
561 refuses every forwarded request (issue #131)"
562 );
563 }
564 }
565 }
566 Ok(_) => {}
567 Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
568 code: &CODES.sidecar_mount_invalid,
569 message,
570 })),
571 }
572}
573
574fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
582 for module in harness.modules() {
583 if !module.migrations().postgres.is_empty() {
584 continue;
585 }
586 for migration in module.migrations().sqlite {
587 for (token, why) in banned_tokens(migration.sql) {
588 failures.push(DoctorFailure {
589 code: &CODES.non_portable_sql,
590 message: format!(
591 "module `{}` migration {}: banned token `{token}` — {why}",
592 module.name(),
593 migration.id
594 ),
595 });
596 }
597 }
598 }
599
600 for module in harness.modules() {
601 let migrations = module.migrations();
602 for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
603 for (fragment, why) in lint_card_data(migration.sql) {
604 failures.push(DoctorFailure {
605 code: &CODES.card_data_in_migration,
606 message: format!(
607 "module `{}` migration {}: `{fragment}` {why}",
608 module.name(),
609 migration.id
610 ),
611 });
612 }
613 }
614 }
615}
616
617fn push_checks(
627 harness: &Harness,
628 env: VentureEnv,
629 output: Output,
630 failures: &mut Vec<DoctorFailure>,
631) {
632 let declaration = PushDeclaration::of(harness.modules());
633 if !declaration.declared {
634 return;
635 }
636 push_wiring_checks(
637 &cratefield_push_wiring::inspect_push(&EnvVars),
638 env,
639 declaration,
640 output,
641 failures,
642 );
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649struct PushDeclaration {
650 declared: bool,
653 required: bool,
656}
657
658impl PushDeclaration {
659 fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
660 Self::from_ports(
661 modules
662 .iter()
663 .map(|module| (module.requires(), module.optional())),
664 )
665 }
666
667 fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
671 let mut declaration = Self {
672 declared: false,
673 required: false,
674 };
675 for (requires, optional) in modules {
676 if requires.contains(&Port::Push) {
677 declaration.declared = true;
678 declaration.required = true;
679 } else if optional.contains(&Port::Push) {
680 declaration.declared = true;
681 }
682 }
683 declaration
684 }
685}
686
687fn push_wiring_checks(
704 wiring: &PushWiring,
705 env: VentureEnv,
706 declaration: PushDeclaration,
707 output: Output,
708 failures: &mut Vec<DoctorFailure>,
709) {
710 match wiring.check(env) {
711 Err(problems) => failures.push(DoctorFailure {
718 code: &CODES.push_transport_misconfigured,
719 message: problems,
720 }),
721 Ok(()) => {
724 if output == Output::Human {
725 for problem in wiring.problems() {
726 eprintln!("fz: warning: {problem}");
727 }
728 }
729 }
730 }
731
732 if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
733 failures.push(DoctorFailure {
734 code: &CODES.push_required_but_unrouted,
735 message: "production venture has modules that require the Push port but no push \
736 transport is configured — every send would answer NotConfigured. Set one \
737 transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
738 push. If this venture provides Push from an adapter of its own rather than \
739 from the environment, that adapter is not visible here (issue #191)"
740 .to_owned(),
741 });
742 }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755pub(crate) struct Configured {
756 pub(crate) auth_issuer: bool,
757 pub(crate) auth_client_id: bool,
758 pub(crate) stripe_webhook_secret: bool,
759 pub(crate) admin_token_bytes: Option<usize>,
764}
765
766impl Configured {
767 fn from_env() -> Self {
768 Self {
769 auth_issuer: set("AUTH_ISSUER"),
770 auth_client_id: set("AUTH_CLIENT_ID"),
771 stripe_webhook_secret: set("STRIPE_WEBHOOK_SECRET"),
772 admin_token_bytes: std::env::var("ADMIN_TOKEN")
777 .ok()
778 .filter(|value| !value.is_empty())
779 .map(|value| value.len()),
780 }
781 }
782}
783
784fn production_port_checks(
785 harness: &Harness,
786 configured: Configured,
787 config: &dyn cratefield_core::Config,
788 allow_no_captcha: Option<&str>,
789 output: Output,
790 failures: &mut Vec<DoctorFailure>,
791) {
792 let guards = cratefield_core::WriteGuards::collect(harness.modules());
793 if let Some(message) = captcha_production_failure(
794 &guards,
795 cratefield_core::captcha_effective(harness.runtime()),
796 ) {
797 match cratefield_core::stated_reason(allow_no_captcha) {
802 Some(reason) => {
803 tracing::warn!(
811 control = "captcha",
812 reason,
813 modules = guards.captcha_modules.join(","),
814 "production abuse control overridden by an operator"
815 );
816 if output == Output::Human {
817 eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
818 eprintln!(
819 "fz: note: this override is recorded; it is for previews only and must \
820 not stand in for a Captcha port in production"
821 );
822 }
823 }
824 None => failures.push(DoctorFailure {
825 code: &CODES.captcha_not_effective,
826 message,
827 }),
828 }
829 }
830
831 if let Some(message) = auth_production_failure(
832 harness
833 .modules()
834 .iter()
835 .any(|module| module.requires().contains(&Port::Auth)),
836 configured.auth_issuer,
837 configured.auth_client_id,
838 ) {
839 failures.push(DoctorFailure {
840 code: &CODES.auth_not_configured,
841 message,
842 });
843 }
844
845 let payments: Vec<String> = guards
850 .payments_signature_modules
851 .iter()
852 .map(|module| {
853 guards.signature_module_named(module, cratefield_core::SignatureVerification::Payments)
854 })
855 .collect();
856 if let Some(message) = payments_webhook_failure(&payments, configured.stripe_webhook_secret) {
857 failures.push(DoctorFailure {
858 code: &CODES.payments_webhook_secret_missing,
859 message,
860 });
861 }
862
863 if let Some(message) = hmac_webhook_failure(&guards.hmac_signature_routes(), config) {
868 failures.push(DoctorFailure {
869 code: &CODES.hmac_webhook_secret_missing,
870 message,
871 });
872 }
873
874 if let Some(message) = rate_limiter_production_failure(
882 guards.needs_rate_limiter(),
883 cratefield_core::rate_limiter_effective(harness.runtime()),
884 &guards
885 .rate_limiter_modules
886 .iter()
887 .map(|(module, routes)| format!("{module} ({})", routes.join(", ")))
888 .collect::<Vec<_>>(),
889 ) {
890 match cratefield_core::unlimited_public_routes_override(config) {
891 Some(reason) => {
892 tracing::warn!(
893 control = "rate-limiter",
894 reason,
895 "production abuse control overridden by an operator"
896 );
897 if output == Output::Human {
898 eprintln!("fz: warning: rate-limiter override accepted ({reason}): {message}");
899 eprintln!(
900 "fz: note: this override is recorded; resolve the RateLimiter binding \
901 and remove HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES before serving \
902 production traffic"
903 );
904 }
905 }
906 None => failures.push(DoctorFailure {
907 code: &CODES.rate_limiter_not_effective,
908 message,
909 }),
910 }
911 }
912}
913
914fn set(key: &str) -> bool {
916 std::env::var(key).is_ok_and(|value| !value.trim().is_empty())
917}
918
919fn auth_production_failure(
935 needs_auth: bool,
936 issuer_set: bool,
937 client_id_set: bool,
938) -> Option<String> {
939 if !needs_auth || (issuer_set && client_id_set) {
940 return None;
941 }
942 let mut missing = Vec::new();
943 if !issuer_set {
944 missing.push("AUTH_ISSUER");
945 }
946 if !client_id_set {
947 missing.push("AUTH_CLIENT_ID");
948 }
949 Some(format!(
950 "a module requires the Auth port and {} is not set; every route that needs a signed-in \
951 caller will answer 503",
952 missing.join(" and ")
953 ))
954}
955
956fn captcha_production_failure(
962 guards: &cratefield_core::WriteGuards,
963 captcha_effective: bool,
964) -> Option<String> {
965 if guards.needs_captcha() && !captcha_effective {
966 Some(format!(
967 "production venture has captcha-guarded public writes from [{}] but the Captcha port \
968 is not effectively configured — provide Turnstile with its secret and expected \
969 hostname (architecture section 11; issue #133)",
970 guards.captcha_modules.join(", ")
971 ))
972 } else {
973 None
974 }
975}
976
977fn payments_webhook_failure(
986 payments_routes: &[String],
987 webhook_secret_present: bool,
988) -> Option<String> {
989 (!payments_routes.is_empty() && !webhook_secret_present).then(|| {
990 format!(
991 "production venture has signature-guarded routes [{}] but STRIPE_WEBHOOK_SECRET is \
992 unset — webhook signatures cannot be verified and forged events would be trusted \
993 (issue #102)",
994 payments_routes.join(", ")
995 )
996 })
997}
998
999fn hmac_webhook_failure(
1006 hmac_modules: &[(String, String, Vec<String>)],
1007 config: &dyn cratefield_core::Config,
1008) -> Option<String> {
1009 let missing: Vec<String> = hmac_modules
1010 .iter()
1011 .filter(|(_, key, _)| config.get(key).is_none_or(|value| value.trim().is_empty()))
1012 .map(|(_, key, routes)| {
1013 if routes.is_empty() {
1014 key.clone()
1015 } else {
1016 format!("{key} on {}", routes.join(", "))
1017 }
1018 })
1019 .collect();
1020 (!missing.is_empty()).then(|| {
1021 format!(
1022 "production venture verifies webhook signatures with the webhook_signature HMAC \
1023 scheme but {} is not set — those handlers refuse every delivery until the \
1024 provider's signing secret is configured (issue #533)",
1025 missing.join(", ")
1026 )
1027 })
1028}
1029
1030fn rate_limiter_production_failure(
1039 needs_rate_limiter: bool,
1040 rate_limiter_effective: bool,
1041 declaring: &[String],
1042) -> Option<String> {
1043 if needs_rate_limiter && !rate_limiter_effective {
1044 let (who, cost) = if declaring.is_empty() {
1050 (
1051 "takes public writes or admin routes".to_owned(),
1052 "admin bearer routes have no brute-force backstop behind the limiter and \
1053 every public write runs without a budget"
1054 .to_owned(),
1055 )
1056 } else {
1057 (
1058 format!(
1059 "declares RateLimiter for public routes from [{}]",
1060 declaring.join(", ")
1061 ),
1062 "those routes — and any public write or admin route beside them — run \
1063 without a budget"
1064 .to_owned(),
1065 )
1066 };
1067 Some(format!(
1068 "production venture {who} but the RateLimiter port is not resolved: {cost} — \
1069 resolve the binding so the runtime actually hands over a limiter, or set \
1070 HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES to a reason to serve unlimited \
1071 (issues #437, #562)"
1072 ))
1073 } else {
1074 None
1075 }
1076}
1077
1078fn admin_token_failure(admin_token_bytes: Option<usize>) -> Option<String> {
1085 let bytes = admin_token_bytes?;
1086 (bytes < MIN_SECRET_BYTES).then(|| {
1087 format!(
1088 "ADMIN_TOKEN is {bytes} bytes but must be at least {MIN_SECRET_BYTES} when set — \
1089 the boot refuses it in every environment, so this deploy cannot start. Lengthen \
1090 it, or remove it to keep the admin routes disabled (issue #437)"
1091 )
1092 })
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::{
1098 CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, admin_token_failure,
1099 captcha_production_failure, hmac_webhook_failure, payments_webhook_failure,
1100 push_wiring_checks, rate_limiter_production_failure,
1101 };
1102 use cratefield_core::{
1103 Config, ConfigError, Harness, MapConfig, Migrations, Module, ModuleContext, Port, Runtime,
1104 Venture, VentureEnv, WriteGuards,
1105 };
1106 use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
1107 use std::sync::Arc;
1108
1109 pub(super) struct NeedsAuth;
1111
1112 pub(super) struct Nothing;
1114
1115 macro_rules! bare_module {
1116 ($ty:ty, $name:literal, $ports:expr) => {
1117 impl Module for $ty {
1118 fn name(&self) -> &'static str {
1119 $name
1120 }
1121 fn version(&self) -> &'static str {
1122 "0.0.0"
1123 }
1124 fn requires(&self) -> &'static [Port] {
1125 $ports
1126 }
1127 fn migrations(&self) -> Migrations {
1128 Migrations::default()
1129 }
1130 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
1131 Ok(())
1132 }
1133 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
1134 cratefield_core::axum::Router::new()
1135 }
1136 }
1137 };
1138 }
1139
1140 pub(super) struct TakesWebhooks;
1142
1143 bare_module!(NeedsAuth, "needs-auth", &[Port::Auth]);
1144 bare_module!(Nothing, "nothing", &[]);
1145 impl Module for TakesWebhooks {
1146 fn name(&self) -> &'static str {
1147 "takes-webhooks"
1148 }
1149 fn version(&self) -> &'static str {
1150 "0.0.0"
1151 }
1152 fn requires(&self) -> &'static [Port] {
1153 &[Port::Payments]
1154 }
1155 fn migrations(&self) -> Migrations {
1156 Migrations::default()
1157 }
1158 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
1159 Ok(())
1160 }
1161 fn surface(&self) -> cratefield_core::Surface {
1162 Self::surface()
1163 }
1164 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
1165 cratefield_core::axum::Router::new()
1166 }
1167 }
1168
1169 impl TakesWebhooks {
1170 fn surface() -> cratefield_core::Surface {
1173 cratefield_core::Surface::new().action(
1174 cratefield_core::Action::post("receive", "/receive")
1175 .policy(cratefield_core::RoutePolicy::Signature)
1176 .outcome(cratefield_core::Outcome::Json),
1177 )
1178 }
1179 }
1180
1181 pub(super) struct TwoVerifiers;
1186
1187 impl Module for TwoVerifiers {
1188 fn name(&self) -> &'static str {
1189 "billing"
1190 }
1191 fn version(&self) -> &'static str {
1192 "0.0.0"
1193 }
1194 fn requires(&self) -> &'static [Port] {
1195 &[Port::Payments]
1196 }
1197 fn migrations(&self) -> Migrations {
1198 Migrations::default()
1199 }
1200 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
1201 Ok(())
1202 }
1203 fn surface(&self) -> cratefield_core::Surface {
1204 Self::surface()
1205 }
1206 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
1207 cratefield_core::axum::Router::new()
1208 }
1209 }
1210
1211 impl TwoVerifiers {
1212 fn surface() -> cratefield_core::Surface {
1213 cratefield_core::Surface::new()
1214 .action(
1215 cratefield_core::Action::post("webhook-stripe", "/webhooks/stripe")
1216 .policy(cratefield_core::RoutePolicy::Signature)
1217 .outcome(cratefield_core::Outcome::Json),
1218 )
1219 .action(
1220 cratefield_core::Action::post("webhook-revenuecat", "/webhooks/revenuecat")
1221 .policy(cratefield_core::RoutePolicy::Signature)
1222 .verification(cratefield_core::SignatureVerification::Hmac {
1223 secret: "REVENUECAT_WEBHOOK_SECRET",
1224 })
1225 .outcome(cratefield_core::Outcome::Json),
1226 )
1227 }
1228 }
1229
1230 struct AllPorts;
1233
1234 impl Runtime for AllPorts {
1235 fn provides(&self) -> Vec<Port> {
1236 Port::ALL.to_vec()
1237 }
1238 }
1239
1240 pub(super) fn harness_of(module: Arc<dyn Module>) -> Harness {
1242 Harness::builder()
1243 .venture(
1244 Venture::new("acme", "acme.test")
1245 .cors_origins(["https://acme.test"])
1246 .env(VentureEnv::Production),
1247 )
1248 .module_arc(module)
1249 .runtime(AllPorts)
1250 .build()
1251 .expect("the fixture composes")
1252 }
1253
1254 const OPTIONAL: PushDeclaration = PushDeclaration {
1257 declared: true,
1258 required: false,
1259 };
1260
1261 const REQUIRED: PushDeclaration = PushDeclaration {
1263 declared: true,
1264 required: true,
1265 };
1266
1267 fn guards(needs_captcha: bool) -> WriteGuards {
1268 WriteGuards {
1269 captcha_modules: if needs_captcha {
1270 vec!["writer".to_owned()]
1271 } else {
1272 Vec::new()
1273 },
1274 signature_modules: Vec::new(),
1275 signed_link_modules: Vec::new(),
1276 ..WriteGuards::default()
1279 }
1280 }
1281
1282 #[test]
1283 fn a_venture_that_needs_no_caller_is_not_asked_for_an_issuer() {
1284 assert_eq!(super::auth_production_failure(false, false, false), None);
1288 }
1289
1290 #[test]
1291 fn a_module_that_must_know_who_is_calling_needs_an_issuer_in_production() {
1292 let refusal = super::auth_production_failure(true, false, false)
1296 .expect("a module needs a caller and nothing can name one");
1297 assert!(refusal.contains("AUTH_ISSUER"), "{refusal}");
1298 assert!(refusal.contains("AUTH_CLIENT_ID"), "{refusal}");
1299 assert!(refusal.contains("503"), "say what goes wrong: {refusal}");
1300 }
1301
1302 #[test]
1303 fn half_a_verifier_is_still_a_failure_and_names_the_half() {
1304 let missing_id = super::auth_production_failure(true, true, false).expect("half");
1308 assert!(missing_id.contains("AUTH_CLIENT_ID"), "{missing_id}");
1309 assert!(!missing_id.contains("AUTH_ISSUER"), "{missing_id}");
1310
1311 let missing_issuer = super::auth_production_failure(true, false, true).expect("half");
1312 assert!(missing_issuer.contains("AUTH_ISSUER"), "{missing_issuer}");
1313 assert!(
1314 !missing_issuer.contains("AUTH_CLIENT_ID"),
1315 "{missing_issuer}"
1316 );
1317 }
1318
1319 #[test]
1320 fn a_configured_verifier_is_no_failure() {
1321 assert_eq!(super::auth_production_failure(true, true, true), None);
1322 }
1323
1324 #[test]
1325 fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
1326 let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
1329 assert!(failure.contains("writer"));
1330 assert!(failure.contains("effectively configured"));
1331 assert!(captcha_production_failure(&guards(true), true).is_none());
1332 assert!(captcha_production_failure(&guards(false), false).is_none());
1333
1334 for nothing in ["", " "] {
1341 assert!(
1342 cratefield_core::stated_reason(Some(nothing)).is_none(),
1343 "an empty reason counted as an acceptance: {nothing:?}"
1344 );
1345 }
1346 assert_eq!(
1347 cratefield_core::stated_reason(Some(" previewing the new form ")),
1348 Some("previewing the new form"),
1349 "a real reason still overrides, trimmed"
1350 );
1351 }
1352
1353 #[test]
1354 fn payments_in_production_needs_a_webhook_secret() {
1355 let payments = vec!["billing (POST /v1/billing/webhooks/stripe)".to_owned()];
1358 let failure = payments_webhook_failure(&payments, false).expect("no secret fails");
1359 assert!(
1360 failure.contains("POST /v1/billing/webhooks/stripe"),
1361 "{failure}"
1362 );
1363 assert!(payments_webhook_failure(&payments, true).is_none());
1364 assert!(payments_webhook_failure(&[], false).is_none());
1365
1366 let modules = vec![(
1370 "pos".to_owned(),
1371 "POS_WEBHOOK_SECRET".to_owned(),
1372 vec!["POST /webhook".to_owned()],
1373 )];
1374 let failure = hmac_webhook_failure(&modules, &MapConfig::default()).expect("unset fails");
1375 assert!(failure.contains("POS_WEBHOOK_SECRET"), "{failure}");
1376 assert!(failure.contains("POST /webhook"), "{failure}");
1377 assert!(
1378 hmac_webhook_failure(
1379 &modules,
1380 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", " ")])
1381 )
1382 .is_some()
1383 );
1384 assert!(
1385 hmac_webhook_failure(
1386 &modules,
1387 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "x")])
1388 )
1389 .is_none()
1390 );
1391 assert!(hmac_webhook_failure(&[], &MapConfig::default()).is_none());
1392 }
1393
1394 #[test]
1395 fn a_venture_with_public_writes_or_admin_routes_needs_a_resolved_limiter() {
1396 let failure =
1399 rate_limiter_production_failure(true, false, &[]).expect("an unlimited venture fails");
1400 assert!(failure.contains("RateLimiter"), "{failure}");
1401 assert!(
1402 failure.contains("HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES"),
1403 "the hatch is named the way the captcha rule names its own: {failure}"
1404 );
1405 assert!(rate_limiter_production_failure(true, true, &[]).is_none());
1406 assert!(rate_limiter_production_failure(false, false, &[]).is_none());
1408 }
1409
1410 #[test]
1411 fn a_doctor_limiter_failure_names_the_declaring_modules() {
1412 let failure = rate_limiter_production_failure(
1415 true,
1416 false,
1417 &["waitlist (POST /v1/waitlist, GET /v1/waitlist/search)".to_owned()],
1418 )
1419 .expect("a declaring venture fails");
1420 assert!(failure.contains("declares RateLimiter"), "{failure}");
1421 assert!(
1422 failure.contains("waitlist (POST /v1/waitlist, GET /v1/waitlist/search)"),
1423 "{failure}"
1424 );
1425 }
1426
1427 #[test]
1428 fn an_absent_admin_token_is_not_a_failure() {
1429 assert_eq!(admin_token_failure(None), None);
1432 }
1433
1434 #[test]
1435 fn a_set_but_short_admin_token_fails_at_the_floor_the_boot_enforces() {
1436 let refusal = admin_token_failure(Some(8)).expect("a short token");
1437 assert!(refusal.contains("ADMIN_TOKEN"), "{refusal}");
1438 assert!(refusal.contains("32"), "name the floor: {refusal}");
1439 assert!(
1440 refusal.contains("every environment"),
1441 "the rule is not production-only, and says so: {refusal}"
1442 );
1443
1444 assert_eq!(admin_token_failure(Some(32)), None);
1447 assert_eq!(admin_token_failure(Some(64)), None);
1448 }
1449
1450 #[test]
1453 fn report_renders_the_json_contract() {
1454 let report = DoctorReport {
1455 failures: vec![
1456 DoctorFailure {
1457 code: &CODES.captcha_not_effective,
1458 message: "production venture has captcha-guarded writes".to_owned(),
1459 },
1460 DoctorFailure {
1461 code: &CODES.payments_webhook_secret_missing,
1462 message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
1463 },
1464 ],
1465 };
1466 let payload = report.render_json().expect("serializes");
1467 assert_eq!(
1468 payload,
1469 "{\"schema\":1,\"ok\":false,\"failures\":[\
1470 {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
1471 captcha-guarded writes\"},\
1472 {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
1473 is unset\"}]}"
1474 );
1475 assert!(!report.ok());
1476
1477 let clean = DoctorReport {
1478 failures: Vec::new(),
1479 };
1480 assert_eq!(
1481 clean.render_json().expect("serializes"),
1482 "{\"schema\":1,\"ok\":true,\"failures\":[]}"
1483 );
1484 assert!(clean.ok());
1485 }
1486
1487 fn half_wired() -> PushWiring {
1491 let var = PUSH_ENV
1492 .iter()
1493 .find(|var| var.required)
1494 .expect("the table has a required variable");
1495 let wiring = inspect_push(&MapConfig::from_pairs([(
1496 var.name().to_owned(),
1497 "set-but-alone".to_owned(),
1498 )]));
1499 assert!(
1500 !wiring.problems().is_empty(),
1501 "one variable of a transport is a half-wired transport: {}",
1502 wiring.summary()
1503 );
1504 wiring
1505 }
1506
1507 #[test]
1508 fn a_half_wired_transport_fails_the_doctor_in_production_only() {
1509 let wiring = half_wired();
1510
1511 let mut failures = Vec::new();
1512 push_wiring_checks(
1513 &wiring,
1514 VentureEnv::Production,
1515 OPTIONAL,
1516 Output::Human,
1517 &mut failures,
1518 );
1519 assert_eq!(failures.len(), 1, "{failures:?}");
1520 for problem in wiring.problems() {
1521 assert!(failures[0].message.contains(&problem), "{failures:?}");
1522 }
1523 assert_eq!(failures[0].code.code, "push-transport-misconfigured");
1527
1528 for env in [VentureEnv::Development, VentureEnv::Staging] {
1531 let mut failures = Vec::new();
1532 push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
1533 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1534 }
1535 }
1536
1537 #[test]
1538 fn a_venture_that_wires_no_push_at_all_is_silent() {
1539 let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1542 let mut failures = Vec::new();
1543 push_wiring_checks(
1544 &wiring,
1545 VentureEnv::Production,
1546 OPTIONAL,
1547 Output::Human,
1548 &mut failures,
1549 );
1550 assert!(failures.is_empty(), "{failures:?}");
1551 assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
1552 }
1553
1554 #[test]
1555 fn a_module_that_requires_push_needs_a_transport_that_can_send() {
1556 let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1562 assert!(!nothing.any_routed());
1563
1564 let mut failures = Vec::new();
1565 push_wiring_checks(
1566 ¬hing,
1567 VentureEnv::Production,
1568 REQUIRED,
1569 Output::Human,
1570 &mut failures,
1571 );
1572 assert_eq!(failures.len(), 1, "{failures:?}");
1573 assert!(
1574 failures[0].message.contains("require the Push port"),
1575 "{failures:?}"
1576 );
1577 assert_eq!(failures[0].code.code, "push-required-but-unrouted");
1581
1582 for env in [VentureEnv::Development, VentureEnv::Staging] {
1584 let mut failures = Vec::new();
1585 push_wiring_checks(¬hing, env, REQUIRED, Output::Human, &mut failures);
1586 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1587 }
1588 let mut failures = Vec::new();
1589 push_wiring_checks(
1590 ¬hing,
1591 VentureEnv::Production,
1592 OPTIONAL,
1593 Output::Human,
1594 &mut failures,
1595 );
1596 assert!(failures.is_empty(), "{failures:?}");
1597 }
1598
1599 #[test]
1600 fn the_push_rules_are_gated_on_what_the_venture_declares() {
1601 let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
1607 assert!(!none.declared);
1608 assert!(!none.required);
1609
1610 let optional =
1611 PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
1612 assert!(optional.declared);
1613 assert!(
1614 !optional.required,
1615 "a module that only *may* use push does not make one mandatory"
1616 );
1617
1618 let required = PushDeclaration::from_ports(
1619 [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
1620 );
1621 assert!(required.declared);
1622 assert!(required.required);
1623 }
1624
1625 #[test]
1626 fn a_configured_transport_satisfies_a_module_that_requires_push() {
1627 let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
1634 cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
1635 uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
1636 -----END PRIVATE KEY-----";
1637 let wiring = inspect_push(&MapConfig::from_pairs([
1638 (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
1639 (
1640 PushKey::VapidSubject.name().to_owned(),
1641 "mailto:ops@example.test".to_owned(),
1642 ),
1643 ]));
1644 assert!(wiring.any_routed(), "{}", wiring.summary());
1645
1646 let mut failures = Vec::new();
1647 push_wiring_checks(
1648 &wiring,
1649 VentureEnv::Production,
1650 REQUIRED,
1651 Output::Human,
1652 &mut failures,
1653 );
1654 assert!(failures.is_empty(), "{failures:?}");
1655 }
1656}
1657
1658#[cfg(test)]
1659mod production_wiring {
1660 use super::tests::{NeedsAuth, Nothing, harness_of};
1691 use super::{CODES, Configured, DoctorFailure, Output, production_port_checks, run_checks};
1692
1693 fn failures(
1694 module: std::sync::Arc<dyn cratefield_core::Module>,
1695 configured: Configured,
1696 ) -> Vec<String> {
1697 let harness = harness_of(module);
1698 let mut out = Vec::new();
1699 production_port_checks(
1700 &harness,
1701 configured,
1702 &cratefield_core::MapConfig::default(),
1703 None,
1704 Output::Json,
1705 &mut out,
1706 );
1707 out.iter()
1708 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1709 .collect()
1710 }
1711
1712 fn messages(
1715 module: std::sync::Arc<dyn cratefield_core::Module>,
1716 configured: Configured,
1717 config: &dyn cratefield_core::Config,
1718 ) -> Vec<(String, String)> {
1719 let harness = harness_of(module);
1720 let mut out = Vec::new();
1721 production_port_checks(&harness, configured, config, None, Output::Json, &mut out);
1722 out.iter()
1723 .map(|failure| (failure.code.code.to_owned(), failure.message.clone()))
1724 .collect()
1725 }
1726
1727 const NONE_SET: Configured = Configured {
1728 auth_issuer: false,
1729 auth_client_id: false,
1730 stripe_webhook_secret: false,
1731 admin_token_bytes: None,
1732 };
1733
1734 #[test]
1735 fn a_module_needing_a_caller_with_no_issuer_is_reported() {
1736 assert!(
1737 failures(std::sync::Arc::new(NeedsAuth), NONE_SET)
1738 .contains(&CODES.auth_not_configured.code.to_owned()),
1739 "the rule is correct and nothing calls it"
1740 );
1741 }
1742
1743 #[test]
1744 fn the_same_module_with_an_issuer_is_not() {
1745 let configured = Configured {
1746 auth_issuer: true,
1747 auth_client_id: true,
1748 ..NONE_SET
1749 };
1750 assert!(
1751 !failures(std::sync::Arc::new(NeedsAuth), configured)
1752 .contains(&CODES.auth_not_configured.code.to_owned())
1753 );
1754 }
1755
1756 #[test]
1757 fn a_webhook_receiver_with_no_secret_is_reported() {
1758 assert!(
1762 failures(std::sync::Arc::new(super::tests::TakesWebhooks), NONE_SET)
1763 .contains(&CODES.payments_webhook_secret_missing.code.to_owned()),
1764 "the payments rule is correct and nothing calls it"
1765 );
1766 }
1767
1768 #[test]
1769 fn the_same_receiver_with_a_secret_is_not() {
1770 let configured = Configured {
1771 stripe_webhook_secret: true,
1772 ..NONE_SET
1773 };
1774 assert!(
1775 !failures(std::sync::Arc::new(super::tests::TakesWebhooks), configured)
1776 .contains(&CODES.payments_webhook_secret_missing.code.to_owned())
1777 );
1778 }
1779
1780 #[test]
1781 fn a_module_needing_no_caller_is_never_asked() {
1782 assert!(
1783 !failures(std::sync::Arc::new(Nothing), NONE_SET)
1784 .contains(&CODES.auth_not_configured.code.to_owned())
1785 );
1786 }
1787
1788 #[test]
1789 fn a_two_verifier_module_names_the_route_each_webhook_rule_blocks() {
1790 use cratefield_core::MapConfig;
1795 let message = |pairs: &[(String, String)], code: &str| -> Option<String> {
1796 pairs
1797 .iter()
1798 .find(|(found, _)| found == code)
1799 .map(|(_, message)| message.clone())
1800 };
1801 let two = || std::sync::Arc::new(super::tests::TwoVerifiers);
1802 let stripe_set = Configured {
1803 stripe_webhook_secret: true,
1804 ..NONE_SET
1805 };
1806 let secret = MapConfig::from_pairs([("BILLING_REVENUECAT_WEBHOOK_SECRET", "whsec-dummy")]);
1807
1808 let both = messages(two(), stripe_set, &secret);
1810 assert!(message(&both, "payments-webhook-secret-missing").is_none());
1811 assert!(message(&both, "hmac-webhook-secret-missing").is_none());
1812
1813 let no_stripe = messages(two(), NONE_SET, &secret);
1816 let payment = message(&no_stripe, "payments-webhook-secret-missing").expect("fires");
1817 assert!(
1818 payment.contains("POST /v1/billing/webhooks/stripe"),
1819 "{payment}"
1820 );
1821 assert!(
1822 !payment.contains("POST /v1/billing/webhooks/revenuecat"),
1823 "{payment}"
1824 );
1825
1826 let no_revenuecat = messages(two(), stripe_set, &MapConfig::default());
1828 let hmac = message(&no_revenuecat, "hmac-webhook-secret-missing").expect("fires");
1829 assert!(
1830 hmac.contains("POST /v1/billing/webhooks/revenuecat"),
1831 "{hmac}"
1832 );
1833 assert!(!hmac.contains("POST /v1/billing/webhooks/stripe"), "{hmac}");
1834 }
1835
1836 fn run_report(configured: Configured) -> Vec<String> {
1843 let harness = harness_of(std::sync::Arc::new(Nothing));
1844 run_checks(
1845 &harness,
1846 std::path::Path::new("no-migrations-here"),
1847 None,
1848 None,
1849 Output::Json,
1850 configured,
1851 )
1852 .failures
1853 .iter()
1854 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1855 .collect()
1856 }
1857
1858 #[test]
1859 fn a_short_admin_token_reaches_the_report() {
1860 let configured = Configured {
1861 admin_token_bytes: Some(4),
1862 ..NONE_SET
1863 };
1864 assert_eq!(
1865 run_report(configured),
1866 vec![CODES.admin_token_too_short.code.to_owned()],
1867 "the rule is correct and nothing calls it"
1868 );
1869 }
1870
1871 #[test]
1872 fn an_absent_admin_token_leaves_the_report_clean() {
1873 assert!(
1874 run_report(NONE_SET).is_empty(),
1875 "{:?}",
1876 run_report(NONE_SET)
1877 );
1878 }
1879}