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, Port, SIDECAR_GATEWAY_SECRET, VentureEnv, deployed_env,
22    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    );
124    if report.ok() {
125        return Ok(());
126    }
127    let messages: Vec<String> = report
128        .failures
129        .into_iter()
130        .map(|failure| failure.message)
131        .collect();
132    Err(messages.join("\n  - "))
133}
134
135/// Runs every doctor check under `--json` discipline (harness #140):
136/// prints exactly one JSON object to stdout — `schema`, `ok`, `failures`
137/// with their stable codes — and nothing else, then reports the verdict
138/// as the exit code.
139#[must_use = "call process::exit with the returned ExitCode"]
140pub fn doctor_json(
141    harness: &Harness,
142    migrations_dir: &Path,
143    allow_no_captcha: Option<&str>,
144    sidecars: Option<&str>,
145) -> ExitCode {
146    let report = run_checks(
147        harness,
148        migrations_dir,
149        allow_no_captcha,
150        sidecars,
151        Output::Json,
152    );
153    match report.render_json() {
154        Ok(payload) => println!("{payload}"),
155        Err(err) => {
156            eprintln!("fz: cannot serialize the doctor report: {err}");
157            return ExitCode::FAILURE;
158        }
159    }
160    if report.ok() {
161        ExitCode::SUCCESS
162    } else {
163        ExitCode::FAILURE
164    }
165}
166
167/// The doctor's checks under `--json` discipline, without printing —
168/// the exact report [`doctor_json`] serializes, for tests and future
169/// consumers. Operator warnings are suppressed, as `--json` promises.
170#[must_use]
171pub fn doctor_report_json(
172    harness: &Harness,
173    migrations_dir: &Path,
174    allow_no_captcha: Option<&str>,
175    sidecars: Option<&str>,
176) -> DoctorReport {
177    run_checks(
178        harness,
179        migrations_dir,
180        allow_no_captcha,
181        sidecars,
182        Output::Json,
183    )
184}
185
186fn run_checks(
187    harness: &Harness,
188    migrations_dir: &Path,
189    allow_no_captcha: Option<&str>,
190    sidecars: Option<&str>,
191    output: Output,
192) -> DoctorReport {
193    let mut failures: Vec<DoctorFailure> = Vec::new();
194
195    contract_failures(harness, &mut failures);
196    self_check_failures(harness, &mut failures);
197
198    // Harness::build already succeeded by construction; the production-only
199    // port rules (captcha, payments webhook) are checked against the runtime's
200    // provided ports.
201    //
202    // The environment is the deployment's, not only the compiled one
203    // (issue #143): the doctor runs where `ENV` is set, and a venture that
204    // never called `.env()` still ships to production.
205    let env = deployed_env(harness.venture().env, &EnvVars);
206    if let Some(note) = env_disagreement(harness.venture().env, env)
207        && output == Output::Human
208    {
209        eprintln!("fz: warning: {note}");
210    }
211    if env == VentureEnv::Production {
212        production_port_checks(harness, allow_no_captcha, output, &mut failures);
213    }
214
215    push_checks(harness, env, output, &mut failures);
216    lockfile_failures(harness, migrations_dir, &mut failures);
217    sidecar_failures(harness, sidecars, output, &mut failures);
218    lint_failures(harness, &mut failures);
219
220    DoctorReport { failures }
221}
222
223/// The contract-version re-check (issue #17). `Harness::build` already
224/// refuses a mismatched module, so a harness that reaches the doctor is
225/// coherent; the re-check keeps the rule visible here as well, and reports
226/// the module, its version and the core crate if it ever fires.
227fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
228    for module in harness.modules() {
229        if module.harness_api() != HARNESS_API {
230            failures.push(DoctorFailure {
231                code: &CODES.harness_api_mismatch,
232                message: harness_api_mismatch(module.as_ref()),
233            });
234        }
235    }
236}
237
238/// What each module says about its own embedded data
239/// ([`Module::self_check`](cratefield_core::Module::self_check)).
240///
241/// The doctor has no deploy config, so it cannot run a module's
242/// `validate_config` — those values live on the runtime `Env` and exist
243/// only per request. It *can* ask whether what the module compiled in is
244/// coherent, and that question has the same answer everywhere: the
245/// notifications module answers it with every translation its catalog is
246/// missing for a message the venture declared (issue #190).
247///
248/// Unconditional, not production-only. A missing translation is a person
249/// reading `booking-confirmed.title` on a lock screen in every
250/// environment, and finding it on staging is the point.
251fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
252    for module in harness.modules() {
253        for message in module.self_check() {
254            failures.push(DoctorFailure {
255                code: &CODES.module_self_check,
256                message,
257            });
258        }
259    }
260}
261
262/// Lockfile consistency: every module migration locked, every locked
263/// file present and byte-identical.
264fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
265    let lock = match read_lock(migrations_dir) {
266        Ok(lock) => lock,
267        Err(err) => {
268            failures.push(DoctorFailure {
269                code: &CODES.lockfile_unreadable,
270                message: err,
271            });
272            Lock::default()
273        }
274    };
275    for module in harness.modules() {
276        for migration in module.migrations().sqlite {
277            let key = format!("{}/{}", module.name(), migration.id);
278            match lock.get(&key) {
279                Some(entry) => {
280                    if let Err(err) = verify_locked(migrations_dir, &key, entry) {
281                        let code = match &err {
282                            LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
283                            LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
284                        };
285                        failures.push(DoctorFailure {
286                            code,
287                            message: err.to_string(),
288                        });
289                    }
290                }
291                None => failures.push(DoctorFailure {
292                    code: &CODES.migration_not_collected,
293                    message: format!(
294                        "migration {key} is not collected yet — run `fz migrations collect`"
295                    ),
296                }),
297            }
298        }
299    }
300}
301
302/// Sidecars (issue #66). The doctor sees only compiled-in modules, so
303/// it says plainly what it cannot check rather than reporting a clean
304/// bill for half the venture. A mount that shadows a compiled-in
305/// module is a real misconfiguration: the runtime ignores it and
306/// keeps the compiled-in module, so someone believes a sidecar is
307/// serving a prefix that it is not.
308fn sidecar_failures(
309    harness: &Harness,
310    sidecars: Option<&str>,
311    output: Output,
312    failures: &mut Vec<DoctorFailure>,
313) {
314    match crate::sidecars::parse(sidecars) {
315        Ok(mounts) if !mounts.is_empty() => {
316            for mount in crate::sidecars::shadowing(harness, &mounts) {
317                failures.push(DoctorFailure {
318                    code: &CODES.sidecar_shadows_module,
319                    message: format!(
320                        "sidecar mount `{}` names a module compiled into this venture; the runtime \
321                         ignores the mount and serves the compiled-in module. Remove it from \
322                         {HARNESS_SIDECARS} or remove the module from the composition",
323                        mount.name
324                    ),
325                });
326            }
327            if output == Output::Human {
328                eprintln!(
329                    "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
330                     checked here — they live in its own repository (docs/MOUNTING.md)",
331                    crate::sidecars::listed(&mounts)
332                );
333                // Gateway advisory (issue #131): without the shared secret the
334                // host stamps no token, so a sidecar that requires one refuses
335                // every forwarded request and a sidecar that does not cannot
336                // tell the host from the open internet.
337                if std::env::var(SIDECAR_GATEWAY_SECRET)
338                    .ok()
339                    .is_none_or(|value| value.trim().is_empty())
340                {
341                    eprintln!(
342                        "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
343                         gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
344                         refuses every forwarded request (issue #131)"
345                    );
346                }
347            }
348        }
349        Ok(_) => {}
350        Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
351            code: &CODES.sidecar_mount_invalid,
352            message,
353        })),
354    }
355}
356
357/// The SQL lints: the portable-SQL check and the card-data check (#44).
358/// The portable lint skips a module that ships a `postgres` set — it has
359/// declared where its SQL differs, the same rule
360/// `cratefield_adapter_postgres::select_set` applies when it chooses a set,
361/// and the doctor should not disagree with the runner. The card-data lint
362/// never skips: no dialect stores a card number, verification code or full
363/// expiry, unlike the portable lint above.
364fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
365    for module in harness.modules() {
366        if !module.migrations().postgres.is_empty() {
367            continue;
368        }
369        for migration in module.migrations().sqlite {
370            for (token, why) in banned_tokens(migration.sql) {
371                failures.push(DoctorFailure {
372                    code: &CODES.non_portable_sql,
373                    message: format!(
374                        "module `{}` migration {}: banned token `{token}` — {why}",
375                        module.name(),
376                        migration.id
377                    ),
378                });
379            }
380        }
381    }
382
383    for module in harness.modules() {
384        let migrations = module.migrations();
385        for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
386            for (fragment, why) in lint_card_data(migration.sql) {
387                failures.push(DoctorFailure {
388                    code: &CODES.card_data_in_migration,
389                    message: format!(
390                        "module `{}` migration {}: `{fragment}` {why}",
391                        module.name(),
392                        migration.id
393                    ),
394                });
395            }
396        }
397    }
398}
399
400/// The push-wiring section (issue #191). The doctor does not read the push
401/// environment itself: it calls the same `build_push` `serve()` and `fz push`
402/// call, through `inspect_push`, so the three cannot check different variable
403/// names.
404///
405/// Gated on what the harness declares, like every other production-only rule
406/// here: the process environment is shared, and a venture that mounts no push
407/// module at all must not fail its deploy because the env file it inherits
408/// exports someone else's `VAPID_SUBJECT`.
409fn push_checks(
410    harness: &Harness,
411    env: VentureEnv,
412    output: Output,
413    failures: &mut Vec<DoctorFailure>,
414) {
415    let declaration = PushDeclaration::of(harness.modules());
416    if !declaration.declared {
417        return;
418    }
419    push_wiring_checks(
420        &cratefield_push_wiring::inspect_push(&EnvVars),
421        env,
422        declaration,
423        output,
424        failures,
425    );
426}
427
428/// What this venture says about push, so the doctor's push rules key on the
429/// harness rather than on whatever the process environment happens to
430/// export (issue #191).
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432struct PushDeclaration {
433    /// A module lists [`Port::Push`] in `requires()` or `optional()`. Only
434    /// then is the push environment this venture's business.
435    declared: bool,
436    /// A module lists it in `requires()`: this venture does not work
437    /// without a transport that can actually send.
438    required: bool,
439}
440
441impl PushDeclaration {
442    fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
443        Self::from_ports(
444            modules
445                .iter()
446                .map(|module| (module.requires(), module.optional())),
447        )
448    }
449
450    /// The rule itself, over nothing but the port lists — so it is unit
451    /// tested without composing a harness, which for a module that
452    /// *requires* a port would mean a runtime that provides it too.
453    fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
454        let mut declaration = Self {
455            declared: false,
456            required: false,
457        };
458        for (requires, optional) in modules {
459            if requires.contains(&Port::Push) {
460                declaration.declared = true;
461                declaration.required = true;
462            } else if optional.contains(&Port::Push) {
463                declaration.declared = true;
464            }
465        }
466        declaration
467    }
468}
469
470/// Turns a push wiring report into doctor output: failures in production,
471/// warnings below it (issue #191). Split out so the rules are unit tested
472/// without a process environment.
473///
474/// The severity rule itself is [`PushWiring::check`]'s, called rather than
475/// restated — the same rule encoded twice is the drift this crate exists to
476/// prevent, one copy short of a test.
477///
478/// The second rule is the build-time gate `push_from_env()` cannot keep. A
479/// runtime that assembles the port from the environment provides `Port::Push`
480/// whatever that environment holds — on Workers the `Env` does not exist
481/// until the first fetch, so `Harness::build` cannot know — and a module that
482/// requires push then compiles, deploys, and answers `NotConfigured` for
483/// every send. The doctor is where the deployment's environment *is* known,
484/// so the refusal lives here: in production, a venture whose modules require
485/// push and whose environment routes no transport at all fails.
486fn push_wiring_checks(
487    wiring: &PushWiring,
488    env: VentureEnv,
489    declaration: PushDeclaration,
490    output: Output,
491    failures: &mut Vec<DoctorFailure>,
492) {
493    match wiring.check(env) {
494        // Half-wired and refused-credentials are one code, not two: the
495        // message already names which transport and which variables, and
496        // `check` reports every misconfigured transport in one string, so a
497        // venture with one of each has a single failure to classify. The
498        // operator action is the same either way — finish or correct that
499        // transport's variables, or unset them to say it is deliberately off.
500        Err(problems) => failures.push(DoctorFailure {
501            code: &CODES.push_transport_misconfigured,
502            message: problems,
503        }),
504        // Below production the same problems are a warning: wiring a
505        // transport one variable at a time is what development looks like.
506        Ok(()) => {
507            if output == Output::Human {
508                for problem in wiring.problems() {
509                    eprintln!("fz: warning: {problem}");
510                }
511            }
512        }
513    }
514
515    if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
516        failures.push(DoctorFailure {
517            code: &CODES.push_required_but_unrouted,
518            message: "production venture has modules that require the Push port but no push \
519                      transport is configured — every send would answer NotConfigured. Set one \
520                      transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
521                      push. If this venture provides Push from an adapter of its own rather than \
522                      from the environment, that adapter is not visible here (issue #191)"
523                .to_owned(),
524        });
525    }
526}
527
528/// The production-only port rules, gathered so `doctor` stays a flat list of
529/// checks: the captcha rule (with its override) and the payments webhook rule.
530fn production_port_checks(
531    harness: &Harness,
532    allow_no_captcha: Option<&str>,
533    output: Output,
534    failures: &mut Vec<DoctorFailure>,
535) {
536    let guards = cratefield_core::WriteGuards::collect(harness.modules());
537    if let Some(message) = captcha_production_failure(
538        &guards,
539        cratefield_core::captcha_effective(harness.runtime()),
540    ) {
541        match allow_no_captcha {
542            Some(reason) => {
543                // An override of a production abuse control is a decision
544                // someone has to answer for later, so it is recorded, not
545                // just printed (issue #143). `tracing` is the audited sink
546                // — scrubbed by #135 and shipped wherever the operator
547                // sends logs — and fires in both disciplines, while the
548                // stderr line keeps the operator running the command
549                // informed.
550                tracing::warn!(
551                    control = "captcha",
552                    reason,
553                    modules = guards.captcha_modules.join(","),
554                    "production abuse control overridden by an operator"
555                );
556                if output == Output::Human {
557                    eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
558                    eprintln!(
559                        "fz: note: this override is recorded; it is for previews only and must \
560                         not stand in for a Captcha port in production"
561                    );
562                }
563            }
564            None => failures.push(DoctorFailure {
565                code: &CODES.captcha_not_effective,
566                message,
567            }),
568        }
569    }
570
571    let webhook_secret_present =
572        std::env::var("STRIPE_WEBHOOK_SECRET").is_ok_and(|value| !value.trim().is_empty());
573    if let Some(message) = payments_webhook_failure(guards.needs_payments(), webhook_secret_present)
574    {
575        failures.push(DoctorFailure {
576            code: &CODES.payments_webhook_secret_missing,
577            message,
578        });
579    }
580}
581
582/// The production captcha rule (issue #133): captcha-guarded writes need a
583/// Captcha port that is *effectively* configured — provided **and** bound per
584/// the adapter's report — not merely present. The builder already refuses
585/// this composition (`Harness::build`); the doctor re-checks it so the rule
586/// stays visible in operator tooling.
587fn captcha_production_failure(
588    guards: &cratefield_core::WriteGuards,
589    captcha_effective: bool,
590) -> Option<String> {
591    if guards.needs_captcha() && !captcha_effective {
592        Some(format!(
593            "production venture has captcha-guarded public writes from [{}] but the Captcha port \
594             is not effectively configured — provide Turnstile with its secret and expected \
595             hostname (architecture section 11; issue #133)",
596            guards.captcha_modules.join(", ")
597        ))
598    } else {
599        None
600    }
601}
602
603/// The production-payments rule: a production venture that declares
604/// [`Signature`]-guarded routes without a webhook signing secret cannot
605/// verify Stripe webhooks, so it would process forged events. Pure so the
606/// truth table is unit-tested; the caller supplies the env-derived booleans.
607///
608/// [`Signature`]: cratefield_core::RoutePolicy::Signature
609fn payments_webhook_failure(
610    signature_routes_present: bool,
611    webhook_secret_present: bool,
612) -> Option<String> {
613    if signature_routes_present && !webhook_secret_present {
614        Some(
615            "production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset \
616             — webhook signatures cannot be verified and forged events would be trusted \
617             (issue #102)"
618                .to_owned(),
619        )
620    } else {
621        None
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::{
628        CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, captcha_production_failure,
629        payments_webhook_failure, push_wiring_checks,
630    };
631    use cratefield_core::{MapConfig, Port, VentureEnv, WriteGuards};
632    use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
633
634    /// A venture whose modules only *may* use push: the wiring is checked,
635    /// but nothing has to be routed.
636    const OPTIONAL: PushDeclaration = PushDeclaration {
637        declared: true,
638        required: false,
639    };
640
641    /// A venture whose modules cannot work without a transport.
642    const REQUIRED: PushDeclaration = PushDeclaration {
643        declared: true,
644        required: true,
645    };
646
647    fn guards(needs_captcha: bool) -> WriteGuards {
648        WriteGuards {
649            captcha_modules: if needs_captcha {
650                vec!["writer".to_owned()]
651            } else {
652                Vec::new()
653            },
654            signature_modules: Vec::new(),
655            signed_link_modules: Vec::new(),
656        }
657    }
658
659    #[test]
660    fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
661        // Guarded writes without an effective port fail — even if the port
662        // is *present*, since effectiveness is what the gate measures.
663        let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
664        assert!(failure.contains("writer"));
665        assert!(failure.contains("effectively configured"));
666        assert!(captcha_production_failure(&guards(true), true).is_none());
667        assert!(captcha_production_failure(&guards(false), false).is_none());
668    }
669
670    #[test]
671    fn payments_in_production_needs_a_webhook_secret() {
672        assert!(payments_webhook_failure(true, false).is_some());
673        assert!(payments_webhook_failure(true, true).is_none());
674        assert!(payments_webhook_failure(false, false).is_none());
675    }
676
677    /// The `--json` wire contract (harness #140): field order, the schema
678    /// number, and the code string straight from the catalogue.
679    #[test]
680    fn report_renders_the_json_contract() {
681        let report = DoctorReport {
682            failures: vec![
683                DoctorFailure {
684                    code: &CODES.captcha_not_effective,
685                    message: "production venture has captcha-guarded writes".to_owned(),
686                },
687                DoctorFailure {
688                    code: &CODES.payments_webhook_secret_missing,
689                    message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
690                },
691            ],
692        };
693        let payload = report.render_json().expect("serializes");
694        assert_eq!(
695            payload,
696            "{\"schema\":1,\"ok\":false,\"failures\":[\
697             {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
698             captcha-guarded writes\"},\
699             {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
700             is unset\"}]}"
701        );
702        assert!(!report.ok());
703
704        let clean = DoctorReport {
705            failures: Vec::new(),
706        };
707        assert_eq!(
708            clean.render_json().expect("serializes"),
709            "{\"schema\":1,\"ok\":true,\"failures\":[]}"
710        );
711        assert!(clean.ok());
712    }
713
714    /// A half-wired push environment, built through the real reader so this
715    /// test names no environment variable of its own — the property
716    /// `cli-acceptance`'s guard enforces across the workspace.
717    fn half_wired() -> PushWiring {
718        let var = PUSH_ENV
719            .iter()
720            .find(|var| var.required)
721            .expect("the table has a required variable");
722        let wiring = inspect_push(&MapConfig::from_pairs([(
723            var.name().to_owned(),
724            "set-but-alone".to_owned(),
725        )]));
726        assert!(
727            !wiring.problems().is_empty(),
728            "one variable of a transport is a half-wired transport: {}",
729            wiring.summary()
730        );
731        wiring
732    }
733
734    #[test]
735    fn a_half_wired_transport_fails_the_doctor_in_production_only() {
736        let wiring = half_wired();
737
738        let mut failures = Vec::new();
739        push_wiring_checks(
740            &wiring,
741            VentureEnv::Production,
742            OPTIONAL,
743            Output::Human,
744            &mut failures,
745        );
746        assert_eq!(failures.len(), 1, "{failures:?}");
747        for problem in wiring.problems() {
748            assert!(failures[0].message.contains(&problem), "{failures:?}");
749        }
750        // The slug itself, written out: the catalogue's contract is that a
751        // code is never renamed, and a test that reads it back off `CODES`
752        // would rename with it.
753        assert_eq!(failures[0].code.code, "push-transport-misconfigured");
754
755        // Below production it is a printed warning, not a failure: wiring a
756        // transport one variable at a time is what development looks like.
757        for env in [VentureEnv::Development, VentureEnv::Staging] {
758            let mut failures = Vec::new();
759            push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
760            assert!(failures.is_empty(), "{env:?}: {failures:?}");
761        }
762    }
763
764    #[test]
765    fn a_venture_that_wires_no_push_at_all_is_silent() {
766        // Absent is a choice, not a defect — the doctor must not nag a
767        // venture that sends no notifications, even in production.
768        let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
769        let mut failures = Vec::new();
770        push_wiring_checks(
771            &wiring,
772            VentureEnv::Production,
773            OPTIONAL,
774            Output::Human,
775            &mut failures,
776        );
777        assert!(failures.is_empty(), "{failures:?}");
778        assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
779    }
780
781    #[test]
782    fn a_module_that_requires_push_needs_a_transport_that_can_send() {
783        // The build-time gate `push_from_env()` cannot keep: on Workers the
784        // `Env` does not exist when `Harness::build` runs, so a module
785        // requiring Push compiles and deploys against an environment that
786        // configures nothing and answers NotConfigured for every send. The
787        // doctor runs where the environment is known, so it refuses.
788        let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
789        assert!(!nothing.any_routed());
790
791        let mut failures = Vec::new();
792        push_wiring_checks(
793            &nothing,
794            VentureEnv::Production,
795            REQUIRED,
796            Output::Human,
797            &mut failures,
798        );
799        assert_eq!(failures.len(), 1, "{failures:?}");
800        assert!(
801            failures[0].message.contains("require the Push port"),
802            "{failures:?}"
803        );
804        // Its own code, not the half-wired one: nothing here is half-wired,
805        // and the operator's move is to route a transport or to drop the
806        // modules that require one.
807        assert_eq!(failures[0].code.code, "push-required-but-unrouted");
808
809        // Only in production, and only when a module actually requires it.
810        for env in [VentureEnv::Development, VentureEnv::Staging] {
811            let mut failures = Vec::new();
812            push_wiring_checks(&nothing, env, REQUIRED, Output::Human, &mut failures);
813            assert!(failures.is_empty(), "{env:?}: {failures:?}");
814        }
815        let mut failures = Vec::new();
816        push_wiring_checks(
817            &nothing,
818            VentureEnv::Production,
819            OPTIONAL,
820            Output::Human,
821            &mut failures,
822        );
823        assert!(failures.is_empty(), "{failures:?}");
824    }
825
826    #[test]
827    fn the_push_rules_are_gated_on_what_the_venture_declares() {
828        // The process environment is shared. A venture that mounts no push
829        // module at all must not fail its production doctor run because the
830        // env file it inherits exports somebody else's VAPID_SUBJECT — every
831        // other production-only rule here keys on the harness, and this one
832        // did not.
833        let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
834        assert!(!none.declared);
835        assert!(!none.required);
836
837        let optional =
838            PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
839        assert!(optional.declared);
840        assert!(
841            !optional.required,
842            "a module that only *may* use push does not make one mandatory"
843        );
844
845        let required = PushDeclaration::from_ports(
846            [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
847        );
848        assert!(required.declared);
849        assert!(required.required);
850    }
851
852    #[test]
853    fn a_configured_transport_satisfies_a_module_that_requires_push() {
854        // The rule must pass on the deployment it is meant to allow, or it
855        // is just a way to fail every production doctor run.
856        //
857        // The variables are named through `PushKey`, not written out: the
858        // guard in `cli-acceptance` allows exactly one source in the
859        // workspace to spell one of these names, and it is not this one.
860        let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
861                   cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
862                   uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
863                   -----END PRIVATE KEY-----";
864        let wiring = inspect_push(&MapConfig::from_pairs([
865            (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
866            (
867                PushKey::VapidSubject.name().to_owned(),
868                "mailto:ops@example.test".to_owned(),
869            ),
870        ]));
871        assert!(wiring.any_routed(), "{}", wiring.summary());
872
873        let mut failures = Vec::new();
874        push_wiring_checks(
875            &wiring,
876            VentureEnv::Production,
877            REQUIRED,
878            Output::Human,
879            &mut failures,
880        );
881        assert!(failures.is_empty(), "{failures:?}");
882    }
883}