Skip to content

Commit 60316c1

Browse files
hyperpolymathclaude
andcommitted
test: add smoke tests for CloudGuard CLI library (Phase 5, CRG C)
11 tests: CfZone/CfDnsRecord construction + serde round-trips, audit_settings all-pass/all-fail/missing/mixed scenarios, hardening_policy well-formedness and CRITICAL entry presence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a345ade commit 60316c1

4 files changed

Lines changed: 302 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ serde_json = "1"
1818
dirs = "6"
1919
once_cell = "1"
2020

21+
[lib]
22+
name = "cloudguard_cli"
23+
path = "src/lib.rs"
24+
2125
[[bin]]
2226
name = "cloudguard"
2327
path = "src/main.rs"

src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
3+
//! CloudGuard CLI library — re-exports public API types for testing and external use.
4+
//!
5+
//! This crate exposes the Cloudflare API types, audit logic, and hardening policy
6+
//! so that integration tests and downstream consumers can access them without
7+
//! depending on the binary entry point.
8+
9+
pub mod api;

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
//! `~/.config/cloudguard/credentials` file.
2121
2222
#![forbid(unsafe_code)]
23-
mod api;
23+
use cloudguard_cli::api;
2424

2525
use clap::{Parser, Subcommand};
2626

tests/smoke_test.rs

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
3+
//! Smoke tests for the CloudGuard CLI library.
4+
//!
5+
//! These tests validate the public API types and pure logic functions without
6+
//! making any real network calls. Each test is self-contained and exercises
7+
//! one discrete concern: struct construction, serialisation round-trips, audit
8+
//! logic correctness, and the hardening policy surface.
9+
10+
use cloudguard_cli::api::{
11+
audit_settings, hardening_policy, AuditFinding, CfDnsRecord, CfPlan, CfSetting, CfZone,
12+
};
13+
14+
// ---------------------------------------------------------------------------
15+
// Struct construction tests
16+
// ---------------------------------------------------------------------------
17+
18+
/// Verify that a CfZone can be constructed from field values and that the
19+
/// derived Debug/Clone implementations are available.
20+
#[test]
21+
fn cf_zone_construction_and_clone() {
22+
let zone = CfZone {
23+
id: "abc123".to_string(),
24+
name: "example.com".to_string(),
25+
status: "active".to_string(),
26+
paused: false,
27+
plan: CfPlan {
28+
id: "free".to_string(),
29+
name: "Free".to_string(),
30+
},
31+
name_servers: vec!["ns1.cloudflare.com".to_string()],
32+
};
33+
34+
let cloned = zone.clone();
35+
assert_eq!(cloned.id, "abc123");
36+
assert_eq!(cloned.name, "example.com");
37+
assert!(!cloned.paused);
38+
assert_eq!(cloned.plan.name, "Free");
39+
assert_eq!(cloned.name_servers.len(), 1);
40+
41+
// Debug should not panic.
42+
let _ = format!("{:?}", zone);
43+
}
44+
45+
/// Verify that CfDnsRecord can be constructed with optional fields absent and
46+
/// that default TTL (1 = automatic) is what the type encodes in doc comments.
47+
#[test]
48+
fn cf_dns_record_construction_optional_fields() {
49+
let record = CfDnsRecord {
50+
id: "rec01".to_string(),
51+
record_type: "TXT".to_string(),
52+
name: "_dmarc.example.com".to_string(),
53+
content: "v=DMARC1; p=reject".to_string(),
54+
ttl: 1,
55+
proxied: None,
56+
priority: None,
57+
comment: None,
58+
};
59+
60+
assert_eq!(record.record_type, "TXT");
61+
assert_eq!(record.ttl, 1, "TTL 1 means automatic in Cloudflare");
62+
assert!(record.proxied.is_none());
63+
assert!(record.priority.is_none());
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// Serialisation round-trip tests
68+
// ---------------------------------------------------------------------------
69+
70+
/// CfZone must round-trip through JSON without losing any field.
71+
#[test]
72+
fn cf_zone_serde_round_trip() {
73+
let zone = CfZone {
74+
id: "zone-id-xyz".to_string(),
75+
name: "roundtrip.example".to_string(),
76+
status: "active".to_string(),
77+
paused: true,
78+
plan: CfPlan {
79+
id: "pro".to_string(),
80+
name: "Pro".to_string(),
81+
},
82+
name_servers: vec!["ns1.cf.net".to_string(), "ns2.cf.net".to_string()],
83+
};
84+
85+
let json = serde_json::to_string(&zone).expect("serialisation must succeed");
86+
let restored: CfZone = serde_json::from_str(&json).expect("deserialisation must succeed");
87+
88+
assert_eq!(restored.id, zone.id);
89+
assert_eq!(restored.name, zone.name);
90+
assert_eq!(restored.paused, zone.paused);
91+
assert_eq!(restored.plan.id, zone.plan.id);
92+
assert_eq!(restored.name_servers.len(), 2);
93+
}
94+
95+
/// CfDnsRecord must round-trip through JSON, preserving optional fields when set.
96+
#[test]
97+
fn cf_dns_record_serde_round_trip_with_optionals() {
98+
let record = CfDnsRecord {
99+
id: "dns-rec-001".to_string(),
100+
record_type: "MX".to_string(),
101+
name: "example.com".to_string(),
102+
content: "10 mail.example.com".to_string(),
103+
ttl: 300,
104+
proxied: Some(false),
105+
priority: Some(10),
106+
comment: Some("primary MX".to_string()),
107+
};
108+
109+
let json = serde_json::to_string(&record).expect("serialisation must succeed");
110+
let restored: CfDnsRecord = serde_json::from_str(&json).expect("deserialisation must succeed");
111+
112+
assert_eq!(restored.priority, Some(10));
113+
assert_eq!(restored.comment.as_deref(), Some("primary MX"));
114+
assert_eq!(restored.proxied, Some(false));
115+
}
116+
117+
// ---------------------------------------------------------------------------
118+
// Audit logic tests
119+
// ---------------------------------------------------------------------------
120+
121+
/// When every policy setting is correctly configured the audit must return
122+
/// zero failures and zero findings.
123+
#[test]
124+
fn audit_settings_all_pass_returns_zero_failures() {
125+
// Build a settings slice that satisfies every entry in the hardening policy.
126+
let settings: Vec<CfSetting> = hardening_policy()
127+
.iter()
128+
.map(|&(id, expected, _)| CfSetting {
129+
id: id.to_string(),
130+
value: serde_json::Value::String(expected.to_string()),
131+
editable: true,
132+
modified_on: String::new(),
133+
})
134+
.collect();
135+
136+
let (passed, failed, findings) = audit_settings("example.com", &settings);
137+
138+
assert_eq!(failed, 0, "expected no failures when all settings match policy");
139+
assert!(findings.is_empty(), "expected no findings");
140+
assert_eq!(passed, hardening_policy().len());
141+
}
142+
143+
/// When every policy setting is wrong the audit must flag all as failures with
144+
/// appropriate severity and domain labels.
145+
#[test]
146+
fn audit_settings_all_fail_returns_full_finding_list() {
147+
let settings: Vec<CfSetting> = hardening_policy()
148+
.iter()
149+
.map(|&(id, _, _)| CfSetting {
150+
id: id.to_string(),
151+
// Use an obviously wrong value for every policy setting.
152+
value: serde_json::Value::String("__wrong__".to_string()),
153+
editable: true,
154+
modified_on: String::new(),
155+
})
156+
.collect();
157+
158+
let (passed, failed, findings) = audit_settings("bad.example", &settings);
159+
160+
assert_eq!(passed, 0, "expected zero passes when all settings are wrong");
161+
assert_eq!(failed, hardening_policy().len());
162+
assert_eq!(findings.len(), hardening_policy().len());
163+
164+
// Every finding must name the correct domain.
165+
for f in &findings {
166+
assert_eq!(f.domain, "bad.example");
167+
}
168+
}
169+
170+
/// A missing setting (not present in the slice) must be reported as a failure
171+
/// with actual value "<missing>".
172+
#[test]
173+
fn audit_settings_missing_entry_reported_as_missing() {
174+
// Pass an empty settings slice — nothing is configured.
175+
let (passed, failed, findings) = audit_settings("empty.example", &[]);
176+
177+
assert_eq!(passed, 0);
178+
assert_eq!(failed, hardening_policy().len());
179+
180+
for f in &findings {
181+
assert_eq!(
182+
f.actual, "<missing>",
183+
"missing settings must report actual as <missing>"
184+
);
185+
}
186+
}
187+
188+
/// A mixed slice (some pass, some fail) must produce the correct pass/fail split.
189+
#[test]
190+
fn audit_settings_partial_match_correct_counts() {
191+
let policy = hardening_policy();
192+
// Make the first half correct, the rest wrong.
193+
let half = policy.len() / 2;
194+
195+
let settings: Vec<CfSetting> = policy
196+
.iter()
197+
.enumerate()
198+
.map(|(i, &(id, expected, _))| CfSetting {
199+
id: id.to_string(),
200+
value: if i < half {
201+
serde_json::Value::String(expected.to_string())
202+
} else {
203+
serde_json::Value::String("off".to_string())
204+
},
205+
editable: true,
206+
modified_on: String::new(),
207+
})
208+
.collect();
209+
210+
let (passed, failed, findings) = audit_settings("partial.example", &settings);
211+
212+
assert_eq!(passed, half);
213+
assert_eq!(failed, policy.len() - half);
214+
assert_eq!(findings.len(), policy.len() - half);
215+
}
216+
217+
// ---------------------------------------------------------------------------
218+
// Hardening policy surface tests
219+
// ---------------------------------------------------------------------------
220+
221+
/// The hardening policy must be non-empty and each entry must have a non-empty
222+
/// setting ID, expected value, and severity label.
223+
#[test]
224+
fn hardening_policy_entries_are_well_formed() {
225+
let policy = hardening_policy();
226+
227+
assert!(
228+
!policy.is_empty(),
229+
"hardening policy must define at least one rule"
230+
);
231+
232+
for &(id, expected, severity) in policy {
233+
assert!(!id.is_empty(), "policy setting id must not be empty");
234+
assert!(!expected.is_empty(), "policy expected value must not be empty");
235+
assert!(
236+
matches!(severity, "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"),
237+
"severity must be one of CRITICAL/HIGH/MEDIUM/LOW, got: {}",
238+
severity
239+
);
240+
}
241+
}
242+
243+
/// The policy must include the minimum required security settings that are
244+
/// considered CRITICAL for Cloudflare zone hardening.
245+
#[test]
246+
fn hardening_policy_includes_critical_tls_settings() {
247+
let policy = hardening_policy();
248+
249+
let has_ssl = policy.iter().any(|&(id, _, sev)| id == "ssl" && sev == "CRITICAL");
250+
let has_always_https = policy
251+
.iter()
252+
.any(|&(id, _, sev)| id == "always_use_https" && sev == "CRITICAL");
253+
254+
assert!(
255+
has_ssl,
256+
"policy must include a CRITICAL ssl setting"
257+
);
258+
assert!(
259+
has_always_https,
260+
"policy must include a CRITICAL always_use_https setting"
261+
);
262+
}
263+
264+
// ---------------------------------------------------------------------------
265+
// AuditFinding field access test
266+
// ---------------------------------------------------------------------------
267+
268+
/// AuditFinding must be constructable and its public fields accessible.
269+
/// The struct is used in audit reports so its Debug/Serialize impls must work.
270+
#[test]
271+
fn audit_finding_fields_accessible_and_serialisable() {
272+
let finding = AuditFinding {
273+
domain: "test.example".to_string(),
274+
setting_id: "ssl".to_string(),
275+
severity: "CRITICAL".to_string(),
276+
expected: "full_strict".to_string(),
277+
actual: "flexible".to_string(),
278+
};
279+
280+
assert_eq!(finding.domain, "test.example");
281+
assert_eq!(finding.severity, "CRITICAL");
282+
assert_ne!(finding.expected, finding.actual);
283+
284+
// Must serialise to JSON without error.
285+
let json = serde_json::to_string(&finding).expect("AuditFinding must be serialisable");
286+
assert!(json.contains("full_strict"));
287+
assert!(json.contains("flexible"));
288+
}

0 commit comments

Comments
 (0)