Skip to main content

mongreldb_server/
lib.rs

1//! mongreldb-server — a long-lived process holding a multi-table `Database`
2//! open, serving SQL + table-qualified native APIs over HTTP.
3//!
4//! Endpoints:
5//!   GET    /health                    → 200 OK
6//!   GET    /tables                    → ["t1", "t2", ...]
7//!   POST   /tables                    → create table
8//!   DELETE /tables/{name}              → drop table
9//!   POST   /tables/{name}/put          → upsert one row
10//!   POST   /tables/{name}/count        → { "count": N }
11//!   POST   /tables/{name}/commit       → { "epoch": N }
12//!   POST   /sql                       → Arrow IPC bytes
13//!   POST   /txn                       → atomic cross-table transaction
14//!
15//! Usage: `mongreldb-server <db_dir> [port]`
16
17use std::sync::Arc;
18
19use axum::extract::{Path, State};
20use axum::http::header;
21use axum::http::StatusCode;
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::Json;
25use mongreldb_core::schema::{Schema, TypeId};
26use mongreldb_core::{Database, Value};
27use mongreldb_query::{ExternalTableModule, MongrelSession};
28use serde::Deserialize;
29use serde_json::json;
30
31mod kit;
32mod procedure;
33mod trigger;
34
35struct AppState {
36    db: Arc<Database>,
37    idem: kit::IdempotencyStore,
38    external_modules: Vec<Arc<dyn ExternalTableModule>>,
39    auth_token: Option<String>,
40    /// When true, authenticate via catalog users (HTTP Basic auth).
41    user_auth: bool,
42}
43
44pub fn build_app(db: Arc<Database>) -> axum::Router {
45    build_app_with_config(db, std::iter::empty::<Arc<dyn ExternalTableModule>>(), None, None)
46}
47
48pub fn build_app_with_external_modules(
49    db: Arc<Database>,
50    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
51) -> axum::Router {
52    build_app_with_config(db, external_modules, None, None)
53}
54
55/// Build the daemon router with optional auth token and max-connections limit.
56pub fn build_app_with_config(
57    db: Arc<Database>,
58    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
59    auth_token: Option<String>,
60    max_connections: Option<usize>,
61) -> axum::Router {
62    build_app_full(db, external_modules, auth_token, max_connections, false)
63}
64
65/// Build the daemon router with full auth configuration including user-based auth.
66pub fn build_app_full(
67    db: Arc<Database>,
68    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
69    auth_token: Option<String>,
70    max_connections: Option<usize>,
71    user_auth: bool,
72) -> axum::Router {
73    let state = Arc::new(AppState {
74        idem: kit::IdempotencyStore::new(db.root()),
75        db,
76        external_modules: external_modules.into_iter().collect(),
77        auth_token,
78        user_auth,
79    });
80    let router = axum::Router::new()
81        .route("/health", get(health))
82        .route("/tables", get(list_tables).post(create_table))
83        .route("/tables/{name}", axum::routing::delete(drop_table))
84        .route("/tables/{name}/put", post(put_row))
85        .route("/tables/{name}/count", get(count))
86        .route("/tables/{name}/commit", post(commit))
87        .route("/sql", post(sql))
88        .route("/txn", post(txn))
89        .route("/procedures", get(procedure::list).post(procedure::create))
90        .route(
91            "/procedures/{name}",
92            get(procedure::describe)
93                .put(procedure::replace)
94                .delete(procedure::drop_procedure),
95        )
96        .route("/procedures/{name}/call", post(procedure::call))
97        .route("/triggers", get(trigger::list).post(trigger::create))
98        .route(
99            "/triggers/{name}",
100            get(trigger::describe)
101                .put(trigger::replace)
102                .delete(trigger::drop_trigger),
103        )
104        // Typed Kit-aware surface (authoritative validation + constraints).
105        .route("/kit/schema", get(kit::schema_all))
106        .route("/kit/schema/{table}", get(kit::schema_one))
107        .route("/kit/txn", post(kit::kit_txn))
108        .route("/kit/query", post(kit::kit_query))
109        .route("/kit/create_table", post(kit::kit_create_table))
110        .route("/kit/procedures/{name}/call", post(procedure::kit_call))
111        .route("/compact", post(compact_all))
112        .route("/tables/{name}/compact", post(compact_table))
113        .route("/wal/stream", get(wal_stream))
114        .route("/events", get(events_stream))
115        .with_state(state.clone());
116
117    // Apply auth middleware if token auth or user auth is enabled.
118    let router = if state.auth_token.is_some() || state.user_auth {
119        router.layer(axum::middleware::from_fn_with_state(
120            state.clone(),
121            auth_middleware,
122        ))
123    } else {
124        router
125    };
126
127    // Apply connection limit if configured.
128    if let Some(max) = max_connections {
129        router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
130    } else {
131        router
132    }
133}
134
135/// Auth middleware supporting three modes:
136/// 1. **Token** (`--auth-token <token>`): checks `Authorization: Bearer <token>`.
137/// 2. **User auth** (`--auth-users`): checks `Authorization: Basic <base64(user:pass)>`
138///    against catalog users (Argon2id-verified). Injects a `Principal` into
139///    request extensions.
140/// 3. **Both**: token OR valid user credentials accepted.
141async fn auth_middleware(
142    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
143    mut req: axum::extract::Request,
144    next: axum::middleware::Next,
145) -> Result<axum::response::Response, axum::http::StatusCode> {
146    let header = req
147        .headers()
148        .get("authorization")
149        .and_then(|v| v.to_str().ok())
150        .unwrap_or("");
151
152    // Mode 1: Token auth (Bearer).
153    if let Some(token) = &state.auth_token {
154        if let Some(provided) = header.strip_prefix("Bearer ") {
155            if provided == token {
156                return Ok(next.run(req).await);
157            }
158        }
159    }
160
161    // Mode 2: User auth (Basic).
162    if state.user_auth {
163        if let Some(encoded) = header.strip_prefix("Basic ") {
164            if let Ok(decoded) = base64_decode(encoded) {
165                if let Ok(creds) = std::str::from_utf8(&decoded) {
166                    if let Some((username, password)) = creds.split_once(':') {
167                        if let Ok(Some(_user)) = state.db.verify_user(username, password) {
168                            // Inject the principal for permission checks.
169                            if let Some(principal) = state.db.resolve_principal(username) {
170                                req.extensions_mut().insert(principal);
171                            }
172                            return Ok(next.run(req).await);
173                        }
174                    }
175                }
176            }
177        }
178    }
179
180    Err(axum::http::StatusCode::UNAUTHORIZED)
181}
182
183/// Minimal Base64 decoder (no extra dep).
184fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
185    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
186    let input: Vec<u8> = input.bytes().filter(|&b| b != b'\n' && b != b'\r' && b != b' ').collect();
187    let mut out = Vec::with_capacity(input.len() * 3 / 4);
188    let mut buf = 0u32;
189    let mut bits = 0u32;
190    for &b in &input {
191        if b == b'=' {
192            break;
193        }
194        let val = TABLE.iter().position(|&t| t == b).ok_or(())? as u32;
195        buf = (buf << 6) | val;
196        bits += 6;
197        if bits >= 8 {
198            bits -= 8;
199            out.push((buf >> bits) as u8);
200        }
201    }
202    Ok(out)
203}
204
205/// `GET /wal/stream?since=<seq>` — stream committed WAL records as
206/// newline-delimited JSON for replication followers. Each line is a JSON
207/// object `{ "seq": N, "txn_id": N, "op": {...} }`.
208async fn wal_stream(
209    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
210    axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
211) -> Result<Response, StatusCode> {
212    let db_root = state.db.root().to_path_buf();
213    let since = params.since.unwrap_or(0);
214
215    // Read all committed WAL records with seq > since and stream as NDJSON.
216    let body = tokio::task::spawn_blocking(move || -> Result<String, String> {
217        let records = mongreldb_core::wal::SharedWal::replay(&db_root).map_err(|e| e.to_string())?;
218        let mut out = String::new();
219        for record in records.iter().filter(|r| r.seq.0 > since) {
220            if let Ok(json) = serde_json::to_string(record) {
221                out.push_str(&json);
222                out.push('\n');
223            }
224        }
225        Ok(out)
226    })
227    .await
228    .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
229
230    let body = body.map_err(|e| {
231        eprintln!("wal_stream error: {e}");
232        StatusCode::INTERNAL_SERVER_ERROR
233    })?;
234
235    Ok((
236        [
237            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
238            (header::CACHE_CONTROL, "no-cache".to_string()),
239        ],
240        body,
241    )
242        .into_response())
243}
244
245#[derive(serde::Deserialize)]
246struct WalStreamParams {
247    since: Option<u64>,
248}
249
250/// `GET /events` — stream change-data-capture events (from NOTIFY/LISTEN and
251/// committed Put/Delete operations) as newline-delimited JSON. Each line is
252/// a JSON `ChangeEvent { channel, table, op, epoch, message }`.
253async fn events_stream(
254    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
255) -> Result<Response, StatusCode> {
256    let mut rx = state.db.subscribe_changes();
257    let body = tokio::task::spawn_blocking(move || {
258        // Drain the current backlog (non-blocking).
259        let mut out = String::new();
260        while let Ok(event) = rx.try_recv() {
261            if let Ok(json) = serde_json::to_string(&event) {
262                out.push_str(&json);
263                out.push('\n');
264            }
265        }
266        out
267    })
268    .await
269    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
270
271    Ok((
272        [
273            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
274            (header::CACHE_CONTROL, "no-cache".to_string()),
275        ],
276        body,
277    )
278        .into_response())
279}
280
281/// Launch the §5.9 background auto-compaction sweep (run-count cost trigger).
282/// One OS thread, sleeping `interval` between sweeps; each tick locks each
283/// table individually and calls `Table::maybe_compact`. Best-effort: a
284/// compaction error is logged and never aborts the sweep.
285pub fn spawn_auto_compactor(db: Arc<Database>) {
286    std::thread::Builder::new()
287        .name("mongreldb-auto-compact".into())
288        .spawn(move || loop {
289            std::thread::sleep(std::time::Duration::from_secs(30));
290            for name in db.table_names() {
291                let Ok(handle) = db.table(&name) else {
292                    continue;
293                };
294                let mut t = handle.lock();
295                let before = t.run_count();
296                match t.maybe_compact() {
297                    Ok(true) => {
298                        eprintln!(
299                            "[auto-compact] {name}: {} runs -> {}",
300                            before,
301                            t.run_count()
302                        );
303                    }
304                    Ok(false) => {}
305                    Err(e) => {
306                        eprintln!("[auto-compact] {name}: compaction failed: {e}");
307                    }
308                }
309            }
310        })
311        .expect("spawn auto-compact thread");
312}
313
314async fn health() -> StatusCode {
315    StatusCode::OK
316}
317
318/// `POST /compact` — compact all mounted tables.
319async fn compact_all(State(state): State<Arc<AppState>>) -> (StatusCode, Json<serde_json::Value>) {
320    match state.db.compact() {
321        Ok((compacted, skipped)) => (
322            StatusCode::OK,
323            Json(json!({
324                "status": "ok",
325                "compacted": compacted,
326                "skipped": skipped,
327            })),
328        ),
329        Err(e) => (
330            StatusCode::INTERNAL_SERVER_ERROR,
331            Json(json!({ "status": "error", "message": format!("{e}") })),
332        ),
333    }
334}
335
336/// `POST /tables/{name}/compact` — compact a single table.
337async fn compact_table(
338    State(state): State<Arc<AppState>>,
339    Path(name): Path<String>,
340) -> (StatusCode, Json<serde_json::Value>) {
341    match state.db.compact_table(&name) {
342        Ok(true) => (
343            StatusCode::OK,
344            Json(json!({ "status": "compacted", "table": name })),
345        ),
346        Ok(false) => (
347            StatusCode::OK,
348            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
349        ),
350        Err(e) => (
351            StatusCode::INTERNAL_SERVER_ERROR,
352            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
353        ),
354    }
355}
356
357#[derive(Deserialize)]
358struct CreateTableRequest {
359    name: String,
360    columns: Vec<ColumnDefJson>,
361}
362
363#[derive(Deserialize)]
364struct ColumnDefJson {
365    id: u16,
366    name: String,
367    ty: String,
368    primary_key: bool,
369    #[serde(default)]
370    nullable: bool,
371}
372
373async fn create_table(
374    State(state): State<Arc<AppState>>,
375    Json(req): Json<CreateTableRequest>,
376) -> Response {
377    let mut columns = Vec::new();
378    for c in &req.columns {
379        let ty = match c.ty.as_str() {
380            "int64" | "bigint" => TypeId::Int64,
381            "float64" | "double" => TypeId::Float64,
382            "bytes" | "varchar" | "text" => TypeId::Bytes,
383            "bool" => TypeId::Bool,
384            other => {
385                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
386            }
387        };
388        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
389        if c.primary_key {
390            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
391        }
392        if c.nullable {
393            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
394        }
395        columns.push(mongreldb_core::schema::ColumnDef {
396            id: c.id,
397            name: c.name.clone(),
398            ty,
399            flags,
400        });
401    }
402    let schema = Schema {
403        schema_id: 0,
404        columns,
405        indexes: vec![],
406        colocation: vec![],
407        constraints: Default::default(),
408        clustered: false,
409    };
410    if let Err(msg) = validate_table_name(&req.name) {
411        return (StatusCode::BAD_REQUEST, msg).into_response();
412    }
413    match state.db.create_table(&req.name, schema) {
414        Ok(id) => Json(json!({ "table_id": id })).into_response(),
415        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
416    }
417}
418
419async fn list_tables(State(state): State<Arc<AppState>>) -> Json<Vec<String>> {
420    Json(state.db.table_names())
421}
422
423async fn drop_table(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
424    match state.db.drop_table(&name) {
425        Ok(_) => StatusCode::OK.into_response(),
426        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
427    }
428}
429
430#[derive(Deserialize)]
431struct PutRequest {
432    row: Vec<serde_json::Value>,
433}
434
435pub(crate) fn json_to_value(v: &serde_json::Value, expected: TypeId) -> Value {
436    match (v, expected) {
437        (serde_json::Value::Number(n), TypeId::Float64) => {
438            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
439        }
440        (serde_json::Value::Number(n), TypeId::Int64) => {
441            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
442        }
443        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
444        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
445        (serde_json::Value::Null, _) => Value::Null,
446        // Lenient fallbacks for unknown/loosely-typed JSON.
447        (serde_json::Value::Number(n), _) => {
448            if let Some(i) = n.as_i64() {
449                Value::Int64(i)
450            } else if let Some(f) = n.as_f64() {
451                Value::Float64(f)
452            } else {
453                Value::Null
454            }
455        }
456        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
457        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
458        _ => Value::Null,
459    }
460}
461
462/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
463/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
464fn parse_cells(
465    row: &[serde_json::Value],
466    schema: &mongreldb_core::schema::Schema,
467) -> Result<Vec<(u16, Value)>, String> {
468    if row.len() & 1 != 0 {
469        return Err("row must be an even-length array of [col_id, value] pairs".into());
470    }
471    let mut out = Vec::with_capacity(row.len() / 2);
472    for chunk in row.chunks(2) {
473        let col_id = chunk[0]
474            .as_u64()
475            .ok_or("column id must be a non-negative integer")? as u16;
476        let expected = schema
477            .columns
478            .iter()
479            .find(|c| c.id == col_id)
480            .map(|c| c.ty)
481            .ok_or_else(|| format!("unknown column id {col_id}"))?;
482        let val = json_to_value(&chunk[1], expected);
483        out.push((col_id, val));
484    }
485    Ok(out)
486}
487
488/// Basic validation for a table name: non-empty and no path separators.
489pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
490    if name.is_empty() {
491        return Err("table name must not be empty".into());
492    }
493    if name.contains('/') || name.contains('\\') || name.contains('\0') {
494        return Err("table name contains invalid characters".into());
495    }
496    Ok(())
497}
498
499async fn put_row(
500    State(state): State<Arc<AppState>>,
501    Path(name): Path<String>,
502    Json(req): Json<PutRequest>,
503) -> Response {
504    let handle = match state.db.table(&name) {
505        Ok(h) => h,
506        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
507    };
508    let mut g = handle.lock();
509    let schema = g.schema().clone();
510    let row = match parse_cells(&req.row, &schema) {
511        Ok(r) => r,
512        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
513    };
514    match g.put(row) {
515        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
516        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
517    }
518}
519
520async fn count(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
521    let handle = match state.db.table(&name) {
522        Ok(h) => h,
523        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
524    };
525    Json(json!({ "count": handle.lock().count() })).into_response()
526}
527
528async fn commit(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
529    let handle = match state.db.table(&name) {
530        Ok(h) => h,
531        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
532    };
533    let mut g = handle.lock();
534    match g.commit() {
535        Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
536        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
537    }
538}
539
540#[derive(Deserialize)]
541struct SqlRequest {
542    sql: String,
543}
544
545async fn sql(State(state): State<Arc<AppState>>, Json(req): Json<SqlRequest>) -> Response {
546    let session = match MongrelSession::open_with_external_modules(
547        Arc::clone(&state.db),
548        state.external_modules.iter().cloned(),
549    ) {
550        Ok(s) => s,
551        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
552    };
553    match session.run(&req.sql).await {
554        Ok(batches) => {
555            if batches.is_empty() {
556                return StatusCode::OK.into_response();
557            }
558            let schema = batches[0].schema();
559            let mut buf = Vec::new();
560            let mut writer =
561                arrow::ipc::writer::FileWriter::try_new(&mut buf, schema.as_ref()).unwrap();
562            for b in &batches {
563                let _ = writer.write(b);
564            }
565            let _ = writer.finish();
566            drop(writer);
567            (
568                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.file")],
569                buf,
570            )
571                .into_response()
572        }
573        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
574    }
575}
576
577#[derive(Deserialize)]
578struct TxnOp {
579    table: String,
580    op: String,
581    cells: Option<Vec<serde_json::Value>>,
582    row_id: Option<u64>,
583}
584
585#[derive(Deserialize)]
586struct TxnRequest {
587    ops: Vec<TxnOp>,
588}
589
590async fn txn(State(state): State<Arc<AppState>>, Json(req): Json<TxnRequest>) -> Response {
591    // Pre-validate every op against the live schemas before entering the
592    // transaction, so malformed input returns 400 without consuming an epoch
593    // or poisoning a txn.
594    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
595    for op in &req.ops {
596        match op.op.as_str() {
597            "put" => {
598                let cells_json = match op.cells.as_ref() {
599                    Some(c) if !c.is_empty() => c,
600                    _ => {
601                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
602                            .into_response()
603                    }
604                };
605                let handle = match state.db.table(&op.table) {
606                    Ok(h) => h,
607                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
608                };
609                let schema = handle.lock().schema().clone();
610                let cells = match parse_cells(cells_json, &schema) {
611                    Ok(c) => c,
612                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
613                };
614                parsed.push((op.table.clone(), TxnAction::Put(cells)));
615            }
616            "delete" => {
617                let rid = match op.row_id {
618                    Some(r) => r,
619                    None => {
620                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
621                            .into_response()
622                    }
623                };
624                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
625            }
626            other => {
627                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
628            }
629        }
630    }
631
632    let result = state.db.transaction(|t| {
633        for (table, action) in &parsed {
634            match action {
635                TxnAction::Put(cells) => {
636                    t.put(table, cells.clone())?;
637                }
638                TxnAction::Delete(rid) => {
639                    t.delete(table, mongreldb_core::RowId(*rid))?;
640                }
641            }
642        }
643        Ok(())
644    });
645    match result {
646        Ok(_) => Json(json!({ "status": "committed" })).into_response(),
647        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
648    }
649}
650
651enum TxnAction {
652    Put(Vec<(u16, Value)>),
653    Delete(u64),
654}
655
656#[cfg(test)]
657mod auth_tests {
658    use super::*;
659    use mongreldb_core::Database;
660    use tempfile::tempdir;
661
662    #[tokio::test]
663    async fn auth_rejects_missing_token() {
664        let dir = tempdir().unwrap();
665        let db = Arc::new(Database::create(dir.path()).unwrap());
666        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
667        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
668        let addr = listener.local_addr().unwrap();
669        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
670        // Give the server a moment to start.
671        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
672
673        let client = reqwest::Client::new();
674        let resp = client
675            .get(format!("http://{addr}/health"))
676            .send()
677            .await
678            .unwrap();
679        assert_eq!(resp.status(), 401);
680    }
681
682    #[tokio::test]
683    async fn auth_accepts_valid_token() {
684        let dir = tempdir().unwrap();
685        let db = Arc::new(Database::create(dir.path()).unwrap());
686        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
687        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
688        let addr = listener.local_addr().unwrap();
689        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
690        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
691
692        let client = reqwest::Client::new();
693        let resp = client
694            .get(format!("http://{addr}/health"))
695            .header("Authorization", "Bearer secret")
696            .send()
697            .await
698            .unwrap();
699        assert_eq!(resp.status(), 200);
700    }
701
702    #[tokio::test]
703    async fn no_auth_when_token_unset() {
704        let dir = tempdir().unwrap();
705        let db = Arc::new(Database::create(dir.path()).unwrap());
706        let app = build_app_with_config(db, std::iter::empty(), None, None);
707        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
708        let addr = listener.local_addr().unwrap();
709        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
710        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
711
712        let client = reqwest::Client::new();
713        let resp = client
714            .get(format!("http://{addr}/health"))
715            .send()
716            .await
717            .unwrap();
718        assert_eq!(resp.status(), 200);
719    }
720}
721
722#[cfg(test)]
723mod wal_stream_tests {
724    use super::*;
725    use mongreldb_core::Database;
726    use tempfile::tempdir;
727
728    #[tokio::test]
729    async fn wal_stream_returns_records_after_commit() {
730        let dir = tempdir().unwrap();
731        let db = Arc::new(Database::create(dir.path()).unwrap());
732        let table_schema = mongreldb_core::schema::Schema {
733            schema_id: 1,
734            columns: vec![mongreldb_core::schema::ColumnDef {
735                id: 1,
736                name: "id".into(),
737                ty: TypeId::Int64,
738                flags: mongreldb_core::schema::ColumnFlags::empty()
739                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
740            }],
741            indexes: vec![],
742            colocation: vec![],
743            constraints: Default::default(),
744            clustered: false,
745        };
746        db.create_table("items", table_schema).unwrap();
747        // Write a row to generate WAL records.
748        let handle = db.table("items").unwrap();
749        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
750        handle.lock().flush().unwrap();
751
752        let app = build_app(db);
753        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
754        let addr = listener.local_addr().unwrap();
755        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
756        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
757
758        let resp = reqwest::get(format!("http://{addr}/wal/stream")).await.unwrap();
759        assert_eq!(resp.status(), 200);
760        let body = resp.text().await.unwrap();
761        // Should contain at least one record (the flush commit).
762        assert!(!body.is_empty(), "wal_stream should return records");
763        assert!(body.contains("seq"), "response should contain seq field");
764    }
765}