Skip to main content

cratefield_cli/
doctor.rs

1//! `fz doctor` (issue #8): harness validity, lockfile consistency and the
2//! portable-SQL lint. It re-asserts the contract-version rule (issue #17)
3//! and the production abuse-port rules (issues #13/#102/#133) — the same
4//! checks `Harness::build` enforces at initialization, kept visible here
5//! for operator tooling.
6//!
7//! Output comes in two disciplines (harness #140). Without `--json` the
8//! doctor is prose: warnings on stderr, every failure joined into one
9//! message, byte-for-byte as it has always been. With `--json` it prints
10//! exactly one JSON object to stdout — `schema`, `ok` and the failures,
11//! each with its stable code from [`crate::codes`] — and nothing else,
12//! so an agent can parse the verdict without screen-scraping.
13
14use 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
28/// The `schema` field of the `fz doctor --json` object (harness #140):
29/// consumers branch on it, so it only ever grows.
30pub const REPORT_SCHEMA: u32 = 1;
31
32/// One failed doctor check: its stable code from the catalogue
33/// ([`crate::codes`]) plus the human message, which is byte-identical to
34/// the prose path's.
35#[derive(Debug)]
36pub struct DoctorFailure {
37    /// The catalogue definition this failure is classified under.
38    pub code: &'static DoctorCodeDef,
39    /// The failure message, exactly as the prose path prints it.
40    pub message: String,
41}
42
43/// The outcome of a doctor run: every failed check, in check order.
44#[derive(Debug)]
45pub struct DoctorReport {
46    /// Empty when every check passed.
47    pub failures: Vec<DoctorFailure>,
48}
49
50impl DoctorReport {
51    /// `true` when every check passed.
52    #[must_use]
53    pub fn ok(&self) -> bool {
54        self.failures.is_empty()
55    }
56
57    /// The exact stdout payload of `fz doctor --json`: one object, one
58    /// line, no trailing newline.
59    ///
60    /// # Errors
61    ///
62    /// Only if `serde_json` cannot serialize the report — a struct of
63    /// strings and numbers cannot hit that, but the Result keeps every
64    /// caller total instead of panicking.
65    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/// Which output discipline the doctor runs under.
93#[derive(Clone, Copy, PartialEq, Eq)]
94enum Output {
95    /// Prose: operator warnings on stderr, failures joined into one
96    /// message by [`doctor`].
97    Human,
98    /// `--json`: exactly one object on stdout, operator warnings
99    /// suppressed so nothing but the JSON reaches a consumer.
100    Json,
101}
102
103/// Runs every doctor check. `allow_no_captcha` downgrades the
104/// production-captcha failure to a printed warning for the stated
105/// reason (issue #13).
106///
107/// # Errors
108///
109/// One message listing every failed check — the same bytes `fz doctor`
110/// has always produced.
111pub 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/// Runs every doctor check under `--json` discipline (harness #140):
137/// prints exactly one JSON object to stdout — `schema`, `ok`, `failures`
138/// with their stable codes — and nothing else, then reports the verdict
139/// as the exit code.
140#[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/// The doctor's checks under `--json` discipline, without printing —
170/// the exact report [`doctor_json`] serializes, for tests and future
171/// consumers. Operator warnings are suppressed, as `--json` promises.
172#[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    // Harness::build already succeeded by construction; the production-only
203    // port rules (captcha, payments webhook) are checked against the runtime's
204    // provided ports.
205    //
206    // The environment is the deployment's, not only the compiled one
207    // (issue #143): the doctor runs where `ENV` is set, and a venture that
208    // never called `.env()` still ships to production.
209    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    // The admin-token floor (issue #437) is deliberately *not*
220    // production-only: `HarnessConfig::from_config` refuses a set-but-short
221    // `ADMIN_TOKEN` in every environment (crates/core/src/config.rs), so a
222    // production gate here would leave the doctor more permissive than the
223    // boot it predicts. Absent stays legal here exactly as it does there —
224    // removing the token is how the admin plane stays disabled.
225    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
240/// The contract-version re-check (issue #17). `Harness::build` already
241/// refuses a mismatched module, so a harness that reaches the doctor is
242/// coherent; the re-check keeps the rule visible here as well, and reports
243/// the module, its version and the core crate if it ever fires.
244fn 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
255/// What each module says about its own embedded data
256/// ([`Module::self_check`](cratefield_core::Module::self_check)).
257///
258/// The doctor has no deploy config, so it cannot run a module's
259/// `validate_config` — those values live on the runtime `Env` and exist
260/// only per request. It *can* ask whether what the module compiled in is
261/// coherent, and that question has the same answer everywhere: the
262/// notifications module answers it with every translation its catalog is
263/// missing for a message the venture declared (issue #190).
264///
265/// Unconditional, not production-only. A missing translation is a person
266/// reading `booking-confirmed.title` on a lock screen in every
267/// environment, and finding it on staging is the point.
268fn 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
279/// Lockfile consistency: every module migration locked, every locked
280/// file present and byte-identical.
281fn 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
319/// Sidecars (issue #66). The doctor sees only compiled-in modules, so
320/// it says plainly what it cannot check rather than reporting a clean
321/// bill for half the venture. A mount that shadows a compiled-in
322/// module is a real misconfiguration: the runtime ignores it and
323/// keeps the compiled-in module, so someone believes a sidecar is
324/// serving a prefix that it is not.
325fn 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                // Gateway advisory (issue #131): without the shared secret the
351                // host stamps no token, so a sidecar that requires one refuses
352                // every forwarded request and a sidecar that does not cannot
353                // tell the host from the open internet.
354                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
374/// The SQL lints: the portable-SQL check and the card-data check (#44).
375/// The portable lint skips a module that ships a `postgres` set — it has
376/// declared where its SQL differs, the same rule
377/// `cratefield_adapter_postgres::select_set` applies when it chooses a set,
378/// and the doctor should not disagree with the runner. The card-data lint
379/// never skips: no dialect stores a card number, verification code or full
380/// expiry, unlike the portable lint above.
381fn 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
417/// The push-wiring section (issue #191). The doctor does not read the push
418/// environment itself: it calls the same `build_push` `serve()` and `fz push`
419/// call, through `inspect_push`, so the three cannot check different variable
420/// names.
421///
422/// Gated on what the harness declares, like every other production-only rule
423/// here: the process environment is shared, and a venture that mounts no push
424/// module at all must not fail its deploy because the env file it inherits
425/// exports someone else's `VAPID_SUBJECT`.
426fn 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/// What this venture says about push, so the doctor's push rules key on the
446/// harness rather than on whatever the process environment happens to
447/// export (issue #191).
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449struct PushDeclaration {
450    /// A module lists [`Port::Push`] in `requires()` or `optional()`. Only
451    /// then is the push environment this venture's business.
452    declared: bool,
453    /// A module lists it in `requires()`: this venture does not work
454    /// without a transport that can actually send.
455    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    /// The rule itself, over nothing but the port lists — so it is unit
468    /// tested without composing a harness, which for a module that
469    /// *requires* a port would mean a runtime that provides it too.
470    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
487/// Turns a push wiring report into doctor output: failures in production,
488/// warnings below it (issue #191). Split out so the rules are unit tested
489/// without a process environment.
490///
491/// The severity rule itself is [`PushWiring::check`]'s, called rather than
492/// restated — the same rule encoded twice is the drift this crate exists to
493/// prevent, one copy short of a test.
494///
495/// The second rule is the build-time gate `push_from_env()` cannot keep. A
496/// runtime that assembles the port from the environment provides `Port::Push`
497/// whatever that environment holds — on Workers the `Env` does not exist
498/// until the first fetch, so `Harness::build` cannot know — and a module that
499/// requires push then compiles, deploys, and answers `NotConfigured` for
500/// every send. The doctor is where the deployment's environment *is* known,
501/// so the refusal lives here: in production, a venture whose modules require
502/// push and whose environment routes no transport at all fails.
503fn 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        // Half-wired and refused-credentials are one code, not two: the
512        // message already names which transport and which variables, and
513        // `check` reports every misconfigured transport in one string, so a
514        // venture with one of each has a single failure to classify. The
515        // operator action is the same either way — finish or correct that
516        // transport's variables, or unset them to say it is deliberately off.
517        Err(problems) => failures.push(DoctorFailure {
518            code: &CODES.push_transport_misconfigured,
519            message: problems,
520        }),
521        // Below production the same problems are a warning: wiring a
522        // transport one variable at a time is what development looks like.
523        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/// The production-only port rules, gathered so `doctor` stays a flat list of
546/// checks: the captcha rule (with its override), the payments webhook rule,
547/// and the rate-limiter rule (with its override).
548///
549/// Read once and passed in, rather than each check reaching for
550/// `std::env` itself. Environment variables are process-global and tests
551/// run in one process, so a check that reads them directly can be tested
552/// as a pure function and never as a *called* one — falsifying the auth
553/// check showed exactly that: deleting the call left every test green.
554#[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    /// The `ADMIN_TOKEN`'s length, not its presence: the boot's floor is a
560    /// length (`MIN_SECRET_BYTES`, issue #437), so the doctor has to
561    /// measure the same thing the boot measures. `None` is unset — legal
562    /// everywhere, here as in `HarnessConfig::from_config`.
563    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            // Read the way `EnvVars::get` reads — the read the boot's
573            // config makes — so a set-but-empty variable is unset here
574            // too, and the length is the raw value's, untrimmed: the boot
575            // measures the raw value, and the doctor may not be stricter.
576            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        // The same rule the gate in `route_policy` applies, from the
597        // same function: `--allow-no-captcha ""` is not an acceptance,
598        // so it does not downgrade the failure and does not get logged
599        // as though somebody had answered for it.
600        match cratefield_core::stated_reason(allow_no_captcha) {
601            Some(reason) => {
602                // An override of a production abuse control is a decision
603                // someone has to answer for later, so it is recorded, not
604                // just printed (issue #143). `tracing` is the audited sink
605                // — scrubbed by #135 and shipped wherever the operator
606                // sends logs — and fires in both disciplines, while the
607                // stderr line keeps the operator running the command
608                // informed.
609                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    // The other half of the webhook rule (issue #533): the key is per
654    // module, so this check takes composed keys and a config rather than a
655    // `Configured` bool — still pure, the caller supplies `&EnvVars`.
656    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    // The limiter leg mirrors the captcha rule above, hatch and all: the
664    // same two answers the boot gate (`production_readiness`) reads —
665    // what the composition declares and whether the runtime's limiter is
666    // effective — with an operator's recorded reason downgrading the
667    // failure to a logged warning. `unlimited_public_routes_override`
668    // applies the blank-reason rule itself, so an empty acceptance never
669    // counts, exactly as `stated_reason` does for the captcha flag.
670    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
698/// Whether an environment variable is set to something non-blank.
699fn set(key: &str) -> bool {
700    std::env::var(key).is_ok_and(|value| !value.trim().is_empty())
701}
702
703/// The production auth rule (issue #153): a module that declares
704/// `Port::Auth` is a module that must know who is calling, and a
705/// deployment with no issuer configured cannot tell it.
706///
707/// The runtime provides the port either way — on Workers the `Env` exists
708/// per request, so a runtime cannot know at compose time whether the
709/// issuer is set, and refusing to provide it would stop every such
710/// deployment booting, including the ones whose tables are all public.
711/// The cost of that choice is that the failure moves to request time: an
712/// unconfigured deployment serves its public routes and answers `503` on
713/// every route that needs a caller.
714///
715/// `fz doctor` runs where the operator's environment is, which is why the
716/// check belongs here — the same place `STRIPE_WEBHOOK_SECRET` is read,
717/// and for the same reason.
718fn 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
740/// The production captcha rule (issue #133): captcha-guarded writes need a
741/// Captcha port that is *effectively* configured — provided **and** bound per
742/// the adapter's report — not merely present. The builder already refuses
743/// this composition (`Harness::build`); the doctor re-checks it so the rule
744/// stays visible in operator tooling.
745fn 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
761/// The production-payments rule: a production venture that declares
762/// [`Signature`]-guarded routes without a webhook signing secret cannot
763/// verify Stripe webhooks, so it would process forged events. Pure so the
764/// truth table is unit-tested; the caller supplies the env-derived booleans.
765///
766/// [`Signature`]: cratefield_core::RoutePolicy::Signature
767fn 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
783/// The production webhook-secret rule for modules that verify through the
784/// core HMAC scheme (issue #533): a deployment missing one has a module
785/// whose handler refuses every delivery. Keys are named, never values; pure
786/// so the truth table is unit-tested, the caller supplies the config.
787fn 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
806/// The production rate-limiter rule (issue #437): a venture that takes
807/// public writes or admin routes needs a limiter the runtime can actually
808/// consult — an admin bearer token is guessed rather than submitted and has
809/// no captcha or cooldown behind it, and every public write runs without a
810/// budget otherwise. The boot gate (`production_readiness`, via
811/// `Harness::build` and `Harness::router`) enforces the same rule from the
812/// same two answers; the doctor re-checks it so the operator learns before
813/// the deploy, which is where this command runs.
814fn 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
832/// The admin-token floor (issue #437). `HarnessConfig::from_config`
833/// refuses a set-but-short `ADMIN_TOKEN` in **every** environment
834/// (`MIN_SECRET_BYTES`), so this rule runs unconditionally too: gated on
835/// production it would leave the doctor more permissive than the boot it
836/// predicts. Absent stays legal — the token is what turns the admin plane
837/// on, and removing it is the documented way to keep that plane disabled.
838fn 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    /// A module that must know who is calling.
864    pub(super) struct NeedsAuth;
865
866    /// One that does not.
867    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    /// A module with a signature-guarded write — a webhook receiver.
895    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        /// Declared through the surface, which is where `WriteGuards`
925        /// reads a route's policy from.
926        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    /// A runtime that provides everything, so composition succeeds and the
936    /// production checks are what the test is about.
937    struct AllPorts;
938
939    impl Runtime for AllPorts {
940        fn provides(&self) -> Vec<Port> {
941            Port::ALL.to_vec()
942        }
943    }
944
945    /// A composed harness mounting one module.
946    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    /// A venture whose modules only *may* use push: the wiring is checked,
960    /// but nothing has to be routed.
961    const OPTIONAL: PushDeclaration = PushDeclaration {
962        declared: true,
963        required: false,
964    };
965
966    /// A venture whose modules cannot work without a transport.
967    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            // The rule under test reads only `captcha_modules`; the
982            // limiter-leg answers come from `Default`.
983            ..WriteGuards::default()
984        }
985    }
986
987    #[test]
988    fn a_venture_that_needs_no_caller_is_not_asked_for_an_issuer() {
989        // Most ventures do not mount a module that identifies callers.
990        // Demanding an auth service from them would be demanding one they
991        // have no use for.
992        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        // The runtime provides the port either way, so the failure is not
998        // at boot: it is every route that needs a caller answering 503,
999        // which is the thing an operator wants told before the deploy.
1000        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        // Setting one of the two is the likely mistake, and a message
1010        // that said "auth is not configured" would send an operator to
1011        // check the one they already set.
1012        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        // Guarded writes without an effective port fail — even if the port
1032        // is *present*, since effectiveness is what the gate measures.
1033        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        // The waiver this failure is matched against is the *reason*,
1040        // not the flag. `--allow-no-captcha ""` used to reach the arm
1041        // that downgrades the failure to a warning and logs the
1042        // operator's acceptance — with an empty reason in the log.
1043        // `docs/SECURITY.md` says "A blank reason is not an
1044        // acceptance"; this is the half of that sentence the CLI owns.
1045        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        // The other half (issue #533): the module's own key, unset or
1065        // blank, fails; set passes; no such module never fails.
1066        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        // Fails closed on exactly the two answers the boot gate reads:
1088        // something writable or an admin plane, and no effective limiter.
1089        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        // Nothing writable, no admin plane: no budget is owed.
1098        assert!(rate_limiter_production_failure(false, false).is_none());
1099    }
1100
1101    #[test]
1102    fn an_absent_admin_token_is_not_a_failure() {
1103        // Absent is how the admin plane stays disabled, and the boot
1104        // treats it the same in every environment.
1105        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        // At the floor and above is what the boot accepts, so the doctor
1119        // agrees at the boundary rather than one byte stricter.
1120        assert_eq!(admin_token_failure(Some(32)), None);
1121        assert_eq!(admin_token_failure(Some(64)), None);
1122    }
1123
1124    /// The `--json` wire contract (harness #140): field order, the schema
1125    /// number, and the code string straight from the catalogue.
1126    #[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    /// A half-wired push environment, built through the real reader so this
1162    /// test names no environment variable of its own — the property
1163    /// `cli-acceptance`'s guard enforces across the workspace.
1164    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        // The slug itself, written out: the catalogue's contract is that a
1198        // code is never renamed, and a test that reads it back off `CODES`
1199        // would rename with it.
1200        assert_eq!(failures[0].code.code, "push-transport-misconfigured");
1201
1202        // Below production it is a printed warning, not a failure: wiring a
1203        // transport one variable at a time is what development looks like.
1204        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        // Absent is a choice, not a defect — the doctor must not nag a
1214        // venture that sends no notifications, even in production.
1215        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        // The build-time gate `push_from_env()` cannot keep: on Workers the
1231        // `Env` does not exist when `Harness::build` runs, so a module
1232        // requiring Push compiles and deploys against an environment that
1233        // configures nothing and answers NotConfigured for every send. The
1234        // doctor runs where the environment is known, so it refuses.
1235        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            &nothing,
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        // Its own code, not the half-wired one: nothing here is half-wired,
1252        // and the operator's move is to route a transport or to drop the
1253        // modules that require one.
1254        assert_eq!(failures[0].code.code, "push-required-but-unrouted");
1255
1256        // Only in production, and only when a module actually requires it.
1257        for env in [VentureEnv::Development, VentureEnv::Staging] {
1258            let mut failures = Vec::new();
1259            push_wiring_checks(&nothing, 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            &nothing,
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        // The process environment is shared. A venture that mounts no push
1276        // module at all must not fail its production doctor run because the
1277        // env file it inherits exports somebody else's VAPID_SUBJECT — every
1278        // other production-only rule here keys on the harness, and this one
1279        // did not.
1280        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        // The rule must pass on the deployment it is meant to allow, or it
1302        // is just a way to fail every production doctor run.
1303        //
1304        // The variables are named through `PushKey`, not written out: the
1305        // guard in `cli-acceptance` allows exactly one source in the
1306        // workspace to spell one of these names, and it is not this one.
1307        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    //! That the production checks are *called*, not only correct.
1335    //!
1336    //! Falsifying the auth rule showed the gap: deleting its call from
1337    //! `production_port_checks` left every test green, because each check
1338    //! was only ever exercised as a pure function. The same was true of
1339    //! the payments rule, and both are covered here.
1340    //!
1341    //! The captcha rule is **not**, and cannot be: `Harness::build`
1342    //! refuses a production composition with a form-guarded write and an
1343    //! ineffective Captcha port, so a harness that would trip the
1344    //! doctor's copy of that rule cannot be composed to hand it. The
1345    //! doctor re-checks it deliberately — to keep the rule visible in
1346    //! operator tooling — and the only path that reaches it is a venture
1347    //! built with `--allow-no-captcha`, which is the override, not the
1348    //! failure. Its own pure test stands.
1349    //!
1350    //! The rate-limiter leg (issue #437) joins that club for the same
1351    //! reason: `Harness::build` runs `append_production_readiness` over
1352    //! the same two answers the doctor reads — `WriteGuards::collect`
1353    //! plus `rate_limiter_effective` — so a production harness that
1354    //! would trip the doctor's copy cannot be composed either (the
1355    //! escape is the `HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES` env var,
1356    //! which a test must not export into a shared process). Its own
1357    //! pure test stands.
1358    //!
1359    //! The admin-token check is wired differently — it is called from
1360    //! `run_checks` directly, because the boot enforces it in *every*
1361    //! environment — so its proof runs the whole doctor against a
1362    //! crafted [`Configured`], no process environment involved.
1363
1364    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        // The same gap the auth rule had: the payments rule was correct
1411        // and only ever tested as a pure function, so deleting its call
1412        // left every test green.
1413        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    /// Runs the whole doctor — the path `fz doctor` actually takes — so a
1441    /// deleted call to the admin-token rule fails here instead of leaving
1442    /// the pure tests green. The module declares nothing, the migrations
1443    /// directory does not exist (`read_lock` treats that as an empty
1444    /// lock), and no sidecars are mounted: the report is empty unless the
1445    /// rule under test fires.
1446    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}