Skip to main content

reinhardt_admin/server/
security.rs

1//! Security utilities for admin panel
2//!
3//! This module provides security-related functionality including:
4//! - CSP and security response headers
5//! - CSRF token generation and validation
6//! - XSS prevention helpers for mutation data
7
8use std::collections::HashMap;
9
10/// Security headers for admin panel responses.
11///
12/// Provides a set of HTTP security headers recommended by OWASP for
13/// web application security hardening.
14///
15/// # Headers included
16///
17/// - `Content-Security-Policy`: Restricts resource loading origins
18/// - `X-Content-Type-Options`: Prevents MIME-type sniffing
19/// - `X-Frame-Options`: Prevents clickjacking
20/// - `X-XSS-Protection`: Legacy XSS protection (for older browsers)
21/// - `Referrer-Policy`: Controls referrer information
22/// - `Permissions-Policy`: Restricts browser features
23///
24/// # Examples
25///
26/// ```
27/// use reinhardt_admin::server::security::SecurityHeaders;
28///
29/// let headers = SecurityHeaders::default();
30/// let header_map = headers.to_header_map();
31/// assert!(header_map.contains_key("Content-Security-Policy"));
32/// assert!(header_map.contains_key("X-Content-Type-Options"));
33/// ```
34#[derive(Debug, Clone)]
35pub struct SecurityHeaders {
36	/// Content Security Policy directives
37	pub csp: ContentSecurityPolicy,
38	/// X-Frame-Options value
39	pub frame_options: FrameOptions,
40	/// Referrer-Policy value
41	pub referrer_policy: ReferrerPolicy,
42	/// Permissions-Policy header value
43	pub permissions_policy: String,
44}
45
46impl Default for SecurityHeaders {
47	fn default() -> Self {
48		Self {
49			csp: ContentSecurityPolicy::admin_default(),
50			frame_options: FrameOptions::Deny,
51			referrer_policy: ReferrerPolicy::StrictOriginWhenCrossOrigin,
52			permissions_policy: "camera=(), microphone=(), geolocation=(), payment=()".to_string(),
53		}
54	}
55}
56
57impl SecurityHeaders {
58	/// Convert security headers to a map of header name -> value pairs.
59	///
60	/// This can be used to apply headers to HTTP responses.
61	///
62	/// # Examples
63	///
64	/// ```
65	/// use reinhardt_admin::server::security::SecurityHeaders;
66	///
67	/// let headers = SecurityHeaders::default();
68	/// let map = headers.to_header_map();
69	/// assert_eq!(map.get("X-Content-Type-Options").unwrap(), "nosniff");
70	/// assert_eq!(map.get("X-Frame-Options").unwrap(), "DENY");
71	/// ```
72	pub fn to_header_map(&self) -> HashMap<&'static str, String> {
73		let mut headers = HashMap::new();
74
75		headers.insert("Content-Security-Policy", self.csp.to_header_value());
76		headers.insert("X-Content-Type-Options", "nosniff".to_string());
77		headers.insert("X-Frame-Options", self.frame_options.to_string());
78		headers.insert("X-XSS-Protection", "1; mode=block".to_string());
79		headers.insert("Referrer-Policy", self.referrer_policy.to_string());
80		headers.insert("Permissions-Policy", self.permissions_policy.clone());
81
82		headers
83	}
84}
85
86/// Content Security Policy configuration for admin panel.
87///
88/// Provides a structured way to build CSP headers with appropriate
89/// directives for the admin panel.
90#[derive(Debug, Clone)]
91pub struct ContentSecurityPolicy {
92	/// default-src directive
93	pub default_src: Vec<String>,
94	/// script-src directive
95	pub script_src: Vec<String>,
96	/// style-src directive
97	pub style_src: Vec<String>,
98	/// img-src directive
99	pub img_src: Vec<String>,
100	/// font-src directive
101	pub font_src: Vec<String>,
102	/// connect-src directive
103	pub connect_src: Vec<String>,
104	/// frame-ancestors directive
105	pub frame_ancestors: Vec<String>,
106	/// base-uri directive
107	pub base_uri: Vec<String>,
108	/// form-action directive
109	pub form_action: Vec<String>,
110}
111
112impl ContentSecurityPolicy {
113	/// Creates a default CSP suitable for admin panel usage.
114	///
115	/// Allows:
116	/// - Scripts from same origin with WASM evaluation
117	/// - Inline styles (required for admin UI components)
118	/// - Images from same origin and data URIs (for favicons)
119	/// - Connections to same origin only (for API calls)
120	/// - No framing allowed
121	pub fn admin_default() -> Self {
122		Self {
123			default_src: vec!["'self'".to_string()],
124			script_src: vec!["'self'".to_string(), "'wasm-unsafe-eval'".to_string()],
125			style_src: vec!["'self'".to_string(), "'unsafe-inline'".to_string()],
126			img_src: vec!["'self'".to_string(), "data:".to_string()],
127			font_src: vec!["'self'".to_string()],
128			connect_src: vec!["'self'".to_string()],
129			frame_ancestors: vec!["'none'".to_string()],
130			base_uri: vec!["'self'".to_string()],
131			form_action: vec!["'self'".to_string()],
132		}
133	}
134
135	/// Converts the CSP configuration to a header value string.
136	fn to_header_value(&self) -> String {
137		let mut directives = Vec::new();
138
139		if !self.default_src.is_empty() {
140			directives.push(format!("default-src {}", self.default_src.join(" ")));
141		}
142		if !self.script_src.is_empty() {
143			directives.push(format!("script-src {}", self.script_src.join(" ")));
144		}
145		if !self.style_src.is_empty() {
146			directives.push(format!("style-src {}", self.style_src.join(" ")));
147		}
148		if !self.img_src.is_empty() {
149			directives.push(format!("img-src {}", self.img_src.join(" ")));
150		}
151		if !self.font_src.is_empty() {
152			directives.push(format!("font-src {}", self.font_src.join(" ")));
153		}
154		if !self.connect_src.is_empty() {
155			directives.push(format!("connect-src {}", self.connect_src.join(" ")));
156		}
157		if !self.frame_ancestors.is_empty() {
158			directives.push(format!(
159				"frame-ancestors {}",
160				self.frame_ancestors.join(" ")
161			));
162		}
163		if !self.base_uri.is_empty() {
164			directives.push(format!("base-uri {}", self.base_uri.join(" ")));
165		}
166		if !self.form_action.is_empty() {
167			directives.push(format!("form-action {}", self.form_action.join(" ")));
168		}
169
170		directives.join("; ")
171	}
172}
173
174/// X-Frame-Options header values
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum FrameOptions {
177	/// Completely prevents framing
178	Deny,
179	/// Allows framing only from same origin
180	SameOrigin,
181}
182
183impl std::fmt::Display for FrameOptions {
184	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185		match self {
186			FrameOptions::Deny => write!(f, "DENY"),
187			FrameOptions::SameOrigin => write!(f, "SAMEORIGIN"),
188		}
189	}
190}
191
192/// Referrer-Policy header values
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ReferrerPolicy {
195	/// No referrer information sent
196	NoReferrer,
197	/// Only send origin on cross-origin, full URL on same-origin
198	StrictOriginWhenCrossOrigin,
199	/// Only send origin on same-origin, nothing on cross-origin
200	SameOrigin,
201}
202
203impl std::fmt::Display for ReferrerPolicy {
204	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205		match self {
206			ReferrerPolicy::NoReferrer => write!(f, "no-referrer"),
207			ReferrerPolicy::StrictOriginWhenCrossOrigin => {
208				write!(f, "strict-origin-when-cross-origin")
209			}
210			ReferrerPolicy::SameOrigin => write!(f, "same-origin"),
211		}
212	}
213}
214
215impl std::str::FromStr for FrameOptions {
216	type Err = std::convert::Infallible;
217
218	/// Parse from a string, falling back to `Deny` for unrecognized values.
219	fn from_str(s: &str) -> Result<Self, Self::Err> {
220		Ok(match s.to_lowercase().as_str() {
221			"deny" => Self::Deny,
222			"sameorigin" => Self::SameOrigin,
223			_ => Self::Deny,
224		})
225	}
226}
227
228impl std::str::FromStr for ReferrerPolicy {
229	type Err = std::convert::Infallible;
230
231	/// Parse from a string, falling back to `StrictOriginWhenCrossOrigin`
232	/// for unrecognized values.
233	fn from_str(s: &str) -> Result<Self, Self::Err> {
234		Ok(match s.to_lowercase().as_str() {
235			"no-referrer" => Self::NoReferrer,
236			"strict-origin-when-cross-origin" => Self::StrictOriginWhenCrossOrigin,
237			"same-origin" => Self::SameOrigin,
238			_ => Self::StrictOriginWhenCrossOrigin,
239		})
240	}
241}
242
243/// CSRF token length in bytes (before base64 encoding)
244const CSRF_TOKEN_BYTES: usize = 32;
245
246/// Generates a cryptographically secure CSRF token.
247///
248/// Uses the operating system's secure random number generator to produce
249/// a 256-bit (32-byte) random value, encoded as URL-safe base64.
250///
251/// # Examples
252///
253/// ```
254/// use reinhardt_admin::server::security::generate_csrf_token;
255///
256/// let token = generate_csrf_token();
257/// assert!(!token.is_empty());
258/// assert!(token.len() >= 32);
259///
260/// // Each call produces a unique token
261/// let token2 = generate_csrf_token();
262/// assert_ne!(token, token2);
263/// ```
264pub fn generate_csrf_token() -> String {
265	use base64::Engine;
266	let mut bytes = vec![0u8; CSRF_TOKEN_BYTES];
267	// Use getrandom for cryptographically secure randomness
268	getrandom::fill(&mut bytes).expect("Failed to generate random bytes for CSRF token");
269	base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
270}
271
272/// Validates a CSRF token against the expected value using constant-time comparison.
273///
274/// This prevents timing side-channel attacks that could be used to
275/// guess the CSRF token byte by byte.
276///
277/// # Arguments
278///
279/// * `provided` - The CSRF token provided in the request
280/// * `expected` - The expected CSRF token stored in the session
281///
282/// # Examples
283///
284/// ```
285/// use reinhardt_admin::server::security::{generate_csrf_token, validate_csrf_token};
286///
287/// let token = generate_csrf_token();
288/// assert!(validate_csrf_token(&token, &token));
289/// assert!(!validate_csrf_token("invalid", &token));
290/// assert!(!validate_csrf_token("", &token));
291/// ```
292pub fn validate_csrf_token(provided: &str, expected: &str) -> bool {
293	// Reject empty tokens immediately
294	if provided.is_empty() || expected.is_empty() {
295		return false;
296	}
297
298	// Constant-time comparison to prevent timing attacks
299	constant_time_eq(provided.as_bytes(), expected.as_bytes())
300}
301
302/// Constant-time comparison to prevent timing attacks.
303///
304/// Hashes both inputs with SHA-256 to produce fixed-length digests,
305/// then compares the digests in constant time using `subtle`. This
306/// prevents leaking the length of either input through timing.
307fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
308	use sha2::{Digest, Sha256};
309	use subtle::ConstantTimeEq;
310
311	let hash_a = Sha256::digest(a);
312	let hash_b = Sha256::digest(b);
313	hash_a.ct_eq(&hash_b).into()
314}
315
316/// The header name used for CSRF token submission.
317pub const CSRF_HEADER_NAME: &str = "x-csrf-token";
318
319/// Header spelling used by `reinhardt_pages::csrf::csrf_headers`.
320pub const PAGES_CSRF_HEADER_NAME: &str = "X-CSRFToken";
321
322/// The cookie name used for CSRF token storage (double-submit cookie pattern).
323pub const CSRF_COOKIE_NAME: &str = "csrftoken";
324
325/// Extracts the CSRF token from the `X-CSRF-Token` request header.
326///
327/// Returns `None` if the header is missing or not valid UTF-8.
328///
329/// # Arguments
330///
331/// * `headers` - The HTTP request headers
332#[cfg(server)]
333pub fn extract_csrf_header(headers: &hyper::HeaderMap) -> Option<String> {
334	headers
335		.get(CSRF_HEADER_NAME)
336		.or_else(|| headers.get(PAGES_CSRF_HEADER_NAME))
337		.and_then(|v| v.to_str().ok())
338		.map(|s| s.to_string())
339}
340
341/// Extracts the CSRF token from the `csrftoken` cookie.
342///
343/// Parses the `Cookie` header and returns the value of the `csrftoken`
344/// cookie if present.
345///
346/// # Arguments
347///
348/// * `headers` - The HTTP request headers
349#[cfg(server)]
350pub fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
351	headers
352		.get("cookie")
353		.and_then(|v| v.to_str().ok())
354		.and_then(|cookie_header| {
355			cookie_header.split(';').find_map(|pair| {
356				let pair = pair.trim();
357				let (name, value) = pair.split_once('=')?;
358				if name.trim() == CSRF_COOKIE_NAME {
359					Some(value.trim().to_string())
360				} else {
361					None
362				}
363			})
364		})
365}
366
367/// Builds a `Set-Cookie` header value for the CSRF token.
368///
369/// Sets the cookie with security attributes:
370/// - `SameSite=Strict`: prevents cross-site cookie sending
371/// - `Secure`: only sent over HTTPS (skipped for localhost)
372/// - `HttpOnly` is NOT set so client-side JS/WASM can read the cookie
373/// - `Path=/admin`: scoped to admin panel routes
374///
375/// # Arguments
376///
377/// * `token` - The CSRF token value
378/// * `is_secure` - Whether to add the `Secure` flag (false for localhost)
379pub fn build_csrf_cookie(token: &str, is_secure: bool) -> String {
380	let secure_flag = if is_secure { "; Secure" } else { "" };
381	format!(
382		"{}={}; SameSite=Strict; Path=/admin{}",
383		CSRF_COOKIE_NAME, token, secure_flag
384	)
385}
386
387/// Validates CSRF tokens using the double-submit cookie pattern.
388///
389/// Compares the token submitted in the request body (or `X-CSRF-Token` header)
390/// against the token stored in the `csrftoken` cookie. The cookie is set by the
391/// server and cannot be read or forged by a cross-origin attacker, making this
392/// pattern secure against CSRF attacks.
393///
394/// # Arguments
395///
396/// * `body_token` - The CSRF token from the request body
397/// * `headers` - The HTTP request headers (to extract the cookie token)
398///
399/// # Errors
400///
401/// Returns a `ServerFnError` with status 403 if:
402/// - The `csrftoken` cookie is missing
403/// - The body token is empty
404/// - The tokens do not match
405#[cfg(server)]
406pub fn require_csrf_token(
407	body_token: &str,
408	headers: &hyper::HeaderMap,
409) -> Result<(), reinhardt_pages::server_fn::ServerFnError> {
410	// Double-submit cookie pattern: compare body token with cookie token.
411	// Fallback: accept the X-CSRFToken header when cookies are unavailable
412	// (e.g. WASM reqwest client which does not send browser cookies).
413	let expected_token = extract_csrf_cookie(headers)
414		.or_else(|| extract_csrf_header(headers))
415		.ok_or_else(|| {
416			reinhardt_pages::server_fn::ServerFnError::server(
417				403,
418				"CSRF token missing from cookie and header",
419			)
420		})?;
421
422	if !validate_csrf_token(body_token, &expected_token) {
423		return Err(reinhardt_pages::server_fn::ServerFnError::server(
424			403,
425			"CSRF token validation failed",
426		));
427	}
428
429	Ok(())
430}
431
432/// Validates CSRF using the `X-CSRFToken` request header as the provided token.
433#[cfg(server)]
434pub fn require_csrf_header(
435	headers: &hyper::HeaderMap,
436) -> Result<(), reinhardt_pages::server_fn::ServerFnError> {
437	let header_token = extract_csrf_header(headers).ok_or_else(|| {
438		reinhardt_pages::server_fn::ServerFnError::server(403, "CSRF token missing from header")
439	})?;
440
441	let expected_token = extract_csrf_cookie(headers).ok_or_else(|| {
442		reinhardt_pages::server_fn::ServerFnError::server(403, "CSRF token missing from cookie")
443	})?;
444	if !validate_csrf_token(&header_token, &expected_token) {
445		return Err(reinhardt_pages::server_fn::ServerFnError::server(
446			403,
447			"CSRF token validation failed",
448		));
449	}
450
451	Ok(())
452}
453
454/// Sanitizes mutation data values to prevent stored XSS.
455///
456/// Checks all string values in the mutation data for dangerous HTML/JavaScript
457/// patterns and escapes them before storage. This provides defense-in-depth
458/// on top of the output escaping done by the rendering framework.
459///
460/// # Arguments
461///
462/// * `data` - Mutable reference to the mutation data to sanitize
463///
464/// # Examples
465///
466/// ```
467/// use reinhardt_admin::server::security::sanitize_mutation_values;
468/// use std::collections::HashMap;
469///
470/// let mut data = HashMap::new();
471/// data.insert("name".to_string(), serde_json::json!("<script>alert('xss')</script>"));
472/// data.insert("age".to_string(), serde_json::json!(25));
473///
474/// sanitize_mutation_values(&mut data);
475///
476/// // String values are escaped
477/// assert_eq!(
478///     data.get("name").unwrap().as_str().unwrap(),
479///     "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
480/// );
481/// // Non-string values are unchanged
482/// assert_eq!(data.get("age").unwrap().as_i64().unwrap(), 25);
483/// ```
484#[cfg(server)]
485pub fn sanitize_mutation_values(data: &mut HashMap<String, serde_json::Value>) {
486	for value in data.values_mut() {
487		sanitize_json_value(value);
488	}
489}
490
491/// Recursively sanitizes a JSON value, escaping HTML in strings.
492#[cfg(server)]
493fn sanitize_json_value(value: &mut serde_json::Value) {
494	match value {
495		serde_json::Value::String(s) => {
496			if needs_html_escaping(s) {
497				*s = escape_html(s);
498			}
499		}
500		serde_json::Value::Array(arr) => {
501			for item in arr.iter_mut() {
502				sanitize_json_value(item);
503			}
504		}
505		serde_json::Value::Object(obj) => {
506			for val in obj.values_mut() {
507				sanitize_json_value(val);
508			}
509		}
510		// Numbers, booleans, null are safe
511		_ => {}
512	}
513}
514
515/// Checks if a string contains characters that need HTML escaping.
516#[cfg(server)]
517fn needs_html_escaping(s: &str) -> bool {
518	s.contains('<') || s.contains('>') || s.contains('&') || s.contains('"') || s.contains('\'')
519}
520
521/// Escapes HTML special characters in a string.
522#[cfg(server)]
523fn escape_html(input: &str) -> String {
524	reinhardt_core::security::escape_html(input)
525}
526
527// --- Admin authentication cookie ---
528
529/// The cookie name used for admin JWT authentication.
530///
531/// This cookie stores the JWT token as an HTTP-Only, `SameSite=Strict` cookie,
532/// preventing JavaScript access (XSS protection) and cross-origin sending
533/// (CSRF protection).
534pub const ADMIN_AUTH_COOKIE_NAME: &str = "reinhardt_admin_token";
535
536/// Builds a `Set-Cookie` header value for the admin authentication JWT.
537///
538/// Cookie attributes:
539/// - `HttpOnly`: not accessible via JavaScript (XSS protection)
540/// - `SameSite=Strict`: never sent on cross-origin requests (CSRF protection)
541/// - `Secure`: HTTPS-only (skipped for localhost development)
542/// - `Path=/admin`: scoped to admin panel routes only
543/// - `Max-Age=86400`: 24-hour expiry (aligned with JWT expiry)
544///
545/// # Arguments
546///
547/// * `token` - The JWT token string
548/// * `is_secure` - Whether to add the `Secure` flag (false for localhost)
549///
550/// # Examples
551///
552/// ```
553/// use reinhardt_admin::server::security::build_admin_auth_cookie;
554///
555/// let cookie = build_admin_auth_cookie("eyJhbGciOiJIUzI1NiJ9.test", true);
556/// assert!(cookie.contains("HttpOnly"));
557/// assert!(cookie.contains("SameSite=Strict"));
558/// assert!(cookie.contains("Secure"));
559/// assert!(cookie.contains("Path=/admin"));
560/// ```
561pub fn build_admin_auth_cookie(token: &str, is_secure: bool) -> String {
562	let secure_flag = if is_secure { "; Secure" } else { "" };
563	format!(
564		"{}={}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=86400{}",
565		ADMIN_AUTH_COOKIE_NAME, token, secure_flag
566	)
567}
568
569/// Builds a `Set-Cookie` header value that clears the admin authentication cookie.
570///
571/// Sets `Max-Age=0` to instruct the browser to delete the cookie immediately.
572///
573/// # Examples
574///
575/// ```
576/// use reinhardt_admin::server::security::build_admin_auth_cookie_clear;
577///
578/// let cookie = build_admin_auth_cookie_clear();
579/// assert!(cookie.contains("Max-Age=0"));
580/// ```
581pub fn build_admin_auth_cookie_clear() -> String {
582	format!(
583		"{}=; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=0",
584		ADMIN_AUTH_COOKIE_NAME
585	)
586}
587
588/// Extracts the admin JWT token from the `Cookie` header.
589///
590/// Parses the `Cookie` header and returns the value of the
591/// `reinhardt_admin_token` cookie if present.
592///
593/// # Arguments
594///
595/// * `headers` - The HTTP request headers
596#[cfg(not(target_arch = "wasm32"))]
597pub fn extract_admin_auth_cookie(headers: &hyper::HeaderMap) -> Option<String> {
598	headers
599		.get("cookie")
600		.and_then(|v| v.to_str().ok())
601		.and_then(|cookie_header| {
602			cookie_header.split(';').find_map(|pair| {
603				let pair = pair.trim();
604				let (name, value) = pair.split_once('=')?;
605				if name.trim() == ADMIN_AUTH_COOKIE_NAME {
606					Some(value.trim().to_string())
607				} else {
608					None
609				}
610			})
611		})
612}
613
614#[cfg(all(test, server))]
615mod tests {
616	use super::*;
617	use rstest::rstest;
618
619	// ============================================================
620	// SecurityHeaders tests
621	// ============================================================
622
623	#[rstest]
624	fn test_security_headers_default_contains_all_headers() {
625		// Arrange
626		let headers = SecurityHeaders::default();
627
628		// Act
629		let map = headers.to_header_map();
630
631		// Assert
632		assert!(map.contains_key("Content-Security-Policy"));
633		assert!(map.contains_key("X-Content-Type-Options"));
634		assert!(map.contains_key("X-Frame-Options"));
635		assert!(map.contains_key("X-XSS-Protection"));
636		assert!(map.contains_key("Referrer-Policy"));
637		assert!(map.contains_key("Permissions-Policy"));
638	}
639
640	#[rstest]
641	fn test_security_headers_x_content_type_options() {
642		// Arrange
643		let headers = SecurityHeaders::default();
644
645		// Act
646		let map = headers.to_header_map();
647
648		// Assert
649		assert_eq!(map.get("X-Content-Type-Options").unwrap(), "nosniff");
650	}
651
652	#[rstest]
653	fn test_security_headers_x_frame_options_deny() {
654		// Arrange
655		let headers = SecurityHeaders::default();
656
657		// Act
658		let map = headers.to_header_map();
659
660		// Assert
661		assert_eq!(map.get("X-Frame-Options").unwrap(), "DENY");
662	}
663
664	#[rstest]
665	fn test_security_headers_x_xss_protection() {
666		// Arrange
667		let headers = SecurityHeaders::default();
668
669		// Act
670		let map = headers.to_header_map();
671
672		// Assert
673		assert_eq!(map.get("X-XSS-Protection").unwrap(), "1; mode=block");
674	}
675
676	#[rstest]
677	fn test_security_headers_referrer_policy() {
678		// Arrange
679		let headers = SecurityHeaders::default();
680
681		// Act
682		let map = headers.to_header_map();
683
684		// Assert
685		assert_eq!(
686			map.get("Referrer-Policy").unwrap(),
687			"strict-origin-when-cross-origin"
688		);
689	}
690
691	#[rstest]
692	fn test_security_headers_permissions_policy() {
693		// Arrange
694		let headers = SecurityHeaders::default();
695
696		// Act
697		let map = headers.to_header_map();
698
699		// Assert
700		let pp = map.get("Permissions-Policy").unwrap();
701		assert!(pp.contains("camera=()"));
702		assert!(pp.contains("microphone=()"));
703		assert!(pp.contains("geolocation=()"));
704	}
705
706	// ============================================================
707	// CSP tests
708	// ============================================================
709
710	#[rstest]
711	fn test_csp_admin_default_contains_self() {
712		// Arrange
713		let csp = ContentSecurityPolicy::admin_default();
714
715		// Act
716		let csp_string = csp.to_header_value();
717
718		// Assert
719		assert!(csp_string.contains("default-src 'self'"));
720		assert!(csp_string.contains("script-src 'self'"));
721	}
722
723	#[rstest]
724	fn test_csp_admin_default_prevents_framing() {
725		// Arrange
726		let csp = ContentSecurityPolicy::admin_default();
727
728		// Act
729		let csp_string = csp.to_header_value();
730
731		// Assert
732		assert!(csp_string.contains("frame-ancestors 'none'"));
733	}
734
735	#[rstest]
736	fn test_csp_admin_default_allows_inline_styles() {
737		// Arrange
738		let csp = ContentSecurityPolicy::admin_default();
739
740		// Act
741		let csp_string = csp.to_header_value();
742
743		// Assert
744		assert!(csp_string.contains("style-src 'self' 'unsafe-inline'"));
745	}
746
747	#[rstest]
748	fn test_csp_admin_default_restricts_form_action() {
749		// Arrange
750		let csp = ContentSecurityPolicy::admin_default();
751
752		// Act
753		let csp_string = csp.to_header_value();
754
755		// Assert
756		assert!(csp_string.contains("form-action 'self'"));
757	}
758
759	#[rstest]
760	fn test_csp_admin_default_allows_wasm_eval() {
761		// Arrange
762		let csp = ContentSecurityPolicy::admin_default();
763
764		// Act
765		let csp_string = csp.to_header_value();
766
767		// Assert
768		assert!(
769			csp_string.contains("'wasm-unsafe-eval'"),
770			"CSP should allow WASM evaluation for admin SPA, got: {}",
771			csp_string
772		);
773	}
774
775	// ============================================================
776	// FrameOptions tests
777	// ============================================================
778
779	#[rstest]
780	fn test_frame_options_deny() {
781		// Assert
782		assert_eq!(FrameOptions::Deny.to_string(), "DENY");
783	}
784
785	#[rstest]
786	fn test_frame_options_same_origin() {
787		// Assert
788		assert_eq!(FrameOptions::SameOrigin.to_string(), "SAMEORIGIN");
789	}
790
791	// ============================================================
792	// ReferrerPolicy tests
793	// ============================================================
794
795	#[rstest]
796	fn test_referrer_policy_no_referrer() {
797		// Assert
798		assert_eq!(ReferrerPolicy::NoReferrer.to_string(), "no-referrer");
799	}
800
801	#[rstest]
802	fn test_referrer_policy_strict_origin() {
803		// Assert
804		assert_eq!(
805			ReferrerPolicy::StrictOriginWhenCrossOrigin.to_string(),
806			"strict-origin-when-cross-origin"
807		);
808	}
809
810	#[rstest]
811	fn test_referrer_policy_same_origin() {
812		// Assert
813		assert_eq!(ReferrerPolicy::SameOrigin.to_string(), "same-origin");
814	}
815
816	// ============================================================
817	// CSRF token tests
818	// ============================================================
819
820	#[rstest]
821	fn test_generate_csrf_token_is_non_empty() {
822		// Act
823		let token = generate_csrf_token();
824
825		// Assert
826		assert!(!token.is_empty());
827		assert!(token.len() >= 32);
828	}
829
830	#[rstest]
831	fn test_generate_csrf_token_is_unique() {
832		// Act
833		let token1 = generate_csrf_token();
834		let token2 = generate_csrf_token();
835
836		// Assert
837		assert_ne!(token1, token2);
838	}
839
840	#[rstest]
841	fn test_validate_csrf_token_with_matching_tokens() {
842		// Arrange
843		let token = generate_csrf_token();
844
845		// Act & Assert
846		assert!(validate_csrf_token(&token, &token));
847	}
848
849	#[rstest]
850	fn test_validate_csrf_token_with_mismatching_tokens() {
851		// Arrange
852		let token = generate_csrf_token();
853
854		// Act & Assert
855		assert!(!validate_csrf_token("invalid-token", &token));
856	}
857
858	#[rstest]
859	fn test_validate_csrf_token_rejects_empty_provided() {
860		// Arrange
861		let token = generate_csrf_token();
862
863		// Act & Assert
864		assert!(!validate_csrf_token("", &token));
865	}
866
867	#[rstest]
868	fn test_validate_csrf_token_rejects_empty_expected() {
869		// Act & Assert
870		assert!(!validate_csrf_token("some-token", ""));
871	}
872
873	#[rstest]
874	fn test_validate_csrf_token_rejects_both_empty() {
875		// Act & Assert
876		assert!(!validate_csrf_token("", ""));
877	}
878
879	// ============================================================
880	// Constant-time comparison tests
881	// ============================================================
882
883	#[rstest]
884	fn test_constant_time_eq_equal() {
885		// Assert
886		assert!(constant_time_eq(b"hello", b"hello"));
887	}
888
889	#[rstest]
890	fn test_constant_time_eq_different_content() {
891		// Assert
892		assert!(!constant_time_eq(b"hello", b"world"));
893	}
894
895	#[rstest]
896	fn test_constant_time_eq_different_length() {
897		// Assert
898		assert!(!constant_time_eq(b"hello", b"hi"));
899	}
900
901	#[rstest]
902	fn test_constant_time_eq_empty() {
903		// Assert
904		assert!(constant_time_eq(b"", b""));
905	}
906
907	// ============================================================
908	// Mutation data sanitization tests
909	// ============================================================
910
911	#[rstest]
912	fn test_sanitize_mutation_values_escapes_script_tags() {
913		// Arrange
914		let mut data = HashMap::new();
915		data.insert(
916			"name".to_string(),
917			serde_json::json!("<script>alert('xss')</script>"),
918		);
919
920		// Act
921		sanitize_mutation_values(&mut data);
922
923		// Assert
924		let name = data.get("name").unwrap().as_str().unwrap();
925		assert!(!name.contains("<script>"));
926		assert!(name.contains("&lt;script&gt;"));
927	}
928
929	#[rstest]
930	fn test_sanitize_mutation_values_preserves_non_string_values() {
931		// Arrange
932		let mut data = HashMap::new();
933		data.insert("age".to_string(), serde_json::json!(25));
934		data.insert("active".to_string(), serde_json::json!(true));
935		data.insert("tags".to_string(), serde_json::json!(null));
936
937		// Act
938		sanitize_mutation_values(&mut data);
939
940		// Assert
941		assert_eq!(data.get("age").unwrap().as_i64().unwrap(), 25);
942		assert_eq!(data.get("active").unwrap().as_bool().unwrap(), true);
943		assert!(data.get("tags").unwrap().is_null());
944	}
945
946	#[rstest]
947	fn test_sanitize_mutation_values_handles_nested_arrays() {
948		// Arrange
949		let mut data = HashMap::new();
950		data.insert(
951			"items".to_string(),
952			serde_json::json!(["<b>bold</b>", "safe text"]),
953		);
954
955		// Act
956		sanitize_mutation_values(&mut data);
957
958		// Assert
959		let items = data.get("items").unwrap().as_array().unwrap();
960		assert_eq!(items[0].as_str().unwrap(), "&lt;b&gt;bold&lt;/b&gt;");
961		assert_eq!(items[1].as_str().unwrap(), "safe text");
962	}
963
964	#[rstest]
965	fn test_sanitize_mutation_values_handles_nested_objects() {
966		// Arrange
967		let mut data = HashMap::new();
968		data.insert(
969			"metadata".to_string(),
970			serde_json::json!({"bio": "<img onerror=alert(1)>"}),
971		);
972
973		// Act
974		sanitize_mutation_values(&mut data);
975
976		// Assert
977		let meta = data.get("metadata").unwrap().as_object().unwrap();
978		let bio = meta.get("bio").unwrap().as_str().unwrap();
979		assert!(!bio.contains("<img"));
980		assert!(bio.contains("&lt;img"));
981	}
982
983	#[rstest]
984	fn test_sanitize_mutation_values_safe_strings_unchanged() {
985		// Arrange
986		let mut data = HashMap::new();
987		data.insert("name".to_string(), serde_json::json!("Alice Johnson"));
988		data.insert("email".to_string(), serde_json::json!("alice@example.com"));
989
990		// Act
991		sanitize_mutation_values(&mut data);
992
993		// Assert
994		assert_eq!(data.get("name").unwrap().as_str().unwrap(), "Alice Johnson");
995		assert_eq!(
996			data.get("email").unwrap().as_str().unwrap(),
997			"alice@example.com"
998		);
999	}
1000
1001	#[rstest]
1002	fn test_escape_html_special_characters() {
1003		// Assert
1004		assert_eq!(escape_html("<"), "&lt;");
1005		assert_eq!(escape_html(">"), "&gt;");
1006		assert_eq!(escape_html("&"), "&amp;");
1007		assert_eq!(escape_html("\""), "&quot;");
1008		assert_eq!(escape_html("'"), "&#x27;");
1009	}
1010
1011	#[rstest]
1012	fn test_needs_html_escaping_detects_dangerous_chars() {
1013		// Assert
1014		assert!(needs_html_escaping("<script>"));
1015		assert!(needs_html_escaping("a > b"));
1016		assert!(needs_html_escaping("a & b"));
1017		assert!(needs_html_escaping("a\"b"));
1018		assert!(needs_html_escaping("a'b"));
1019		assert!(!needs_html_escaping("safe text"));
1020		assert!(!needs_html_escaping("hello world 123"));
1021	}
1022
1023	// ============================================================
1024	// CSRF header extraction tests
1025	// ============================================================
1026
1027	#[rstest]
1028	fn test_extract_csrf_header_present() {
1029		// Arrange
1030		let mut headers = hyper::HeaderMap::new();
1031		headers.insert("x-csrf-token", "test-token".parse().unwrap());
1032
1033		// Act
1034		let result = extract_csrf_header(&headers);
1035
1036		// Assert
1037		assert_eq!(result, Some("test-token".to_string()));
1038	}
1039
1040	#[rstest]
1041	fn test_extract_csrf_header_missing() {
1042		// Arrange
1043		let headers = hyper::HeaderMap::new();
1044
1045		// Act
1046		let result = extract_csrf_header(&headers);
1047
1048		// Assert
1049		assert_eq!(result, None);
1050	}
1051
1052	// ============================================================
1053	// CSRF cookie extraction tests
1054	// ============================================================
1055
1056	#[rstest]
1057	fn test_extract_csrf_cookie_present() {
1058		// Arrange
1059		let mut headers = hyper::HeaderMap::new();
1060		headers.insert(
1061			"cookie",
1062			"session=abc; csrftoken=test-token-value; other=xyz"
1063				.parse()
1064				.unwrap(),
1065		);
1066
1067		// Act
1068		let result = extract_csrf_cookie(&headers);
1069
1070		// Assert
1071		assert_eq!(result, Some("test-token-value".to_string()));
1072	}
1073
1074	#[rstest]
1075	fn test_extract_csrf_cookie_missing() {
1076		// Arrange
1077		let mut headers = hyper::HeaderMap::new();
1078		headers.insert("cookie", "session=abc; other=xyz".parse().unwrap());
1079
1080		// Act
1081		let result = extract_csrf_cookie(&headers);
1082
1083		// Assert
1084		assert_eq!(result, None);
1085	}
1086
1087	#[rstest]
1088	fn test_extract_csrf_cookie_no_cookie_header() {
1089		// Arrange
1090		let headers = hyper::HeaderMap::new();
1091
1092		// Act
1093		let result = extract_csrf_cookie(&headers);
1094
1095		// Assert
1096		assert_eq!(result, None);
1097	}
1098
1099	#[rstest]
1100	fn test_extract_csrf_cookie_only_csrf() {
1101		// Arrange
1102		let mut headers = hyper::HeaderMap::new();
1103		headers.insert("cookie", "csrftoken=solo-value".parse().unwrap());
1104
1105		// Act
1106		let result = extract_csrf_cookie(&headers);
1107
1108		// Assert
1109		assert_eq!(result, Some("solo-value".to_string()));
1110	}
1111
1112	// ============================================================
1113	// build_csrf_cookie tests
1114	// ============================================================
1115
1116	#[rstest]
1117	fn test_build_csrf_cookie_secure() {
1118		// Act
1119		let cookie = build_csrf_cookie("token123", true);
1120
1121		// Assert
1122		assert_eq!(
1123			cookie,
1124			"csrftoken=token123; SameSite=Strict; Path=/admin; Secure"
1125		);
1126	}
1127
1128	#[rstest]
1129	fn test_build_csrf_cookie_insecure() {
1130		// Act
1131		let cookie = build_csrf_cookie("token123", false);
1132
1133		// Assert
1134		assert_eq!(cookie, "csrftoken=token123; SameSite=Strict; Path=/admin");
1135	}
1136
1137	// ============================================================
1138	// require_csrf_token (cookie-based) tests
1139	// ============================================================
1140
1141	#[rstest]
1142	fn test_require_csrf_token_matching_cookie() {
1143		// Arrange
1144		let token = generate_csrf_token();
1145		let mut headers = hyper::HeaderMap::new();
1146		let cookie_value = format!("csrftoken={}", token);
1147		headers.insert("cookie", cookie_value.parse().unwrap());
1148
1149		// Act & Assert
1150		// unwrap() verifies success; panics with the error if validation fails
1151		require_csrf_token(&token, &headers).unwrap();
1152	}
1153
1154	#[rstest]
1155	fn test_require_csrf_token_mismatching_cookie() {
1156		// Arrange
1157		let body_token = generate_csrf_token();
1158		let cookie_token = generate_csrf_token();
1159		let mut headers = hyper::HeaderMap::new();
1160		let cookie_value = format!("csrftoken={}", cookie_token);
1161		headers.insert("cookie", cookie_value.parse().unwrap());
1162
1163		// Act
1164		let result = require_csrf_token(&body_token, &headers);
1165
1166		// Assert
1167		let err = result.unwrap_err();
1168		match err {
1169			reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1170				assert_eq!(status, 403);
1171				assert_eq!(message, "CSRF token validation failed");
1172			}
1173			other => panic!("Expected Server error with status 403, got: {:?}", other),
1174		}
1175	}
1176
1177	#[rstest]
1178	fn test_require_csrf_token_missing_cookie() {
1179		// Arrange
1180		let body_token = generate_csrf_token();
1181		let headers = hyper::HeaderMap::new();
1182
1183		// Act
1184		let result = require_csrf_token(&body_token, &headers);
1185
1186		// Assert
1187		let err = result.unwrap_err();
1188		match err {
1189			reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1190				assert_eq!(status, 403);
1191				assert_eq!(message, "CSRF token missing from cookie and header");
1192			}
1193			other => panic!("Expected Server error with status 403, got: {:?}", other),
1194		}
1195	}
1196
1197	#[rstest]
1198	fn test_require_csrf_token_empty_body_token() {
1199		// Arrange
1200		let cookie_token = generate_csrf_token();
1201		let mut headers = hyper::HeaderMap::new();
1202		let cookie_value = format!("csrftoken={}", cookie_token);
1203		headers.insert("cookie", cookie_value.parse().unwrap());
1204
1205		// Act
1206		let result = require_csrf_token("", &headers);
1207
1208		// Assert
1209		let err = result.unwrap_err();
1210		match err {
1211			reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1212				assert_eq!(status, 403);
1213				assert_eq!(message, "CSRF token validation failed");
1214			}
1215			other => panic!("Expected Server error with status 403, got: {:?}", other),
1216		}
1217	}
1218
1219	#[rstest]
1220	fn test_require_csrf_header_matching_cookie() {
1221		// Arrange
1222		let token = generate_csrf_token();
1223		let mut headers = hyper::HeaderMap::new();
1224		let cookie_value = format!("csrftoken={}", token);
1225		headers.insert("cookie", cookie_value.parse().unwrap());
1226		headers.insert("X-CSRFToken", token.parse().unwrap());
1227
1228		// Act & Assert
1229		require_csrf_header(&headers).unwrap();
1230	}
1231
1232	#[rstest]
1233	fn test_require_csrf_header_rejects_mismatching_cookie() {
1234		// Arrange
1235		let header_token = generate_csrf_token();
1236		let cookie_token = generate_csrf_token();
1237		let mut headers = hyper::HeaderMap::new();
1238		let cookie_value = format!("csrftoken={}", cookie_token);
1239		headers.insert("cookie", cookie_value.parse().unwrap());
1240		headers.insert("X-CSRFToken", header_token.parse().unwrap());
1241
1242		// Act
1243		let result = require_csrf_header(&headers);
1244
1245		// Assert
1246		let err = result.unwrap_err();
1247		match err {
1248			reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1249				assert_eq!(status, 403);
1250				assert_eq!(message, "CSRF token validation failed");
1251			}
1252			other => panic!("Expected Server error with status 403, got: {:?}", other),
1253		}
1254	}
1255
1256	#[rstest]
1257	fn test_require_csrf_header_rejects_missing_cookie() {
1258		// Arrange
1259		let token = generate_csrf_token();
1260		let mut headers = hyper::HeaderMap::new();
1261		headers.insert("X-CSRFToken", token.parse().unwrap());
1262
1263		// Act
1264		let result = require_csrf_header(&headers);
1265
1266		// Assert
1267		let err = result.unwrap_err();
1268		match err {
1269			reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1270				assert_eq!(status, 403);
1271				assert_eq!(message, "CSRF token missing from cookie");
1272			}
1273			other => panic!("Expected Server error with status 403, got: {:?}", other),
1274		}
1275	}
1276
1277	// ============================================================
1278	// CSRF token uniqueness and entropy tests
1279	// ============================================================
1280
1281	#[rstest]
1282	fn test_csrf_token_generation_uniqueness() {
1283		// Arrange
1284		let mut tokens = std::collections::HashSet::new();
1285
1286		// Act
1287		for _ in 0..100 {
1288			let token = generate_csrf_token();
1289			tokens.insert(token);
1290		}
1291
1292		// Assert
1293		assert_eq!(
1294			tokens.len(),
1295			100,
1296			"All 100 generated CSRF tokens should be unique"
1297		);
1298	}
1299
1300	#[rstest]
1301	fn test_csrf_token_minimum_entropy() {
1302		// Act
1303		let token = generate_csrf_token();
1304
1305		// Assert
1306		// CSRF_TOKEN_BYTES is 32, base64 encoding of 32 bytes = 43 chars (URL_SAFE_NO_PAD)
1307		assert!(
1308			token.len() >= 32,
1309			"CSRF token length {} should be at least 32 characters for sufficient entropy",
1310			token.len()
1311		);
1312	}
1313
1314	// ============================================================
1315	// CSRF validation edge case tests
1316	// ============================================================
1317
1318	#[rstest]
1319	fn test_csrf_validation_accepts_matching_tokens() {
1320		// Arrange
1321		let token = generate_csrf_token();
1322		let mut headers = hyper::HeaderMap::new();
1323		let cookie_value = format!("csrftoken={}", token);
1324		headers.insert("cookie", cookie_value.parse().unwrap());
1325
1326		// Act
1327		let result = require_csrf_token(&token, &headers);
1328
1329		// Assert
1330		assert!(
1331			result.is_ok(),
1332			"Matching tokens should pass CSRF validation"
1333		);
1334	}
1335
1336	#[rstest]
1337	fn test_csrf_validation_rejects_empty_token() {
1338		// Arrange
1339		let cookie_token = generate_csrf_token();
1340		let mut headers = hyper::HeaderMap::new();
1341		let cookie_value = format!("csrftoken={}", cookie_token);
1342		headers.insert("cookie", cookie_value.parse().unwrap());
1343
1344		// Act
1345		let result = require_csrf_token("", &headers);
1346
1347		// Assert
1348		assert!(result.is_err(), "Empty body token should be rejected");
1349		let err = result.unwrap_err();
1350		match err {
1351			reinhardt_pages::server_fn::ServerFnError::Server { status, .. } => {
1352				assert_eq!(status, 403);
1353			}
1354			other => panic!("Expected Server error with status 403, got: {:?}", other),
1355		}
1356	}
1357
1358	#[rstest]
1359	fn test_csrf_validation_rejects_whitespace_only_token() {
1360		// Arrange
1361		let cookie_token = generate_csrf_token();
1362		let mut headers = hyper::HeaderMap::new();
1363		let cookie_value = format!("csrftoken={}", cookie_token);
1364		headers.insert("cookie", cookie_value.parse().unwrap());
1365
1366		// Act
1367		let result = require_csrf_token("   ", &headers);
1368
1369		// Assert
1370		assert!(
1371			result.is_err(),
1372			"Whitespace-only body token should be rejected"
1373		);
1374	}
1375
1376	// ============================================================
1377	// Sanitization additional tests
1378	// ============================================================
1379
1380	#[rstest]
1381	fn test_sanitize_html_removes_script_tags() {
1382		// Arrange
1383		let mut data = HashMap::new();
1384		data.insert(
1385			"content".to_string(),
1386			serde_json::json!("<script>document.cookie</script>"),
1387		);
1388
1389		// Act
1390		sanitize_mutation_values(&mut data);
1391
1392		// Assert
1393		let content = data.get("content").unwrap().as_str().unwrap();
1394		assert!(
1395			!content.contains("<script>"),
1396			"Script tags should be escaped, got: {}",
1397			content
1398		);
1399		assert!(
1400			content.contains("&lt;script&gt;"),
1401			"Script tags should be HTML-escaped, got: {}",
1402			content
1403		);
1404	}
1405
1406	#[rstest]
1407	#[case("hello world", "hello world")]
1408	#[case("", "")]
1409	#[case(
1410		"normal text without special chars",
1411		"normal text without special chars"
1412	)]
1413	fn test_sanitize_html_idempotent_safe_strings(#[case] input: &str, #[case] expected: &str) {
1414		// Arrange
1415		let mut data = HashMap::new();
1416		data.insert("val".to_string(), serde_json::json!(input));
1417
1418		// Act — first pass
1419		sanitize_mutation_values(&mut data);
1420		let after_first = data.get("val").unwrap().as_str().unwrap().to_string();
1421
1422		// Act — second pass on already-sanitized output
1423		let mut data2 = HashMap::new();
1424		data2.insert("val".to_string(), serde_json::json!(after_first));
1425		sanitize_mutation_values(&mut data2);
1426		let after_second = data2.get("val").unwrap().as_str().unwrap().to_string();
1427
1428		// Assert — safe strings are unchanged through both passes
1429		assert_eq!(after_first, expected);
1430		assert_eq!(after_first, after_second);
1431	}
1432
1433	#[rstest]
1434	#[case("<b>bold</b>", "&lt;b&gt;bold&lt;/b&gt;")]
1435	#[case("<script>alert(1)</script>", "&lt;script&gt;alert(1)&lt;/script&gt;")]
1436	fn test_sanitize_html_escapes_dangerous_input(
1437		#[case] input: &str,
1438		#[case] expected_escaped: &str,
1439	) {
1440		// Arrange
1441		let mut data = HashMap::new();
1442		data.insert("val".to_string(), serde_json::json!(input));
1443
1444		// Act
1445		sanitize_mutation_values(&mut data);
1446
1447		// Assert
1448		let result = data.get("val").unwrap().as_str().unwrap();
1449		assert_eq!(result, expected_escaped);
1450	}
1451
1452	// ============================================================
1453	// Security headers count test
1454	// ============================================================
1455
1456	#[rstest]
1457	fn test_security_headers_count() {
1458		// Arrange
1459		let headers = SecurityHeaders::default();
1460
1461		// Act
1462		let map = headers.to_header_map();
1463
1464		// Assert
1465		assert_eq!(
1466			map.len(),
1467			6,
1468			"SecurityHeaders should produce exactly 6 headers: CSP, X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy"
1469		);
1470	}
1471
1472	// ============================================================
1473	// FrameOptions from_str tests
1474	// ============================================================
1475
1476	#[rstest]
1477	fn test_frame_options_from_str_deny() {
1478		// Assert
1479		assert_eq!("deny".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
1480	}
1481
1482	#[rstest]
1483	fn test_frame_options_from_str_deny_uppercase() {
1484		// Assert
1485		assert_eq!("DENY".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
1486	}
1487
1488	#[rstest]
1489	fn test_frame_options_from_str_sameorigin() {
1490		// Assert
1491		assert_eq!(
1492			"sameorigin".parse::<FrameOptions>().unwrap(),
1493			FrameOptions::SameOrigin
1494		);
1495	}
1496
1497	#[rstest]
1498	fn test_frame_options_from_str_unknown_falls_back_to_deny() {
1499		// Assert
1500		assert_eq!(
1501			"invalid".parse::<FrameOptions>().unwrap(),
1502			FrameOptions::Deny
1503		);
1504	}
1505
1506	// ============================================================
1507	// ReferrerPolicy from_str tests
1508	// ============================================================
1509
1510	#[rstest]
1511	fn test_referrer_policy_from_str_no_referrer() {
1512		// Assert
1513		assert_eq!(
1514			"no-referrer".parse::<ReferrerPolicy>().unwrap(),
1515			ReferrerPolicy::NoReferrer
1516		);
1517	}
1518
1519	#[rstest]
1520	fn test_referrer_policy_from_str_strict_origin() {
1521		// Assert
1522		assert_eq!(
1523			"strict-origin-when-cross-origin"
1524				.parse::<ReferrerPolicy>()
1525				.unwrap(),
1526			ReferrerPolicy::StrictOriginWhenCrossOrigin
1527		);
1528	}
1529
1530	#[rstest]
1531	fn test_referrer_policy_from_str_same_origin() {
1532		// Assert
1533		assert_eq!(
1534			"same-origin".parse::<ReferrerPolicy>().unwrap(),
1535			ReferrerPolicy::SameOrigin
1536		);
1537	}
1538
1539	#[rstest]
1540	fn test_referrer_policy_from_str_unknown_falls_back() {
1541		// Assert
1542		assert_eq!(
1543			"invalid".parse::<ReferrerPolicy>().unwrap(),
1544			ReferrerPolicy::StrictOriginWhenCrossOrigin
1545		);
1546	}
1547}