Skip to main content

synapse_pingora/
block_page.rs

1//! Custom Block Page Rendering Module
2//!
3//! Provides template-based rendering for block pages with support for both
4//! browser (HTML) and API (JSON) clients.
5
6use html_escape::encode_text;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// Block page errors.
12#[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/// Reason why a request was blocked.
24#[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/// Block page context for template rendering.
73#[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/// JSON response for API clients.
133#[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/// Configuration for block page rendering.
164#[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
238/// Block page renderer.
239pub struct BlockPageRenderer {
240    config: BlockPageConfig,
241}
242
243impl BlockPageRenderer {
244    pub fn new(config: BlockPageConfig) -> Self {
245        Self { config }
246    }
247
248    /// Renders a block page based on Accept header.
249    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    /// Renders HTML block page.
264    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    /// Renders JSON response.
274    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        // Core variables
285        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        // Optional variables
299        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        // Visibility flags
322        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        // Process conditionals first: {{#if var}}...{{/if}}
393        if let Ok(conditional_re) = regex::Regex::new(r"\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{/if\}\}") {
394            result = conditional_re
395                .replace_all(&result, |caps: &regex::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        // Then substitute variables: {{var}}
409        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        // Parse Accept header with quality values
420        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        // Should be RFC3339 format
682        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("&lt;script&gt;alert(1)&lt;/script&gt;"));
747        assert!(html.contains("&lt;img src=x onerror=alert(1)&gt;"));
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        // HTML preference
770        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        // JSON preference
776        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        // No header defaults to HTML
781        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        // JSON with higher quality
789        assert!(BlockPageRenderer::prefers_json(
790            "text/html;q=0.5,application/json;q=0.9"
791        ));
792
793        // HTML with higher quality
794        assert!(!BlockPageRenderer::prefers_json(
795            "text/html;q=0.9,application/json;q=0.5"
796        ));
797
798        // Equal quality, HTML wins (default)
799        assert!(!BlockPageRenderer::prefers_json(
800            "text/html,application/json"
801        ));
802
803        // JSON only
804        assert!(BlockPageRenderer::prefers_json("application/json"));
805
806        // HTML only
807        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>")); // Not using default template
822    }
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        // Empty value should not render
835        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        // Check for accessibility attributes
848        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        // Details section should be empty when show_details is false
873        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}