feat: Phase 6 — proxy polish, TLS, admin UI, replay CLI

## TLS (#11)
- Self-signed cert generated at startup via rcgen (covers localhost, 127.0.0.1,
  fut.ea.com, utas.mob.v4.fut.ea.com); activated with TLS_ENABLED=true
- Custom accept loop: tokio-rustls acceptor → hyper-util auto Builder → axum
  Router (no axum-server dependency — uses hyper 1.x natively)

## Replay CLI (#12)
- New binary: openfut-bridge-replay <file.json|dir> [bridge-url]
- Replays single capture or entire directory against Bridge
- Accepts self-signed certs automatically

## Capture quality (#13, #14)
- DELETE /_bridge/captures — wipe all capture files from disk
- Deduplication: same method+path within 1 s is skipped (configurable constant)

## Admin UI (#21, #22, #23)
- GET /_bridge/admin — embedded HTML dashboard; auto-refresh every 10 s
  Shows: live stats, SSE log of incoming traffic, endpoint status table,
  recent captures list with delete button
- GET /_bridge/status — per-endpoint mapped/known/unknown status
- GET /_bridge/captures/stream — SSE stream; event: capture on each request
  Uses tokio::sync::broadcast channel (capacity 256) in ProxyState

## Tests (#24, #25)
- 9 new tests: placeholder format, full HTTP integration (health, placeholder,
  captures list, delete captures, status), TLS cert generation + acceptor build
- Total bridge tests: 13/13 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 16:15:48 -07:00
parent a826e5f7d3
commit 3b7a4928c9
12 changed files with 1063 additions and 39 deletions
+166 -1
View File
@@ -1,4 +1,18 @@
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use openfut_bridge::{
capture::CapturedRequest,
config::Config,
mapper::{map_to_core, placeholder_response},
proxy::ProxyState,
routes,
};
use axum::routing::{any, delete, get};
use tower::ServiceExt;
// ── Unit tests ────────────────────────────────────────────────────────────────
#[test]
fn test_known_endpoint_maps_to_core() {
@@ -46,3 +60,154 @@ fn test_capture_with_response() {
assert_eq!(capture.response_status, Some(200));
assert!(capture.response_body.is_some());
}
// ── #24 Placeholder response format ──────────────────────────────────────────
#[test]
fn test_placeholder_response_has_required_fields() {
let resp = placeholder_response("GET", "/ut/game/fut/unknown");
assert_eq!(resp["status"], "ok");
assert!(resp["openfut_note"].is_string());
assert_eq!(resp["method"], "GET");
assert_eq!(resp["path"], "/ut/game/fut/unknown");
}
#[test]
fn test_placeholder_response_for_post() {
let resp = placeholder_response("POST", "/ut/auth/fifa");
assert_eq!(resp["status"], "ok");
assert_eq!(resp["method"], "POST");
}
// ── #25 Full HTTP integration test against bridge ────────────────────────────
fn build_test_app() -> axum::Router {
let cfg = Config {
listen_addr: "127.0.0.1:0".into(),
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
captures_dir: "/tmp/openfut-test-captures".into(),
placeholder_mode: true,
tls_enabled: false,
};
let state = ProxyState::new(cfg);
axum::Router::new()
.route("/_bridge/health", get(routes::health::get_health))
.route("/_bridge/captures", get(routes::admin::get_captures))
.route("/_bridge/captures", delete(routes::admin::delete_captures))
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.with_state(state)
}
#[tokio::test]
async fn test_bridge_health_endpoint() {
let app = build_test_app();
let resp = app
.oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "openfut-bridge");
}
#[tokio::test]
async fn test_bridge_placeholder_mode_returns_ok() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/ut/game/fut/completely/unknown/endpoint")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["openfut_note"].is_string());
}
#[tokio::test]
async fn test_bridge_captures_endpoint_returns_list() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/captures")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["captures"].is_array());
assert!(json["total"].is_number());
}
#[tokio::test]
async fn test_bridge_delete_captures() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/_bridge/captures")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["deleted"].is_number());
}
#[tokio::test]
async fn test_bridge_status_endpoint() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["endpoints"].is_array());
}
#[tokio::test]
async fn test_tls_cert_generation() {
let result = openfut_bridge::tls::generate_self_signed_cert();
assert!(result.is_ok(), "cert generation failed: {:?}", result.err());
let (cert_pem, key_pem) = result.unwrap();
assert!(!cert_pem.is_empty());
assert!(!key_pem.is_empty());
// Verify the PEM blocks are well-formed
let cert_str = String::from_utf8(cert_pem).unwrap();
let key_str = String::from_utf8(key_pem).unwrap();
assert!(cert_str.contains("BEGIN CERTIFICATE"));
assert!(key_str.contains("PRIVATE KEY"));
}
#[tokio::test]
async fn test_tls_acceptor_construction() {
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap();
let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem);
assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err());
}