Skip to content

Commit efb003c

Browse files
porco-rsclaude
andcommitted
test: add security integration tests as regression guards
9 integration tests proving that security fixes (pre_validate, exit codes, to_bytes Result) are wired into the actual pipelines. Prevents silent regression if someone removes a pre_validate() call or changes an exit code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06977d0 commit efb003c

2 files changed

Lines changed: 402 additions & 0 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
//! # Security Integration Tests — Regression Guards
2+
//!
3+
//! These tests prove that security fixes (pre_validate, exit codes, etc.)
4+
//! are actually wired into the compilation and CLI pipelines.
5+
//!
6+
//! If someone removes a `pre_validate()` call or changes an exit code,
7+
//! these tests will fail immediately.
8+
//!
9+
//! ```text
10+
//! GROUP 1: compile_dynamic() + pre_validate pipeline
11+
//! GROUP 2: compile_dynamic_from_values() + pre_validate_value pipeline
12+
//! GROUP 3: CLI exit codes (validate, inspect, compile)
13+
//! GROUP 4: GrmHeader::to_bytes() returns Result (compile-time guard)
14+
//! ```
15+
16+
// ============================================================================
17+
// GROUP 1: compile_dynamic() rejects oversized input (pipeline integration)
18+
// ============================================================================
19+
20+
/// Proves that `pre_validate()` is wired into `compile_dynamic()`.
21+
///
22+
/// If someone removes the size check from `compile_dynamic()`,
23+
/// this test will fail.
24+
#[test]
25+
fn compile_dynamic_rejects_oversized_input() {
26+
use germanic::dynamic::compile_dynamic;
27+
use std::io::Write;
28+
use tempfile::NamedTempFile;
29+
30+
// Minimal schema — accepts arbitrary string fields
31+
let schema_json = r#"{
32+
"schema_id": "test.oversized.v1",
33+
"version": 1,
34+
"fields": {
35+
"name": { "type": "string", "required": false }
36+
}
37+
}"#;
38+
let mut schema_file = NamedTempFile::with_suffix(".schema.json").unwrap();
39+
schema_file.write_all(schema_json.as_bytes()).unwrap();
40+
41+
// Data > 5 MB: many fields with 1000-char values
42+
let mut data = String::from("{");
43+
for i in 0..6000 {
44+
if i > 0 {
45+
data.push(',');
46+
}
47+
data.push_str(&format!(r#""f{}":"{}""#, i, "x".repeat(1000)));
48+
}
49+
data.push('}');
50+
assert!(data.len() > 5_242_880, "Test data must be > 5 MB");
51+
52+
let mut data_file = NamedTempFile::with_suffix(".json").unwrap();
53+
data_file.write_all(data.as_bytes()).unwrap();
54+
55+
let result = compile_dynamic(schema_file.path(), data_file.path());
56+
assert!(result.is_err(), "Oversized input must be rejected");
57+
58+
let err_msg = format!("{}", result.unwrap_err());
59+
assert!(
60+
err_msg.contains("input size") || err_msg.contains("exceeds maximum"),
61+
"Error must mention size limit, was: {}",
62+
err_msg
63+
);
64+
}
65+
66+
/// Boundary test: input at exactly MAX_INPUT_SIZE must NOT trigger size rejection.
67+
///
68+
/// Uses many small fields (each well under MAX_STRING_LENGTH) to reach
69+
/// the boundary without triggering per-string limits.
70+
#[test]
71+
fn compile_dynamic_boundary_at_limit() {
72+
use germanic::dynamic::compile_dynamic;
73+
use germanic::pre_validate::MAX_INPUT_SIZE;
74+
use std::io::Write;
75+
use tempfile::NamedTempFile;
76+
77+
let schema_json = r#"{
78+
"schema_id": "test.boundary.v1",
79+
"version": 1,
80+
"fields": {
81+
"data": { "type": "string", "required": false }
82+
}
83+
}"#;
84+
let mut schema_file = NamedTempFile::with_suffix(".schema.json").unwrap();
85+
schema_file.write_all(schema_json.as_bytes()).unwrap();
86+
87+
// Build JSON with many small fields, staying just under MAX_INPUT_SIZE.
88+
// Each value is 500 bytes — well under MAX_STRING_LENGTH (1 MB).
89+
let value = "a".repeat(500);
90+
let mut data = String::from("{");
91+
let mut i = 0;
92+
loop {
93+
let field = format!(r#""f{}":"{}""#, i, value);
94+
// +2 for potential comma and closing brace
95+
if data.len() + field.len() + 2 > MAX_INPUT_SIZE {
96+
break;
97+
}
98+
if i > 0 {
99+
data.push(',');
100+
}
101+
data.push_str(&field);
102+
i += 1;
103+
}
104+
data.push('}');
105+
assert!(
106+
data.len() <= MAX_INPUT_SIZE,
107+
"Test data must be <= {} bytes, was {}",
108+
MAX_INPUT_SIZE,
109+
data.len()
110+
);
111+
// Sanity: we should be reasonably close to the limit
112+
assert!(
113+
data.len() > MAX_INPUT_SIZE - 1000,
114+
"Test data should be close to the limit, was {} (limit {})",
115+
data.len(),
116+
MAX_INPUT_SIZE
117+
);
118+
119+
let mut data_file = NamedTempFile::with_suffix(".json").unwrap();
120+
data_file.write_all(data.as_bytes()).unwrap();
121+
122+
let result = compile_dynamic(schema_file.path(), data_file.path());
123+
124+
// The result may fail due to schema validation (extra fields) — that's fine.
125+
// We only assert it does NOT fail due to input size.
126+
if let Err(ref e) = result {
127+
let msg = format!("{}", e);
128+
assert!(
129+
!msg.contains("input size") && !msg.contains("exceeds maximum"),
130+
"Input <= {} bytes must not be rejected for size, error was: {}",
131+
MAX_INPUT_SIZE,
132+
msg
133+
);
134+
}
135+
}
136+
137+
// ============================================================================
138+
// GROUP 2: compile_dynamic_from_values() rejects oversized values
139+
// ============================================================================
140+
141+
/// Proves that `pre_validate_value()` is wired into `compile_dynamic_from_values()`.
142+
///
143+
/// If someone removes the pre_validate_value() call, this test will fail.
144+
#[test]
145+
fn compile_from_values_rejects_oversized_string() {
146+
use germanic::dynamic::compile_dynamic_from_values;
147+
use germanic::dynamic::schema_def::SchemaDefinition;
148+
use germanic::pre_validate::MAX_STRING_LENGTH;
149+
150+
let schema_json = r#"{
151+
"schema_id": "test.string_limit.v1",
152+
"version": 1,
153+
"fields": {
154+
"name": { "type": "string", "required": false }
155+
}
156+
}"#;
157+
let schema: SchemaDefinition = serde_json::from_str(schema_json).unwrap();
158+
159+
let big_string = "x".repeat(MAX_STRING_LENGTH + 1);
160+
let data = serde_json::json!({ "name": big_string });
161+
162+
let result = compile_dynamic_from_values(&schema, &data);
163+
assert!(result.is_err(), "String > 1 MB must be rejected");
164+
165+
let err_msg = format!("{}", result.unwrap_err());
166+
assert!(
167+
err_msg.contains("string length"),
168+
"Error must mention string length, was: {}",
169+
err_msg
170+
);
171+
}
172+
173+
/// Proves that array size limits are enforced in the from_values pipeline.
174+
#[test]
175+
fn compile_from_values_rejects_oversized_array() {
176+
use germanic::dynamic::compile_dynamic_from_values;
177+
use germanic::dynamic::schema_def::SchemaDefinition;
178+
use germanic::pre_validate::MAX_ARRAY_ELEMENTS;
179+
180+
let schema_json = r#"{
181+
"schema_id": "test.array_limit.v1",
182+
"version": 1,
183+
"fields": {
184+
"items": { "type": "[string]", "required": false }
185+
}
186+
}"#;
187+
let schema: SchemaDefinition = serde_json::from_str(schema_json).unwrap();
188+
189+
let items: Vec<serde_json::Value> = (0..MAX_ARRAY_ELEMENTS + 1)
190+
.map(|i| serde_json::Value::String(format!("x{}", i)))
191+
.collect();
192+
let data = serde_json::json!({ "items": items });
193+
194+
let result = compile_dynamic_from_values(&schema, &data);
195+
assert!(
196+
result.is_err(),
197+
"Array > {} elements must be rejected",
198+
MAX_ARRAY_ELEMENTS
199+
);
200+
201+
let err_msg = format!("{}", result.unwrap_err());
202+
assert!(
203+
err_msg.contains("array has") || err_msg.contains("elements"),
204+
"Error must mention array size, was: {}",
205+
err_msg
206+
);
207+
}
208+
209+
// ============================================================================
210+
// GROUP 3: CLI exit codes
211+
// ============================================================================
212+
213+
/// `germanic validate` must exit 1 on corrupt .grm file.
214+
#[test]
215+
fn cli_validate_exit_1_on_invalid_grm() {
216+
use std::io::Write;
217+
use std::process::Command;
218+
use tempfile::NamedTempFile;
219+
220+
let mut corrupt = NamedTempFile::with_suffix(".grm").unwrap();
221+
corrupt.write_all(b"this is not a grm file").unwrap();
222+
223+
let output = Command::new(env!("CARGO_BIN_EXE_germanic"))
224+
.args(["validate", corrupt.path().to_str().unwrap()])
225+
.output()
226+
.expect("Binary must be callable");
227+
228+
assert!(
229+
!output.status.success(),
230+
"Exit code must be != 0 for invalid .grm, was: {}",
231+
output.status
232+
);
233+
}
234+
235+
/// `germanic validate` must exit 0 on a valid .grm file.
236+
#[test]
237+
fn cli_validate_exit_0_on_valid_grm() {
238+
use std::io::Write;
239+
use std::process::Command;
240+
use tempfile::NamedTempFile;
241+
242+
// Step 1: Create valid practice JSON
243+
let valid_json = r#"{
244+
"name": "Dr. Test",
245+
"bezeichnung": "Allgemeinmedizin",
246+
"adresse": {
247+
"strasse": "Teststrasse",
248+
"hausnummer": "1",
249+
"plz": "12345",
250+
"ort": "Teststadt",
251+
"land": "DE"
252+
}
253+
}"#;
254+
let mut input = NamedTempFile::with_suffix(".json").unwrap();
255+
input.write_all(valid_json.as_bytes()).unwrap();
256+
257+
let output_grm = NamedTempFile::with_suffix(".grm").unwrap();
258+
259+
// Step 2: Compile to .grm
260+
let compile = Command::new(env!("CARGO_BIN_EXE_germanic"))
261+
.args([
262+
"compile",
263+
"--schema",
264+
"practice",
265+
"--input",
266+
input.path().to_str().unwrap(),
267+
"--output",
268+
output_grm.path().to_str().unwrap(),
269+
])
270+
.output()
271+
.expect("Compile must work");
272+
assert!(
273+
compile.status.success(),
274+
"Compile must succeed, stderr: {}",
275+
String::from_utf8_lossy(&compile.stderr)
276+
);
277+
278+
// Step 3: Validate the .grm
279+
let validate = Command::new(env!("CARGO_BIN_EXE_germanic"))
280+
.args(["validate", output_grm.path().to_str().unwrap()])
281+
.output()
282+
.expect("Validate must be callable");
283+
284+
assert!(
285+
validate.status.success(),
286+
"Exit code must be 0 for valid .grm, was: {}.\nStderr: {}",
287+
validate.status,
288+
String::from_utf8_lossy(&validate.stderr)
289+
);
290+
}
291+
292+
/// `germanic inspect` must exit 1 on corrupt .grm file.
293+
#[test]
294+
fn cli_inspect_exit_1_on_invalid_grm() {
295+
use std::io::Write;
296+
use std::process::Command;
297+
use tempfile::NamedTempFile;
298+
299+
let mut corrupt = NamedTempFile::with_suffix(".grm").unwrap();
300+
corrupt.write_all(b"corrupt").unwrap();
301+
302+
let output = Command::new(env!("CARGO_BIN_EXE_germanic"))
303+
.args(["inspect", corrupt.path().to_str().unwrap()])
304+
.output()
305+
.expect("Binary must be callable");
306+
307+
assert!(
308+
!output.status.success(),
309+
"Exit code must be != 0 for corrupt .grm, was: {}",
310+
output.status
311+
);
312+
}
313+
314+
/// `germanic compile` must reject oversized input with exit 1.
315+
#[test]
316+
fn cli_compile_rejects_oversized_input() {
317+
use std::io::Write;
318+
use std::process::Command;
319+
use tempfile::NamedTempFile;
320+
321+
// Create JSON > 5 MB
322+
let mut data = String::from("{");
323+
for i in 0..6000 {
324+
if i > 0 {
325+
data.push(',');
326+
}
327+
data.push_str(&format!(r#""f{}":"{}""#, i, "x".repeat(1000)));
328+
}
329+
data.push('}');
330+
assert!(data.len() > 5_242_880);
331+
332+
let mut input = NamedTempFile::with_suffix(".json").unwrap();
333+
input.write_all(data.as_bytes()).unwrap();
334+
335+
let output = Command::new(env!("CARGO_BIN_EXE_germanic"))
336+
.args([
337+
"compile",
338+
"--schema",
339+
"practice",
340+
"--input",
341+
input.path().to_str().unwrap(),
342+
])
343+
.output()
344+
.expect("Binary must be callable");
345+
346+
assert!(
347+
!output.status.success(),
348+
"Oversized input must produce exit != 0"
349+
);
350+
351+
let stderr = String::from_utf8_lossy(&output.stderr);
352+
let stdout = String::from_utf8_lossy(&output.stdout);
353+
let all_output = format!("{}{}", stdout, stderr);
354+
assert!(
355+
all_output.contains("input size") || all_output.contains("exceeds maximum"),
356+
"Output must mention size limit, was:\nstdout: {}\nstderr: {}",
357+
stdout,
358+
stderr
359+
);
360+
}
361+
362+
// ============================================================================
363+
// GROUP 4: GrmHeader::to_bytes() returns Result (compile-time guard)
364+
// ============================================================================
365+
366+
/// Compile-time regression guard: if someone changes `to_bytes()` back to
367+
/// returning `Vec<u8>` instead of `Result<Vec<u8>, _>`, this test won't compile.
368+
#[test]
369+
fn header_to_bytes_returns_result() {
370+
use germanic::types::GrmHeader;
371+
372+
let header = GrmHeader::new("test.v1");
373+
// This only compiles if to_bytes() returns Result<Vec<u8>, _>.
374+
let bytes: Result<Vec<u8>, _> = header.to_bytes();
375+
assert!(bytes.is_ok());
376+
}

0 commit comments

Comments
 (0)