use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use powdb_server::protocol::{decode_error_class, ErrorClass, Message};
fn encode_connect(db: &str, password: Option<&str>) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&(db.len() as u32).to_le_bytes());
payload.extend_from_slice(db.as_bytes());
match password {
Some(pw) => {
payload.extend_from_slice(&(pw.len() as u32).to_le_bytes());
payload.extend_from_slice(pw.as_bytes());
}
None => payload.extend_from_slice(&0u32.to_le_bytes()),
}
let mut frame = Vec::new();
frame.push(0x01); frame.push(0); frame.extend_from_slice(&(payload.len() as u32).to_le_bytes());
frame.extend_from_slice(&payload);
frame
}
fn encode_query(q: &str) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&(q.len() as u32).to_le_bytes());
payload.extend_from_slice(q.as_bytes());
let mut frame = Vec::new();
frame.push(0x03); frame.push(0);
frame.extend_from_slice(&(payload.len() as u32).to_le_bytes());
frame.extend_from_slice(&payload);
frame
}
async fn read_response(stream: &mut TcpStream) -> Vec<u8> {
let mut header = [0u8; 6];
stream.read_exact(&mut header).await.unwrap();
let payload_len = u32::from_le_bytes(header[2..6].try_into().unwrap()) as usize;
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
stream.read_exact(&mut payload).await.unwrap();
}
let mut full = Vec::new();
full.extend_from_slice(&header);
full.extend_from_slice(&payload);
full
}
fn old_client_error_message(frame: &[u8]) -> String {
assert_eq!(frame[0], 0x0A, "expected MSG_ERROR, got 0x{:02X}", frame[0]);
match Message::decode(frame).expect("old-style decode must succeed") {
Message::Error { message } => message,
other => panic!("expected Error, got {other:?}"),
}
}
async fn spawn_server(data_dir: std::path::PathBuf, password: Option<&str>) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let expected_password = password.map(|p| zeroize::Zeroizing::new(p.to_string()));
tokio::spawn(async move {
let engine = powdb_query::executor::Engine::new(&data_dir).unwrap();
let engine = std::sync::Arc::new(std::sync::RwLock::new(engine));
let tx_gate = powdb_server::handler::new_tx_gate();
loop {
let (stream, peer) = listener.accept().await.unwrap();
let eng = engine.clone();
let tx_gate = tx_gate.clone();
let expected_password = expected_password.clone();
let (_, mut rx) = tokio::sync::watch::channel(false);
tokio::spawn(async move {
powdb_server::handler::handle_connection(
stream,
powdb_server::handler::ConnOpts {
tx_wait_timeout: Duration::from_secs(5),
db_name: None,
engine: eng,
tx_gate,
expected_password,
users: std::sync::Arc::new(powdb_auth::UserStore::new()),
shutdown_rx: &mut rx,
idle_timeout: Duration::from_secs(300),
query_timeout: Duration::from_secs(30),
rate_limiter: None,
peer_addr: Some(peer),
metrics: std::sync::Arc::new(powdb_server::metrics::Metrics::new()),
},
)
.await;
});
}
});
addr
}
async fn connect_ok(addr: &str) -> TcpStream {
let mut stream = TcpStream::connect(addr).await.unwrap();
stream
.write_all(&encode_connect("testdb", None))
.await
.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp[0], 0x02, "expected CONNECT_OK");
stream
}
async fn query_error_frame(stream: &mut TcpStream, query: &str) -> Vec<u8> {
stream.write_all(&encode_query(query)).await.unwrap();
let frame = read_response(stream).await;
assert_eq!(
frame[0], 0x0A,
"expected MSG_ERROR for {query:?}, got 0x{:02X}",
frame[0]
);
frame
}
#[tokio::test]
async fn server_errors_carry_stable_class_and_stay_old_client_decodable() {
let data_dir = tempfile::tempdir().unwrap();
let addr = spawn_server(data_dir.path().to_path_buf(), None).await;
let mut stream = connect_ok(&addr).await;
let frame = query_error_frame(&mut stream, "type type type {{{").await;
assert_eq!(
decode_error_class(&frame),
Some(ErrorClass::Parse.as_u8()),
"parse errors must carry class byte 1"
);
let message = old_client_error_message(&frame);
assert!(
!message.is_empty(),
"old-style decode must still yield the message"
);
let frame = query_error_frame(&mut stream, "NoSuchTable filter .x = 1").await;
assert_eq!(
decode_error_class(&frame),
Some(ErrorClass::Execution.as_u8())
);
let message = old_client_error_message(&frame);
assert!(
message.contains("not found"),
"unexpected message: {message}"
);
let payload = &frame[6..];
let string_len = u32::from_le_bytes(payload[..4].try_into().unwrap()) as usize;
assert_eq!(
payload.len(),
4 + string_len + 1,
"MSG_ERROR payload must be string + exactly one class byte"
);
}
#[tokio::test]
async fn auth_failure_carries_auth_class() {
let data_dir = tempfile::tempdir().unwrap();
let addr = spawn_server(data_dir.path().to_path_buf(), Some("hunter2")).await;
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream
.write_all(&encode_connect("testdb", Some("wrong-password")))
.await
.unwrap();
let frame = read_response(&mut stream).await;
assert_eq!(frame[0], 0x0A, "expected MSG_ERROR for bad password");
assert_eq!(
decode_error_class(&frame),
Some(ErrorClass::AuthFailed.as_u8())
);
assert_eq!(old_client_error_message(&frame), "authentication failed");
}
#[tokio::test]
async fn legacy_error_frame_without_class_byte_decodes_as_no_class() {
let legacy_frame = Message::Error {
message: "table 'users' not found".into(),
}
.encode();
assert_eq!(decode_error_class(&legacy_frame), None);
assert_eq!(
old_client_error_message(&legacy_frame),
"table 'users' not found"
);
}
#[tokio::test]
async fn readonly_refusal_never_leaks_sentinel_and_carries_readonly_class() {
const SENTINEL: &str = "__POWDB_READONLY_NEEDS_WRITE__";
let data_dir = tempfile::tempdir().unwrap();
{
let mut engine = powdb_query::executor::Engine::new(data_dir.path()).unwrap();
engine
.execute_powql("type User { required name: str, age: int }")
.unwrap();
engine
.execute_powql(r#"insert User { name := "Ada", age := 36 }"#)
.unwrap();
}
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_powdb-server"))
.arg("--port")
.arg(port.to_string())
.arg("--bind")
.arg("127.0.0.1")
.arg("--data-dir")
.arg(data_dir.path())
.arg("--readonly")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn powdb-server --readonly");
let mut stream = {
let start = std::time::Instant::now();
loop {
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)).await {
break stream;
}
assert!(
start.elapsed() <= Duration::from_secs(10),
"read-only server did not bind within 10s"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
};
stream
.write_all(&encode_connect("testdb", None))
.await
.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp[0], 0x02, "expected CONNECT_OK");
let frame = query_error_frame(&mut stream, r#"insert User { name := "Eve", age := 1 }"#).await;
let message = old_client_error_message(&frame);
assert!(
!message.contains(SENTINEL),
"readonly sentinel leaked over the wire: {message}"
);
assert!(
message.starts_with("readonly mode"),
"expected the operator-facing readonly message, got: {message}"
);
assert_eq!(
decode_error_class(&frame),
Some(ErrorClass::ReadonlyRefused.as_u8()),
"readonly refusal must carry class byte 5"
);
stream
.write_all(&encode_query("User filter .age > 0"))
.await
.unwrap();
let read_frame = read_response(&mut stream).await;
assert_eq!(read_frame[0], 0x07, "expected RESULT_ROWS after refusal");
assert_eq!(decode_error_class(&read_frame), None);
child.kill().ok();
child.wait().ok();
}