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;
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(harness, configured, allow_no_captcha, output, &mut failures);
217 }
218
219 if let Some(message) = admin_token_failure(configured.admin_token_bytes) {
226 failures.push(DoctorFailure {
227 code: &CODES.admin_token_too_short,
228 message,
229 });
230 }
231
232 push_checks(harness, env, output, &mut failures);
233 lockfile_failures(harness, migrations_dir, &mut failures);
234 sidecar_failures(harness, sidecars, output, &mut failures);
235 lint_failures(harness, &mut failures);
236
237 DoctorReport { failures }
238}
239
240fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
245 for module in harness.modules() {
246 if module.harness_api() != HARNESS_API {
247 failures.push(DoctorFailure {
248 code: &CODES.harness_api_mismatch,
249 message: harness_api_mismatch(module.as_ref()),
250 });
251 }
252 }
253}
254
255fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
269 for module in harness.modules() {
270 for message in module.self_check() {
271 failures.push(DoctorFailure {
272 code: &CODES.module_self_check,
273 message,
274 });
275 }
276 }
277}
278
279fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
282 let lock = match read_lock(migrations_dir) {
283 Ok(lock) => lock,
284 Err(err) => {
285 failures.push(DoctorFailure {
286 code: &CODES.lockfile_unreadable,
287 message: err,
288 });
289 Lock::default()
290 }
291 };
292 for module in harness.modules() {
293 for migration in module.migrations().sqlite {
294 let key = format!("{}/{}", module.name(), migration.id);
295 match lock.get(&key) {
296 Some(entry) => {
297 if let Err(err) = verify_locked(migrations_dir, &key, entry) {
298 let code = match &err {
299 LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
300 LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
301 };
302 failures.push(DoctorFailure {
303 code,
304 message: err.to_string(),
305 });
306 }
307 }
308 None => failures.push(DoctorFailure {
309 code: &CODES.migration_not_collected,
310 message: format!(
311 "migration {key} is not collected yet — run `fz migrations collect`"
312 ),
313 }),
314 }
315 }
316 }
317}
318
319fn sidecar_failures(
326 harness: &Harness,
327 sidecars: Option<&str>,
328 output: Output,
329 failures: &mut Vec<DoctorFailure>,
330) {
331 match crate::sidecars::parse(sidecars) {
332 Ok(mounts) if !mounts.is_empty() => {
333 for mount in crate::sidecars::shadowing(harness, &mounts) {
334 failures.push(DoctorFailure {
335 code: &CODES.sidecar_shadows_module,
336 message: format!(
337 "sidecar mount `{}` names a module compiled into this venture; the runtime \
338 ignores the mount and serves the compiled-in module. Remove it from \
339 {HARNESS_SIDECARS} or remove the module from the composition",
340 mount.name
341 ),
342 });
343 }
344 if output == Output::Human {
345 eprintln!(
346 "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
347 checked here — they live in its own repository (docs/MOUNTING.md)",
348 crate::sidecars::listed(&mounts)
349 );
350 if std::env::var(SIDECAR_GATEWAY_SECRET)
355 .ok()
356 .is_none_or(|value| value.trim().is_empty())
357 {
358 eprintln!(
359 "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
360 gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
361 refuses every forwarded request (issue #131)"
362 );
363 }
364 }
365 }
366 Ok(_) => {}
367 Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
368 code: &CODES.sidecar_mount_invalid,
369 message,
370 })),
371 }
372}
373
374fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
382 for module in harness.modules() {
383 if !module.migrations().postgres.is_empty() {
384 continue;
385 }
386 for migration in module.migrations().sqlite {
387 for (token, why) in banned_tokens(migration.sql) {
388 failures.push(DoctorFailure {
389 code: &CODES.non_portable_sql,
390 message: format!(
391 "module `{}` migration {}: banned token `{token}` — {why}",
392 module.name(),
393 migration.id
394 ),
395 });
396 }
397 }
398 }
399
400 for module in harness.modules() {
401 let migrations = module.migrations();
402 for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
403 for (fragment, why) in lint_card_data(migration.sql) {
404 failures.push(DoctorFailure {
405 code: &CODES.card_data_in_migration,
406 message: format!(
407 "module `{}` migration {}: `{fragment}` {why}",
408 module.name(),
409 migration.id
410 ),
411 });
412 }
413 }
414 }
415}
416
417fn push_checks(
427 harness: &Harness,
428 env: VentureEnv,
429 output: Output,
430 failures: &mut Vec<DoctorFailure>,
431) {
432 let declaration = PushDeclaration::of(harness.modules());
433 if !declaration.declared {
434 return;
435 }
436 push_wiring_checks(
437 &cratefield_push_wiring::inspect_push(&EnvVars),
438 env,
439 declaration,
440 output,
441 failures,
442 );
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449struct PushDeclaration {
450 declared: bool,
453 required: bool,
456}
457
458impl PushDeclaration {
459 fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
460 Self::from_ports(
461 modules
462 .iter()
463 .map(|module| (module.requires(), module.optional())),
464 )
465 }
466
467 fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
471 let mut declaration = Self {
472 declared: false,
473 required: false,
474 };
475 for (requires, optional) in modules {
476 if requires.contains(&Port::Push) {
477 declaration.declared = true;
478 declaration.required = true;
479 } else if optional.contains(&Port::Push) {
480 declaration.declared = true;
481 }
482 }
483 declaration
484 }
485}
486
487fn push_wiring_checks(
504 wiring: &PushWiring,
505 env: VentureEnv,
506 declaration: PushDeclaration,
507 output: Output,
508 failures: &mut Vec<DoctorFailure>,
509) {
510 match wiring.check(env) {
511 Err(problems) => failures.push(DoctorFailure {
518 code: &CODES.push_transport_misconfigured,
519 message: problems,
520 }),
521 Ok(()) => {
524 if output == Output::Human {
525 for problem in wiring.problems() {
526 eprintln!("fz: warning: {problem}");
527 }
528 }
529 }
530 }
531
532 if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
533 failures.push(DoctorFailure {
534 code: &CODES.push_required_but_unrouted,
535 message: "production venture has modules that require the Push port but no push \
536 transport is configured — every send would answer NotConfigured. Set one \
537 transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
538 push. If this venture provides Push from an adapter of its own rather than \
539 from the environment, that adapter is not visible here (issue #191)"
540 .to_owned(),
541 });
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub(crate) struct Configured {
556 pub(crate) auth_issuer: bool,
557 pub(crate) auth_client_id: bool,
558 pub(crate) stripe_webhook_secret: bool,
559 pub(crate) admin_token_bytes: Option<usize>,
564}
565
566impl Configured {
567 fn from_env() -> Self {
568 Self {
569 auth_issuer: set("AUTH_ISSUER"),
570 auth_client_id: set("AUTH_CLIENT_ID"),
571 stripe_webhook_secret: set("STRIPE_WEBHOOK_SECRET"),
572 admin_token_bytes: std::env::var("ADMIN_TOKEN")
577 .ok()
578 .filter(|value| !value.is_empty())
579 .map(|value| value.len()),
580 }
581 }
582}
583
584fn production_port_checks(
585 harness: &Harness,
586 configured: Configured,
587 allow_no_captcha: Option<&str>,
588 output: Output,
589 failures: &mut Vec<DoctorFailure>,
590) {
591 let guards = cratefield_core::WriteGuards::collect(harness.modules());
592 if let Some(message) = captcha_production_failure(
593 &guards,
594 cratefield_core::captcha_effective(harness.runtime()),
595 ) {
596 match cratefield_core::stated_reason(allow_no_captcha) {
601 Some(reason) => {
602 tracing::warn!(
610 control = "captcha",
611 reason,
612 modules = guards.captcha_modules.join(","),
613 "production abuse control overridden by an operator"
614 );
615 if output == Output::Human {
616 eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
617 eprintln!(
618 "fz: note: this override is recorded; it is for previews only and must \
619 not stand in for a Captcha port in production"
620 );
621 }
622 }
623 None => failures.push(DoctorFailure {
624 code: &CODES.captcha_not_effective,
625 message,
626 }),
627 }
628 }
629
630 if let Some(message) = auth_production_failure(
631 harness
632 .modules()
633 .iter()
634 .any(|module| module.requires().contains(&Port::Auth)),
635 configured.auth_issuer,
636 configured.auth_client_id,
637 ) {
638 failures.push(DoctorFailure {
639 code: &CODES.auth_not_configured,
640 message,
641 });
642 }
643
644 if let Some(message) =
645 payments_webhook_failure(guards.needs_payments(), configured.stripe_webhook_secret)
646 {
647 failures.push(DoctorFailure {
648 code: &CODES.payments_webhook_secret_missing,
649 message,
650 });
651 }
652
653 if let Some(message) = hmac_webhook_failure(&guards.hmac_signature_secrets(), &EnvVars) {
657 failures.push(DoctorFailure {
658 code: &CODES.hmac_webhook_secret_missing,
659 message,
660 });
661 }
662
663 if let Some(message) = rate_limiter_production_failure(
671 guards.needs_rate_limiter(),
672 cratefield_core::rate_limiter_effective(harness.runtime()),
673 ) {
674 match cratefield_core::unlimited_public_routes_override(&EnvVars) {
675 Some(reason) => {
676 tracing::warn!(
677 control = "rate-limiter",
678 reason,
679 "production abuse control overridden by an operator"
680 );
681 if output == Output::Human {
682 eprintln!("fz: warning: rate-limiter override accepted ({reason}): {message}");
683 eprintln!(
684 "fz: note: this override is recorded; resolve the RateLimiter binding \
685 and remove HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES before serving \
686 production traffic"
687 );
688 }
689 }
690 None => failures.push(DoctorFailure {
691 code: &CODES.rate_limiter_not_effective,
692 message,
693 }),
694 }
695 }
696}
697
698fn set(key: &str) -> bool {
700 std::env::var(key).is_ok_and(|value| !value.trim().is_empty())
701}
702
703fn auth_production_failure(
719 needs_auth: bool,
720 issuer_set: bool,
721 client_id_set: bool,
722) -> Option<String> {
723 if !needs_auth || (issuer_set && client_id_set) {
724 return None;
725 }
726 let mut missing = Vec::new();
727 if !issuer_set {
728 missing.push("AUTH_ISSUER");
729 }
730 if !client_id_set {
731 missing.push("AUTH_CLIENT_ID");
732 }
733 Some(format!(
734 "a module requires the Auth port and {} is not set; every route that needs a signed-in \
735 caller will answer 503",
736 missing.join(" and ")
737 ))
738}
739
740fn captcha_production_failure(
746 guards: &cratefield_core::WriteGuards,
747 captcha_effective: bool,
748) -> Option<String> {
749 if guards.needs_captcha() && !captcha_effective {
750 Some(format!(
751 "production venture has captcha-guarded public writes from [{}] but the Captcha port \
752 is not effectively configured — provide Turnstile with its secret and expected \
753 hostname (architecture section 11; issue #133)",
754 guards.captcha_modules.join(", ")
755 ))
756 } else {
757 None
758 }
759}
760
761fn payments_webhook_failure(
768 signature_routes_present: bool,
769 webhook_secret_present: bool,
770) -> Option<String> {
771 if signature_routes_present && !webhook_secret_present {
772 Some(
773 "production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset \
774 — webhook signatures cannot be verified and forged events would be trusted \
775 (issue #102)"
776 .to_owned(),
777 )
778 } else {
779 None
780 }
781}
782
783fn hmac_webhook_failure(
788 hmac_modules: &[(String, String)],
789 config: &dyn cratefield_core::Config,
790) -> Option<String> {
791 let missing: Vec<String> = hmac_modules
792 .iter()
793 .filter(|(_, key)| config.get(key).is_none_or(|value| value.trim().is_empty()))
794 .map(|(_, key)| key.clone())
795 .collect();
796 (!missing.is_empty()).then(|| {
797 format!(
798 "production venture verifies webhook signatures with the webhook_signature HMAC \
799 scheme but {} is not set — those handlers refuse every delivery until the \
800 provider's signing secret is configured (issue #533)",
801 missing.join(", ")
802 )
803 })
804}
805
806fn rate_limiter_production_failure(
815 needs_rate_limiter: bool,
816 rate_limiter_effective: bool,
817) -> Option<String> {
818 if needs_rate_limiter && !rate_limiter_effective {
819 Some(
820 "production venture takes public writes or admin routes but the RateLimiter port \
821 is not resolved: admin bearer routes have no brute-force backstop behind the \
822 limiter and every public write runs without a budget — resolve the binding so \
823 the runtime actually hands over a limiter, or set \
824 HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES to a reason to serve unlimited (issue #437)"
825 .to_owned(),
826 )
827 } else {
828 None
829 }
830}
831
832fn admin_token_failure(admin_token_bytes: Option<usize>) -> Option<String> {
839 let bytes = admin_token_bytes?;
840 (bytes < MIN_SECRET_BYTES).then(|| {
841 format!(
842 "ADMIN_TOKEN is {bytes} bytes but must be at least {MIN_SECRET_BYTES} when set — \
843 the boot refuses it in every environment, so this deploy cannot start. Lengthen \
844 it, or remove it to keep the admin routes disabled (issue #437)"
845 )
846 })
847}
848
849#[cfg(test)]
850mod tests {
851 use super::{
852 CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, admin_token_failure,
853 captcha_production_failure, hmac_webhook_failure, payments_webhook_failure,
854 push_wiring_checks, rate_limiter_production_failure,
855 };
856 use cratefield_core::{
857 Config, ConfigError, Harness, MapConfig, Migrations, Module, ModuleContext, Port, Runtime,
858 Venture, VentureEnv, WriteGuards,
859 };
860 use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
861 use std::sync::Arc;
862
863 pub(super) struct NeedsAuth;
865
866 pub(super) struct Nothing;
868
869 macro_rules! bare_module {
870 ($ty:ty, $name:literal, $ports:expr) => {
871 impl Module for $ty {
872 fn name(&self) -> &'static str {
873 $name
874 }
875 fn version(&self) -> &'static str {
876 "0.0.0"
877 }
878 fn requires(&self) -> &'static [Port] {
879 $ports
880 }
881 fn migrations(&self) -> Migrations {
882 Migrations::default()
883 }
884 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
885 Ok(())
886 }
887 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
888 cratefield_core::axum::Router::new()
889 }
890 }
891 };
892 }
893
894 pub(super) struct TakesWebhooks;
896
897 bare_module!(NeedsAuth, "needs-auth", &[Port::Auth]);
898 bare_module!(Nothing, "nothing", &[]);
899 impl Module for TakesWebhooks {
900 fn name(&self) -> &'static str {
901 "takes-webhooks"
902 }
903 fn version(&self) -> &'static str {
904 "0.0.0"
905 }
906 fn requires(&self) -> &'static [Port] {
907 &[Port::Payments]
908 }
909 fn migrations(&self) -> Migrations {
910 Migrations::default()
911 }
912 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
913 Ok(())
914 }
915 fn surface(&self) -> cratefield_core::Surface {
916 Self::surface()
917 }
918 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
919 cratefield_core::axum::Router::new()
920 }
921 }
922
923 impl TakesWebhooks {
924 fn surface() -> cratefield_core::Surface {
927 cratefield_core::Surface::new().action(
928 cratefield_core::Action::post("receive", "/receive")
929 .policy(cratefield_core::RoutePolicy::Signature)
930 .outcome(cratefield_core::Outcome::Json),
931 )
932 }
933 }
934
935 struct AllPorts;
938
939 impl Runtime for AllPorts {
940 fn provides(&self) -> Vec<Port> {
941 Port::ALL.to_vec()
942 }
943 }
944
945 pub(super) fn harness_of(module: Arc<dyn Module>) -> Harness {
947 Harness::builder()
948 .venture(
949 Venture::new("acme", "acme.test")
950 .cors_origins(["https://acme.test"])
951 .env(VentureEnv::Production),
952 )
953 .module_arc(module)
954 .runtime(AllPorts)
955 .build()
956 .expect("the fixture composes")
957 }
958
959 const OPTIONAL: PushDeclaration = PushDeclaration {
962 declared: true,
963 required: false,
964 };
965
966 const REQUIRED: PushDeclaration = PushDeclaration {
968 declared: true,
969 required: true,
970 };
971
972 fn guards(needs_captcha: bool) -> WriteGuards {
973 WriteGuards {
974 captcha_modules: if needs_captcha {
975 vec!["writer".to_owned()]
976 } else {
977 Vec::new()
978 },
979 signature_modules: Vec::new(),
980 signed_link_modules: Vec::new(),
981 ..WriteGuards::default()
984 }
985 }
986
987 #[test]
988 fn a_venture_that_needs_no_caller_is_not_asked_for_an_issuer() {
989 assert_eq!(super::auth_production_failure(false, false, false), None);
993 }
994
995 #[test]
996 fn a_module_that_must_know_who_is_calling_needs_an_issuer_in_production() {
997 let refusal = super::auth_production_failure(true, false, false)
1001 .expect("a module needs a caller and nothing can name one");
1002 assert!(refusal.contains("AUTH_ISSUER"), "{refusal}");
1003 assert!(refusal.contains("AUTH_CLIENT_ID"), "{refusal}");
1004 assert!(refusal.contains("503"), "say what goes wrong: {refusal}");
1005 }
1006
1007 #[test]
1008 fn half_a_verifier_is_still_a_failure_and_names_the_half() {
1009 let missing_id = super::auth_production_failure(true, true, false).expect("half");
1013 assert!(missing_id.contains("AUTH_CLIENT_ID"), "{missing_id}");
1014 assert!(!missing_id.contains("AUTH_ISSUER"), "{missing_id}");
1015
1016 let missing_issuer = super::auth_production_failure(true, false, true).expect("half");
1017 assert!(missing_issuer.contains("AUTH_ISSUER"), "{missing_issuer}");
1018 assert!(
1019 !missing_issuer.contains("AUTH_CLIENT_ID"),
1020 "{missing_issuer}"
1021 );
1022 }
1023
1024 #[test]
1025 fn a_configured_verifier_is_no_failure() {
1026 assert_eq!(super::auth_production_failure(true, true, true), None);
1027 }
1028
1029 #[test]
1030 fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
1031 let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
1034 assert!(failure.contains("writer"));
1035 assert!(failure.contains("effectively configured"));
1036 assert!(captcha_production_failure(&guards(true), true).is_none());
1037 assert!(captcha_production_failure(&guards(false), false).is_none());
1038
1039 for nothing in ["", " "] {
1046 assert!(
1047 cratefield_core::stated_reason(Some(nothing)).is_none(),
1048 "an empty reason counted as an acceptance: {nothing:?}"
1049 );
1050 }
1051 assert_eq!(
1052 cratefield_core::stated_reason(Some(" previewing the new form ")),
1053 Some("previewing the new form"),
1054 "a real reason still overrides, trimmed"
1055 );
1056 }
1057
1058 #[test]
1059 fn payments_in_production_needs_a_webhook_secret() {
1060 assert!(payments_webhook_failure(true, false).is_some());
1061 assert!(payments_webhook_failure(true, true).is_none());
1062 assert!(payments_webhook_failure(false, false).is_none());
1063
1064 let modules = vec![("pos".to_owned(), "POS_WEBHOOK_SECRET".to_owned())];
1067 assert!(hmac_webhook_failure(&modules, &MapConfig::default()).is_some());
1068 assert!(
1069 hmac_webhook_failure(
1070 &modules,
1071 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", " ")])
1072 )
1073 .is_some()
1074 );
1075 assert!(
1076 hmac_webhook_failure(
1077 &modules,
1078 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "x")])
1079 )
1080 .is_none()
1081 );
1082 assert!(hmac_webhook_failure(&[], &MapConfig::default()).is_none());
1083 }
1084
1085 #[test]
1086 fn a_venture_with_public_writes_or_admin_routes_needs_a_resolved_limiter() {
1087 let failure =
1090 rate_limiter_production_failure(true, false).expect("an unlimited venture fails");
1091 assert!(failure.contains("RateLimiter"), "{failure}");
1092 assert!(
1093 failure.contains("HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES"),
1094 "the hatch is named the way the captcha rule names its own: {failure}"
1095 );
1096 assert!(rate_limiter_production_failure(true, true).is_none());
1097 assert!(rate_limiter_production_failure(false, false).is_none());
1099 }
1100
1101 #[test]
1102 fn an_absent_admin_token_is_not_a_failure() {
1103 assert_eq!(admin_token_failure(None), None);
1106 }
1107
1108 #[test]
1109 fn a_set_but_short_admin_token_fails_at_the_floor_the_boot_enforces() {
1110 let refusal = admin_token_failure(Some(8)).expect("a short token");
1111 assert!(refusal.contains("ADMIN_TOKEN"), "{refusal}");
1112 assert!(refusal.contains("32"), "name the floor: {refusal}");
1113 assert!(
1114 refusal.contains("every environment"),
1115 "the rule is not production-only, and says so: {refusal}"
1116 );
1117
1118 assert_eq!(admin_token_failure(Some(32)), None);
1121 assert_eq!(admin_token_failure(Some(64)), None);
1122 }
1123
1124 #[test]
1127 fn report_renders_the_json_contract() {
1128 let report = DoctorReport {
1129 failures: vec![
1130 DoctorFailure {
1131 code: &CODES.captcha_not_effective,
1132 message: "production venture has captcha-guarded writes".to_owned(),
1133 },
1134 DoctorFailure {
1135 code: &CODES.payments_webhook_secret_missing,
1136 message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
1137 },
1138 ],
1139 };
1140 let payload = report.render_json().expect("serializes");
1141 assert_eq!(
1142 payload,
1143 "{\"schema\":1,\"ok\":false,\"failures\":[\
1144 {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
1145 captcha-guarded writes\"},\
1146 {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
1147 is unset\"}]}"
1148 );
1149 assert!(!report.ok());
1150
1151 let clean = DoctorReport {
1152 failures: Vec::new(),
1153 };
1154 assert_eq!(
1155 clean.render_json().expect("serializes"),
1156 "{\"schema\":1,\"ok\":true,\"failures\":[]}"
1157 );
1158 assert!(clean.ok());
1159 }
1160
1161 fn half_wired() -> PushWiring {
1165 let var = PUSH_ENV
1166 .iter()
1167 .find(|var| var.required)
1168 .expect("the table has a required variable");
1169 let wiring = inspect_push(&MapConfig::from_pairs([(
1170 var.name().to_owned(),
1171 "set-but-alone".to_owned(),
1172 )]));
1173 assert!(
1174 !wiring.problems().is_empty(),
1175 "one variable of a transport is a half-wired transport: {}",
1176 wiring.summary()
1177 );
1178 wiring
1179 }
1180
1181 #[test]
1182 fn a_half_wired_transport_fails_the_doctor_in_production_only() {
1183 let wiring = half_wired();
1184
1185 let mut failures = Vec::new();
1186 push_wiring_checks(
1187 &wiring,
1188 VentureEnv::Production,
1189 OPTIONAL,
1190 Output::Human,
1191 &mut failures,
1192 );
1193 assert_eq!(failures.len(), 1, "{failures:?}");
1194 for problem in wiring.problems() {
1195 assert!(failures[0].message.contains(&problem), "{failures:?}");
1196 }
1197 assert_eq!(failures[0].code.code, "push-transport-misconfigured");
1201
1202 for env in [VentureEnv::Development, VentureEnv::Staging] {
1205 let mut failures = Vec::new();
1206 push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
1207 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1208 }
1209 }
1210
1211 #[test]
1212 fn a_venture_that_wires_no_push_at_all_is_silent() {
1213 let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1216 let mut failures = Vec::new();
1217 push_wiring_checks(
1218 &wiring,
1219 VentureEnv::Production,
1220 OPTIONAL,
1221 Output::Human,
1222 &mut failures,
1223 );
1224 assert!(failures.is_empty(), "{failures:?}");
1225 assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
1226 }
1227
1228 #[test]
1229 fn a_module_that_requires_push_needs_a_transport_that_can_send() {
1230 let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1236 assert!(!nothing.any_routed());
1237
1238 let mut failures = Vec::new();
1239 push_wiring_checks(
1240 ¬hing,
1241 VentureEnv::Production,
1242 REQUIRED,
1243 Output::Human,
1244 &mut failures,
1245 );
1246 assert_eq!(failures.len(), 1, "{failures:?}");
1247 assert!(
1248 failures[0].message.contains("require the Push port"),
1249 "{failures:?}"
1250 );
1251 assert_eq!(failures[0].code.code, "push-required-but-unrouted");
1255
1256 for env in [VentureEnv::Development, VentureEnv::Staging] {
1258 let mut failures = Vec::new();
1259 push_wiring_checks(¬hing, env, REQUIRED, Output::Human, &mut failures);
1260 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1261 }
1262 let mut failures = Vec::new();
1263 push_wiring_checks(
1264 ¬hing,
1265 VentureEnv::Production,
1266 OPTIONAL,
1267 Output::Human,
1268 &mut failures,
1269 );
1270 assert!(failures.is_empty(), "{failures:?}");
1271 }
1272
1273 #[test]
1274 fn the_push_rules_are_gated_on_what_the_venture_declares() {
1275 let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
1281 assert!(!none.declared);
1282 assert!(!none.required);
1283
1284 let optional =
1285 PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
1286 assert!(optional.declared);
1287 assert!(
1288 !optional.required,
1289 "a module that only *may* use push does not make one mandatory"
1290 );
1291
1292 let required = PushDeclaration::from_ports(
1293 [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
1294 );
1295 assert!(required.declared);
1296 assert!(required.required);
1297 }
1298
1299 #[test]
1300 fn a_configured_transport_satisfies_a_module_that_requires_push() {
1301 let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
1308 cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
1309 uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
1310 -----END PRIVATE KEY-----";
1311 let wiring = inspect_push(&MapConfig::from_pairs([
1312 (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
1313 (
1314 PushKey::VapidSubject.name().to_owned(),
1315 "mailto:ops@example.test".to_owned(),
1316 ),
1317 ]));
1318 assert!(wiring.any_routed(), "{}", wiring.summary());
1319
1320 let mut failures = Vec::new();
1321 push_wiring_checks(
1322 &wiring,
1323 VentureEnv::Production,
1324 REQUIRED,
1325 Output::Human,
1326 &mut failures,
1327 );
1328 assert!(failures.is_empty(), "{failures:?}");
1329 }
1330}
1331
1332#[cfg(test)]
1333mod production_wiring {
1334 use super::tests::{NeedsAuth, Nothing, harness_of};
1365 use super::{CODES, Configured, DoctorFailure, Output, production_port_checks, run_checks};
1366
1367 fn failures(
1368 module: std::sync::Arc<dyn cratefield_core::Module>,
1369 configured: Configured,
1370 ) -> Vec<String> {
1371 let harness = harness_of(module);
1372 let mut out = Vec::new();
1373 production_port_checks(&harness, configured, None, Output::Json, &mut out);
1374 out.iter()
1375 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1376 .collect()
1377 }
1378
1379 const NONE_SET: Configured = Configured {
1380 auth_issuer: false,
1381 auth_client_id: false,
1382 stripe_webhook_secret: false,
1383 admin_token_bytes: None,
1384 };
1385
1386 #[test]
1387 fn a_module_needing_a_caller_with_no_issuer_is_reported() {
1388 assert!(
1389 failures(std::sync::Arc::new(NeedsAuth), NONE_SET)
1390 .contains(&CODES.auth_not_configured.code.to_owned()),
1391 "the rule is correct and nothing calls it"
1392 );
1393 }
1394
1395 #[test]
1396 fn the_same_module_with_an_issuer_is_not() {
1397 let configured = Configured {
1398 auth_issuer: true,
1399 auth_client_id: true,
1400 ..NONE_SET
1401 };
1402 assert!(
1403 !failures(std::sync::Arc::new(NeedsAuth), configured)
1404 .contains(&CODES.auth_not_configured.code.to_owned())
1405 );
1406 }
1407
1408 #[test]
1409 fn a_webhook_receiver_with_no_secret_is_reported() {
1410 assert!(
1414 failures(std::sync::Arc::new(super::tests::TakesWebhooks), NONE_SET)
1415 .contains(&CODES.payments_webhook_secret_missing.code.to_owned()),
1416 "the payments rule is correct and nothing calls it"
1417 );
1418 }
1419
1420 #[test]
1421 fn the_same_receiver_with_a_secret_is_not() {
1422 let configured = Configured {
1423 stripe_webhook_secret: true,
1424 ..NONE_SET
1425 };
1426 assert!(
1427 !failures(std::sync::Arc::new(super::tests::TakesWebhooks), configured)
1428 .contains(&CODES.payments_webhook_secret_missing.code.to_owned())
1429 );
1430 }
1431
1432 #[test]
1433 fn a_module_needing_no_caller_is_never_asked() {
1434 assert!(
1435 !failures(std::sync::Arc::new(Nothing), NONE_SET)
1436 .contains(&CODES.auth_not_configured.code.to_owned())
1437 );
1438 }
1439
1440 fn run_report(configured: Configured) -> Vec<String> {
1447 let harness = harness_of(std::sync::Arc::new(Nothing));
1448 run_checks(
1449 &harness,
1450 std::path::Path::new("no-migrations-here"),
1451 None,
1452 None,
1453 Output::Json,
1454 configured,
1455 )
1456 .failures
1457 .iter()
1458 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1459 .collect()
1460 }
1461
1462 #[test]
1463 fn a_short_admin_token_reaches_the_report() {
1464 let configured = Configured {
1465 admin_token_bytes: Some(4),
1466 ..NONE_SET
1467 };
1468 assert_eq!(
1469 run_report(configured),
1470 vec![CODES.admin_token_too_short.code.to_owned()],
1471 "the rule is correct and nothing calls it"
1472 );
1473 }
1474
1475 #[test]
1476 fn an_absent_admin_token_leaves_the_report_clean() {
1477 assert!(
1478 run_report(NONE_SET).is_empty(),
1479 "{:?}",
1480 run_report(NONE_SET)
1481 );
1482 }
1483}