tidaldb/tidal-net/tests/mtls.rs
jordan.washburn fe711870be feat: M8 phases 7-10 — gRPC transport, cluster server, scatter-gather, multi-node UAT
Delivers the distributed fabric's network layer and HTTP cluster surface:

**m8p7: tidal-net crate (gRPC transport)**
- GrpcTransport implementing Transport trait via tonic 0.12
- Per-peer circuit breaker (Closed/Open/HalfOpen), mutual TLS via rustls
- Boxed error types (clippy-clean), graceful mutex recovery, debug_assert
  against calling block_on from tokio context
- Proto: WalShipping service (ShipSegment, StreamSegments stub, Heartbeat)
- 19 tests: contract, mTLS, reconnection, multi-node UAT, benchmarks

**m8p8: cluster subcommand + HTTP routes**
- ClusterState wrapping SimulatedCluster with region name mapping
- Routes: /health, /cluster/status, /cluster/promote, /partition, /heal
- Data routes: /items, /embeddings, /signals, /feed, /search (region-aware)
- Ranking profiles wired through ClusterConfig to all cluster nodes
- Topology YAML config, docker/cluster/Dockerfile (ENTRYPOINT+CMD, non-root)

**m8p9: scatter-gather query routing**
- Entity-sharded writes via Knuth multiplicative hash
- Scatter-gather RETRIEVE and SEARCH with deadline propagation (50ms-5ms)
- Partial failure: degraded=true with unavailable_shards metadata
- 6 tests: distribution, determinism, multi-shard retrieve, degraded
  partial results, deadline propagation, scatter-gather search

**m8p10: gRPC transport integration tests**
- 8 tests over real gRPC: replication convergence, idempotent replay,
  mixed signals, 3-node fan-out, partition/heal, degraded follower, perf
- Documented as tier-2 (in-process+gRPC); tier-3 multi-process pending
2026-04-11 13:51:08 -06:00

124 lines
4.0 KiB
Rust

//! Tests for mutual TLS configuration.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::thread;
use std::time::Duration;
use tidaldb::replication::WalSegmentId;
use tidaldb::replication::shard::{RegionId, ShardId};
use tidaldb::replication::transport::{Transport, WalSegmentPayload};
use tidal_net::GrpcTransport;
use tidal_net::config::{GrpcTransportConfig, TlsConfig};
fn free_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
}
/// Generate a self-signed CA and server/client certificates using rcgen.
fn generate_certs(dir: &std::path::Path) -> TlsConfig {
use rcgen::{CertificateParams, KeyPair};
// Generate CA.
let ca_key = KeyPair::generate().unwrap();
let mut ca_params = CertificateParams::new(vec!["tidaldb-ca".to_string()]).unwrap();
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
// Generate server cert signed by CA.
let server_key = KeyPair::generate().unwrap();
let server_params =
CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]).unwrap();
let server_cert = server_params
.signed_by(&server_key, &ca_cert, &ca_key)
.unwrap();
// Generate client cert signed by CA.
let client_key = KeyPair::generate().unwrap();
let client_params = CertificateParams::new(vec!["tidaldb-client".to_string()]).unwrap();
let client_cert = client_params
.signed_by(&client_key, &ca_cert, &ca_key)
.unwrap();
// Write to files.
let ca_cert_path = dir.join("ca.pem");
let server_cert_path = dir.join("server.pem");
let server_key_path = dir.join("server-key.pem");
let client_cert_path = dir.join("client.pem");
let client_key_path = dir.join("client-key.pem");
std::fs::write(&ca_cert_path, ca_cert.pem()).unwrap();
std::fs::write(&server_cert_path, server_cert.pem()).unwrap();
std::fs::write(&server_key_path, server_key.serialize_pem()).unwrap();
std::fs::write(&client_cert_path, client_cert.pem()).unwrap();
std::fs::write(&client_key_path, client_key.serialize_pem()).unwrap();
TlsConfig {
ca_cert: ca_cert_path,
server_cert: server_cert_path,
server_key: server_key_path,
client_cert: Some(client_cert_path),
client_key: Some(client_key_path),
}
}
#[test]
fn mtls_send_and_receive() {
let tmp = tempfile::tempdir().unwrap();
let tls = generate_certs(tmp.path());
let addr0 = free_addr();
let addr1 = free_addr();
let config0 = GrpcTransportConfig {
local_shard: ShardId(0),
listen_addr: addr0,
peers: HashMap::from([(ShardId(1), addr1)]),
tls: Some(tls.clone()),
insecure: false,
..Default::default()
};
let config1 = GrpcTransportConfig {
local_shard: ShardId(1),
listen_addr: addr1,
peers: HashMap::from([(ShardId(0), addr0)]),
tls: Some(tls),
insecure: false,
..Default::default()
};
let t0 = GrpcTransport::new(config0).expect("transport 0 with TLS");
let t1 = GrpcTransport::new(config1).expect("transport 1 with TLS");
thread::sleep(Duration::from_millis(200));
let payload = WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 99),
bytes: vec![0xEF; 64],
event_count: 2,
};
t0.send_segment(ShardId(1), payload).unwrap();
let received = t1.recv_segment().unwrap();
assert_eq!(received.id.seqno, 99);
assert_eq!(received.event_count, 2);
}
#[test]
fn plaintext_rejected_when_not_insecure() {
let addr = free_addr();
let config = GrpcTransportConfig {
local_shard: ShardId(0),
listen_addr: addr,
peers: HashMap::new(),
tls: None,
insecure: false, // Should reject — no TLS and not insecure.
..Default::default()
};
let result = GrpcTransport::new(config);
assert!(result.is_err(), "should reject plaintext when not insecure");
}