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:
+166
-1
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user