Server gap pass: invite-partner subcommand, CLI and drain tests, push info log (D-113, D-114)
- kvitt-server invite-partner: a partner invite by the admin as E-12 does (cancels the pending one, 72 h, link with ?from=<admin>), exit 2 household full, 3 no admin; README lines in server/ and infra/. - Tests: invite-partner, migrate (schema at SCHEMA_VERSION, idempotent), healthcheck (200, 503, nothing listening), serve SIGTERM drain (in-flight request finishes, new connections refused, exit 0; gives up after SHUTDOWN_DRAIN). - Push sender: one info line "push sent" per message with push_type, devices_sent, devices_targeted and 8-character device-id tails (SPEC §8.7 aggregates); per-device line stays at debug. - tools/contracts-check.py: stale TODO(spec) replaced by the §9.11 CS-5 citation; shapes updated for invite_kind (D-086), app_version_name (D-090) and fid (D-096), 134 checks pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! `serve` as deployed: the router with `ConnectInfo<SocketAddr>` (so the rate limiter sees each client's address,
|
||||
//! SPEC §8.6), `/i/` with an empty code as the HTML 404 page, and `rotate-epoch` as the real binary (SPEC §10.4).
|
||||
//! SPEC §8.6), `/i/` with an empty code as the HTML 404 page, and `rotate-epoch`, `migrate`, `healthcheck` and the
|
||||
//! SIGTERM drain of `serve` as the real binary (SPEC §10.4).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::process::Command;
|
||||
@@ -88,3 +89,225 @@ async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() {
|
||||
assert!(after.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains(&after));
|
||||
}
|
||||
|
||||
/// The real binary with a data directory and listen address of the test's own; push disabled (no credentials).
|
||||
fn binary(dir: &std::path::Path, bind: SocketAddr) -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_kvitt-server"));
|
||||
command
|
||||
.env("KVITT_DATA_DIR", dir)
|
||||
.env("KVITT_BIND", bind.to_string())
|
||||
.env("KVITT_FCM_CREDENTIALS", dir.join("no-such-key.json"))
|
||||
.env("RUST_LOG", "warn");
|
||||
command
|
||||
}
|
||||
|
||||
/// A loopback address with a port nothing listens on (bound once, then released).
|
||||
fn free_addr() -> SocketAddr {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrate_creates_the_database_at_the_current_schema_and_is_idempotent() {
|
||||
let dir = TempDir::new();
|
||||
let data = dir.0.join("data");
|
||||
for _ in 0..2 {
|
||||
let output = binary(&data, free_addr()).arg("migrate").output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{output:?}");
|
||||
}
|
||||
// Opened without migrating, so what is read is what `migrate` wrote.
|
||||
let pool = kvitt_server::db::connect(&data.join("kvitt.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
let version: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'schema_version'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version, kvitt_server::db::SCHEMA_VERSION.to_string());
|
||||
let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(users, 0);
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthcheck_exits_0_on_200_and_1_on_another_status_or_no_server() {
|
||||
let dir = TempDir::new();
|
||||
let healthcheck = |addr: SocketAddr| binary(&dir.0, addr).arg("healthcheck").output().unwrap();
|
||||
|
||||
// The real router: 200.
|
||||
let app = TestApp::empty().await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let up = listener.local_addr().unwrap();
|
||||
let service = make_service(app.state.clone());
|
||||
tokio::spawn(async move { axum::serve(listener, service).await.unwrap() });
|
||||
let ok = tokio::task::spawn_blocking({
|
||||
let dir = dir.0.clone();
|
||||
move || binary(&dir, up).arg("healthcheck").output().unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ok.status.code(), Some(0), "{ok:?}");
|
||||
|
||||
// A server answering 503 (as E-1 does when the database is unreachable).
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let unhealthy = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
|
||||
let _ = tokio::io::AsyncWriteExt::write_all(
|
||||
&mut stream,
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
let failed = tokio::task::spawn_blocking({
|
||||
let dir = dir.0.clone();
|
||||
move || binary(&dir, unhealthy).arg("healthcheck").output().unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(failed.status.code(), Some(1), "{failed:?}");
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("returned 503"));
|
||||
|
||||
// Nothing listening.
|
||||
let down = healthcheck(free_addr());
|
||||
assert_eq!(down.status.code(), Some(1), "{down:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod drain {
|
||||
use std::net::SocketAddr;
|
||||
use std::process::{Child, ExitStatus, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use super::{binary, free_addr};
|
||||
use crate::common::TempDir;
|
||||
use kvitt_server::cli::SHUTDOWN_DRAIN;
|
||||
|
||||
/// The body of an E-3 request whose last byte the test holds back.
|
||||
const BODY: &[u8] = br#"{"code":"0000-0000-0000","device_name":"x"}"#;
|
||||
|
||||
/// A running `serve`, killed and reaped on drop if the test did not see it exit.
|
||||
struct Serve(Child);
|
||||
|
||||
impl Drop for Serve {
|
||||
fn drop(&mut self) {
|
||||
if self.0.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `serve` on a free port, once `/health` answers 200.
|
||||
async fn start(dir: &TempDir) -> (Serve, SocketAddr) {
|
||||
let addr = free_addr();
|
||||
let child = Serve(
|
||||
binary(&dir.0, addr)
|
||||
.arg("serve")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Ok(reply) = reqwest::get(format!("http://{addr}/health")).await
|
||||
&& reply.status() == 200
|
||||
{
|
||||
return (child, addr);
|
||||
}
|
||||
assert!(Instant::now() < deadline, "serve did not come up");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// An E-3 request in flight: the head and all but the last byte of the body sent.
|
||||
async fn in_flight(addr: SocketAddr) -> TcpStream {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let head = format!(
|
||||
"POST /invites/accept HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
BODY.len()
|
||||
);
|
||||
stream.write_all(head.as_bytes()).await.unwrap();
|
||||
stream.write_all(&BODY[..BODY.len() - 1]).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
stream
|
||||
}
|
||||
|
||||
fn sigterm(child: &Serve) {
|
||||
let status = std::process::Command::new("kill")
|
||||
.args(["-TERM", &child.0.id().to_string()])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
async fn wait(child: &mut Serve, within: Duration) -> ExitStatus {
|
||||
let deadline = Instant::now() + within;
|
||||
loop {
|
||||
if let Some(status) = child.0.try_wait().unwrap() {
|
||||
return status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "serve did not exit");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sigterm_lets_an_in_flight_request_finish_then_exits_0() {
|
||||
let dir = TempDir::new();
|
||||
let (mut child, addr) = start(&dir).await;
|
||||
let mut stream = in_flight(addr).await;
|
||||
|
||||
sigterm(&child);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
child.0.try_wait().unwrap().is_none(),
|
||||
"exited with a request in flight"
|
||||
);
|
||||
assert!(
|
||||
TcpStream::connect(addr).await.is_err(),
|
||||
"new connections are no longer accepted"
|
||||
);
|
||||
|
||||
stream.write_all(&BODY[BODY.len() - 1..]).await.unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).await.unwrap();
|
||||
let reply = String::from_utf8_lossy(&reply);
|
||||
assert!(reply.starts_with("HTTP/1.1 400"), "{reply}");
|
||||
assert!(reply.contains("invite_invalid"), "{reply}");
|
||||
|
||||
assert_eq!(
|
||||
wait(&mut child, Duration::from_secs(5)).await.code(),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sigterm_stops_waiting_after_the_drain_period_and_exits_0() {
|
||||
let dir = TempDir::new();
|
||||
let (mut child, addr) = start(&dir).await;
|
||||
let _stuck = in_flight(addr).await;
|
||||
|
||||
let signalled = Instant::now();
|
||||
sigterm(&child);
|
||||
let status = wait(&mut child, SHUTDOWN_DRAIN + Duration::from_secs(5)).await;
|
||||
assert_eq!(status.code(), Some(0));
|
||||
assert!(
|
||||
signalled.elapsed() >= SHUTDOWN_DRAIN - Duration::from_millis(500),
|
||||
"exited after {:?}, before the drain period",
|
||||
signalled.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user