use fallow_config::Severity;
use fallow_core::results::{AnalysisResults, SecurityFindingKind, TraceHopRole};
use super::common::{create_config, create_config_with_rules, fixture_path};
fn analyze_with_security() -> AnalysisResults {
let root = fixture_path("security-client-server-leak");
let config = create_config_with_rules(root, |rules| {
rules.security_client_server_leak = Severity::Warn;
});
fallow_core::analyze(&config).expect("analysis should succeed")
}
fn anchored_on(results: &AnalysisResults, suffix: &str) -> bool {
results.security_findings.iter().any(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with(suffix)
})
}
fn server_only_findings_on(results: &AnalysisResults, suffix: &str) -> usize {
results
.security_findings
.iter()
.filter(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with(suffix)
&& f.category.as_deref() == Some("server-only-import")
})
.count()
}
#[test]
fn single_hop_leak_is_reported_with_named_secret() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/client.tsx")
})
.expect("client.tsx should be flagged");
assert!(matches!(
finding.kind,
SecurityFindingKind::ClientServerLeak
));
assert!(
finding.evidence.contains("DATABASE_URL"),
"evidence should name the secret var: {}",
finding.evidence
);
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::SecretSource));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/server.ts")
);
}
#[test]
fn no_use_client_directive_is_not_scanned() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/plain.tsx"),
"a file without \"use client\" must not be flagged"
);
}
#[test]
fn public_prefix_env_read_is_not_a_secret() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/public-client.tsx"),
"a client file reaching only a NEXT_PUBLIC_ read must not be flagged"
);
assert!(
!anchored_on(&results, "src/vite-public-client.tsx"),
"a client file reaching only a VITE_ read must not be flagged"
);
}
#[test]
fn multi_hop_leak_through_barrel_lists_every_hop() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/client2.tsx")
})
.expect("client2.tsx should be flagged");
assert!(
finding.evidence.contains("SESSION_SECRET"),
"evidence should name the secret: {}",
finding.evidence
);
let hops: Vec<String> = finding
.trace
.iter()
.map(|h| h.path.to_string_lossy().replace('\\', "/"))
.collect();
assert!(
hops.len() >= 3,
"multi-hop trace should list every hop: {hops:?}"
);
assert!(hops[0].ends_with("src/client2.tsx"));
assert!(hops.iter().any(|h| h.ends_with("src/barrel.ts")));
assert!(hops.last().unwrap().ends_with("src/secret2.ts"));
assert!(matches!(
finding.trace[0].role,
TraceHopRole::ClientBoundary
));
}
#[test]
fn dynamic_import_blind_spot_is_counted_in_band() {
let results = analyze_with_security();
assert!(
results.security_unresolved_edge_files >= 1,
"dyn-client.tsx's dynamic import should count as an unresolved edge"
);
}
#[test]
fn direct_secret_read_in_client_file_is_reported() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/direct-client.tsx")
})
.expect("direct-client.tsx should be flagged");
assert!(finding.evidence.contains("STRIPE_SECRET_KEY"));
assert_eq!(finding.trace.len(), 1);
assert!(matches!(finding.trace[0].role, TraceHopRole::SecretSource));
}
#[test]
fn direct_import_meta_env_secret_read_in_client_file_is_reported() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/vite-direct-client.tsx")
})
.expect("vite-direct-client.tsx should be flagged");
assert!(
finding
.evidence
.contains("import.meta.env.DIRECT_SECRET_KEY")
);
assert_eq!(finding.trace.len(), 1);
assert!(matches!(finding.trace[0].role, TraceHopRole::SecretSource));
}
#[test]
fn transitive_import_meta_env_secret_read_is_reported() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/vite-client.tsx")
})
.expect("vite-client.tsx should be flagged");
assert!(finding.evidence.contains("import.meta.env.SECRET_KEY"));
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::SecretSource));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/vite-config.ts")
);
}
#[test]
fn package_self_reference_import_condition_does_not_visit_server_entry() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/conditional-client.tsx"),
"client import through the package import condition must not report the node entry"
);
for finding in &results.security_findings {
assert!(
finding.trace.iter().all(|hop| {
!hop.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/export-server.ts")
}),
"server-only export entry must not appear in client trace: {:?}",
finding.trace
);
}
}
#[test]
fn file_level_suppression_opts_out() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/suppressed-client.tsx"),
"a file-level-suppressed client file must not be flagged"
);
}
#[test]
fn every_finding_carries_a_suppress_action() {
let results = analyze_with_security();
assert!(!results.security_findings.is_empty());
for f in &results.security_findings {
assert!(
!f.actions.is_empty(),
"finding must carry actions: {:?}",
f.path
);
}
}
#[test]
fn exactly_ten_findings_reported() {
let results = analyze_with_security();
assert_eq!(
results.security_findings.len(),
10,
"expected exactly ten findings (5 secret-leak + 5 server-only-import), got: {:?}",
results
.security_findings
.iter()
.map(|f| {
format!(
"{} [{}]",
f.path.to_string_lossy(),
f.category.as_deref().unwrap_or("none")
)
})
.collect::<Vec<_>>()
);
}
#[test]
fn server_only_import_is_a_distinct_category() {
let results = analyze_with_security();
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/server-only-client.tsx")
})
.expect("server-only-client.tsx should be flagged");
assert!(matches!(
finding.kind,
SecurityFindingKind::ClientServerLeak
));
assert_eq!(
finding.category.as_deref(),
Some("server-only-import"),
"server-only sink must carry the distinct category"
);
assert_eq!(
finding.candidate.sink.category.as_deref(),
Some("server-only-import")
);
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::Sink));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/headers-util.ts"),
"sink hop should be the next/headers module: {:?}",
last.path
);
assert!(matches!(
finding.trace[0].role,
TraceHopRole::ClientBoundary
));
assert!(
finding.evidence.contains("SERVER-ONLY"),
"evidence should explain the server-only sink: {}",
finding.evidence
);
}
#[test]
fn plain_shared_util_is_not_flagged() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/shared-util-client.tsx"),
"a client importing a plain util with no server-only sink must not be flagged"
);
}
#[test]
fn ssr_false_dynamic_import_to_server_is_not_flagged() {
let results = analyze_with_security();
assert!(
!anchored_on(&results, "src/ssr-false-client.tsx"),
"a server module reached only via next/dynamic ssr:false must not be flagged"
);
assert!(
!anchored_on(&results, "src/server-mod.ts"),
"the server-only module is not a client boundary and must not be an anchor"
);
}
#[test]
fn direct_server_only_import_in_client_file_is_reported_once() {
let results = analyze_with_security();
assert_eq!(
server_only_findings_on(&results, "src/direct-fs-client.tsx"),
1,
"direct node:fs client must produce exactly one server-only-import finding"
);
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/direct-fs-client.tsx")
})
.expect("direct-fs-client.tsx should be flagged");
assert!(matches!(
finding.kind,
SecurityFindingKind::ClientServerLeak
));
assert_eq!(finding.category.as_deref(), Some("server-only-import"));
assert_eq!(
finding.candidate.sink.category.as_deref(),
Some("server-only-import")
);
assert_eq!(finding.trace.len(), 1);
assert!(matches!(finding.trace[0].role, TraceHopRole::Sink));
assert!(
finding.trace[0]
.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/direct-fs-client.tsx")
);
}
#[test]
fn use_server_directive_module_is_a_server_only_sink() {
let results = analyze_with_security();
assert_eq!(
server_only_findings_on(&results, "src/use-server-client.tsx"),
1,
"a client reaching a \"use server\" module must report one server-only-import finding"
);
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/use-server-client.tsx")
})
.expect("use-server-client.tsx should be flagged");
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::Sink));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/use-server-mod.ts")
);
}
#[test]
fn server_only_package_import_is_a_server_only_sink() {
let results = analyze_with_security();
assert_eq!(
server_only_findings_on(&results, "src/server-only-pkg-client.tsx"),
1,
"a client reaching a `server-only` importer must report one server-only-import finding"
);
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/server-only-pkg-client.tsx")
})
.expect("server-only-pkg-client.tsx should be flagged");
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::Sink));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/server-only-pkg-mod.ts")
);
}
#[test]
fn child_process_import_is_a_server_only_sink() {
let results = analyze_with_security();
assert_eq!(
server_only_findings_on(&results, "src/child-process-client.tsx"),
1,
"a client reaching a node:child_process importer must report one server-only-import finding"
);
let finding = results
.security_findings
.iter()
.find(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/child-process-client.tsx")
})
.expect("child-process-client.tsx should be flagged");
let last = finding.trace.last().expect("trace must have hops");
assert!(matches!(last.role, TraceHopRole::Sink));
assert!(
last.path
.to_string_lossy()
.replace('\\', "/")
.ends_with("src/child-process-mod.ts")
);
}
#[test]
fn server_only_import_is_off_by_default() {
let root = fixture_path("security-client-server-leak");
let config = create_config(root);
assert_eq!(config.rules.security_client_server_leak, Severity::Off);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
assert!(
!results
.security_findings
.iter()
.any(|f| f.category.as_deref() == Some("server-only-import")),
"default-off rule must not populate server-only-import findings"
);
}
#[test]
fn default_off_emits_no_security_findings() {
let root = fixture_path("security-client-server-leak");
let config = create_config(root);
assert_eq!(config.rules.security_client_server_leak, Severity::Off);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
assert!(
results.security_findings.is_empty(),
"default-off rule must not populate security_findings"
);
assert_eq!(results.security_unresolved_edge_files, 0);
}