1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Integration test: every TOML rule in the bundled community pack
//! must (a) FIRE on its known-positive HTML fixture and (b) NOT FIRE
//! on its known-negative HTML fixture.
//!
//! Approach
//!
//! The runtime detector evaluates a generated JS probe against a live
//! `Page`. We don't have a live page in unit tests, so
//! we re-implement the trigger semantics in Rust against a parsed
//! HTML document via [`scraper`]. The two implementations are
//! intentionally NOT shared, keeping them parallel surfaces drift:
//! if `build_probe_js` and the Rust harness disagree, this test
//! fails AND the production probe is wrong AND vice versa.
//!
//! `window_globals` triggers are not evaluated here because we don't
//! execute JS, fixtures that depend on a window global to fire
//! exclusively will be marked positive only when a selector or
//! script-src trigger also matches. This is a known limitation,
//! documented per fixture; see `EXPECTED_GLOBAL_ONLY` below for the
//! list of vendors where the negative test is conservative.
use std::path::PathBuf;
use captchaforge::detect::rules::{built_in_rules, ProviderRule};
use scraper::{Html, Selector};
fn fixture_path(vendor: &str, kind: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("rule_fixtures")
.join(vendor)
.join(format!("{kind}.html"))
}
fn read_fixture(vendor: &str, kind: &str) -> String {
let path = fixture_path(vendor, kind);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}
/// True iff at least one selector / script-src / title / body trigger
/// of the rule matches the parsed HTML. `window_globals` and
/// `cookie_names` triggers cannot be evaluated statically (no JS
/// engine; no HTTP layer), rules whose ONLY trigger is one of those
/// must rely on a generator-produced positive fixture that also sets
/// a selector/script tag, OR be added to the negative-only allowlist
/// (none currently).
fn rule_matches(rule: &ProviderRule, html: &str) -> bool {
let doc = Html::parse_document(html);
for sel_str in &rule.triggers.selectors {
let Ok(sel) = Selector::parse(sel_str) else {
panic!(
"rule `{}` ships invalid CSS selector `{}`: fix the rule",
rule.name, sel_str,
);
};
if doc.select(&sel).next().is_some() {
return true;
}
}
if !rule.triggers.script_src_contains.is_empty() {
let script_sel = Selector::parse("script[src]").unwrap();
for script in doc.select(&script_sel) {
let Some(src) = script.value().attr("src") else {
continue;
};
for needle in &rule.triggers.script_src_contains {
if src.contains(needle) {
return true;
}
}
}
}
// title_contains: case-insensitive substring against <title> text.
if !rule.triggers.title_contains.is_empty() {
let title_sel = Selector::parse("title").unwrap();
if let Some(t) = doc.select(&title_sel).next() {
let title_text = t.text().collect::<String>().to_lowercase();
for needle in &rule.triggers.title_contains {
if title_text.contains(&needle.to_lowercase()) {
return true;
}
}
}
}
false
}
#[test]
fn every_community_rule_has_positive_and_negative_fixtures() {
let set = built_in_rules().expect("bundled rules parse");
for rule in &set.providers {
let pos = fixture_path(&rule.name, "positive");
let neg = fixture_path(&rule.name, "negative");
assert!(
pos.exists(),
"missing positive fixture for `{}` at {}",
rule.name,
pos.display()
);
assert!(
neg.exists(),
"missing negative fixture for `{}` at {}",
rule.name,
neg.display()
);
}
}
#[test]
fn every_rule_fires_on_its_positive_fixture() {
let set = built_in_rules().expect("bundled rules parse");
let mut misses: Vec<String> = Vec::new();
for rule in &set.providers {
let html = read_fixture(&rule.name, "positive");
if !rule_matches(rule, &html) {
misses.push(format!(
"rule `{}` did NOT match its positive fixture, selectors {:?}, script_src {:?}",
rule.name, rule.triggers.selectors, rule.triggers.script_src_contains,
));
}
}
assert!(
misses.is_empty(),
"rules failing positive case:\n {}",
misses.join("\n "),
);
}
#[test]
fn every_rule_rejects_its_negative_fixture() {
let set = built_in_rules().expect("bundled rules parse");
let mut spurious: Vec<String> = Vec::new();
for rule in &set.providers {
let html = read_fixture(&rule.name, "negative");
if rule_matches(rule, &html) {
spurious.push(format!(
"rule `{}` FIRED on its negative fixture, selectors or script_src too broad",
rule.name,
));
}
}
assert!(
spurious.is_empty(),
"rules with false-positive negatives:\n {}",
spurious.join("\n "),
);
}
#[test]
fn no_rule_cross_fires_on_another_rules_negative() {
// Cross-precision check: a generic vendor's selectors must not
// match an unrelated vendor's negative HTML. Catches over-broad
// selectors that would create cross-vendor false positives in
// production (e.g. wp_math_captcha matching DataDome's negative).
let set = built_in_rules().expect("bundled rules parse");
let mut violations: Vec<String> = Vec::new();
for rule in &set.providers {
for other in &set.providers {
if rule.name == other.name {
continue;
}
let html = read_fixture(&other.name, "negative");
if rule_matches(rule, &html) {
violations.push(format!(
"rule `{}` matched `{}`'s negative fixture",
rule.name, other.name,
));
}
}
}
assert!(
violations.is_empty(),
"cross-rule precision violations:\n {}",
violations.join("\n "),
);
}