1use html_escape::encode_text;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum BlockPageError {
14 #[error("template error: {0}")]
15 TemplateError(String),
16
17 #[error("missing variable: {0}")]
18 MissingVariable(String),
19}
20
21pub type BlockPageResult<T> = Result<T, BlockPageError>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum BlockReason {
27 WafRule,
28 RateLimit,
29 AccessDenied,
30 DlpViolation,
31 Maintenance,
32}
33
34impl BlockReason {
35 pub fn description(&self) -> &'static str {
36 match self {
37 Self::WafRule => "Request blocked by security rules",
38 Self::RateLimit => "Rate limit exceeded",
39 Self::AccessDenied => "Access denied",
40 Self::DlpViolation => "Data loss prevention policy violation",
41 Self::Maintenance => "Service temporarily unavailable",
42 }
43 }
44
45 pub fn http_status(&self) -> u16 {
46 match self {
47 Self::WafRule => 403,
48 Self::RateLimit => 429,
49 Self::AccessDenied => 403,
50 Self::DlpViolation => 403,
51 Self::Maintenance => 503,
52 }
53 }
54
55 pub fn error_code(&self) -> &'static str {
56 match self {
57 Self::WafRule => "WAF_BLOCKED",
58 Self::RateLimit => "RATE_LIMITED",
59 Self::AccessDenied => "ACCESS_DENIED",
60 Self::DlpViolation => "DLP_VIOLATION",
61 Self::Maintenance => "MAINTENANCE",
62 }
63 }
64}
65
66impl std::fmt::Display for BlockReason {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(f, "{}", self.description())
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BlockContext {
75 pub reason: BlockReason,
76 pub request_id: String,
77 pub client_ip: String,
78 pub timestamp: String,
79 pub site_name: Option<String>,
80 pub rule_id: Option<String>,
81 pub message: Option<String>,
82 pub support_email: Option<String>,
83 pub show_details: bool,
84}
85
86impl BlockContext {
87 pub fn new(
88 reason: BlockReason,
89 request_id: impl Into<String>,
90 client_ip: impl Into<String>,
91 ) -> Self {
92 let timestamp = chrono::Utc::now().to_rfc3339();
93 Self {
94 reason,
95 request_id: request_id.into(),
96 client_ip: client_ip.into(),
97 timestamp,
98 site_name: None,
99 rule_id: None,
100 message: None,
101 support_email: None,
102 show_details: true,
103 }
104 }
105
106 pub fn with_site_name(mut self, name: impl Into<String>) -> Self {
107 self.site_name = Some(name.into());
108 self
109 }
110
111 pub fn with_rule_id(mut self, id: impl Into<String>) -> Self {
112 self.rule_id = Some(id.into());
113 self
114 }
115
116 pub fn with_message(mut self, msg: impl Into<String>) -> Self {
117 self.message = Some(msg.into());
118 self
119 }
120
121 pub fn with_support_email(mut self, email: impl Into<String>) -> Self {
122 self.support_email = Some(email.into());
123 self
124 }
125
126 pub fn with_show_details(mut self, show: bool) -> Self {
127 self.show_details = show;
128 self
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct BlockPageJsonResponse {
135 pub error: String,
136 pub code: String,
137 pub message: String,
138 pub request_id: String,
139 pub timestamp: String,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub support_email: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub rule_id: Option<String>,
144}
145
146impl BlockPageJsonResponse {
147 pub fn from_context(ctx: &BlockContext) -> Self {
148 Self {
149 error: ctx.reason.error_code().to_string(),
150 code: ctx.reason.http_status().to_string(),
151 message: ctx
152 .message
153 .clone()
154 .unwrap_or_else(|| ctx.reason.description().to_string()),
155 request_id: ctx.request_id.clone(),
156 timestamp: ctx.timestamp.clone(),
157 support_email: ctx.support_email.clone(),
158 rule_id: ctx.rule_id.clone(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct BlockPageConfig {
166 pub custom_template: Option<String>,
167 pub custom_css: Option<String>,
168 pub logo_url: Option<String>,
169 pub company_name: Option<String>,
170 pub support_email: Option<String>,
171 pub show_request_id: bool,
172 pub show_timestamp: bool,
173 pub show_client_ip: bool,
174 pub show_rule_id: bool,
175}
176
177impl Default for BlockPageConfig {
178 fn default() -> Self {
179 Self {
180 custom_template: None,
181 custom_css: None,
182 logo_url: None,
183 company_name: None,
184 support_email: None,
185 show_request_id: true,
186 show_timestamp: true,
187 show_client_ip: false,
188 show_rule_id: false,
189 }
190 }
191}
192
193impl BlockPageConfig {
194 pub fn new() -> Self {
195 Self::default()
196 }
197
198 pub fn with_template(mut self, template: impl Into<String>) -> Self {
199 self.custom_template = Some(template.into());
200 self
201 }
202
203 pub fn with_css(mut self, css: impl Into<String>) -> Self {
204 self.custom_css = Some(css.into());
205 self
206 }
207
208 pub fn with_logo(mut self, url: impl Into<String>) -> Self {
209 self.logo_url = Some(url.into());
210 self
211 }
212
213 pub fn with_company_name(mut self, name: impl Into<String>) -> Self {
214 self.company_name = Some(name.into());
215 self
216 }
217
218 pub fn with_support_email(mut self, email: impl Into<String>) -> Self {
219 self.support_email = Some(email.into());
220 self
221 }
222
223 pub fn with_show_details(
224 mut self,
225 request_id: bool,
226 timestamp: bool,
227 client_ip: bool,
228 rule_id: bool,
229 ) -> Self {
230 self.show_request_id = request_id;
231 self.show_timestamp = timestamp;
232 self.show_client_ip = client_ip;
233 self.show_rule_id = rule_id;
234 self
235 }
236}
237
238pub struct BlockPageRenderer {
240 config: BlockPageConfig,
241}
242
243impl BlockPageRenderer {
244 pub fn new(config: BlockPageConfig) -> Self {
245 Self { config }
246 }
247
248 pub fn render(
250 &self,
251 ctx: &BlockContext,
252 accept_header: Option<&str>,
253 ) -> (String, &'static str) {
254 let prefers_json = accept_header.map(Self::prefers_json).unwrap_or(false);
255
256 if prefers_json {
257 (self.render_json(ctx), "application/json")
258 } else {
259 (self.render_html(ctx), "text/html; charset=utf-8")
260 }
261 }
262
263 pub fn render_html(&self, ctx: &BlockContext) -> String {
265 let template = self
266 .config
267 .custom_template
268 .as_deref()
269 .unwrap_or(DEFAULT_TEMPLATE);
270 self.render_template(template, ctx)
271 }
272
273 pub fn render_json(&self, ctx: &BlockContext) -> String {
275 let response = BlockPageJsonResponse::from_context(ctx);
276 serde_json::to_string_pretty(&response).unwrap_or_else(|_| {
277 r#"{"error":"INTERNAL_ERROR","message":"Failed to render response"}"#.to_string()
278 })
279 }
280
281 fn render_template(&self, template: &str, ctx: &BlockContext) -> String {
282 let mut vars: HashMap<&str, String> = HashMap::new();
283
284 vars.insert("status_code", ctx.reason.http_status().to_string());
286 vars.insert("error_code", ctx.reason.error_code().to_string());
287 vars.insert("title", ctx.reason.description().to_string());
288 vars.insert(
289 "message",
290 ctx.message
291 .clone()
292 .unwrap_or_else(|| ctx.reason.description().to_string()),
293 );
294 vars.insert("request_id", ctx.request_id.clone());
295 vars.insert("timestamp", ctx.timestamp.clone());
296 vars.insert("client_ip", ctx.client_ip.clone());
297
298 vars.insert("site_name", ctx.site_name.clone().unwrap_or_default());
300 vars.insert("rule_id", ctx.rule_id.clone().unwrap_or_default());
301 vars.insert(
302 "support_email",
303 ctx.support_email
304 .clone()
305 .or_else(|| self.config.support_email.clone())
306 .unwrap_or_default(),
307 );
308 vars.insert(
309 "company_name",
310 self.config
311 .company_name
312 .clone()
313 .unwrap_or_else(|| "WAF Protection".to_string()),
314 );
315 vars.insert("logo_url", self.config.logo_url.clone().unwrap_or_default());
316 vars.insert(
317 "custom_css",
318 self.config.custom_css.clone().unwrap_or_default(),
319 );
320
321 vars.insert(
323 "show_request_id",
324 if self.config.show_request_id && ctx.show_details {
325 "true"
326 } else {
327 ""
328 }
329 .to_string(),
330 );
331 vars.insert(
332 "show_timestamp",
333 if self.config.show_timestamp && ctx.show_details {
334 "true"
335 } else {
336 ""
337 }
338 .to_string(),
339 );
340 vars.insert(
341 "show_client_ip",
342 if self.config.show_client_ip && ctx.show_details {
343 "true"
344 } else {
345 ""
346 }
347 .to_string(),
348 );
349 vars.insert(
350 "show_rule_id",
351 if self.config.show_rule_id && ctx.rule_id.is_some() && ctx.show_details {
352 "true"
353 } else {
354 ""
355 }
356 .to_string(),
357 );
358 vars.insert(
359 "has_support_email",
360 if ctx.support_email.is_some() || self.config.support_email.is_some() {
361 "true"
362 } else {
363 ""
364 }
365 .to_string(),
366 );
367 vars.insert(
368 "has_logo",
369 if self.config.logo_url.is_some() {
370 "true"
371 } else {
372 ""
373 }
374 .to_string(),
375 );
376 vars.insert(
377 "has_custom_css",
378 if self.config.custom_css.is_some() {
379 "true"
380 } else {
381 ""
382 }
383 .to_string(),
384 );
385
386 Self::substitute_template(template, &vars)
387 }
388
389 fn substitute_template(template: &str, vars: &HashMap<&str, String>) -> String {
390 let mut result = template.to_string();
391
392 if let Ok(conditional_re) = regex::Regex::new(r"\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{/if\}\}") {
394 result = conditional_re
395 .replace_all(&result, |caps: ®ex::Captures| {
396 let var_name = &caps[1];
397 let content = &caps[2];
398 if let Some(value) = vars.get(var_name) {
399 if !value.is_empty() {
400 return content.to_string();
401 }
402 }
403 String::new()
404 })
405 .to_string();
406 }
407
408 for (key, value) in vars {
410 let pattern = format!("{{{{{}}}}}", key);
411 let escaped = encode_text(value);
412 result = result.replace(&pattern, escaped.as_ref());
413 }
414
415 result
416 }
417
418 fn prefers_json(accept: &str) -> bool {
419 let mut best_html: f32 = 0.0;
421 let mut best_json: f32 = 0.0;
422
423 for part in accept.split(',') {
424 let part = part.trim();
425 let (mime, quality) = Self::parse_accept_part(part);
426
427 if mime == "application/json" || mime == "text/json" {
428 best_json = best_json.max(quality);
429 } else if mime == "text/html" || mime == "*/*" {
430 best_html = best_html.max(quality);
431 }
432 }
433
434 best_json > best_html
435 }
436
437 fn parse_accept_part(part: &str) -> (&str, f32) {
438 let mut parts = part.split(';');
439 let mime = parts.next().unwrap_or("").trim();
440
441 let mut quality: f32 = 1.0;
442 for param in parts {
443 let param = param.trim();
444 if let Some(q) = param.strip_prefix("q=") {
445 quality = q.parse().unwrap_or(1.0);
446 }
447 }
448
449 (mime, quality)
450 }
451
452 pub fn http_status(&self, reason: BlockReason) -> u16 {
453 reason.http_status()
454 }
455}
456
457impl Default for BlockPageRenderer {
458 fn default() -> Self {
459 Self::new(BlockPageConfig::default())
460 }
461}
462
463const DEFAULT_TEMPLATE: &str = r#"<!DOCTYPE html>
464<html lang="en">
465<head>
466 <meta charset="UTF-8">
467 <meta name="viewport" content="width=device-width, initial-scale=1.0">
468 <meta name="robots" content="noindex, nofollow">
469 <title>{{status_code}} - {{title}}</title>
470 <style>
471 * {
472 margin: 0;
473 padding: 0;
474 box-sizing: border-box;
475 }
476 body {
477 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
478 background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
479 min-height: 100vh;
480 display: flex;
481 align-items: center;
482 justify-content: center;
483 padding: 20px;
484 color: #e0e0e0;
485 }
486 .container {
487 background: rgba(255, 255, 255, 0.05);
488 backdrop-filter: blur(10px);
489 border-radius: 16px;
490 padding: 48px;
491 max-width: 600px;
492 width: 100%;
493 text-align: center;
494 border: 1px solid rgba(255, 255, 255, 0.1);
495 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
496 }
497 .status-code {
498 font-size: 96px;
499 font-weight: 700;
500 color: #e94560;
501 line-height: 1;
502 margin-bottom: 16px;
503 text-shadow: 0 0 30px rgba(233, 69, 96, 0.5);
504 }
505 .title {
506 font-size: 24px;
507 font-weight: 600;
508 margin-bottom: 16px;
509 color: #fff;
510 }
511 .message {
512 font-size: 16px;
513 color: #a0a0a0;
514 margin-bottom: 32px;
515 line-height: 1.6;
516 }
517 .details {
518 background: rgba(0, 0, 0, 0.2);
519 border-radius: 8px;
520 padding: 20px;
521 margin-top: 24px;
522 text-align: left;
523 font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
524 font-size: 13px;
525 }
526 .details-row {
527 display: flex;
528 justify-content: space-between;
529 padding: 8px 0;
530 border-bottom: 1px solid rgba(255, 255, 255, 0.05);
531 }
532 .details-row:last-child {
533 border-bottom: none;
534 }
535 .details-label {
536 color: #808080;
537 }
538 .details-value {
539 color: #e0e0e0;
540 word-break: break-all;
541 }
542 .support {
543 margin-top: 24px;
544 font-size: 14px;
545 color: #808080;
546 }
547 .support a {
548 color: #e94560;
549 text-decoration: none;
550 }
551 .support a:hover {
552 text-decoration: underline;
553 }
554 .logo {
555 max-height: 48px;
556 margin-bottom: 24px;
557 }
558 @media (max-width: 480px) {
559 .container {
560 padding: 32px 24px;
561 }
562 .status-code {
563 font-size: 72px;
564 }
565 .title {
566 font-size: 20px;
567 }
568 }
569 @media (prefers-reduced-motion: reduce) {
570 * {
571 animation: none !important;
572 transition: none !important;
573 }
574 }
575 </style>
576 {{#if has_custom_css}}<style>{{custom_css}}</style>{{/if}}
577</head>
578<body>
579 <main class="container" role="main" aria-labelledby="error-title">
580 {{#if has_logo}}<img src="{{logo_url}}" alt="{{company_name}}" class="logo">{{/if}}
581 <div class="status-code" aria-hidden="true">{{status_code}}</div>
582 <h1 class="title" id="error-title">{{title}}</h1>
583 <p class="message">{{message}}</p>
584 {{#if show_request_id}}
585 <div class="details" role="complementary" aria-label="Request details">
586 {{#if show_request_id}}
587 <div class="details-row">
588 <span class="details-label">Request ID</span>
589 <span class="details-value">{{request_id}}</span>
590 </div>
591 {{/if}}
592 {{#if show_timestamp}}
593 <div class="details-row">
594 <span class="details-label">Time</span>
595 <span class="details-value">{{timestamp}}</span>
596 </div>
597 {{/if}}
598 {{#if show_client_ip}}
599 <div class="details-row">
600 <span class="details-label">Client IP</span>
601 <span class="details-value">{{client_ip}}</span>
602 </div>
603 {{/if}}
604 {{#if show_rule_id}}
605 <div class="details-row">
606 <span class="details-label">Rule</span>
607 <span class="details-value">{{rule_id}}</span>
608 </div>
609 {{/if}}
610 </div>
611 {{/if}}
612 {{#if has_support_email}}
613 <p class="support">
614 If you believe this is an error, please contact
615 <a href="mailto:{{support_email}}">{{support_email}}</a>
616 </p>
617 {{/if}}
618 </main>
619</body>
620</html>"#;
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn test_block_reason_description() {
628 assert_eq!(
629 BlockReason::WafRule.description(),
630 "Request blocked by security rules"
631 );
632 assert_eq!(BlockReason::RateLimit.description(), "Rate limit exceeded");
633 assert_eq!(BlockReason::AccessDenied.description(), "Access denied");
634 assert_eq!(
635 BlockReason::DlpViolation.description(),
636 "Data loss prevention policy violation"
637 );
638 assert_eq!(
639 BlockReason::Maintenance.description(),
640 "Service temporarily unavailable"
641 );
642 }
643
644 #[test]
645 fn test_block_reason_http_status() {
646 assert_eq!(BlockReason::WafRule.http_status(), 403);
647 assert_eq!(BlockReason::RateLimit.http_status(), 429);
648 assert_eq!(BlockReason::AccessDenied.http_status(), 403);
649 assert_eq!(BlockReason::DlpViolation.http_status(), 403);
650 assert_eq!(BlockReason::Maintenance.http_status(), 503);
651 }
652
653 #[test]
654 fn test_block_reason_error_code() {
655 assert_eq!(BlockReason::WafRule.error_code(), "WAF_BLOCKED");
656 assert_eq!(BlockReason::RateLimit.error_code(), "RATE_LIMITED");
657 assert_eq!(BlockReason::AccessDenied.error_code(), "ACCESS_DENIED");
658 }
659
660 #[test]
661 fn test_block_context_builder() {
662 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1")
663 .with_site_name("example.com")
664 .with_rule_id("SQLI-001")
665 .with_message("SQL injection attempt detected")
666 .with_support_email("support@example.com")
667 .with_show_details(true);
668
669 assert_eq!(ctx.reason, BlockReason::WafRule);
670 assert_eq!(ctx.request_id, "req-123");
671 assert_eq!(ctx.client_ip, "192.168.1.1");
672 assert_eq!(ctx.site_name, Some("example.com".to_string()));
673 assert_eq!(ctx.rule_id, Some("SQLI-001".to_string()));
674 assert!(ctx.show_details);
675 }
676
677 #[test]
678 fn test_block_context_timestamp() {
679 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
680 assert!(!ctx.timestamp.is_empty());
681 assert!(ctx.timestamp.contains('T'));
683 }
684
685 #[test]
686 fn test_json_response_from_context() {
687 let ctx = BlockContext::new(BlockReason::RateLimit, "req-456", "10.0.0.1")
688 .with_support_email("help@example.com");
689
690 let response = BlockPageJsonResponse::from_context(&ctx);
691
692 assert_eq!(response.error, "RATE_LIMITED");
693 assert_eq!(response.code, "429");
694 assert_eq!(response.request_id, "req-456");
695 assert_eq!(response.support_email, Some("help@example.com".to_string()));
696 }
697
698 #[test]
699 fn test_config_defaults() {
700 let config = BlockPageConfig::default();
701 assert!(config.custom_template.is_none());
702 assert!(config.show_request_id);
703 assert!(config.show_timestamp);
704 assert!(!config.show_client_ip);
705 }
706
707 #[test]
708 fn test_config_builder() {
709 let config = BlockPageConfig::new()
710 .with_company_name("Acme Corp")
711 .with_support_email("security@acme.com")
712 .with_logo("https://acme.com/logo.png")
713 .with_show_details(true, true, true, true);
714
715 assert_eq!(config.company_name, Some("Acme Corp".to_string()));
716 assert_eq!(config.support_email, Some("security@acme.com".to_string()));
717 assert!(config.show_client_ip);
718 assert!(config.show_rule_id);
719 }
720
721 #[test]
722 fn test_render_html() {
723 let renderer = BlockPageRenderer::default();
724 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
725
726 let html = renderer.render_html(&ctx);
727
728 assert!(html.contains("<!DOCTYPE html>"));
729 assert!(html.contains("403"));
730 assert!(html.contains("req-123"));
731 assert!(html.contains("Request blocked by security rules"));
732 }
733
734 #[test]
735 fn test_render_html_escapes_variables() {
736 let renderer = BlockPageRenderer::default();
737 let ctx = BlockContext::new(
738 BlockReason::WafRule,
739 "<script>alert(1)</script>",
740 "192.168.1.1",
741 )
742 .with_message("<img src=x onerror=alert(1)>");
743
744 let html = renderer.render_html(&ctx);
745
746 assert!(html.contains("<script>alert(1)</script>"));
747 assert!(html.contains("<img src=x onerror=alert(1)>"));
748 assert!(!html.contains("<script>alert(1)</script>"));
749 assert!(!html.contains("<img src=x onerror=alert(1)>"));
750 }
751
752 #[test]
753 fn test_render_json() {
754 let renderer = BlockPageRenderer::default();
755 let ctx = BlockContext::new(BlockReason::RateLimit, "req-789", "10.0.0.1");
756
757 let json = renderer.render_json(&ctx);
758
759 assert!(json.contains("RATE_LIMITED"));
760 assert!(json.contains("req-789"));
761 assert!(json.contains("429"));
762 }
763
764 #[test]
765 fn test_render_with_accept_header() {
766 let renderer = BlockPageRenderer::default();
767 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
768
769 let (content, content_type) =
771 renderer.render(&ctx, Some("text/html,application/json;q=0.9"));
772 assert_eq!(content_type, "text/html; charset=utf-8");
773 assert!(content.contains("<!DOCTYPE html>"));
774
775 let (content, content_type) = renderer.render(&ctx, Some("application/json"));
777 assert_eq!(content_type, "application/json");
778 assert!(content.contains("WAF_BLOCKED"));
779
780 let (content, content_type) = renderer.render(&ctx, None);
782 assert_eq!(content_type, "text/html; charset=utf-8");
783 assert!(content.contains("<!DOCTYPE html>"));
784 }
785
786 #[test]
787 fn test_accept_header_quality_parsing() {
788 assert!(BlockPageRenderer::prefers_json(
790 "text/html;q=0.5,application/json;q=0.9"
791 ));
792
793 assert!(!BlockPageRenderer::prefers_json(
795 "text/html;q=0.9,application/json;q=0.5"
796 ));
797
798 assert!(!BlockPageRenderer::prefers_json(
800 "text/html,application/json"
801 ));
802
803 assert!(BlockPageRenderer::prefers_json("application/json"));
805
806 assert!(!BlockPageRenderer::prefers_json("text/html"));
808 }
809
810 #[test]
811 fn test_custom_template() {
812 let config = BlockPageConfig::new()
813 .with_template("<h1>Error {{status_code}}</h1><p>{{message}}</p>");
814 let renderer = BlockPageRenderer::new(config);
815 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
816
817 let html = renderer.render_html(&ctx);
818
819 assert!(html.contains("<h1>Error 403</h1>"));
820 assert!(html.contains("Request blocked by security rules"));
821 assert!(!html.contains("<!DOCTYPE html>")); }
823
824 #[test]
825 fn test_template_conditionals() {
826 let template = "{{#if show_request_id}}ID: {{request_id}}{{/if}}";
827 let mut vars: HashMap<&str, String> = HashMap::new();
828 vars.insert("show_request_id", "true".to_string());
829 vars.insert("request_id", "abc-123".to_string());
830
831 let result = BlockPageRenderer::substitute_template(template, &vars);
832 assert_eq!(result, "ID: abc-123");
833
834 vars.insert("show_request_id", "".to_string());
836 let result = BlockPageRenderer::substitute_template(template, &vars);
837 assert_eq!(result, "");
838 }
839
840 #[test]
841 fn test_html_accessibility() {
842 let renderer = BlockPageRenderer::default();
843 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
844
845 let html = renderer.render_html(&ctx);
846
847 assert!(html.contains("role=\"main\""));
849 assert!(html.contains("aria-labelledby"));
850 assert!(html.contains("aria-label"));
851 assert!(html.contains("lang=\"en\""));
852 }
853
854 #[test]
855 fn test_ipv6_client_ip() {
856 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "2001:db8::1");
857 let renderer = BlockPageRenderer::new(
858 BlockPageConfig::new().with_show_details(true, true, true, false),
859 );
860
861 let html = renderer.render_html(&ctx);
862 assert!(html.contains("2001:db8::1"));
863 }
864
865 #[test]
866 fn test_render_without_details() {
867 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1")
868 .with_show_details(false);
869 let renderer = BlockPageRenderer::default();
870
871 let html = renderer.render_html(&ctx);
872 assert!(html.contains("403"));
874 }
875
876 #[test]
877 fn test_http_status_helper() {
878 let renderer = BlockPageRenderer::default();
879 assert_eq!(renderer.http_status(BlockReason::WafRule), 403);
880 assert_eq!(renderer.http_status(BlockReason::RateLimit), 429);
881 assert_eq!(renderer.http_status(BlockReason::Maintenance), 503);
882 }
883
884 #[test]
885 fn test_json_serialization() {
886 let ctx = BlockContext::new(BlockReason::DlpViolation, "req-dlp", "172.16.0.1")
887 .with_rule_id("DLP-SSN-001");
888
889 let response = BlockPageJsonResponse::from_context(&ctx);
890 let json = serde_json::to_string(&response).unwrap();
891
892 assert!(json.contains("DLP_VIOLATION"));
893 assert!(json.contains("DLP-SSN-001"));
894 }
895
896 #[test]
897 fn test_block_reason_display() {
898 assert_eq!(
899 format!("{}", BlockReason::WafRule),
900 "Request blocked by security rules"
901 );
902 assert_eq!(format!("{}", BlockReason::RateLimit), "Rate limit exceeded");
903 }
904
905 #[test]
906 fn test_config_custom_css() {
907 let config = BlockPageConfig::new().with_css("body { background: red; }");
908 let renderer = BlockPageRenderer::new(config);
909 let ctx = BlockContext::new(BlockReason::WafRule, "req-123", "192.168.1.1");
910
911 let html = renderer.render_html(&ctx);
912 assert!(html.contains("body { background: red; }"));
913 }
914}