Skip to main content

reinhardt_admin/types/
requests.rs

1//! Request types for admin panel API
2
3use serde::{Deserialize, Deserializer, Serialize};
4use std::collections::HashMap;
5
6/// Maximum number of filter parameters allowed in a single request.
7///
8/// Prevents abuse through excessive filter parameters which could lead to
9/// complex database queries or resource exhaustion.
10const MAX_FILTER_COUNT: usize = 20;
11
12/// Maximum length for a single filter key or value (in bytes).
13///
14/// Prevents excessively long filter strings from reaching the database layer.
15const MAX_FILTER_STRING_LENGTH: usize = 500;
16
17/// Query parameters for list endpoint.
18///
19/// Filter parameters are explicitly provided via the `filters` field rather than
20/// captured via `serde(flatten)`, preventing unrecognized query parameters from
21/// silently becoming database filters.
22#[derive(Debug, Serialize, Deserialize, Default)]
23pub struct ListQueryParams {
24	/// Page number (1-indexed)
25	pub page: Option<u64>,
26	/// Items per page
27	pub page_size: Option<u64>,
28	/// Search query
29	pub search: Option<String>,
30	/// Sort field (prefix with "-" for descending, e.g., "created_at" or "-created_at")
31	pub sort_by: Option<String>,
32	/// Filter field=value pairs.
33	///
34	/// Only explicitly provided filter parameters are accepted.
35	/// Each filter key and value is validated for length constraints.
36	#[serde(default, deserialize_with = "deserialize_validated_filters")]
37	pub filters: HashMap<String, String>,
38}
39
40/// Deserializes and validates filter parameters.
41///
42/// Enforces:
43/// - Maximum number of filters (`MAX_FILTER_COUNT`)
44/// - Maximum length for filter keys and values (`MAX_FILTER_STRING_LENGTH`)
45/// - Filter keys must be non-empty and contain only alphanumeric characters, underscores, or hyphens
46fn deserialize_validated_filters<'de, D>(
47	deserializer: D,
48) -> Result<HashMap<String, String>, D::Error>
49where
50	D: Deserializer<'de>,
51{
52	let filters: HashMap<String, String> = HashMap::deserialize(deserializer)?;
53
54	if filters.len() > MAX_FILTER_COUNT {
55		return Err(serde::de::Error::custom(format!(
56			"too many filter parameters: {} (max {})",
57			filters.len(),
58			MAX_FILTER_COUNT
59		)));
60	}
61
62	for (key, value) in &filters {
63		if key.is_empty() {
64			return Err(serde::de::Error::custom("filter key must not be empty"));
65		}
66
67		if key.len() > MAX_FILTER_STRING_LENGTH {
68			return Err(serde::de::Error::custom(format!(
69				"filter key '{}...' exceeds maximum length of {} bytes",
70				&key[..32.min(key.len())],
71				MAX_FILTER_STRING_LENGTH
72			)));
73		}
74
75		if value.len() > MAX_FILTER_STRING_LENGTH {
76			return Err(serde::de::Error::custom(format!(
77				"filter value for '{}' exceeds maximum length of {} bytes",
78				key, MAX_FILTER_STRING_LENGTH
79			)));
80		}
81
82		// Validate filter key format: only alphanumeric, underscores, hyphens, and dots
83		if !key
84			.chars()
85			.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
86		{
87			return Err(serde::de::Error::custom(format!(
88				"filter key '{}' contains invalid characters (allowed: alphanumeric, '_', '-', '.')",
89				key
90			)));
91		}
92	}
93
94	Ok(filters)
95}
96
97/// Request body for create/update
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MutationRequest {
100	/// CSRF token for mutation verification (double-submit cookie pattern).
101	///
102	/// The client must send the CSRF token received from the dashboard response
103	/// in this field. The server validates this value against the `csrftoken`
104	/// cookie set by the dashboard endpoint. An attacker on a different origin
105	/// cannot read the cookie, preventing CSRF attacks.
106	pub csrf_token: String,
107	/// Data to create/update
108	#[serde(flatten)]
109	pub data: HashMap<String, serde_json::Value>,
110}
111
112/// Request body for bulk delete
113#[derive(Debug, Serialize, Deserialize)]
114pub struct BulkDeleteRequest {
115	/// CSRF token for mutation verification (double-submit cookie pattern).
116	///
117	/// The client must send the CSRF token received from the dashboard response
118	/// in this field. The server validates this value against the `csrftoken`
119	/// cookie set by the dashboard endpoint. An attacker on a different origin
120	/// cannot read the cookie, preventing CSRF attacks.
121	pub csrf_token: String,
122	/// IDs to delete
123	pub ids: Vec<String>,
124}
125
126/// Export format
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
128#[serde(rename_all = "lowercase")]
129#[derive(Default)]
130pub enum ExportFormat {
131	/// JSON format (default).
132	#[default]
133	Json,
134	/// Comma-separated values format.
135	Csv,
136	/// Tab-separated values format.
137	Tsv,
138}
139
140#[cfg(all(test, server))]
141mod tests {
142	use super::*;
143	use rstest::rstest;
144	use serde_json;
145
146	// Helper to deserialize ListQueryParams from JSON
147	fn parse_list_query(json: &str) -> Result<ListQueryParams, serde_json::Error> {
148		serde_json::from_str(json)
149	}
150
151	// ==================== Filter count validation ====================
152
153	#[rstest]
154	fn test_filters_within_limit_accepted() {
155		// Arrange: 5 filters (well within limit of 20)
156		let json = r#"{"filters": {"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}}"#;
157
158		// Act
159		let result = parse_list_query(json);
160
161		// Assert
162		assert!(result.is_ok());
163		assert_eq!(result.unwrap().filters.len(), 5);
164	}
165
166	#[rstest]
167	fn test_filters_at_exact_limit_accepted() {
168		// Arrange: Exactly 20 filters
169		let mut filters = serde_json::Map::new();
170		for i in 0..20 {
171			filters.insert(
172				format!("field_{}", i),
173				serde_json::Value::String(format!("value_{}", i)),
174			);
175		}
176		let json = serde_json::json!({"filters": filters}).to_string();
177
178		// Act
179		let result = parse_list_query(&json);
180
181		// Assert
182		assert!(result.is_ok());
183		assert_eq!(result.unwrap().filters.len(), 20);
184	}
185
186	#[rstest]
187	fn test_filters_exceeding_max_count_rejected() {
188		// Arrange: 21 filters (exceeds limit of 20)
189		let mut filters = serde_json::Map::new();
190		for i in 0..21 {
191			filters.insert(
192				format!("field_{}", i),
193				serde_json::Value::String(format!("value_{}", i)),
194			);
195		}
196		let json = serde_json::json!({"filters": filters}).to_string();
197
198		// Act
199		let result = parse_list_query(&json);
200
201		// Assert
202		assert!(result.is_err());
203		let err = result.unwrap_err().to_string();
204		assert!(
205			err.contains("too many filter parameters"),
206			"Error should mention filter count limit: {}",
207			err
208		);
209	}
210
211	// ==================== Filter key/value length validation ====================
212
213	#[rstest]
214	fn test_filter_key_exceeding_max_length_rejected() {
215		// Arrange: Key of 501 bytes
216		let long_key = "a".repeat(501);
217		let json = serde_json::json!({"filters": {long_key: "value"}}).to_string();
218
219		// Act
220		let result = parse_list_query(&json);
221
222		// Assert
223		assert!(result.is_err());
224		let err = result.unwrap_err().to_string();
225		assert!(
226			err.contains("exceeds maximum length"),
227			"Error should mention length limit: {}",
228			err
229		);
230	}
231
232	#[rstest]
233	fn test_filter_value_exceeding_max_length_rejected() {
234		// Arrange: Value of 501 bytes
235		let long_value = "v".repeat(501);
236		let json = serde_json::json!({"filters": {"field": long_value}}).to_string();
237
238		// Act
239		let result = parse_list_query(&json);
240
241		// Assert
242		assert!(result.is_err());
243		let err = result.unwrap_err().to_string();
244		assert!(
245			err.contains("exceeds maximum length"),
246			"Error should mention length limit: {}",
247			err
248		);
249	}
250
251	// ==================== Filter key format validation ====================
252
253	#[rstest]
254	fn test_empty_filter_key_rejected() {
255		// Arrange: Empty key
256		let json = r#"{"filters": {"": "value"}}"#;
257
258		// Act
259		let result = parse_list_query(json);
260
261		// Assert
262		assert!(result.is_err());
263		let err = result.unwrap_err().to_string();
264		assert!(
265			err.contains("must not be empty"),
266			"Error should mention empty key: {}",
267			err
268		);
269	}
270
271	#[rstest]
272	#[case("field_name", true)] // underscore allowed
273	#[case("field-name", true)] // hyphen allowed
274	#[case("field.name", true)] // period allowed
275	#[case("fieldName123", true)] // alphanumeric allowed
276	fn test_filter_key_with_valid_chars_accepted(#[case] key: &str, #[case] _expected_valid: bool) {
277		// Arrange
278		let json = serde_json::json!({"filters": {key: "value"}}).to_string();
279
280		// Act
281		let result = parse_list_query(&json);
282
283		// Assert
284		assert!(result.is_ok(), "Key '{}' should be accepted", key);
285	}
286
287	#[rstest]
288	fn test_filter_key_with_invalid_chars_rejected() {
289		// Arrange: Key with semicolon (potential SQL injection vector)
290		let json = r#"{"filters": {"field;DROP TABLE users": "value"}}"#;
291
292		// Act
293		let result = parse_list_query(json);
294
295		// Assert
296		assert!(result.is_err());
297		let err = result.unwrap_err().to_string();
298		assert!(
299			err.contains("invalid character"),
300			"Error should mention invalid character: {}",
301			err
302		);
303	}
304
305	// ==================== Default behavior ====================
306
307	#[rstest]
308	fn test_empty_filters_accepted() {
309		// Arrange
310		let json = r#"{"filters": {}}"#;
311
312		// Act
313		let result = parse_list_query(json);
314
315		// Assert
316		assert!(result.is_ok());
317		assert!(result.unwrap().filters.is_empty());
318	}
319
320	#[rstest]
321	fn test_missing_filters_uses_default() {
322		// Arrange: No filters field at all
323		let json = r#"{}"#;
324
325		// Act
326		let result = parse_list_query(json);
327
328		// Assert
329		assert!(result.is_ok());
330		assert!(result.unwrap().filters.is_empty());
331	}
332
333	// ==================== Boundary value: filter count ====================
334
335	#[rstest]
336	#[case::zero_filters(0, true)]
337	#[case::nineteen_filters(19, true)]
338	#[case::twenty_filters(20, true)]
339	#[case::twentyone_filters(21, false)]
340	fn test_filter_count_boundary(#[case] count: usize, #[case] should_pass: bool) {
341		// Arrange
342		let mut filters = serde_json::Map::new();
343		for i in 0..count {
344			filters.insert(
345				format!("field_{}", i),
346				serde_json::Value::String(format!("value_{}", i)),
347			);
348		}
349		let json = serde_json::json!({"filters": filters}).to_string();
350
351		// Act
352		let result = parse_list_query(&json);
353
354		// Assert
355		assert_eq!(
356			result.is_ok(),
357			should_pass,
358			"count={}, expected pass={}, got {:?}",
359			count,
360			should_pass,
361			result
362		);
363	}
364
365	// ==================== Boundary value: filter key length ====================
366
367	#[rstest]
368	#[case::short_key(10, true)]
369	#[case::at_limit(500, true)]
370	#[case::above_limit(501, false)]
371	fn test_filter_key_length_boundary(#[case] length: usize, #[case] should_pass: bool) {
372		// Arrange: key composed of alphanumeric chars only
373		let key: String = "a".repeat(length);
374		let json = serde_json::json!({"filters": {key: "value"}}).to_string();
375
376		// Act
377		let result = parse_list_query(&json);
378
379		// Assert
380		assert_eq!(
381			result.is_ok(),
382			should_pass,
383			"key_length={}, expected pass={}, got {:?}",
384			length,
385			should_pass,
386			result
387		);
388	}
389
390	// ==================== Equivalence partitioning: filter key format ====================
391
392	#[rstest]
393	#[case::alphanumeric("status", true)]
394	#[case::with_underscore("created_at", true)]
395	#[case::with_hyphen("is-active", true)]
396	#[case::with_dot("user.name", true)]
397	#[case::with_semicolon("status;DROP", false)]
398	#[case::with_space("some field", false)]
399	#[case::with_quotes("field\"name", false)]
400	fn test_filter_key_format_equivalence(#[case] key: &str, #[case] should_pass: bool) {
401		// Arrange
402		let mut filters = HashMap::new();
403		filters.insert(key.to_string(), "value".to_string());
404		let json = serde_json::json!({"filters": filters}).to_string();
405
406		// Act
407		let result = parse_list_query(&json);
408
409		// Assert
410		assert_eq!(
411			result.is_ok(),
412			should_pass,
413			"key='{}', expected pass={}, got {:?}",
414			key,
415			should_pass,
416			result
417		);
418	}
419}