Skip to main content

asupersync_conformance/
h2_connect_method_conformance.rs

1//! HTTP/2 CONNECT method handling conformance testing.
2//!
3//! This harness tests that both asupersync and h2 reference implementation
4//! correctly handle CONNECT method requests per RFC 7540 §8.3 for tunnel
5//! establishment (HTTPS proxy, WebSocket upgrade, etc.).
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// Test verdict for CONNECT method conformance cases.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ConnectMethodTestVerdict {
13    Pass,
14    Fail,
15    ExpectedFailure, // Known divergence
16    Skipped,
17}
18
19impl fmt::Display for ConnectMethodTestVerdict {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::Pass => write!(f, "PASS"),
23            Self::Fail => write!(f, "FAIL"),
24            Self::ExpectedFailure => write!(f, "XFAIL"),
25            Self::Skipped => write!(f, "SKIP"),
26        }
27    }
28}
29
30/// Single HTTP/2 CONNECT method conformance test case.
31#[derive(Debug, Clone)]
32pub struct ConnectMethodConformanceCase {
33    pub id: String,
34    pub description: String,
35    pub connect_request: ConnectRequest,
36    pub expected_response_status: Option<u16>,
37    pub should_establish_tunnel: bool,
38}
39
40/// HTTP/2 CONNECT request details.
41#[derive(Debug, Clone)]
42pub struct ConnectRequest {
43    /// Target authority (host:port)
44    pub authority: String,
45    /// Additional headers
46    pub headers: Vec<(String, String)>,
47    /// Test data to send through tunnel (if established)
48    pub tunnel_test_data: Vec<u8>,
49}
50
51/// Result of running a single CONNECT method test case.
52#[derive(Debug, Clone, Serialize)]
53pub struct ConnectMethodTestResult {
54    pub case_id: String,
55    pub verdict: ConnectMethodTestVerdict,
56    pub error: Option<String>,
57    pub asupersync_response_status: Option<u16>,
58    pub h2_response_status: Option<u16>,
59    pub asupersync_tunnel_established: bool,
60    pub h2_tunnel_established: bool,
61    pub response_status_match: bool,
62    pub tunnel_behavior_match: bool,
63    pub test_duration_ms: u64,
64}
65
66/// Summary statistics for CONNECT method conformance run.
67#[derive(Debug, Clone, Serialize)]
68pub struct ConnectMethodComplianceSummary {
69    pub passed: usize,
70    pub failed: usize,
71    pub expected_failures: usize,
72    pub skipped: usize,
73    pub total: usize,
74    pub compliance_score: f64,
75}
76
77/// Complete report for HTTP/2 CONNECT method conformance.
78#[derive(Debug, Clone, Serialize)]
79pub struct ConnectMethodComplianceReport {
80    pub test_run_id: String,
81    pub timestamp: String,
82    pub total_cases: usize,
83    pub results: Vec<ConnectMethodTestResult>,
84    pub summary: ConnectMethodComplianceSummary,
85}
86
87/// HTTP/2 CONNECT method conformance tester.
88pub struct ConnectMethodConformanceTester {
89    pub test_cases: Vec<ConnectMethodConformanceCase>,
90}
91
92impl Default for ConnectMethodConformanceTester {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl ConnectMethodConformanceTester {
99    /// Create a new CONNECT method conformance tester.
100    pub fn new() -> Self {
101        Self {
102            test_cases: Self::create_test_cases(),
103        }
104    }
105
106    /// Create the standard set of CONNECT method conformance test cases.
107    fn create_test_cases() -> Vec<ConnectMethodConformanceCase> {
108        vec![
109            ConnectMethodConformanceCase {
110                id: "CONNECT-001".to_string(),
111                description: "Basic CONNECT to HTTPS endpoint".to_string(),
112                connect_request: ConnectRequest {
113                    authority: "example.com:443".to_string(),
114                    headers: vec![("User-Agent".to_string(), "test-client/1.0".to_string())],
115                    tunnel_test_data: b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec(),
116                },
117                expected_response_status: Some(200),
118                should_establish_tunnel: true,
119            },
120            ConnectMethodConformanceCase {
121                id: "CONNECT-002".to_string(),
122                description: "CONNECT to non-standard port".to_string(),
123                connect_request: ConnectRequest {
124                    authority: "test.example.org:8080".to_string(),
125                    headers: vec![(
126                        "Proxy-Authorization".to_string(),
127                        "Basic dGVzdDp0ZXN0".to_string(),
128                    )],
129                    tunnel_test_data: b"PING".to_vec(),
130                },
131                expected_response_status: Some(200),
132                should_establish_tunnel: true,
133            },
134            ConnectMethodConformanceCase {
135                id: "CONNECT-003".to_string(),
136                description: "CONNECT with IPv4 address".to_string(),
137                connect_request: ConnectRequest {
138                    authority: "192.168.1.100:80".to_string(),
139                    headers: vec![],
140                    tunnel_test_data: b"Hello, tunnel!".to_vec(),
141                },
142                expected_response_status: Some(200),
143                should_establish_tunnel: true,
144            },
145            ConnectMethodConformanceCase {
146                id: "CONNECT-004".to_string(),
147                description: "CONNECT with IPv6 address".to_string(),
148                connect_request: ConnectRequest {
149                    authority: "[2001:db8::1]:443".to_string(),
150                    headers: vec![("X-Forwarded-For".to_string(), "203.0.113.1".to_string())],
151                    tunnel_test_data: b"IPv6 tunnel test".to_vec(),
152                },
153                expected_response_status: Some(200),
154                should_establish_tunnel: true,
155            },
156            ConnectMethodConformanceCase {
157                id: "CONNECT-005".to_string(),
158                description: "CONNECT to blocked/forbidden endpoint".to_string(),
159                connect_request: ConnectRequest {
160                    authority: "blocked.example.com:443".to_string(),
161                    headers: vec![],
162                    tunnel_test_data: Vec::new(),
163                },
164                expected_response_status: Some(403),
165                should_establish_tunnel: false,
166            },
167            ConnectMethodConformanceCase {
168                id: "CONNECT-006".to_string(),
169                description: "CONNECT with invalid authority format".to_string(),
170                connect_request: ConnectRequest {
171                    authority: "invalid-authority-no-port".to_string(),
172                    headers: vec![],
173                    tunnel_test_data: Vec::new(),
174                },
175                expected_response_status: Some(400),
176                should_establish_tunnel: false,
177            },
178            ConnectMethodConformanceCase {
179                id: "CONNECT-007".to_string(),
180                description: "CONNECT tunnel bidirectional data flow".to_string(),
181                connect_request: ConnectRequest {
182                    authority: "echo.example.com:9090".to_string(),
183                    headers: vec![("X-Protocol".to_string(), "websocket".to_string())],
184                    tunnel_test_data: b"ECHO_REQUEST\nHello, bidirectional tunnel!\n".to_vec(),
185                },
186                expected_response_status: Some(200),
187                should_establish_tunnel: true,
188            },
189            ConnectMethodConformanceCase {
190                id: "CONNECT-008".to_string(),
191                description: "CONNECT with large authority string".to_string(),
192                connect_request: ConnectRequest {
193                    authority: format!("{}:443", "a".repeat(253)), // Max domain length
194                    headers: vec![],
195                    tunnel_test_data: b"Large authority test".to_vec(),
196                },
197                expected_response_status: Some(200),
198                should_establish_tunnel: true,
199            },
200            ConnectMethodConformanceCase {
201                id: "CONNECT-009".to_string(),
202                description: "CONNECT with timeout scenario".to_string(),
203                connect_request: ConnectRequest {
204                    authority: "slow.example.com:443".to_string(),
205                    headers: vec![("X-Timeout".to_string(), "30".to_string())],
206                    tunnel_test_data: b"Timeout test".to_vec(),
207                },
208                expected_response_status: Some(200),
209                should_establish_tunnel: true,
210            },
211            ConnectMethodConformanceCase {
212                id: "CONNECT-010".to_string(),
213                description: "CONNECT tunnel termination handling".to_string(),
214                connect_request: ConnectRequest {
215                    authority: "term.example.com:443".to_string(),
216                    headers: vec![],
217                    tunnel_test_data: b"CLOSE_TUNNEL\n".to_vec(),
218                },
219                expected_response_status: Some(200),
220                should_establish_tunnel: true,
221            },
222        ]
223    }
224
225    /// Run all conformance test cases.
226    pub async fn run_all_tests(&mut self) -> ConnectMethodComplianceReport {
227        let test_run_id = uuid::Uuid::new_v4().to_string();
228        let timestamp = chrono::Utc::now().to_rfc3339();
229        let total_cases = self.test_cases.len();
230        let mut results = Vec::new();
231
232        for test_case in &self.test_cases {
233            let result = self.run_single_test(test_case).await;
234            results.push(result);
235        }
236
237        let summary = self.compute_summary(&results);
238
239        ConnectMethodComplianceReport {
240            test_run_id,
241            timestamp,
242            total_cases,
243            results,
244            summary,
245        }
246    }
247
248    /// Run a single conformance test case.
249    async fn run_single_test(
250        &self,
251        test_case: &ConnectMethodConformanceCase,
252    ) -> ConnectMethodTestResult {
253        let start_time = std::time::Instant::now();
254
255        // Run test with asupersync implementation
256        let asupersync_result = self.test_connect_with_asupersync(test_case).await;
257
258        // Run test with h2 reference implementation
259        let h2_result = self.test_connect_with_h2(test_case).await;
260
261        let duration = start_time.elapsed();
262        let test_duration_ms = duration.as_millis() as u64;
263
264        if let Some(error) = backend_unwired_error(&asupersync_result, &h2_result) {
265            return Self::skipped_backend_result(test_case, error, test_duration_ms);
266        }
267
268        match (asupersync_result, h2_result) {
269            (Ok((asupersync_status, asupersync_tunnel)), Ok((h2_status, h2_tunnel))) => {
270                let response_status_match = asupersync_status == h2_status;
271                let tunnel_behavior_match = asupersync_tunnel == h2_tunnel;
272
273                // Determine test verdict based on conformance
274                let verdict = if response_status_match && tunnel_behavior_match {
275                    // Check if behavior matches expected
276                    let status_correct = test_case
277                        .expected_response_status
278                        .is_none_or(|expected| asupersync_status == expected);
279                    let tunnel_correct = asupersync_tunnel == test_case.should_establish_tunnel;
280
281                    if status_correct && tunnel_correct {
282                        ConnectMethodTestVerdict::Pass
283                    } else {
284                        ConnectMethodTestVerdict::Fail
285                    }
286                } else {
287                    ConnectMethodTestVerdict::Fail
288                };
289
290                ConnectMethodTestResult {
291                    case_id: test_case.id.clone(),
292                    verdict,
293                    error: None,
294                    asupersync_response_status: Some(asupersync_status),
295                    h2_response_status: Some(h2_status),
296                    asupersync_tunnel_established: asupersync_tunnel,
297                    h2_tunnel_established: h2_tunnel,
298                    response_status_match,
299                    tunnel_behavior_match,
300                    test_duration_ms,
301                }
302            }
303            (Err(e), _) | (_, Err(e)) => ConnectMethodTestResult {
304                case_id: test_case.id.clone(),
305                verdict: ConnectMethodTestVerdict::Fail,
306                error: Some(e),
307                asupersync_response_status: None,
308                h2_response_status: None,
309                asupersync_tunnel_established: false,
310                h2_tunnel_established: false,
311                response_status_match: false,
312                tunnel_behavior_match: false,
313                test_duration_ms,
314            },
315        }
316    }
317
318    fn skipped_backend_result(
319        test_case: &ConnectMethodConformanceCase,
320        error: String,
321        test_duration_ms: u64,
322    ) -> ConnectMethodTestResult {
323        ConnectMethodTestResult {
324            case_id: test_case.id.clone(),
325            verdict: ConnectMethodTestVerdict::Skipped,
326            error: Some(error),
327            asupersync_response_status: None,
328            h2_response_status: None,
329            asupersync_tunnel_established: false,
330            h2_tunnel_established: false,
331            response_status_match: false,
332            tunnel_behavior_match: false,
333            test_duration_ms,
334        }
335    }
336
337    /// Test CONNECT method with asupersync implementation.
338    async fn test_connect_with_asupersync(
339        &self,
340        test_case: &ConnectMethodConformanceCase,
341    ) -> Result<(u16, bool), String> {
342        Err(format!(
343            "asupersync HTTP/2 CONNECT backend not wired; refusing to synthesize comparison result for {}",
344            test_case.id
345        ))
346    }
347
348    /// Test CONNECT method with h2 reference implementation.
349    async fn test_connect_with_h2(
350        &self,
351        test_case: &ConnectMethodConformanceCase,
352    ) -> Result<(u16, bool), String> {
353        Err(format!(
354            "h2 HTTP/2 CONNECT backend not wired; refusing to synthesize comparison result for {}",
355            test_case.id
356        ))
357    }
358
359    /// Compute summary statistics from test results.
360    fn compute_summary(
361        &self,
362        results: &[ConnectMethodTestResult],
363    ) -> ConnectMethodComplianceSummary {
364        let passed = results
365            .iter()
366            .filter(|r| r.verdict == ConnectMethodTestVerdict::Pass)
367            .count();
368        let failed = results
369            .iter()
370            .filter(|r| r.verdict == ConnectMethodTestVerdict::Fail)
371            .count();
372        let expected_failures = results
373            .iter()
374            .filter(|r| r.verdict == ConnectMethodTestVerdict::ExpectedFailure)
375            .count();
376        let skipped = results
377            .iter()
378            .filter(|r| r.verdict == ConnectMethodTestVerdict::Skipped)
379            .count();
380        let total = results.len();
381
382        let compliance_score = if total > 0 {
383            (passed + expected_failures) as f64 / total as f64
384        } else {
385            0.0
386        };
387
388        ConnectMethodComplianceSummary {
389            passed,
390            failed,
391            expected_failures,
392            skipped,
393            total,
394            compliance_score,
395        }
396    }
397
398    /// Generate a markdown report.
399    pub fn generate_markdown_report(&self, report: &ConnectMethodComplianceReport) -> String {
400        let mut md = String::new();
401
402        md.push_str("# HTTP/2 CONNECT Method Handling Conformance Report\n\n");
403
404        md.push_str(&format!("**Test Run ID:** {}\n", report.test_run_id));
405        md.push_str(&format!("**Timestamp:** {}\n", report.timestamp));
406        md.push_str(&format!("**Total Test Cases:** {}\n\n", report.total_cases));
407
408        md.push_str("## Summary\n\n");
409        md.push_str(&format!("- ✅ **Passed:** {}\n", report.summary.passed));
410        md.push_str(&format!("- ❌ **Failed:** {}\n", report.summary.failed));
411        md.push_str(&format!(
412            "- ⚠️ **Expected Failures:** {}\n",
413            report.summary.expected_failures
414        ));
415        md.push_str(&format!("- ⏭️ **Skipped:** {}\n", report.summary.skipped));
416        md.push_str(&format!(
417            "- 🎯 **Compliance Score:** {:.1}%\n\n",
418            report.summary.compliance_score * 100.0
419        ));
420
421        md.push_str("## Test Results\n\n");
422        md.push_str(
423            "| Test ID | Description | Verdict | Asupersync Status | H2 Status | Tunnel Match |\n",
424        );
425        md.push_str(
426            "|---------|-------------|---------|-------------------|-----------|-------------|\n",
427        );
428
429        for result in &report.results {
430            let tunnel_icon = if result.tunnel_behavior_match {
431                "✅"
432            } else {
433                "❌"
434            };
435            md.push_str(&format!(
436                "| {} | {} | {} | {} | {} | {} |\n",
437                result.case_id,
438                self.test_cases
439                    .iter()
440                    .find(|case| case.id == result.case_id)
441                    .map(|case| case.description.as_str())
442                    .unwrap_or("Unknown"),
443                result.verdict,
444                result
445                    .asupersync_response_status
446                    .map(|s| s.to_string())
447                    .unwrap_or_else(|| "Error".to_string()),
448                result
449                    .h2_response_status
450                    .map(|s| s.to_string())
451                    .unwrap_or_else(|| "Error".to_string()),
452                tunnel_icon
453            ));
454        }
455
456        md.push_str("\n## Failed Tests\n\n");
457        let failed_tests: Vec<_> = report
458            .results
459            .iter()
460            .filter(|r| r.verdict == ConnectMethodTestVerdict::Fail)
461            .collect();
462
463        if failed_tests.is_empty() {
464            md.push_str("No tests failed.\n\n");
465        } else {
466            for result in failed_tests {
467                md.push_str(&format!("### {}\n\n", result.case_id));
468                if let Some(error) = &result.error {
469                    md.push_str(&format!("**Error:** {}\n\n", error));
470                }
471                md.push_str(&format!(
472                    "**Response Status:** asupersync={:?}, h2={:?}\n",
473                    result.asupersync_response_status, result.h2_response_status
474                ));
475                md.push_str(&format!(
476                    "**Tunnel Established:** asupersync={}, h2={}\n\n",
477                    result.asupersync_tunnel_established, result.h2_tunnel_established
478                ));
479            }
480        }
481
482        md.push_str("\n## Skipped Tests\n\n");
483        let skipped_tests: Vec<_> = report
484            .results
485            .iter()
486            .filter(|r| r.verdict == ConnectMethodTestVerdict::Skipped)
487            .collect();
488
489        if skipped_tests.is_empty() {
490            md.push_str("No tests were skipped.\n\n");
491        } else {
492            for result in skipped_tests {
493                md.push_str(&format!("### {}\n\n", result.case_id));
494                if let Some(error) = &result.error {
495                    md.push_str(&format!("**Reason:** {}\n\n", error));
496                }
497            }
498        }
499
500        md.push_str("---\n");
501        md.push_str(&format!(
502            "*Generated by asupersync conformance tester at {}*\n",
503            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
504        ));
505
506        md
507    }
508}
509
510fn backend_unwired_error(
511    asupersync_result: &Result<(u16, bool), String>,
512    h2_result: &Result<(u16, bool), String>,
513) -> Option<String> {
514    let mut errors = Vec::new();
515
516    if let Err(error) = asupersync_result
517        && is_backend_unwired(error)
518    {
519        errors.push(error.as_str());
520    }
521
522    if let Err(error) = h2_result
523        && is_backend_unwired(error)
524    {
525        errors.push(error.as_str());
526    }
527
528    if errors.is_empty() {
529        None
530    } else {
531        Some(errors.join("; "))
532    }
533}
534
535fn is_backend_unwired(error: &str) -> bool {
536    error.contains("backend not wired") && error.contains("refusing to synthesize")
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[tokio::test]
544    async fn connect_harness_does_not_report_synthetic_passes() {
545        let mut tester = ConnectMethodConformanceTester::new();
546        let total_cases = tester.test_cases.len();
547
548        let report = tester.run_all_tests().await;
549
550        assert_eq!(report.summary.total, total_cases);
551        assert_eq!(report.summary.passed, 0);
552        assert_eq!(report.summary.failed, 0);
553        assert_eq!(report.summary.expected_failures, 0);
554        assert_eq!(report.summary.skipped, total_cases);
555        assert_eq!(report.summary.compliance_score, 0.0);
556        assert!(
557            report
558                .results
559                .iter()
560                .all(|result| result.verdict == ConnectMethodTestVerdict::Skipped)
561        );
562        assert!(
563            report
564                .results
565                .iter()
566                .all(|result| { result.error.as_deref().is_some_and(is_backend_unwired) })
567        );
568    }
569
570    #[tokio::test]
571    async fn markdown_report_explains_skipped_unwired_backends() {
572        let mut tester = ConnectMethodConformanceTester::new();
573        let report = tester.run_all_tests().await;
574
575        let markdown = tester.generate_markdown_report(&report);
576
577        assert!(markdown.contains("## Skipped Tests"));
578        assert!(markdown.contains("asupersync HTTP/2 CONNECT backend not wired"));
579        assert!(markdown.contains("h2 HTTP/2 CONNECT backend not wired"));
580        assert!(markdown.contains("refusing to synthesize comparison result"));
581    }
582}