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(
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 sidecar_failures(harness, sidecars, output, &mut failures);
242 lint_failures(harness, &mut failures);
243
244 DoctorReport { failures }
245}
246
247fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
252 for module in harness.modules() {
253 if module.harness_api() != HARNESS_API {
254 failures.push(DoctorFailure {
255 code: &CODES.harness_api_mismatch,
256 message: harness_api_mismatch(module.as_ref()),
257 });
258 }
259 }
260}
261
262fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
276 for module in harness.modules() {
277 for message in module.self_check() {
278 failures.push(DoctorFailure {
279 code: &CODES.module_self_check,
280 message,
281 });
282 }
283 }
284}
285
286fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
289 let lock = match read_lock(migrations_dir) {
290 Ok(lock) => lock,
291 Err(err) => {
292 failures.push(DoctorFailure {
293 code: &CODES.lockfile_unreadable,
294 message: err,
295 });
296 Lock::default()
297 }
298 };
299 for module in harness.modules() {
300 for migration in module.migrations().sqlite {
301 let key = format!("{}/{}", module.name(), migration.id);
302 match lock.get(&key) {
303 Some(entry) => {
304 if let Err(err) = verify_locked(migrations_dir, &key, entry) {
305 let code = match &err {
306 LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
307 LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
308 };
309 failures.push(DoctorFailure {
310 code,
311 message: err.to_string(),
312 });
313 }
314 }
315 None => failures.push(DoctorFailure {
316 code: &CODES.migration_not_collected,
317 message: format!(
318 "migration {key} is not collected yet — run `fz migrations collect`"
319 ),
320 }),
321 }
322 }
323 }
324}
325
326fn sidecar_failures(
333 harness: &Harness,
334 sidecars: Option<&str>,
335 output: Output,
336 failures: &mut Vec<DoctorFailure>,
337) {
338 match crate::sidecars::parse(sidecars) {
339 Ok(mounts) if !mounts.is_empty() => {
340 for mount in crate::sidecars::shadowing(harness, &mounts) {
341 failures.push(DoctorFailure {
342 code: &CODES.sidecar_shadows_module,
343 message: format!(
344 "sidecar mount `{}` names a module compiled into this venture; the runtime \
345 ignores the mount and serves the compiled-in module. Remove it from \
346 {HARNESS_SIDECARS} or remove the module from the composition",
347 mount.name
348 ),
349 });
350 }
351 if output == Output::Human {
352 eprintln!(
353 "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
354 checked here — they live in its own repository (docs/MOUNTING.md)",
355 crate::sidecars::listed(&mounts)
356 );
357 if std::env::var(SIDECAR_GATEWAY_SECRET)
362 .ok()
363 .is_none_or(|value| value.trim().is_empty())
364 {
365 eprintln!(
366 "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
367 gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
368 refuses every forwarded request (issue #131)"
369 );
370 }
371 }
372 }
373 Ok(_) => {}
374 Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
375 code: &CODES.sidecar_mount_invalid,
376 message,
377 })),
378 }
379}
380
381fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
389 for module in harness.modules() {
390 if !module.migrations().postgres.is_empty() {
391 continue;
392 }
393 for migration in module.migrations().sqlite {
394 for (token, why) in banned_tokens(migration.sql) {
395 failures.push(DoctorFailure {
396 code: &CODES.non_portable_sql,
397 message: format!(
398 "module `{}` migration {}: banned token `{token}` — {why}",
399 module.name(),
400 migration.id
401 ),
402 });
403 }
404 }
405 }
406
407 for module in harness.modules() {
408 let migrations = module.migrations();
409 for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
410 for (fragment, why) in lint_card_data(migration.sql) {
411 failures.push(DoctorFailure {
412 code: &CODES.card_data_in_migration,
413 message: format!(
414 "module `{}` migration {}: `{fragment}` {why}",
415 module.name(),
416 migration.id
417 ),
418 });
419 }
420 }
421 }
422}
423
424fn push_checks(
434 harness: &Harness,
435 env: VentureEnv,
436 output: Output,
437 failures: &mut Vec<DoctorFailure>,
438) {
439 let declaration = PushDeclaration::of(harness.modules());
440 if !declaration.declared {
441 return;
442 }
443 push_wiring_checks(
444 &cratefield_push_wiring::inspect_push(&EnvVars),
445 env,
446 declaration,
447 output,
448 failures,
449 );
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456struct PushDeclaration {
457 declared: bool,
460 required: bool,
463}
464
465impl PushDeclaration {
466 fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
467 Self::from_ports(
468 modules
469 .iter()
470 .map(|module| (module.requires(), module.optional())),
471 )
472 }
473
474 fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
478 let mut declaration = Self {
479 declared: false,
480 required: false,
481 };
482 for (requires, optional) in modules {
483 if requires.contains(&Port::Push) {
484 declaration.declared = true;
485 declaration.required = true;
486 } else if optional.contains(&Port::Push) {
487 declaration.declared = true;
488 }
489 }
490 declaration
491 }
492}
493
494fn push_wiring_checks(
511 wiring: &PushWiring,
512 env: VentureEnv,
513 declaration: PushDeclaration,
514 output: Output,
515 failures: &mut Vec<DoctorFailure>,
516) {
517 match wiring.check(env) {
518 Err(problems) => failures.push(DoctorFailure {
525 code: &CODES.push_transport_misconfigured,
526 message: problems,
527 }),
528 Ok(()) => {
531 if output == Output::Human {
532 for problem in wiring.problems() {
533 eprintln!("fz: warning: {problem}");
534 }
535 }
536 }
537 }
538
539 if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
540 failures.push(DoctorFailure {
541 code: &CODES.push_required_but_unrouted,
542 message: "production venture has modules that require the Push port but no push \
543 transport is configured — every send would answer NotConfigured. Set one \
544 transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
545 push. If this venture provides Push from an adapter of its own rather than \
546 from the environment, that adapter is not visible here (issue #191)"
547 .to_owned(),
548 });
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub(crate) struct Configured {
563 pub(crate) auth_issuer: bool,
564 pub(crate) auth_client_id: bool,
565 pub(crate) stripe_webhook_secret: bool,
566 pub(crate) admin_token_bytes: Option<usize>,
571}
572
573impl Configured {
574 fn from_env() -> Self {
575 Self {
576 auth_issuer: set("AUTH_ISSUER"),
577 auth_client_id: set("AUTH_CLIENT_ID"),
578 stripe_webhook_secret: set("STRIPE_WEBHOOK_SECRET"),
579 admin_token_bytes: std::env::var("ADMIN_TOKEN")
584 .ok()
585 .filter(|value| !value.is_empty())
586 .map(|value| value.len()),
587 }
588 }
589}
590
591fn production_port_checks(
592 harness: &Harness,
593 configured: Configured,
594 config: &dyn cratefield_core::Config,
595 allow_no_captcha: Option<&str>,
596 output: Output,
597 failures: &mut Vec<DoctorFailure>,
598) {
599 let guards = cratefield_core::WriteGuards::collect(harness.modules());
600 if let Some(message) = captcha_production_failure(
601 &guards,
602 cratefield_core::captcha_effective(harness.runtime()),
603 ) {
604 match cratefield_core::stated_reason(allow_no_captcha) {
609 Some(reason) => {
610 tracing::warn!(
618 control = "captcha",
619 reason,
620 modules = guards.captcha_modules.join(","),
621 "production abuse control overridden by an operator"
622 );
623 if output == Output::Human {
624 eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
625 eprintln!(
626 "fz: note: this override is recorded; it is for previews only and must \
627 not stand in for a Captcha port in production"
628 );
629 }
630 }
631 None => failures.push(DoctorFailure {
632 code: &CODES.captcha_not_effective,
633 message,
634 }),
635 }
636 }
637
638 if let Some(message) = auth_production_failure(
639 harness
640 .modules()
641 .iter()
642 .any(|module| module.requires().contains(&Port::Auth)),
643 configured.auth_issuer,
644 configured.auth_client_id,
645 ) {
646 failures.push(DoctorFailure {
647 code: &CODES.auth_not_configured,
648 message,
649 });
650 }
651
652 let payments: Vec<String> = guards
657 .payments_signature_modules
658 .iter()
659 .map(|module| {
660 guards.signature_module_named(module, cratefield_core::SignatureVerification::Payments)
661 })
662 .collect();
663 if let Some(message) = payments_webhook_failure(&payments, configured.stripe_webhook_secret) {
664 failures.push(DoctorFailure {
665 code: &CODES.payments_webhook_secret_missing,
666 message,
667 });
668 }
669
670 if let Some(message) = hmac_webhook_failure(&guards.hmac_signature_routes(), config) {
675 failures.push(DoctorFailure {
676 code: &CODES.hmac_webhook_secret_missing,
677 message,
678 });
679 }
680
681 if let Some(message) = rate_limiter_production_failure(
689 guards.needs_rate_limiter(),
690 cratefield_core::rate_limiter_effective(harness.runtime()),
691 &guards
692 .rate_limiter_modules
693 .iter()
694 .map(|(module, routes)| format!("{module} ({})", routes.join(", ")))
695 .collect::<Vec<_>>(),
696 ) {
697 match cratefield_core::unlimited_public_routes_override(config) {
698 Some(reason) => {
699 tracing::warn!(
700 control = "rate-limiter",
701 reason,
702 "production abuse control overridden by an operator"
703 );
704 if output == Output::Human {
705 eprintln!("fz: warning: rate-limiter override accepted ({reason}): {message}");
706 eprintln!(
707 "fz: note: this override is recorded; resolve the RateLimiter binding \
708 and remove HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES before serving \
709 production traffic"
710 );
711 }
712 }
713 None => failures.push(DoctorFailure {
714 code: &CODES.rate_limiter_not_effective,
715 message,
716 }),
717 }
718 }
719}
720
721fn set(key: &str) -> bool {
723 std::env::var(key).is_ok_and(|value| !value.trim().is_empty())
724}
725
726fn auth_production_failure(
742 needs_auth: bool,
743 issuer_set: bool,
744 client_id_set: bool,
745) -> Option<String> {
746 if !needs_auth || (issuer_set && client_id_set) {
747 return None;
748 }
749 let mut missing = Vec::new();
750 if !issuer_set {
751 missing.push("AUTH_ISSUER");
752 }
753 if !client_id_set {
754 missing.push("AUTH_CLIENT_ID");
755 }
756 Some(format!(
757 "a module requires the Auth port and {} is not set; every route that needs a signed-in \
758 caller will answer 503",
759 missing.join(" and ")
760 ))
761}
762
763fn captcha_production_failure(
769 guards: &cratefield_core::WriteGuards,
770 captcha_effective: bool,
771) -> Option<String> {
772 if guards.needs_captcha() && !captcha_effective {
773 Some(format!(
774 "production venture has captcha-guarded public writes from [{}] but the Captcha port \
775 is not effectively configured — provide Turnstile with its secret and expected \
776 hostname (architecture section 11; issue #133)",
777 guards.captcha_modules.join(", ")
778 ))
779 } else {
780 None
781 }
782}
783
784fn payments_webhook_failure(
793 payments_routes: &[String],
794 webhook_secret_present: bool,
795) -> Option<String> {
796 (!payments_routes.is_empty() && !webhook_secret_present).then(|| {
797 format!(
798 "production venture has signature-guarded routes [{}] but STRIPE_WEBHOOK_SECRET is \
799 unset — webhook signatures cannot be verified and forged events would be trusted \
800 (issue #102)",
801 payments_routes.join(", ")
802 )
803 })
804}
805
806fn hmac_webhook_failure(
813 hmac_modules: &[(String, String, Vec<String>)],
814 config: &dyn cratefield_core::Config,
815) -> Option<String> {
816 let missing: Vec<String> = hmac_modules
817 .iter()
818 .filter(|(_, key, _)| config.get(key).is_none_or(|value| value.trim().is_empty()))
819 .map(|(_, key, routes)| {
820 if routes.is_empty() {
821 key.clone()
822 } else {
823 format!("{key} on {}", routes.join(", "))
824 }
825 })
826 .collect();
827 (!missing.is_empty()).then(|| {
828 format!(
829 "production venture verifies webhook signatures with the webhook_signature HMAC \
830 scheme but {} is not set — those handlers refuse every delivery until the \
831 provider's signing secret is configured (issue #533)",
832 missing.join(", ")
833 )
834 })
835}
836
837fn rate_limiter_production_failure(
846 needs_rate_limiter: bool,
847 rate_limiter_effective: bool,
848 declaring: &[String],
849) -> Option<String> {
850 if needs_rate_limiter && !rate_limiter_effective {
851 let (who, cost) = if declaring.is_empty() {
857 (
858 "takes public writes or admin routes".to_owned(),
859 "admin bearer routes have no brute-force backstop behind the limiter and \
860 every public write runs without a budget"
861 .to_owned(),
862 )
863 } else {
864 (
865 format!(
866 "declares RateLimiter for public routes from [{}]",
867 declaring.join(", ")
868 ),
869 "those routes — and any public write or admin route beside them — run \
870 without a budget"
871 .to_owned(),
872 )
873 };
874 Some(format!(
875 "production venture {who} but the RateLimiter port is not resolved: {cost} — \
876 resolve the binding so the runtime actually hands over a limiter, or set \
877 HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES to a reason to serve unlimited \
878 (issues #437, #562)"
879 ))
880 } else {
881 None
882 }
883}
884
885fn admin_token_failure(admin_token_bytes: Option<usize>) -> Option<String> {
892 let bytes = admin_token_bytes?;
893 (bytes < MIN_SECRET_BYTES).then(|| {
894 format!(
895 "ADMIN_TOKEN is {bytes} bytes but must be at least {MIN_SECRET_BYTES} when set — \
896 the boot refuses it in every environment, so this deploy cannot start. Lengthen \
897 it, or remove it to keep the admin routes disabled (issue #437)"
898 )
899 })
900}
901
902#[cfg(test)]
903mod tests {
904 use super::{
905 CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, admin_token_failure,
906 captcha_production_failure, hmac_webhook_failure, payments_webhook_failure,
907 push_wiring_checks, rate_limiter_production_failure,
908 };
909 use cratefield_core::{
910 Config, ConfigError, Harness, MapConfig, Migrations, Module, ModuleContext, Port, Runtime,
911 Venture, VentureEnv, WriteGuards,
912 };
913 use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
914 use std::sync::Arc;
915
916 pub(super) struct NeedsAuth;
918
919 pub(super) struct Nothing;
921
922 macro_rules! bare_module {
923 ($ty:ty, $name:literal, $ports:expr) => {
924 impl Module for $ty {
925 fn name(&self) -> &'static str {
926 $name
927 }
928 fn version(&self) -> &'static str {
929 "0.0.0"
930 }
931 fn requires(&self) -> &'static [Port] {
932 $ports
933 }
934 fn migrations(&self) -> Migrations {
935 Migrations::default()
936 }
937 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
938 Ok(())
939 }
940 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
941 cratefield_core::axum::Router::new()
942 }
943 }
944 };
945 }
946
947 pub(super) struct TakesWebhooks;
949
950 bare_module!(NeedsAuth, "needs-auth", &[Port::Auth]);
951 bare_module!(Nothing, "nothing", &[]);
952 impl Module for TakesWebhooks {
953 fn name(&self) -> &'static str {
954 "takes-webhooks"
955 }
956 fn version(&self) -> &'static str {
957 "0.0.0"
958 }
959 fn requires(&self) -> &'static [Port] {
960 &[Port::Payments]
961 }
962 fn migrations(&self) -> Migrations {
963 Migrations::default()
964 }
965 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
966 Ok(())
967 }
968 fn surface(&self) -> cratefield_core::Surface {
969 Self::surface()
970 }
971 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
972 cratefield_core::axum::Router::new()
973 }
974 }
975
976 impl TakesWebhooks {
977 fn surface() -> cratefield_core::Surface {
980 cratefield_core::Surface::new().action(
981 cratefield_core::Action::post("receive", "/receive")
982 .policy(cratefield_core::RoutePolicy::Signature)
983 .outcome(cratefield_core::Outcome::Json),
984 )
985 }
986 }
987
988 pub(super) struct TwoVerifiers;
993
994 impl Module for TwoVerifiers {
995 fn name(&self) -> &'static str {
996 "billing"
997 }
998 fn version(&self) -> &'static str {
999 "0.0.0"
1000 }
1001 fn requires(&self) -> &'static [Port] {
1002 &[Port::Payments]
1003 }
1004 fn migrations(&self) -> Migrations {
1005 Migrations::default()
1006 }
1007 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
1008 Ok(())
1009 }
1010 fn surface(&self) -> cratefield_core::Surface {
1011 Self::surface()
1012 }
1013 fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
1014 cratefield_core::axum::Router::new()
1015 }
1016 }
1017
1018 impl TwoVerifiers {
1019 fn surface() -> cratefield_core::Surface {
1020 cratefield_core::Surface::new()
1021 .action(
1022 cratefield_core::Action::post("webhook-stripe", "/webhooks/stripe")
1023 .policy(cratefield_core::RoutePolicy::Signature)
1024 .outcome(cratefield_core::Outcome::Json),
1025 )
1026 .action(
1027 cratefield_core::Action::post("webhook-revenuecat", "/webhooks/revenuecat")
1028 .policy(cratefield_core::RoutePolicy::Signature)
1029 .verification(cratefield_core::SignatureVerification::Hmac {
1030 secret: "REVENUECAT_WEBHOOK_SECRET",
1031 })
1032 .outcome(cratefield_core::Outcome::Json),
1033 )
1034 }
1035 }
1036
1037 struct AllPorts;
1040
1041 impl Runtime for AllPorts {
1042 fn provides(&self) -> Vec<Port> {
1043 Port::ALL.to_vec()
1044 }
1045 }
1046
1047 pub(super) fn harness_of(module: Arc<dyn Module>) -> Harness {
1049 Harness::builder()
1050 .venture(
1051 Venture::new("acme", "acme.test")
1052 .cors_origins(["https://acme.test"])
1053 .env(VentureEnv::Production),
1054 )
1055 .module_arc(module)
1056 .runtime(AllPorts)
1057 .build()
1058 .expect("the fixture composes")
1059 }
1060
1061 const OPTIONAL: PushDeclaration = PushDeclaration {
1064 declared: true,
1065 required: false,
1066 };
1067
1068 const REQUIRED: PushDeclaration = PushDeclaration {
1070 declared: true,
1071 required: true,
1072 };
1073
1074 fn guards(needs_captcha: bool) -> WriteGuards {
1075 WriteGuards {
1076 captcha_modules: if needs_captcha {
1077 vec!["writer".to_owned()]
1078 } else {
1079 Vec::new()
1080 },
1081 signature_modules: Vec::new(),
1082 signed_link_modules: Vec::new(),
1083 ..WriteGuards::default()
1086 }
1087 }
1088
1089 #[test]
1090 fn a_venture_that_needs_no_caller_is_not_asked_for_an_issuer() {
1091 assert_eq!(super::auth_production_failure(false, false, false), None);
1095 }
1096
1097 #[test]
1098 fn a_module_that_must_know_who_is_calling_needs_an_issuer_in_production() {
1099 let refusal = super::auth_production_failure(true, false, false)
1103 .expect("a module needs a caller and nothing can name one");
1104 assert!(refusal.contains("AUTH_ISSUER"), "{refusal}");
1105 assert!(refusal.contains("AUTH_CLIENT_ID"), "{refusal}");
1106 assert!(refusal.contains("503"), "say what goes wrong: {refusal}");
1107 }
1108
1109 #[test]
1110 fn half_a_verifier_is_still_a_failure_and_names_the_half() {
1111 let missing_id = super::auth_production_failure(true, true, false).expect("half");
1115 assert!(missing_id.contains("AUTH_CLIENT_ID"), "{missing_id}");
1116 assert!(!missing_id.contains("AUTH_ISSUER"), "{missing_id}");
1117
1118 let missing_issuer = super::auth_production_failure(true, false, true).expect("half");
1119 assert!(missing_issuer.contains("AUTH_ISSUER"), "{missing_issuer}");
1120 assert!(
1121 !missing_issuer.contains("AUTH_CLIENT_ID"),
1122 "{missing_issuer}"
1123 );
1124 }
1125
1126 #[test]
1127 fn a_configured_verifier_is_no_failure() {
1128 assert_eq!(super::auth_production_failure(true, true, true), None);
1129 }
1130
1131 #[test]
1132 fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
1133 let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
1136 assert!(failure.contains("writer"));
1137 assert!(failure.contains("effectively configured"));
1138 assert!(captcha_production_failure(&guards(true), true).is_none());
1139 assert!(captcha_production_failure(&guards(false), false).is_none());
1140
1141 for nothing in ["", " "] {
1148 assert!(
1149 cratefield_core::stated_reason(Some(nothing)).is_none(),
1150 "an empty reason counted as an acceptance: {nothing:?}"
1151 );
1152 }
1153 assert_eq!(
1154 cratefield_core::stated_reason(Some(" previewing the new form ")),
1155 Some("previewing the new form"),
1156 "a real reason still overrides, trimmed"
1157 );
1158 }
1159
1160 #[test]
1161 fn payments_in_production_needs_a_webhook_secret() {
1162 let payments = vec!["billing (POST /v1/billing/webhooks/stripe)".to_owned()];
1165 let failure = payments_webhook_failure(&payments, false).expect("no secret fails");
1166 assert!(
1167 failure.contains("POST /v1/billing/webhooks/stripe"),
1168 "{failure}"
1169 );
1170 assert!(payments_webhook_failure(&payments, true).is_none());
1171 assert!(payments_webhook_failure(&[], false).is_none());
1172
1173 let modules = vec![(
1177 "pos".to_owned(),
1178 "POS_WEBHOOK_SECRET".to_owned(),
1179 vec!["POST /webhook".to_owned()],
1180 )];
1181 let failure = hmac_webhook_failure(&modules, &MapConfig::default()).expect("unset fails");
1182 assert!(failure.contains("POS_WEBHOOK_SECRET"), "{failure}");
1183 assert!(failure.contains("POST /webhook"), "{failure}");
1184 assert!(
1185 hmac_webhook_failure(
1186 &modules,
1187 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", " ")])
1188 )
1189 .is_some()
1190 );
1191 assert!(
1192 hmac_webhook_failure(
1193 &modules,
1194 &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "x")])
1195 )
1196 .is_none()
1197 );
1198 assert!(hmac_webhook_failure(&[], &MapConfig::default()).is_none());
1199 }
1200
1201 #[test]
1202 fn a_venture_with_public_writes_or_admin_routes_needs_a_resolved_limiter() {
1203 let failure =
1206 rate_limiter_production_failure(true, false, &[]).expect("an unlimited venture fails");
1207 assert!(failure.contains("RateLimiter"), "{failure}");
1208 assert!(
1209 failure.contains("HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES"),
1210 "the hatch is named the way the captcha rule names its own: {failure}"
1211 );
1212 assert!(rate_limiter_production_failure(true, true, &[]).is_none());
1213 assert!(rate_limiter_production_failure(false, false, &[]).is_none());
1215 }
1216
1217 #[test]
1218 fn a_doctor_limiter_failure_names_the_declaring_modules() {
1219 let failure = rate_limiter_production_failure(
1222 true,
1223 false,
1224 &["waitlist (POST /v1/waitlist, GET /v1/waitlist/search)".to_owned()],
1225 )
1226 .expect("a declaring venture fails");
1227 assert!(failure.contains("declares RateLimiter"), "{failure}");
1228 assert!(
1229 failure.contains("waitlist (POST /v1/waitlist, GET /v1/waitlist/search)"),
1230 "{failure}"
1231 );
1232 }
1233
1234 #[test]
1235 fn an_absent_admin_token_is_not_a_failure() {
1236 assert_eq!(admin_token_failure(None), None);
1239 }
1240
1241 #[test]
1242 fn a_set_but_short_admin_token_fails_at_the_floor_the_boot_enforces() {
1243 let refusal = admin_token_failure(Some(8)).expect("a short token");
1244 assert!(refusal.contains("ADMIN_TOKEN"), "{refusal}");
1245 assert!(refusal.contains("32"), "name the floor: {refusal}");
1246 assert!(
1247 refusal.contains("every environment"),
1248 "the rule is not production-only, and says so: {refusal}"
1249 );
1250
1251 assert_eq!(admin_token_failure(Some(32)), None);
1254 assert_eq!(admin_token_failure(Some(64)), None);
1255 }
1256
1257 #[test]
1260 fn report_renders_the_json_contract() {
1261 let report = DoctorReport {
1262 failures: vec![
1263 DoctorFailure {
1264 code: &CODES.captcha_not_effective,
1265 message: "production venture has captcha-guarded writes".to_owned(),
1266 },
1267 DoctorFailure {
1268 code: &CODES.payments_webhook_secret_missing,
1269 message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
1270 },
1271 ],
1272 };
1273 let payload = report.render_json().expect("serializes");
1274 assert_eq!(
1275 payload,
1276 "{\"schema\":1,\"ok\":false,\"failures\":[\
1277 {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
1278 captcha-guarded writes\"},\
1279 {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
1280 is unset\"}]}"
1281 );
1282 assert!(!report.ok());
1283
1284 let clean = DoctorReport {
1285 failures: Vec::new(),
1286 };
1287 assert_eq!(
1288 clean.render_json().expect("serializes"),
1289 "{\"schema\":1,\"ok\":true,\"failures\":[]}"
1290 );
1291 assert!(clean.ok());
1292 }
1293
1294 fn half_wired() -> PushWiring {
1298 let var = PUSH_ENV
1299 .iter()
1300 .find(|var| var.required)
1301 .expect("the table has a required variable");
1302 let wiring = inspect_push(&MapConfig::from_pairs([(
1303 var.name().to_owned(),
1304 "set-but-alone".to_owned(),
1305 )]));
1306 assert!(
1307 !wiring.problems().is_empty(),
1308 "one variable of a transport is a half-wired transport: {}",
1309 wiring.summary()
1310 );
1311 wiring
1312 }
1313
1314 #[test]
1315 fn a_half_wired_transport_fails_the_doctor_in_production_only() {
1316 let wiring = half_wired();
1317
1318 let mut failures = Vec::new();
1319 push_wiring_checks(
1320 &wiring,
1321 VentureEnv::Production,
1322 OPTIONAL,
1323 Output::Human,
1324 &mut failures,
1325 );
1326 assert_eq!(failures.len(), 1, "{failures:?}");
1327 for problem in wiring.problems() {
1328 assert!(failures[0].message.contains(&problem), "{failures:?}");
1329 }
1330 assert_eq!(failures[0].code.code, "push-transport-misconfigured");
1334
1335 for env in [VentureEnv::Development, VentureEnv::Staging] {
1338 let mut failures = Vec::new();
1339 push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
1340 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1341 }
1342 }
1343
1344 #[test]
1345 fn a_venture_that_wires_no_push_at_all_is_silent() {
1346 let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1349 let mut failures = Vec::new();
1350 push_wiring_checks(
1351 &wiring,
1352 VentureEnv::Production,
1353 OPTIONAL,
1354 Output::Human,
1355 &mut failures,
1356 );
1357 assert!(failures.is_empty(), "{failures:?}");
1358 assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
1359 }
1360
1361 #[test]
1362 fn a_module_that_requires_push_needs_a_transport_that_can_send() {
1363 let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1369 assert!(!nothing.any_routed());
1370
1371 let mut failures = Vec::new();
1372 push_wiring_checks(
1373 ¬hing,
1374 VentureEnv::Production,
1375 REQUIRED,
1376 Output::Human,
1377 &mut failures,
1378 );
1379 assert_eq!(failures.len(), 1, "{failures:?}");
1380 assert!(
1381 failures[0].message.contains("require the Push port"),
1382 "{failures:?}"
1383 );
1384 assert_eq!(failures[0].code.code, "push-required-but-unrouted");
1388
1389 for env in [VentureEnv::Development, VentureEnv::Staging] {
1391 let mut failures = Vec::new();
1392 push_wiring_checks(¬hing, env, REQUIRED, Output::Human, &mut failures);
1393 assert!(failures.is_empty(), "{env:?}: {failures:?}");
1394 }
1395 let mut failures = Vec::new();
1396 push_wiring_checks(
1397 ¬hing,
1398 VentureEnv::Production,
1399 OPTIONAL,
1400 Output::Human,
1401 &mut failures,
1402 );
1403 assert!(failures.is_empty(), "{failures:?}");
1404 }
1405
1406 #[test]
1407 fn the_push_rules_are_gated_on_what_the_venture_declares() {
1408 let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
1414 assert!(!none.declared);
1415 assert!(!none.required);
1416
1417 let optional =
1418 PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
1419 assert!(optional.declared);
1420 assert!(
1421 !optional.required,
1422 "a module that only *may* use push does not make one mandatory"
1423 );
1424
1425 let required = PushDeclaration::from_ports(
1426 [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
1427 );
1428 assert!(required.declared);
1429 assert!(required.required);
1430 }
1431
1432 #[test]
1433 fn a_configured_transport_satisfies_a_module_that_requires_push() {
1434 let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
1441 cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
1442 uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
1443 -----END PRIVATE KEY-----";
1444 let wiring = inspect_push(&MapConfig::from_pairs([
1445 (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
1446 (
1447 PushKey::VapidSubject.name().to_owned(),
1448 "mailto:ops@example.test".to_owned(),
1449 ),
1450 ]));
1451 assert!(wiring.any_routed(), "{}", wiring.summary());
1452
1453 let mut failures = Vec::new();
1454 push_wiring_checks(
1455 &wiring,
1456 VentureEnv::Production,
1457 REQUIRED,
1458 Output::Human,
1459 &mut failures,
1460 );
1461 assert!(failures.is_empty(), "{failures:?}");
1462 }
1463}
1464
1465#[cfg(test)]
1466mod production_wiring {
1467 use super::tests::{NeedsAuth, Nothing, harness_of};
1498 use super::{CODES, Configured, DoctorFailure, Output, production_port_checks, run_checks};
1499
1500 fn failures(
1501 module: std::sync::Arc<dyn cratefield_core::Module>,
1502 configured: Configured,
1503 ) -> Vec<String> {
1504 let harness = harness_of(module);
1505 let mut out = Vec::new();
1506 production_port_checks(
1507 &harness,
1508 configured,
1509 &cratefield_core::MapConfig::default(),
1510 None,
1511 Output::Json,
1512 &mut out,
1513 );
1514 out.iter()
1515 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1516 .collect()
1517 }
1518
1519 fn messages(
1522 module: std::sync::Arc<dyn cratefield_core::Module>,
1523 configured: Configured,
1524 config: &dyn cratefield_core::Config,
1525 ) -> Vec<(String, String)> {
1526 let harness = harness_of(module);
1527 let mut out = Vec::new();
1528 production_port_checks(&harness, configured, config, None, Output::Json, &mut out);
1529 out.iter()
1530 .map(|failure| (failure.code.code.to_owned(), failure.message.clone()))
1531 .collect()
1532 }
1533
1534 const NONE_SET: Configured = Configured {
1535 auth_issuer: false,
1536 auth_client_id: false,
1537 stripe_webhook_secret: false,
1538 admin_token_bytes: None,
1539 };
1540
1541 #[test]
1542 fn a_module_needing_a_caller_with_no_issuer_is_reported() {
1543 assert!(
1544 failures(std::sync::Arc::new(NeedsAuth), NONE_SET)
1545 .contains(&CODES.auth_not_configured.code.to_owned()),
1546 "the rule is correct and nothing calls it"
1547 );
1548 }
1549
1550 #[test]
1551 fn the_same_module_with_an_issuer_is_not() {
1552 let configured = Configured {
1553 auth_issuer: true,
1554 auth_client_id: true,
1555 ..NONE_SET
1556 };
1557 assert!(
1558 !failures(std::sync::Arc::new(NeedsAuth), configured)
1559 .contains(&CODES.auth_not_configured.code.to_owned())
1560 );
1561 }
1562
1563 #[test]
1564 fn a_webhook_receiver_with_no_secret_is_reported() {
1565 assert!(
1569 failures(std::sync::Arc::new(super::tests::TakesWebhooks), NONE_SET)
1570 .contains(&CODES.payments_webhook_secret_missing.code.to_owned()),
1571 "the payments rule is correct and nothing calls it"
1572 );
1573 }
1574
1575 #[test]
1576 fn the_same_receiver_with_a_secret_is_not() {
1577 let configured = Configured {
1578 stripe_webhook_secret: true,
1579 ..NONE_SET
1580 };
1581 assert!(
1582 !failures(std::sync::Arc::new(super::tests::TakesWebhooks), configured)
1583 .contains(&CODES.payments_webhook_secret_missing.code.to_owned())
1584 );
1585 }
1586
1587 #[test]
1588 fn a_module_needing_no_caller_is_never_asked() {
1589 assert!(
1590 !failures(std::sync::Arc::new(Nothing), NONE_SET)
1591 .contains(&CODES.auth_not_configured.code.to_owned())
1592 );
1593 }
1594
1595 #[test]
1596 fn a_two_verifier_module_names_the_route_each_webhook_rule_blocks() {
1597 use cratefield_core::MapConfig;
1602 let message = |pairs: &[(String, String)], code: &str| -> Option<String> {
1603 pairs
1604 .iter()
1605 .find(|(found, _)| found == code)
1606 .map(|(_, message)| message.clone())
1607 };
1608 let two = || std::sync::Arc::new(super::tests::TwoVerifiers);
1609 let stripe_set = Configured {
1610 stripe_webhook_secret: true,
1611 ..NONE_SET
1612 };
1613 let secret = MapConfig::from_pairs([("BILLING_REVENUECAT_WEBHOOK_SECRET", "whsec-dummy")]);
1614
1615 let both = messages(two(), stripe_set, &secret);
1617 assert!(message(&both, "payments-webhook-secret-missing").is_none());
1618 assert!(message(&both, "hmac-webhook-secret-missing").is_none());
1619
1620 let no_stripe = messages(two(), NONE_SET, &secret);
1623 let payment = message(&no_stripe, "payments-webhook-secret-missing").expect("fires");
1624 assert!(
1625 payment.contains("POST /v1/billing/webhooks/stripe"),
1626 "{payment}"
1627 );
1628 assert!(
1629 !payment.contains("POST /v1/billing/webhooks/revenuecat"),
1630 "{payment}"
1631 );
1632
1633 let no_revenuecat = messages(two(), stripe_set, &MapConfig::default());
1635 let hmac = message(&no_revenuecat, "hmac-webhook-secret-missing").expect("fires");
1636 assert!(
1637 hmac.contains("POST /v1/billing/webhooks/revenuecat"),
1638 "{hmac}"
1639 );
1640 assert!(!hmac.contains("POST /v1/billing/webhooks/stripe"), "{hmac}");
1641 }
1642
1643 fn run_report(configured: Configured) -> Vec<String> {
1650 let harness = harness_of(std::sync::Arc::new(Nothing));
1651 run_checks(
1652 &harness,
1653 std::path::Path::new("no-migrations-here"),
1654 None,
1655 None,
1656 Output::Json,
1657 configured,
1658 )
1659 .failures
1660 .iter()
1661 .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1662 .collect()
1663 }
1664
1665 #[test]
1666 fn a_short_admin_token_reaches_the_report() {
1667 let configured = Configured {
1668 admin_token_bytes: Some(4),
1669 ..NONE_SET
1670 };
1671 assert_eq!(
1672 run_report(configured),
1673 vec![CODES.admin_token_too_short.code.to_owned()],
1674 "the rule is correct and nothing calls it"
1675 );
1676 }
1677
1678 #[test]
1679 fn an_absent_admin_token_leaves_the_report_clean() {
1680 assert!(
1681 run_report(NONE_SET).is_empty(),
1682 "{:?}",
1683 run_report(NONE_SET)
1684 );
1685 }
1686}