From 9e1a07fac6460ba3c0eda96b985e209a67ac9d4f Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Wed, 6 May 2026 00:16:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(env):=20env=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env.ciInjectedKeys와 env.optionalLocalKeys로 env-missing-concrete 대상에서 CI 주입 및 선택 로컬 키를 제외한다. 기본 동작과 unknown field 실패 계약은 회귀 테스트로 고정한다. Closes #104 --- crates/maximus-checks/src/env.rs | 13 +++- crates/maximus-checks/src/registry.rs | 16 +++-- crates/maximus-checks/tests/env_checks.rs | 61 ++++++++++++++++++- .../maximus-cli/tests/config_and_filtering.rs | 57 +++++++++++++++++ crates/maximus-core/src/config.rs | 21 +++++++ crates/maximus-core/tests/config_loader.rs | 42 +++++++++++++ 6 files changed, 203 insertions(+), 7 deletions(-) diff --git a/crates/maximus-checks/src/env.rs b/crates/maximus-checks/src/env.rs index e6f5453..2fbc2fa 100644 --- a/crates/maximus-checks/src/env.rs +++ b/crates/maximus-checks/src/env.rs @@ -26,6 +26,14 @@ pub struct EnvCheckOptions { pub fn run_env_check_with_options( project: &ProjectSnapshot, options: &EnvCheckOptions, +) -> io::Result { + run_env_check_with_missing_concrete_excluded_keys(project, options, &BTreeSet::new()) +} + +pub fn run_env_check_with_missing_concrete_excluded_keys( + project: &ProjectSnapshot, + options: &EnvCheckOptions, + missing_concrete_excluded_keys: &BTreeSet, ) -> io::Result { let mut findings = Vec::new(); let mut fixes = Vec::new(); @@ -312,7 +320,10 @@ pub fn run_env_check_with_options( .parsed .order .iter() - .filter(|key| !provided_keys.contains(*key)) + .filter(|key| { + !provided_keys.contains(*key) + && !missing_concrete_excluded_keys.contains(*key) + }) .cloned() .collect::>(); diff --git a/crates/maximus-checks/src/registry.rs b/crates/maximus-checks/src/registry.rs index 69cfd34..11cef9e 100644 --- a/crates/maximus-checks/src/registry.rs +++ b/crates/maximus-checks/src/registry.rs @@ -27,8 +27,8 @@ use crate::test_runner_config::run_test_runner_config_check_with_ignore_root; use crate::vite_tsconfig_alias::run_vite_tsconfig_alias_check; use crate::workspace_config::run_workspace_config_check; use crate::{ - build_structure_report, run_config_duplicate_check, run_env_check_with_options, - run_eslint_prettier_check, run_tsconfig_check, EnvCheckOptions, + build_structure_report, run_config_duplicate_check, run_eslint_prettier_check, + run_tsconfig_check, EnvCheckOptions, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -458,10 +458,18 @@ pub fn run_env_check_with_config_root_and_options( } else { discover_project_with_ignore_root(&project.root_dir, &ignored_patterns, ignore_root)? }; - return run_env_check_with_options(&env_project, options); + return crate::env::run_env_check_with_missing_concrete_excluded_keys( + &env_project, + options, + &config.env.missing_concrete_excluded_keys(), + ); } - run_env_check_with_options(project, options) + crate::env::run_env_check_with_missing_concrete_excluded_keys( + project, + options, + &config.env.missing_concrete_excluded_keys(), + ) } fn run_ignore_file_drift_check_registered( diff --git a/crates/maximus-checks/tests/env_checks.rs b/crates/maximus-checks/tests/env_checks.rs index 99e35bb..19da14f 100644 --- a/crates/maximus-checks/tests/env_checks.rs +++ b/crates/maximus-checks/tests/env_checks.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -9,8 +10,8 @@ mod env; use env::{ render_created_env_example, render_created_env_example_with_sources, render_synced_env_example, - render_synced_env_example_with_sources, run_env_check, run_env_check_with_options, - EnvCheckOptions, + render_synced_env_example_with_sources, run_env_check, + run_env_check_with_missing_concrete_excluded_keys, run_env_check_with_options, EnvCheckOptions, }; use maximus_core::{ apply_fix, discover_project, EnvTemplateRenderOptions, EnvTemplateSourceGroup, FixOperation, @@ -126,6 +127,62 @@ fn env_check_matches_js_findings_for_duplicates_invalid_sync_secret_override_and ); } +#[test] +fn env_missing_concrete_excludes_configured_exact_keys_only() { + let fixture = TempDir::new().expect("temp dir should exist"); + + write( + fixture.path().join(".env"), + "API_URL=https://example.test\n", + ); + write( + fixture.path().join(".env.example"), + "API_URL=\nGH_COLLECTOR_TOKEN=\nNEXT_PUBLIC_OKTA_DOMAIN=\nVALIDATION_MODE=\nVALIDATION_LABELS=\n", + ); + + let project = discover_project(fixture.path()).expect("project should discover"); + let default_outcome = run_env_check(&project).expect("default check should run"); + + assert_has_finding( + &default_outcome.findings, + &format!("env-missing-concrete:{}", fixture.path().to_string_lossy()), + Severity::Warn, + "Declared env contract is not satisfied locally", + "No concrete value was found for: GH_COLLECTOR_TOKEN, NEXT_PUBLIC_OKTA_DOMAIN, VALIDATION_MODE, VALIDATION_LABELS.", + "If these are injected by CI, keep the contract documented. Otherwise add them to your local env files.", + Some(fixture.path().join(".env.example")), + false, + &[], + ); + + let excluded_keys = [ + "GH_COLLECTOR_TOKEN", + "NEXT_PUBLIC_OKTA_DOMAIN", + "VALIDATION_*", + ] + .into_iter() + .map(String::from) + .collect::>(); + let configured_outcome = run_env_check_with_missing_concrete_excluded_keys( + &project, + &EnvCheckOptions::default(), + &excluded_keys, + ) + .expect("configured check should run"); + + assert_has_finding( + &configured_outcome.findings, + &format!("env-missing-concrete:{}", fixture.path().to_string_lossy()), + Severity::Warn, + "Declared env contract is not satisfied locally", + "No concrete value was found for: VALIDATION_MODE, VALIDATION_LABELS.", + "If these are injected by CI, keep the contract documented. Otherwise add them to your local env files.", + Some(fixture.path().join(".env.example")), + false, + &[], + ); +} + #[test] fn env_example_secret_warning_uses_key_aware_classifier() { let fixture = TempDir::new().expect("temp dir should exist"); diff --git a/crates/maximus-cli/tests/config_and_filtering.rs b/crates/maximus-cli/tests/config_and_filtering.rs index 52529b0..71c674d 100644 --- a/crates/maximus-cli/tests/config_and_filtering.rs +++ b/crates/maximus-cli/tests/config_and_filtering.rs @@ -458,6 +458,63 @@ fn config_glob_ignore_and_severity_overrides_are_applied() { ); } +#[test] +fn config_env_policy_excludes_missing_concrete_keys_from_cli_audit() { + let fixture = TempDir::new().expect("temp dir should exist"); + write( + fixture.path().join(".env"), + "API_URL=https://example.test\n", + ); + write( + fixture.path().join(".env.example"), + "API_URL=\nGH_COLLECTOR_TOKEN=\nNEXT_PUBLIC_OKTA_DOMAIN=\nVALIDATION_MODE=\n", + ); + write(fixture.path().join(".gitignore"), ".env\n.env.local\n"); + write( + fixture.path().join("maximus.config.json"), + r#" + { + "checks": { "only": ["env"] }, + "env": { + "ciInjectedKeys": ["GH_COLLECTOR_TOKEN"], + "optionalLocalKeys": ["NEXT_PUBLIC_OKTA_DOMAIN"] + } + } + "#, + ); + + let output = maximus_bin() + .args(["audit", fixture.path().to_string_lossy().as_ref(), "--json"]) + .output() + .expect("audit should run"); + + assert_eq!(output.status.code(), Some(1), "{output:?}"); + + let value = parse_json(&output); + let findings = value["findings"] + .as_array() + .expect("findings should be an array"); + assert_eq!(findings.len(), 1); + assert_eq!( + finding_field( + findings[0] + .as_object() + .expect("finding should be an object"), + "id" + ), + format!("env-missing-concrete:{}", fixture.path().to_string_lossy()) + ); + assert_eq!( + finding_field( + findings[0] + .as_object() + .expect("finding should be an object"), + "detail" + ), + "No concrete value was found for: VALIDATION_MODE." + ); +} + #[test] fn config_suppression_by_exact_finding_id_hides_finding_and_counts_summary() { let fixture = TempDir::new().expect("temp dir should exist"); diff --git a/crates/maximus-core/src/config.rs b/crates/maximus-core/src/config.rs index a64d18c..6adafe9 100644 --- a/crates/maximus-core/src/config.rs +++ b/crates/maximus-core/src/config.rs @@ -37,6 +37,8 @@ pub struct MaximusConfig { #[serde(default)] pub checks: CheckFilterConfig, #[serde(default)] + pub env: EnvConfig, + #[serde(default)] pub ignore: Vec, #[serde(default, rename = "ignorePatterns")] pub ignore_patterns: Vec, @@ -85,6 +87,25 @@ pub struct CheckFilterConfig { pub skip: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvConfig { + #[serde(default, rename = "ciInjectedKeys")] + pub ci_injected_keys: Vec, + #[serde(default, rename = "optionalLocalKeys")] + pub optional_local_keys: Vec, +} + +impl EnvConfig { + pub fn missing_concrete_excluded_keys(&self) -> BTreeSet { + self.ci_injected_keys + .iter() + .chain(self.optional_local_keys.iter()) + .cloned() + .collect() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigSuppression { diff --git a/crates/maximus-core/tests/config_loader.rs b/crates/maximus-core/tests/config_loader.rs index 7e423ed..0f772e5 100644 --- a/crates/maximus-core/tests/config_loader.rs +++ b/crates/maximus-core/tests/config_loader.rs @@ -118,6 +118,10 @@ fn parses_jsonc_shape_for_checks_severity_and_report() { "only": ["env", "tsconfig"], "skip": ["duplicates"] }, + "env": { + "ciInjectedKeys": ["GH_COLLECTOR_TOKEN"], + "optionalLocalKeys": ["NEXT_PUBLIC_OKTA_DOMAIN"] + }, "ignore": ["dist", "coverage"], "ignorePatterns": ["**/*.generated.json"], "severity": { @@ -147,6 +151,21 @@ fn parses_jsonc_shape_for_checks_severity_and_report() { assert_eq!(loaded.config.checks.only, vec!["env", "tsconfig"]); assert_eq!(loaded.config.checks.skip, vec!["duplicates"]); + assert_eq!( + loaded.config.env.ci_injected_keys, + vec!["GH_COLLECTOR_TOKEN"] + ); + assert_eq!( + loaded.config.env.optional_local_keys, + vec!["NEXT_PUBLIC_OKTA_DOMAIN"] + ); + assert_eq!( + loaded.config.env.missing_concrete_excluded_keys(), + ["GH_COLLECTOR_TOKEN", "NEXT_PUBLIC_OKTA_DOMAIN"] + .into_iter() + .map(String::from) + .collect() + ); assert_eq!(loaded.config.ignore, vec!["dist", "coverage"]); assert_eq!(loaded.config.ignore_patterns, vec!["**/*.generated.json"]); assert_eq!( @@ -175,6 +194,29 @@ fn parses_jsonc_shape_for_checks_severity_and_report() { assert_eq!(loaded.config.report.fail_on, Some(FailOnLevel::Error)); } +#[test] +fn parse_errors_when_unknown_env_config_keys_are_present() { + let temp = tempdir().expect("temp dir should exist"); + let config_path = temp.path().join("maximus.config.json"); + fs::write( + &config_path, + r#" + { + "env": { + "ciInjectedKey": ["GH_COLLECTOR_TOKEN"] + } + } + "#, + ) + .expect("config should write"); + + let error = load_maximus_config(temp.path()).expect_err("unknown env keys should fail"); + let rendered = error.to_string(); + + assert!(rendered.contains(&config_path.to_string_lossy().to_string())); + assert!(rendered.contains("ciInjectedKey")); +} + #[test] fn returns_none_when_no_config_exists() { let temp = tempdir().expect("temp dir should exist");