1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
9#![cfg_attr(coverage_nightly, allow(unused_features))]
12
13use std::collections::HashSet;
14use std::net::SocketAddr;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::{
20 error_handling::HandleErrorLayer,
21 extract::{rejection::JsonRejection, DefaultBodyLimit, Path, Request, State},
22 http::StatusCode,
23 middleware::{self, Next},
24 response::{IntoResponse, Response},
25 routing::{get, post},
26 Extension, Json, Router,
27};
28use improv_core_model::{Coordinate, MeasureId, MeasureKind, Model};
29use improv_engine::dataflow::evaluate;
30use improv_engine::session::{Edit, Engine};
31use improv_engine::{decode_coord, CellValue, CoordKey};
32use improv_nl_formula::{describe_formula, parse_nl_formula, NlContext};
33use improv_store::{Store, StoreError};
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value as JsonValue};
36use tower::ServiceBuilder;
37
38mod scheduler;
39use scheduler::SharedStatus;
40
41#[derive(Clone)]
47pub enum Auth {
48 Disabled,
49 Tokens {
50 rw: HashSet<String>,
51 ro: HashSet<String>,
52 },
53}
54
55impl Auth {
56 fn from_env() -> Self {
60 let list = |var: &str| -> HashSet<String> {
61 std::env::var(var)
62 .unwrap_or_default()
63 .split(',')
64 .map(str::trim)
65 .filter(|t| !t.is_empty())
66 .map(String::from)
67 .collect()
68 };
69 let mut rw = list("IMPROV_API_TOKENS");
70 if let Ok(t) = std::env::var("IMPROV_API_TOKEN") {
71 let t = t.trim();
72 if !t.is_empty() {
73 rw.insert(t.to_string());
74 }
75 }
76 let ro = list("IMPROV_API_TOKENS_RO");
77 if rw.is_empty() && ro.is_empty() {
78 Auth::Disabled
79 } else {
80 Auth::Tokens { rw, ro }
81 }
82 }
83}
84
85#[derive(Clone, Copy)]
87struct ReadOnly;
88
89const MAX_BODY_BYTES: usize = 1 << 20;
91const MAX_EDITS: usize = 10_000;
93const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
95const MAX_IN_FLIGHT: usize = 64;
97const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
99
100pub struct AppState {
101 db_path: String,
102 auth: Auth,
103 scheduler_status: Option<SharedStatus>,
105 write_lock: tokio::sync::Mutex<()>,
108 cache: tokio::sync::RwLock<Option<Arc<Model>>>,
110 loaded_tx: std::sync::Mutex<Option<i64>>,
113 #[cfg(test)]
115 loads: AtomicU64,
116}
117
118impl AppState {
119 pub fn new(db_path: String, auth: Auth, scheduler_status: Option<SharedStatus>) -> Self {
120 AppState {
121 db_path,
122 auth,
123 scheduler_status,
124 write_lock: tokio::sync::Mutex::new(()),
125 cache: tokio::sync::RwLock::new(None),
126 loaded_tx: std::sync::Mutex::new(None),
127 #[cfg(test)]
128 loads: AtomicU64::new(0),
129 }
130 }
131}
132
133#[cfg_attr(coverage_nightly, coverage(off))]
136#[tokio::main]
137async fn main() {
138 let mut args = std::env::args().skip(1);
141 let db_path = args.next().unwrap_or_else(|| "improv.db".to_string());
142 let addr = args
143 .next()
144 .or_else(|| std::env::var("IMPROV_ADDR").ok())
145 .unwrap_or_else(|| "127.0.0.1:3000".to_string());
146
147 let auth = Auth::from_env();
148 if matches!(auth, Auth::Disabled) {
149 eprintln!("auth disabled: set IMPROV_API_TOKEN to require a bearer token");
150 }
151 let listener = tokio::net::TcpListener::bind(&addr)
152 .await
153 .unwrap_or_else(|e| panic!("bind {addr}: {e}"));
154 let local = listener.local_addr().expect("bound socket has an address");
155 let allow_open = std::env::var("IMPROV_ALLOW_OPEN").ok();
156 if let Err(e) = startup_check(local, &auth, allow_open.as_deref()) {
157 eprintln!("improv-server: {e}");
158 std::process::exit(2);
159 }
160
161 let scheduler_enabled =
162 scheduler::scheduler_enabled(std::env::var("IMPROV_SCHEDULER").ok().as_deref());
163 let tick_secs = std::env::var("IMPROV_SCHEDULER_TICK_SECS")
164 .ok()
165 .and_then(|v| v.parse::<u64>().ok())
166 .unwrap_or(5);
167
168 let scheduler_status = scheduler_enabled.then(|| {
169 Arc::new(std::sync::Mutex::new(scheduler::SchedulerStatus {
170 tick_secs,
171 ..Default::default()
172 }))
173 });
174 let state = Arc::new(AppState::new(db_path, auth, scheduler_status.clone()));
175 if let Some(status) = scheduler_status {
176 let allow = match std::env::var("IMPROV_EXTFN_ALLOW") {
177 Ok(file) => match std::fs::read_to_string(&file) {
178 Ok(text) => scheduler::parse_allowlist(&text),
179 Err(e) => {
180 eprintln!("scheduler: cannot read IMPROV_EXTFN_ALLOW {file}: {e}; no function will run");
181 Default::default()
182 }
183 },
184 Err(_) => {
185 eprintln!("scheduler: IMPROV_EXTFN_ALLOW unset; no function will run");
186 Default::default()
187 }
188 };
189 scheduler::spawn_scheduler(
190 state.clone(),
191 std::time::Duration::from_secs(tick_secs),
192 status,
193 Arc::new(allow),
194 );
195 } else {
196 eprintln!("scheduler disabled (set IMPROV_SCHEDULER=1 to enable)");
197 }
198
199 eprintln!("improv-server listening on {addr}");
200 let header_timeout = std::env::var("IMPROV_HEADER_TIMEOUT_MS")
201 .ok()
202 .and_then(|v| v.parse().ok())
203 .map(Duration::from_millis)
204 .unwrap_or(HEADER_READ_TIMEOUT);
205 serve(listener, app(state), header_timeout).await;
206}
207
208pub fn startup_check(
211 addr: SocketAddr,
212 auth: &Auth,
213 allow_open: Option<&str>,
214) -> Result<(), String> {
215 if matches!(auth, Auth::Disabled) && !addr.ip().is_loopback() && allow_open != Some("1") {
216 return Err(format!(
217 "refusing to serve {addr} without auth: set IMPROV_API_TOKEN, bind a \
218 loopback address, or set IMPROV_ALLOW_OPEN=1"
219 ));
220 }
221 Ok(())
222}
223
224async fn serve(listener: tokio::net::TcpListener, app: Router, header_timeout: Duration) {
229 use hyper_util::rt::{TokioIo, TokioTimer};
230 loop {
231 let stream = match listener.accept().await {
232 Ok((stream, _)) => stream,
233 Err(e) => {
234 eprintln!("accept: {e}");
236 tokio::time::sleep(Duration::from_millis(100)).await;
237 continue;
238 }
239 };
240 let svc = hyper_util::service::TowerToHyperService::new(app.clone());
241 tokio::spawn(async move {
242 let _ = hyper::server::conn::http1::Builder::new()
243 .timer(TokioTimer::new())
244 .header_read_timeout(header_timeout)
245 .serve_connection(TokioIo::new(stream), svc)
246 .await;
247 });
248 }
249}
250
251pub fn app(state: Arc<AppState>) -> Router {
253 let protected = Router::new()
255 .route("/model", get(get_model))
256 .route("/measures", get(list_measures))
257 .route("/measures/:id/values", get(measure_values))
258 .route("/measures/:id/eval", post(eval_measure))
259 .route("/measures/:id/cells", post(set_cell))
260 .route("/nl/parse", post(nl_parse))
261 .route("/nl/describe", post(nl_describe))
262 .route("/scheduler/status", get(scheduler_status_handler))
263 .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
264
265 let limits = ServiceBuilder::new()
268 .layer(HandleErrorLayer::new(|e: tower::BoxError| async move {
269 if e.is::<tower::timeout::error::Elapsed>() {
270 (StatusCode::REQUEST_TIMEOUT, "request timed out")
271 } else {
272 (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
273 }
274 }))
275 .layer(tower::timeout::TimeoutLayer::new(REQUEST_TIMEOUT))
276 .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
277 MAX_IN_FLIGHT,
278 ));
279
280 Router::new()
281 .route("/health", get(health))
282 .merge(protected)
283 .layer(limits)
284 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
285 .with_state(state)
286}
287
288async fn require_token(
293 State(state): State<Arc<AppState>>,
294 mut req: Request,
295 next: Next,
296) -> Response {
297 let (rw, ro) = match &state.auth {
298 Auth::Disabled => return next.run(req).await,
299 Auth::Tokens { rw, ro } => (rw, ro),
300 };
301 let class = req
303 .headers()
304 .get(axum::http::header::AUTHORIZATION)
305 .and_then(|v| v.to_str().ok())
306 .and_then(|v| v.strip_prefix("Bearer "))
307 .map(str::trim)
308 .filter(|t| !t.is_empty())
309 .map(|t| (token_matches(rw, t), token_matches(ro, t)));
310 match class {
311 None => (StatusCode::UNAUTHORIZED, "missing bearer token").into_response(),
312 Some((true, _)) => next.run(req).await,
313 Some((false, true)) => {
314 req.extensions_mut().insert(ReadOnly);
315 next.run(req).await
316 }
317 Some((false, false)) => (StatusCode::FORBIDDEN, "invalid bearer token").into_response(),
318 }
319}
320
321fn token_matches<'a>(tokens: impl IntoIterator<Item = &'a String>, presented: &str) -> bool {
325 tokens.into_iter().fold(false, |hit, t| {
326 hit | ct_eq(t.as_bytes(), presented.as_bytes())
327 })
328}
329
330fn ct_eq(a: &[u8], b: &[u8]) -> bool {
332 let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
333 (a.len() == b.len()) & (diff == 0)
334}
335
336impl From<JsonRejection> for ApiError {
339 fn from(e: JsonRejection) -> Self {
340 let status = match e.status() {
342 StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
343 _ => StatusCode::BAD_REQUEST,
344 };
345 ApiError::Rejected(status, e.body_text())
346 }
347}
348
349enum ApiError {
350 Rejected(StatusCode, String),
352 BadRequest(String),
353 Forbidden(String),
354 NotFound(String),
355 Conflict(String),
357 Internal(String),
358}
359
360static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1);
362
363impl IntoResponse for ApiError {
364 fn into_response(self) -> Response {
365 let (status, msg) = match self {
366 ApiError::Rejected(s, m) => (s, m),
367 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
368 ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
369 ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
370 ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
371 ApiError::Internal(detail) => {
372 let id = NEXT_ERROR_ID.fetch_add(1, Ordering::Relaxed);
375 eprintln!("request {id}: internal error: {detail}");
376 let body = json!({ "error": "internal error", "request_id": id });
377 return (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response();
378 }
379 };
380 (status, Json(json!({ "error": msg }))).into_response()
381 }
382}
383
384async fn load_model(state: &Arc<AppState>) -> Result<Arc<Model>, ApiError> {
388 if let Some(m) = state.cache.read().await.as_ref() {
389 return Ok(m.clone());
390 }
391 let mut cache = state.cache.write().await;
392 if let Some(m) = cache.as_ref() {
393 return Ok(m.clone());
394 }
395 #[cfg(test)]
396 state.loads.fetch_add(1, Ordering::Relaxed);
397 let path = state.db_path.clone();
398 let (model, tx) = tokio::task::spawn_blocking(move || {
399 let mut store = Store::open(&path).map_err(|e| e.to_string())?;
400 let model = store.load_model().map_err(|e| e.to_string())?;
401 Ok((model, store.loaded_tx()))
402 })
403 .await
404 .map_err(|e| ApiError::Internal(format!("task join: {e}")))?
405 .map_err(ApiError::Internal)?;
406 let model = Arc::new(model);
407 *state.loaded_tx.lock().unwrap() = tx;
408 *cache = Some(model.clone());
409 Ok(model)
410}
411
412async fn save_model(state: &Arc<AppState>, model: Model) -> Result<Arc<Model>, ApiError> {
415 let path = state.db_path.clone();
416 let base = *state.loaded_tx.lock().unwrap();
417 let saved = tokio::task::spawn_blocking(move || {
418 let mut store = Store::open(&path)?;
419 store.set_loaded_tx(base);
420 store.save_model(&model)?;
421 Ok((model, store.loaded_tx()))
422 })
423 .await
424 .map_err(|e| ApiError::Internal(format!("task join: {e}")))?;
425 let (model, tx) = match saved {
426 Ok(v) => v,
427 Err(e @ StoreError::Conflict(_)) => {
428 *state.cache.write().await = None;
430 return Err(ApiError::Conflict(e.to_string()));
431 }
432 Err(e) => return Err(ApiError::Internal(e.to_string())),
433 };
434 let model = Arc::new(model);
435 *state.loaded_tx.lock().unwrap() = tx;
436 *state.cache.write().await = Some(model.clone());
437 Ok(model)
438}
439
440fn validate_write(
444 model: &Model,
445 mid: MeasureId,
446 coord: &Coordinate,
447 value: Option<f64>,
448) -> Result<(), ApiError> {
449 let bad = |m: String| Err(ApiError::BadRequest(m));
450 let Some(measure) = model.measures.get(&mid) else {
451 return bad(format!("no measure {}", mid.0));
452 };
453 if !measure.is_input() {
454 return bad(format!("measure {} is derived; cannot set a cell", mid.0));
455 }
456 if value.is_some_and(|v| !v.is_finite()) {
457 return bad("value must be a finite number".into());
458 }
459 let dims_ok = coord.dims.len() == measure.categories.len()
460 && measure.categories.iter().all(|c| {
461 coord
462 .dims
463 .get(c)
464 .and_then(|i| model.items.get(i))
465 .is_some_and(|item| item.category == *c)
466 });
467 if !dims_ok {
468 return bad(format!(
469 "coordinate must name one item for each category of measure {}",
470 mid.0
471 ));
472 }
473 Ok(())
474}
475
476async fn health() -> &'static str {
479 "ok"
480}
481
482async fn scheduler_status_handler(State(state): State<Arc<AppState>>) -> Json<JsonValue> {
485 match &state.scheduler_status {
486 None => Json(json!({ "enabled": false })),
487 Some(status) => {
488 let s = status.lock().unwrap_or_else(|e| e.into_inner());
489 Json(json!({
490 "enabled": true,
491 "tick_secs": s.tick_secs,
492 "last_tick_refreshed": s.last_tick_refreshed,
493 "last_error": s.last_error,
494 }))
495 }
496 }
497}
498
499async fn get_model(State(state): State<Arc<AppState>>) -> Result<Response, ApiError> {
500 Ok(Json(&*load_model(&state).await?).into_response())
501}
502
503#[derive(Serialize)]
504struct MeasureSummary {
505 id: u32,
506 name: String,
507 kind: &'static str,
508 categories: Vec<String>,
509}
510
511async fn list_measures(
512 State(state): State<Arc<AppState>>,
513) -> Result<Json<Vec<MeasureSummary>>, ApiError> {
514 let model = load_model(&state).await?;
515 let mut out: Vec<MeasureSummary> = model
516 .measures
517 .values()
518 .map(|m| MeasureSummary {
519 id: m.id.0,
520 name: m.name.0.clone(),
521 kind: if m.is_input() { "input" } else { "derived" },
522 categories: m
523 .categories
524 .iter()
525 .map(|c| category_name(&model, *c))
526 .collect(),
527 })
528 .collect();
529 out.sort_by_key(|m| m.id);
530 Ok(Json(out))
531}
532
533#[derive(Serialize)]
534struct Cell {
535 coord: Vec<[String; 2]>,
536 value: f64,
537}
538
539async fn measure_values(
540 State(state): State<Arc<AppState>>,
541 Path(id): Path<u32>,
542) -> Result<Json<Vec<Cell>>, ApiError> {
543 let model = load_model(&state).await?;
544 let mid = MeasureId(id);
545 let measure = model
546 .measures
547 .get(&mid)
548 .ok_or_else(|| ApiError::NotFound(format!("no measure {id}")))?;
549
550 let cells = match &measure.kind {
551 MeasureKind::Input => model
552 .inputs
553 .iter()
554 .filter(|((m, _), _)| *m == mid)
555 .filter_map(|((_, coord), val)| {
556 val.as_number().map(|n| readable_cell(&model, coord, n))
557 })
558 .collect(),
559 MeasureKind::Derived(_) => {
560 let mut out =
561 evaluate(&model, &[mid]).map_err(|e| ApiError::Internal(e.to_string()))?;
562 let values = out.remove(&mid).unwrap_or_default();
563 values
564 .into_iter()
565 .map(|(k, n)| readable_key(&model, &k, n.as_num().unwrap_or(f64::NAN)))
566 .collect()
567 }
568 };
569 Ok(Json(cells))
570}
571
572#[derive(Deserialize)]
575struct EvalEdit {
576 measure: u32,
577 coord: CoordKey,
578 value: Option<f64>,
579}
580
581#[derive(Deserialize)]
582struct EvalReq {
583 #[serde(default)]
584 edits: Vec<EvalEdit>,
585 #[serde(default)]
586 persist: bool,
587}
588
589async fn eval_measure(
599 State(state): State<Arc<AppState>>,
600 ro: Option<Extension<ReadOnly>>,
601 Path(id): Path<u32>,
602 body: Result<Json<EvalReq>, JsonRejection>,
603) -> Result<Json<Vec<Cell>>, ApiError> {
604 let Json(req) = body?;
605 if req.persist && ro.is_some() {
606 return Err(ApiError::Forbidden("read-only token cannot persist".into()));
607 }
608 if req.edits.len() > MAX_EDITS {
609 return Err(ApiError::BadRequest(format!(
610 "too many edits ({}); at most {MAX_EDITS} per request",
611 req.edits.len()
612 )));
613 }
614 let mid = MeasureId(id);
615
616 let write = match req.persist {
617 true => Some(state.write_lock.lock().await),
618 false => None,
619 };
620 let mut model = load_model(&state).await?;
621 let measure = model
622 .measures
623 .get(&mid)
624 .ok_or_else(|| ApiError::NotFound(format!("no measure {id}")))?;
625 if measure.is_input() {
626 return Err(ApiError::BadRequest(format!(
627 "measure {id} is an input; use /eval on a derived measure"
628 )));
629 }
630
631 let coords = req
633 .edits
634 .iter()
635 .map(|e| {
636 let coord = decode_coord(&e.coord);
637 if coord.dims.len() != e.coord.len() {
638 return Err(ApiError::BadRequest("coordinate repeats a category".into()));
639 }
640 validate_write(&model, MeasureId(e.measure), &coord, e.value)?;
641 Ok(coord)
642 })
643 .collect::<Result<Vec<_>, ApiError>>()?;
644
645 if req.persist {
648 let mut updated = (*model).clone();
649 for (e, coord) in req.edits.iter().zip(coords) {
650 match e.value {
651 Some(v) => updated.set_input(
652 MeasureId(e.measure),
653 coord,
654 improv_core_model::Value::Number(v),
655 ),
656 None => {
657 updated.inputs.remove(&(MeasureId(e.measure), coord));
658 }
659 }
660 }
661 model = save_model(&state, updated).await?;
662 }
663 drop(write);
664
665 let targets: Vec<MeasureId> = model
667 .measures
668 .iter()
669 .filter(|(_, m)| !m.is_input())
670 .map(|(id, _)| *id)
671 .collect();
672 let edits: Vec<Edit> = req
673 .edits
674 .into_iter()
675 .map(|e| Edit {
676 measure: MeasureId(e.measure),
677 coord: e.coord,
678 value: e.value.map(CellValue::num),
679 })
680 .collect();
681
682 let engine_model = model.clone();
685 let values = tokio::task::spawn_blocking(move || {
686 let (mut engine, _initial) =
687 Engine::new(&engine_model, &targets).map_err(|e| e.to_string())?;
688 let snap = engine.apply(edits).map_err(|e| e.to_string())?;
689 Ok::<_, String>(snap.get(&mid).cloned().unwrap_or_default())
690 })
691 .await
692 .map_err(|e| ApiError::Internal(format!("task join: {e}")))?
693 .map_err(ApiError::Internal)?;
694
695 let cells = values
696 .iter()
697 .map(|(k, n)| readable_key(&model, k, n.as_num().unwrap_or(f64::NAN)))
698 .collect();
699 Ok(Json(cells))
700}
701
702#[derive(Deserialize)]
703struct NlParseReq {
704 text: String,
705}
706
707async fn nl_parse(
708 State(state): State<Arc<AppState>>,
709 body: Result<Json<NlParseReq>, JsonRejection>,
710) -> Result<Json<JsonValue>, ApiError> {
711 let Json(req) = body?;
712 let model = load_model(&state).await?;
713 let ctx = NlContext::new(&model);
714 match parse_nl_formula(&ctx, &req.text) {
715 Ok(formula) => Ok(Json(
716 serde_json::to_value(&formula).map_err(|e| ApiError::Internal(e.to_string()))?,
717 )),
718 Err(e) => Err(ApiError::BadRequest(e.to_string())),
719 }
720}
721
722#[derive(Deserialize)]
723struct NlDescribeReq {
724 formula: JsonValue,
725}
726
727async fn nl_describe(
728 State(state): State<Arc<AppState>>,
729 body: Result<Json<NlDescribeReq>, JsonRejection>,
730) -> Result<Json<JsonValue>, ApiError> {
731 let Json(req) = body?;
732 let formula = serde_json::from_value(req.formula)
733 .map_err(|e| ApiError::BadRequest(format!("invalid formula: {e}")))?;
734 let model = load_model(&state).await?;
735 let ctx = NlContext::new(&model);
736 Ok(Json(json!({ "text": describe_formula(&ctx, &formula) })))
737}
738
739#[derive(Deserialize)]
740struct SetCellReq {
741 coord: Coordinate,
742 value: f64,
743}
744
745async fn set_cell(
746 State(state): State<Arc<AppState>>,
747 ro: Option<Extension<ReadOnly>>,
748 Path(id): Path<u32>,
749 body: Result<Json<SetCellReq>, JsonRejection>,
750) -> Result<Json<JsonValue>, ApiError> {
751 if ro.is_some() {
752 return Err(ApiError::Forbidden("read-only token cannot write".into()));
753 }
754 let Json(req) = body?;
755 let mid = MeasureId(id);
756
757 let _write = state.write_lock.lock().await;
758 let model = load_model(&state).await?;
759 if !model.measures.contains_key(&mid) {
760 return Err(ApiError::NotFound(format!("no measure {id}")));
761 }
762 validate_write(&model, mid, &req.coord, Some(req.value))?;
763 let mut model = (*model).clone();
764 model.set_input(mid, req.coord, improv_core_model::Value::Number(req.value));
765 save_model(&state, model).await?;
766
767 Ok(Json(json!({ "ok": true })))
768}
769
770fn category_name(model: &Model, c: improv_core_model::CategoryId) -> String {
773 model
774 .categories
775 .get(&c)
776 .map(|cat| cat.name.0.clone())
777 .unwrap_or_else(|| format!("category {}", c.0))
778}
779
780fn readable_cell(model: &Model, coord: &Coordinate, value: f64) -> Cell {
783 let mut pairs: Vec<[String; 2]> = coord
784 .dims
785 .iter()
786 .map(|(cat, item)| {
787 let cat_name = category_name(model, *cat);
788 let item_name = model
789 .items
790 .get(item)
791 .map(|i| i.name.0.clone())
792 .unwrap_or_else(|| format!("item {}", item.0));
793 [cat_name, item_name]
794 })
795 .collect();
796 pairs.sort();
797 Cell {
798 coord: pairs,
799 value,
800 }
801}
802
803fn readable_key(model: &Model, key: &CoordKey, value: f64) -> Cell {
804 readable_cell(model, &decode_coord(key), value)
805}
806
807#[cfg(test)]
808#[cfg_attr(coverage_nightly, coverage(off))]
809mod tests {
810 use super::*;
811 use axum::body::{to_bytes, Body};
812 use axum::http::{Request, StatusCode};
813 use improv_core_model::{
814 BinaryOp, DimensionSpec, Expr, ItemId, Measure, MeasureKind, Name, Value, ValueType,
815 };
816 use improv_core_model::{CategoryId, Coordinate};
817 use tower::util::ServiceExt; fn revenue_model() -> Model {
821 let mut m = Model::new();
822 let (time, product) = (CategoryId(1), CategoryId(2));
823 m.add_category(time, "Time");
824 m.add_category(product, "Product");
825 m.add_item(ItemId(10), time, "2025");
826 m.add_item(ItemId(11), time, "2026");
827 m.add_item(ItemId(20), product, "WidgetA");
828 m.add_item(ItemId(21), product, "WidgetB");
829
830 m.add_measure(Measure {
831 id: MeasureId(100),
832 name: Name("Price".into()),
833 value_type: ValueType::Number,
834 categories: vec![product],
835 kind: MeasureKind::Input,
836 description: None,
837 });
838 m.add_measure(Measure {
839 id: MeasureId(101),
840 name: Name("Quantity".into()),
841 value_type: ValueType::Number,
842 categories: vec![time, product],
843 kind: MeasureKind::Input,
844 description: None,
845 });
846 m.add_measure(Measure {
847 id: MeasureId(102),
848 name: Name("Revenue".into()),
849 value_type: ValueType::Number,
850 categories: vec![time, product],
851 kind: MeasureKind::Derived(improv_core_model::Formula::new(Expr::BinaryOp(
852 BinaryOp::Mul,
853 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
854 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
855 ))),
856 description: None,
857 });
858
859 let coord = |pairs: &[(CategoryId, ItemId)]| Coordinate::from_pairs(pairs.iter().copied());
860 m.set_input(
861 MeasureId(100),
862 coord(&[(product, ItemId(20))]),
863 Value::Number(10.0),
864 );
865 m.set_input(
866 MeasureId(100),
867 coord(&[(product, ItemId(21))]),
868 Value::Number(20.0),
869 );
870 m.set_input(
871 MeasureId(101),
872 coord(&[(time, ItemId(10)), (product, ItemId(20))]),
873 Value::Number(100.0),
874 );
875 m.set_input(
876 MeasureId(101),
877 coord(&[(time, ItemId(10)), (product, ItemId(21))]),
878 Value::Number(50.0),
879 );
880 m.set_input(
881 MeasureId(101),
882 coord(&[(time, ItemId(11)), (product, ItemId(20))]),
883 Value::Number(120.0),
884 );
885 m.set_input(
886 MeasureId(101),
887 coord(&[(time, ItemId(11)), (product, ItemId(21))]),
888 Value::Number(80.0),
889 );
890 m
891 }
892
893 fn seeded_app() -> (Router, std::path::PathBuf) {
895 let dir = std::env::temp_dir().join(format!("improv-test-{}", std::process::id()));
896 std::fs::create_dir_all(&dir).unwrap();
897 let path = dir.join(format!(
898 "model-{}.db",
899 std::time::SystemTime::now()
900 .duration_since(std::time::UNIX_EPOCH)
901 .unwrap()
902 .as_nanos()
903 ));
904 let _ = std::fs::remove_file(&path);
905 let mut store = Store::open(path.to_str().unwrap()).unwrap();
906 store.save_model(&revenue_model()).unwrap();
907 let state = Arc::new(AppState::new(
908 path.to_str().unwrap().to_string(),
909 Auth::Disabled,
910 None,
911 ));
912 (app(state), path)
913 }
914
915 fn authed_app(token: &str) -> (Router, std::path::PathBuf) {
917 let (_, path) = seeded_app();
918 let state = Arc::new(AppState::new(
919 path.to_str().unwrap().to_string(),
920 Auth::Tokens {
921 rw: HashSet::from([token.to_string()]),
922 ro: HashSet::new(),
923 },
924 None,
925 ));
926 (app(state), path)
927 }
928
929 fn get_with_bearer(uri: &str, token: Option<&str>) -> Request<Body> {
930 let mut b = Request::get(uri);
931 if let Some(t) = token {
932 b = b.header("authorization", format!("Bearer {t}"));
933 }
934 b.body(Body::empty()).unwrap()
935 }
936
937 async fn body_json(resp: Response) -> JsonValue {
938 let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
939 serde_json::from_slice(&bytes).unwrap()
940 }
941
942 #[tokio::test]
943 async fn health_ok() {
944 let (app, _p) = seeded_app();
945 let resp = app
946 .oneshot(Request::get("/health").body(Body::empty()).unwrap())
947 .await
948 .unwrap();
949 assert_eq!(resp.status(), StatusCode::OK);
950 let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
951 assert_eq!(&bytes[..], b"ok");
952 }
953
954 #[tokio::test]
955 async fn lists_measures() {
956 let (app, _p) = seeded_app();
957 let resp = app
958 .oneshot(Request::get("/measures").body(Body::empty()).unwrap())
959 .await
960 .unwrap();
961 assert_eq!(resp.status(), StatusCode::OK);
962 let v = body_json(resp).await;
963 let arr = v.as_array().unwrap();
964 assert_eq!(arr.len(), 3);
965 let revenue = arr.iter().find(|m| m["name"] == "Revenue").unwrap();
966 assert_eq!(revenue["kind"], "derived");
967 let price = arr.iter().find(|m| m["name"] == "Price").unwrap();
968 assert_eq!(price["kind"], "input");
969 assert_eq!(price["categories"], json!(["Product"]));
970 }
971
972 #[tokio::test]
973 async fn derived_values_match_known_revenue() {
974 let (app, _p) = seeded_app();
975 let resp = app
976 .oneshot(
977 Request::get("/measures/102/values")
978 .body(Body::empty())
979 .unwrap(),
980 )
981 .await
982 .unwrap();
983 assert_eq!(resp.status(), StatusCode::OK);
984 let v = body_json(resp).await;
985 let cells = v.as_array().unwrap();
986 assert_eq!(cells.len(), 4);
987 let mut got = std::collections::HashMap::new();
989 for c in cells {
990 let coord = serde_json::to_string(&c["coord"]).unwrap();
991 got.insert(coord, c["value"].as_f64().unwrap());
992 }
993 let key = |t: &str, p: &str| {
995 serde_json::to_string(&json!([["Product", p], ["Time", t]])).unwrap()
996 };
997 assert_eq!(got[&key("2025", "WidgetA")], 1000.0);
998 assert_eq!(got[&key("2025", "WidgetB")], 1000.0);
999 assert_eq!(got[&key("2026", "WidgetA")], 1200.0);
1000 assert_eq!(got[&key("2026", "WidgetB")], 1600.0);
1001 }
1002
1003 #[tokio::test]
1004 async fn input_values_returned() {
1005 let (app, _p) = seeded_app();
1006 let resp = app
1007 .oneshot(
1008 Request::get("/measures/100/values")
1009 .body(Body::empty())
1010 .unwrap(),
1011 )
1012 .await
1013 .unwrap();
1014 assert_eq!(resp.status(), StatusCode::OK);
1015 let cells = body_json(resp).await;
1016 assert_eq!(cells.as_array().unwrap().len(), 2);
1017 }
1018
1019 #[tokio::test]
1020 async fn unknown_measure_is_404() {
1021 let (app, _p) = seeded_app();
1022 let resp = app
1023 .oneshot(
1024 Request::get("/measures/999/values")
1025 .body(Body::empty())
1026 .unwrap(),
1027 )
1028 .await
1029 .unwrap();
1030 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1031 }
1032
1033 #[tokio::test]
1034 async fn nl_parse_price_times_quantity() {
1035 let (app, _p) = seeded_app();
1036 let resp = app
1037 .oneshot(
1038 Request::post("/nl/parse")
1039 .header("content-type", "application/json")
1040 .body(Body::from(
1041 json!({"text": "price times quantity"}).to_string(),
1042 ))
1043 .unwrap(),
1044 )
1045 .await
1046 .unwrap();
1047 assert_eq!(resp.status(), StatusCode::OK);
1048 let v = body_json(resp).await;
1049 let op = &v["expr"]["BinaryOp"];
1051 assert_eq!(op[0], "Mul");
1052 }
1053
1054 #[tokio::test]
1055 async fn nl_parse_unknown_measure_is_400() {
1056 let (app, _p) = seeded_app();
1057 let resp = app
1058 .oneshot(
1059 Request::post("/nl/parse")
1060 .header("content-type", "application/json")
1061 .body(Body::from(
1062 json!({"text": "widgets times quantity"}).to_string(),
1063 ))
1064 .unwrap(),
1065 )
1066 .await
1067 .unwrap();
1068 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1069 }
1070
1071 #[tokio::test]
1072 async fn malformed_body_is_400_no_panic() {
1073 let (app, _p) = seeded_app();
1074 let resp = app
1075 .clone()
1076 .oneshot(
1077 Request::post("/nl/parse")
1078 .header("content-type", "application/json")
1079 .body(Body::from("{ not json"))
1080 .unwrap(),
1081 )
1082 .await
1083 .unwrap();
1084 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1085
1086 let resp = app
1088 .oneshot(
1089 Request::post("/nl/parse")
1090 .header("content-type", "application/json")
1091 .body(Body::from(json!({"nope": 1}).to_string()))
1092 .unwrap(),
1093 )
1094 .await
1095 .unwrap();
1096 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1097 }
1098
1099 #[tokio::test]
1100 async fn nl_describe_round_trips() {
1101 let (app, _p) = seeded_app();
1102 let resp = app
1104 .clone()
1105 .oneshot(
1106 Request::post("/nl/parse")
1107 .header("content-type", "application/json")
1108 .body(Body::from(
1109 json!({"text": "price times quantity"}).to_string(),
1110 ))
1111 .unwrap(),
1112 )
1113 .await
1114 .unwrap();
1115 let formula = body_json(resp).await;
1116 let resp = app
1117 .oneshot(
1118 Request::post("/nl/describe")
1119 .header("content-type", "application/json")
1120 .body(Body::from(json!({"formula": formula}).to_string()))
1121 .unwrap(),
1122 )
1123 .await
1124 .unwrap();
1125 assert_eq!(resp.status(), StatusCode::OK);
1126 let v = body_json(resp).await;
1127 assert_eq!(v["text"], "Price times Quantity");
1128 }
1129
1130 #[tokio::test]
1134 async fn write_over_another_processes_change_is_409() {
1135 let (app, p) = seeded_app();
1136 let get = || Request::get("/measures").body(Body::empty()).unwrap();
1137 assert_eq!(
1138 app.clone().oneshot(get()).await.unwrap().status(),
1139 StatusCode::OK
1140 );
1141
1142 let mut other = Store::open(p.to_str().unwrap()).unwrap();
1143 let mut m = other.load_model().unwrap();
1144 let c = Coordinate::from_pairs([(improv_core_model::CategoryId(2), ItemId(21))]);
1145 m.set_input(
1146 MeasureId(100),
1147 c.clone(),
1148 improv_core_model::Value::Number(77.0),
1149 );
1150 other.save_model(&m).unwrap();
1151
1152 let post = || {
1153 Request::post("/measures/100/cells")
1154 .header("content-type", "application/json")
1155 .body(Body::from(
1156 json!({"coord": {"dims": {"2": 20}}, "value": 42.0}).to_string(),
1157 ))
1158 .unwrap()
1159 };
1160 let resp = app.clone().oneshot(post()).await.unwrap();
1161 assert_eq!(resp.status(), StatusCode::CONFLICT);
1162 assert!(body_json(resp).await["error"]
1163 .as_str()
1164 .unwrap()
1165 .contains("reload"));
1166 assert_eq!(app.oneshot(post()).await.unwrap().status(), StatusCode::OK);
1167 let after = Store::open(p.to_str().unwrap())
1168 .unwrap()
1169 .load_model()
1170 .unwrap();
1171 assert_eq!(
1172 after.input(MeasureId(100), &c),
1173 Some(&improv_core_model::Value::Number(77.0))
1174 );
1175 }
1176
1177 #[tokio::test]
1178 async fn set_cell_persists() {
1179 let (app, _p) = seeded_app();
1180 let body = json!({
1181 "coord": {"dims": {"2": 20}},
1182 "value": 42.0
1183 });
1184 let resp = app
1185 .clone()
1186 .oneshot(
1187 Request::post("/measures/100/cells")
1188 .header("content-type", "application/json")
1189 .body(Body::from(body.to_string()))
1190 .unwrap(),
1191 )
1192 .await
1193 .unwrap();
1194 assert_eq!(resp.status(), StatusCode::OK);
1195
1196 let resp = app
1198 .oneshot(
1199 Request::get("/measures/100/values")
1200 .body(Body::empty())
1201 .unwrap(),
1202 )
1203 .await
1204 .unwrap();
1205 let cells = body_json(resp).await;
1206 let has_42 = cells
1207 .as_array()
1208 .unwrap()
1209 .iter()
1210 .any(|c| c["value"].as_f64() == Some(42.0));
1211 assert!(has_42, "updated cell value should persist");
1212 }
1213
1214 fn revenue_2025_widgeta(cells: &JsonValue) -> Option<f64> {
1216 let want = json!([["Product", "WidgetA"], ["Time", "2025"]]);
1217 cells
1218 .as_array()
1219 .unwrap()
1220 .iter()
1221 .find(|c| c["coord"] == want)
1222 .and_then(|c| c["value"].as_f64())
1223 }
1224
1225 #[tokio::test]
1226 async fn eval_what_if_does_not_persist() {
1227 let (app, _p) = seeded_app();
1228 let body = json!({
1231 "edits": [{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 200.0}]
1232 });
1233 let resp = app
1234 .clone()
1235 .oneshot(
1236 Request::post("/measures/102/eval")
1237 .header("content-type", "application/json")
1238 .body(Body::from(body.to_string()))
1239 .unwrap(),
1240 )
1241 .await
1242 .unwrap();
1243 assert_eq!(resp.status(), StatusCode::OK);
1244 let cells = body_json(resp).await;
1245 assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1246
1247 let resp = app
1249 .oneshot(
1250 Request::get("/measures/102/values")
1251 .body(Body::empty())
1252 .unwrap(),
1253 )
1254 .await
1255 .unwrap();
1256 let cells = body_json(resp).await;
1257 assert_eq!(revenue_2025_widgeta(&cells), Some(1000.0));
1258 }
1259
1260 #[tokio::test]
1261 async fn eval_with_persist_writes_through() {
1262 let (app, _p) = seeded_app();
1263 let body = json!({
1264 "persist": true,
1265 "edits": [{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 200.0}]
1266 });
1267 let resp = app
1268 .clone()
1269 .oneshot(
1270 Request::post("/measures/102/eval")
1271 .header("content-type", "application/json")
1272 .body(Body::from(body.to_string()))
1273 .unwrap(),
1274 )
1275 .await
1276 .unwrap();
1277 assert_eq!(resp.status(), StatusCode::OK);
1278 let cells = body_json(resp).await;
1279 assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1280
1281 let resp = app
1283 .oneshot(
1284 Request::get("/measures/102/values")
1285 .body(Body::empty())
1286 .unwrap(),
1287 )
1288 .await
1289 .unwrap();
1290 let cells = body_json(resp).await;
1291 assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1292 }
1293
1294 #[tokio::test]
1295 async fn eval_on_input_measure_is_400() {
1296 let (app, _p) = seeded_app();
1297 let resp = app
1298 .oneshot(
1299 Request::post("/measures/101/eval")
1300 .header("content-type", "application/json")
1301 .body(Body::from(json!({"edits": []}).to_string()))
1302 .unwrap(),
1303 )
1304 .await
1305 .unwrap();
1306 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1307 }
1308
1309 #[tokio::test]
1310 async fn eval_unknown_measure_is_404() {
1311 let (app, _p) = seeded_app();
1312 let resp = app
1313 .oneshot(
1314 Request::post("/measures/999/eval")
1315 .header("content-type", "application/json")
1316 .body(Body::from(json!({"edits": []}).to_string()))
1317 .unwrap(),
1318 )
1319 .await
1320 .unwrap();
1321 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1322 }
1323
1324 #[tokio::test]
1325 async fn eval_malformed_body_is_400_no_panic() {
1326 let (app, _p) = seeded_app();
1327 let resp = app
1328 .oneshot(
1329 Request::post("/measures/102/eval")
1330 .header("content-type", "application/json")
1331 .body(Body::from("{ not json"))
1332 .unwrap(),
1333 )
1334 .await
1335 .unwrap();
1336 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1337 }
1338
1339 #[tokio::test]
1340 async fn authed_health_is_public() {
1341 let (app, _p) = authed_app("secret");
1342 let resp = app.oneshot(get_with_bearer("/health", None)).await.unwrap();
1343 assert_eq!(resp.status(), StatusCode::OK);
1344 }
1345
1346 #[tokio::test]
1347 async fn authed_protected_route_requires_token() {
1348 let (app, _p) = authed_app("secret");
1349 let resp = app
1351 .clone()
1352 .oneshot(get_with_bearer("/measures", None))
1353 .await
1354 .unwrap();
1355 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1356 let resp = app
1358 .clone()
1359 .oneshot(get_with_bearer("/measures", Some("nope")))
1360 .await
1361 .unwrap();
1362 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1363 let resp = app
1365 .oneshot(get_with_bearer("/measures", Some("secret")))
1366 .await
1367 .unwrap();
1368 assert_eq!(resp.status(), StatusCode::OK);
1369 }
1370
1371 #[tokio::test]
1372 async fn scheduler_status_is_protected_and_reports_disabled() {
1373 let (app, _p) = seeded_app();
1375 let resp = app
1376 .oneshot(
1377 Request::get("/scheduler/status")
1378 .body(Body::empty())
1379 .unwrap(),
1380 )
1381 .await
1382 .unwrap();
1383 assert_eq!(resp.status(), StatusCode::OK);
1384 let v = body_json(resp).await;
1385 assert_eq!(v["enabled"], false);
1386
1387 let (app, _p) = authed_app("secret");
1389 let resp = app
1390 .oneshot(get_with_bearer("/scheduler/status", None))
1391 .await
1392 .unwrap();
1393 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1394 }
1395
1396 fn post_json(uri: &str, body: String) -> Request<Body> {
1397 Request::post(uri)
1398 .header("content-type", "application/json")
1399 .body(Body::from(body))
1400 .unwrap()
1401 }
1402
1403 #[tokio::test]
1404 async fn over_limit_body_is_413() {
1405 let (app, _p) = seeded_app();
1406 let big = format!("{{\"text\":\"{}\"}}", "x".repeat(MAX_BODY_BYTES + 1));
1407 let resp = app.oneshot(post_json("/nl/parse", big)).await.unwrap();
1408 assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
1409 }
1410
1411 #[tokio::test]
1412 async fn too_many_edits_is_400() {
1413 let (app, _p) = seeded_app();
1414 let edit = json!({"measure": 101, "coord": [[1, 10], [2, 20]], "value": 1.0});
1415 let body = json!({ "edits": vec![edit; MAX_EDITS + 1] }).to_string();
1416 assert!(
1417 body.len() < MAX_BODY_BYTES,
1418 "must hit the edit cap, not the body cap"
1419 );
1420 let resp = app
1421 .oneshot(post_json("/measures/102/eval", body))
1422 .await
1423 .unwrap();
1424 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1425 assert!(body_json(resp).await["error"]
1426 .as_str()
1427 .unwrap()
1428 .contains("too many edits"));
1429 }
1430
1431 async fn partial_request_closed_after(header_timeout: Duration) -> Duration {
1434 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1435 let (app, _p) = seeded_app();
1436 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1437 let addr = listener.local_addr().unwrap();
1438 let server = tokio::spawn(serve(listener, app, header_timeout));
1439 let mut c = tokio::net::TcpStream::connect(addr).await.unwrap();
1440 c.write_all(b"GET /health HTTP/1.1\r\nHost: x\r\n")
1441 .await
1442 .unwrap();
1443 let start = std::time::Instant::now();
1444 let mut buf = Vec::new();
1445 let _ = tokio::time::timeout(header_timeout * 3, c.read_to_end(&mut buf))
1447 .await
1448 .expect("server should close a stalled connection");
1449 server.abort();
1450 start.elapsed()
1451 }
1452
1453 #[tokio::test]
1454 async fn slow_loris_closed_fast_timeout() {
1455 let t = partial_request_closed_after(Duration::from_millis(200)).await;
1456 assert!(t < Duration::from_secs(2), "closed after {t:?}");
1457 }
1458
1459 #[tokio::test]
1460 #[ignore = "takes ~10 s (default header timeout)"]
1461 async fn slow_loris_closed_default_timeout() {
1462 let t = partial_request_closed_after(HEADER_READ_TIMEOUT).await;
1463 assert!(t < Duration::from_secs(11), "closed after {t:?}");
1464 }
1465
1466 #[tokio::test]
1467 async fn internal_error_hides_store_path() {
1468 let state = Arc::new(AppState::new(
1469 "/nonexistent-improv-dir/secret-store.db".into(),
1470 Auth::Disabled,
1471 None,
1472 ));
1473 let resp = app(state)
1474 .oneshot(Request::get("/measures").body(Body::empty()).unwrap())
1475 .await
1476 .unwrap();
1477 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1478 let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1479 let text = String::from_utf8_lossy(&bytes);
1480 assert!(
1481 !text.contains("nonexistent-improv-dir"),
1482 "path leaked: {text}"
1483 );
1484 let v: JsonValue = serde_json::from_slice(&bytes).unwrap();
1485 assert_eq!(v["error"], "internal error");
1486 assert!(v["request_id"].as_u64().is_some());
1487 }
1488
1489 #[test]
1490 fn ct_eq_and_token_matches() {
1491 assert!(ct_eq(b"secret", b"secret"));
1492 assert!(!ct_eq(b"secret", b"secreT"));
1493 assert!(!ct_eq(b"secret", b"secret2"));
1494 assert!(!ct_eq(b"", b"x"));
1495 let toks = HashSet::from(["a1".to_string(), "b2".to_string()]);
1496 assert!(token_matches(&toks, "b2"));
1497 assert!(!token_matches(&toks, "b"));
1498 }
1499
1500 #[test]
1501 fn startup_check_refuses_open_non_loopback() {
1502 let open = Auth::Disabled;
1503 let toks = Auth::Tokens {
1504 rw: HashSet::from(["t".to_string()]),
1505 ro: HashSet::new(),
1506 };
1507 let lo: SocketAddr = "127.0.0.1:3000".parse().unwrap();
1508 let lo6: SocketAddr = "[::1]:3000".parse().unwrap();
1509 let any: SocketAddr = "0.0.0.0:3000".parse().unwrap();
1510 assert!(startup_check(lo, &open, None).is_ok());
1511 assert!(startup_check(lo6, &open, None).is_ok());
1512 assert!(startup_check(any, &open, None).is_err());
1513 assert!(startup_check(any, &open, Some("0")).is_err());
1514 assert!(startup_check(any, &open, Some("1")).is_ok());
1515 assert!(startup_check(any, &toks, None).is_ok());
1516 }
1517
1518 fn post_cell(uri: &str, body: JsonValue, token: Option<&str>) -> Request<Body> {
1519 let mut b = Request::post(uri).header("content-type", "application/json");
1520 if let Some(t) = token {
1521 b = b.header("authorization", format!("Bearer {t}"));
1522 }
1523 b.body(Body::from(body.to_string())).unwrap()
1524 }
1525
1526 #[tokio::test]
1527 async fn write_to_derived_or_unknown_measure_is_rejected() {
1528 let (app, _p) = seeded_app();
1529 let cell = json!({"coord": {"dims": {"1": 10, "2": 20}}, "value": 1.0});
1530 let resp = app
1531 .clone()
1532 .oneshot(post_cell("/measures/102/cells", cell.clone(), None));
1533 assert_eq!(resp.await.unwrap().status(), StatusCode::BAD_REQUEST);
1534 let resp = app
1535 .clone()
1536 .oneshot(post_cell("/measures/999/cells", cell, None));
1537 assert_eq!(resp.await.unwrap().status(), StatusCode::NOT_FOUND);
1538 for m in [102, 999] {
1540 let body = json!({"persist": true,
1541 "edits": [{"measure": m, "coord": [[1, 10], [2, 20]], "value": 1.0}]});
1542 let resp = app
1543 .clone()
1544 .oneshot(post_cell("/measures/102/eval", body, None));
1545 assert_eq!(
1546 resp.await.unwrap().status(),
1547 StatusCode::BAD_REQUEST,
1548 "measure {m}"
1549 );
1550 }
1551 }
1552
1553 #[tokio::test]
1554 async fn write_with_bad_coordinate_is_400() {
1555 let (app, _p) = seeded_app();
1556 for dims in [
1558 json!({"1": 10}), json!({"2": 10}), json!({"2": 20, "1": 10}), json!({"2": 99}), json!({}), ] {
1564 let body = json!({"coord": {"dims": dims}, "value": 1.0});
1565 let resp = app
1566 .clone()
1567 .oneshot(post_cell("/measures/100/cells", body, None));
1568 assert_eq!(
1569 resp.await.unwrap().status(),
1570 StatusCode::BAD_REQUEST,
1571 "{dims}"
1572 );
1573 }
1574 for coord in [json!([[2, 20]]), json!([[1, 10], [1, 11], [2, 20]])] {
1575 let body = json!({"edits": [{"measure": 101, "coord": coord, "value": 1.0}]});
1576 let resp = app
1577 .clone()
1578 .oneshot(post_cell("/measures/102/eval", body, None));
1579 assert_eq!(
1580 resp.await.unwrap().status(),
1581 StatusCode::BAD_REQUEST,
1582 "{coord}"
1583 );
1584 }
1585 }
1586
1587 #[test]
1588 fn validate_write_rejects_non_finite() {
1589 let m = revenue_model();
1590 let c = Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
1591 assert!(validate_write(&m, MeasureId(100), &c, Some(1.0)).is_ok());
1592 assert!(validate_write(&m, MeasureId(100), &c, None).is_ok());
1593 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1594 assert!(validate_write(&m, MeasureId(100), &c, Some(v)).is_err());
1595 }
1596 }
1597
1598 #[tokio::test]
1599 async fn read_only_token_can_read_not_write() {
1600 let (_, path) = seeded_app();
1601 let auth = Auth::Tokens {
1602 rw: HashSet::from(["w".to_string()]),
1603 ro: HashSet::from(["r".to_string()]),
1604 };
1605 let app = app(Arc::new(AppState::new(
1606 path.to_str().unwrap().to_string(),
1607 auth,
1608 None,
1609 )));
1610 let resp = app.clone().oneshot(get_with_bearer("/measures", Some("r")));
1611 assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1612 let cell = json!({"coord": {"dims": {"2": 20}}, "value": 5.0});
1613 let resp = app
1614 .clone()
1615 .oneshot(post_cell("/measures/100/cells", cell.clone(), Some("r")));
1616 assert_eq!(resp.await.unwrap().status(), StatusCode::FORBIDDEN);
1617 let edit = json!([{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 1.0}]);
1618 let persist = json!({"persist": true, "edits": edit});
1619 let resp = app
1620 .clone()
1621 .oneshot(post_cell("/measures/102/eval", persist, Some("r")));
1622 assert_eq!(resp.await.unwrap().status(), StatusCode::FORBIDDEN);
1623 let what_if = json!({"edits": edit});
1624 let resp = app
1625 .clone()
1626 .oneshot(post_cell("/measures/102/eval", what_if, Some("r")));
1627 assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1628 let resp = app.oneshot(post_cell("/measures/100/cells", cell, Some("w")));
1629 assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1630 }
1631
1632 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1633 async fn concurrent_writes_do_not_lose_updates() {
1634 let (app, _p) = seeded_app();
1635 let mut tasks = Vec::new();
1636 for (t, p) in [(10, 20), (10, 21), (11, 20), (11, 21)] {
1637 let app = app.clone();
1638 tasks.push(tokio::spawn(async move {
1639 let body = json!({"coord": {"dims": {"1": t, "2": p}}, "value": 1000.0 + f64::from(t * 100 + p)});
1640 app.oneshot(post_cell("/measures/101/cells", body, None)).await.unwrap().status()
1641 }));
1642 }
1643 for t in tasks {
1644 assert_eq!(t.await.unwrap(), StatusCode::OK);
1645 }
1646 let resp = app.oneshot(
1647 Request::get("/measures/101/values")
1648 .body(Body::empty())
1649 .unwrap(),
1650 );
1651 let cells = body_json(resp.await.unwrap()).await;
1652 let mut vals: Vec<f64> = cells
1653 .as_array()
1654 .unwrap()
1655 .iter()
1656 .map(|c| c["value"].as_f64().unwrap())
1657 .collect();
1658 vals.sort_by(f64::total_cmp);
1659 assert_eq!(
1660 vals,
1661 vec![2020.0, 2021.0, 2120.0, 2121.0],
1662 "an update was lost"
1663 );
1664 }
1665
1666 #[tokio::test]
1667 async fn reads_share_one_load_until_a_write() {
1668 let (_, path) = seeded_app();
1669 let state = Arc::new(AppState::new(
1670 path.to_str().unwrap().into(),
1671 Auth::Disabled,
1672 None,
1673 ));
1674 let app = app(state.clone());
1675 let get = |uri: &str| Request::get(uri).body(Body::empty()).unwrap();
1676 for uri in ["/measures", "/measures/102/values"] {
1677 let resp = app.clone().oneshot(get(uri)).await.unwrap();
1678 assert_eq!(resp.status(), StatusCode::OK);
1679 }
1680 assert_eq!(state.loads.load(Ordering::Relaxed), 1, "two GETs, one load");
1681
1682 let body = json!({"coord": {"dims": {"2": 20}}, "value": 42.0});
1684 let resp = app
1685 .clone()
1686 .oneshot(post_cell("/measures/100/cells", body, None));
1687 assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1688 let resp = app.oneshot(get("/measures/100/values")).await.unwrap();
1689 let cells = body_json(resp).await;
1690 assert!(cells.as_array().unwrap().iter().any(|c| c["value"] == 42.0));
1691 assert_eq!(state.loads.load(Ordering::Relaxed), 1);
1692 }
1693
1694 #[test]
1695 fn auth_from_env_reads_rw_and_ro_tokens() {
1696 std::env::set_var("IMPROV_API_TOKEN", " solo ");
1698 std::env::set_var("IMPROV_API_TOKENS", "a, b,,");
1699 std::env::set_var("IMPROV_API_TOKENS_RO", "r");
1700 let Auth::Tokens { rw, ro } = Auth::from_env() else {
1701 panic!("tokens are set");
1702 };
1703 assert_eq!(rw, HashSet::from(["solo", "a", "b"].map(String::from)));
1704 assert_eq!(ro, HashSet::from(["r".to_string()]));
1705
1706 std::env::set_var("IMPROV_API_TOKEN", " ");
1708 std::env::remove_var("IMPROV_API_TOKENS");
1709 let Auth::Tokens { rw, ro } = Auth::from_env() else {
1710 panic!("a read-only token is set");
1711 };
1712 assert!(rw.is_empty());
1713 assert_eq!(ro.len(), 1);
1714
1715 std::env::remove_var("IMPROV_API_TOKEN");
1716 std::env::remove_var("IMPROV_API_TOKENS_RO");
1717 assert!(matches!(Auth::from_env(), Auth::Disabled));
1718 }
1719
1720 #[tokio::test(start_paused = true)]
1722 async fn request_past_the_deadline_is_408() {
1723 let state = Arc::new(AppState::new("unused.db".into(), Auth::Disabled, None));
1724 let _held = state.write_lock.lock().await;
1725 let body = json!({"coord": {"dims": {"2": 20}}, "value": 1.0});
1726 let resp = app(state.clone())
1727 .oneshot(post_cell("/measures/100/cells", body, None))
1728 .await
1729 .unwrap();
1730 assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT);
1731 }
1732
1733 #[tokio::test]
1734 async fn scheduler_status_reports_a_failing_tick() {
1735 let status: SharedStatus = Arc::new(std::sync::Mutex::new(scheduler::SchedulerStatus {
1736 tick_secs: 1,
1737 ..Default::default()
1738 }));
1739 let state = Arc::new(AppState::new(
1740 "/nonexistent-improv-dir/sched.db".into(),
1741 Auth::Disabled,
1742 Some(status.clone()),
1743 ));
1744 let handle = scheduler::spawn_scheduler(
1745 state.clone(),
1746 Duration::from_millis(10),
1747 status.clone(),
1748 Default::default(),
1749 );
1750 for _ in 0..500 {
1751 if status.lock().unwrap().last_error.is_some() {
1752 break;
1753 }
1754 tokio::time::sleep(Duration::from_millis(10)).await;
1755 }
1756 handle.abort();
1757 let resp = app(state)
1758 .oneshot(
1759 Request::get("/scheduler/status")
1760 .body(Body::empty())
1761 .unwrap(),
1762 )
1763 .await
1764 .unwrap();
1765 let v = body_json(resp).await;
1766 assert_eq!(v["enabled"], true);
1767 assert_eq!(v["tick_secs"], 1);
1768 assert!(v["last_error"].is_string(), "{v}");
1769 }
1770
1771 #[tokio::test]
1772 async fn get_model_returns_the_stored_model() {
1773 let (app, _p) = seeded_app();
1774 let resp = app
1775 .oneshot(Request::get("/model").body(Body::empty()).unwrap())
1776 .await
1777 .unwrap();
1778 assert_eq!(resp.status(), StatusCode::OK);
1779 assert_eq!(
1780 body_json(resp).await["measures"].as_object().unwrap().len(),
1781 3
1782 );
1783 }
1784
1785 #[tokio::test]
1788 async fn unresolvable_model_parts_are_reported_not_panicked() {
1789 let mut m = revenue_model();
1790 m.add_measure(Measure {
1791 id: MeasureId(103),
1792 name: Name("Broken".into()),
1793 value_type: ValueType::Number,
1794 categories: vec![CategoryId(1)],
1795 kind: MeasureKind::Derived(improv_core_model::Formula::new(Expr::Ref(
1796 MeasureId(999),
1797 DimensionSpec::default(),
1798 ))),
1799 description: None,
1800 });
1801 m.set_input(
1802 MeasureId(100),
1803 Coordinate::from_pairs([(CategoryId(9), ItemId(99))]),
1804 Value::Number(1.0),
1805 );
1806 let (app, path) = seeded_app();
1807 Store::open(path.to_str().unwrap())
1808 .unwrap()
1809 .save_model(&m)
1810 .unwrap();
1811
1812 let get = |uri: &str| Request::get(uri).body(Body::empty()).unwrap();
1813 let resp = app
1814 .clone()
1815 .oneshot(get("/measures/103/values"))
1816 .await
1817 .unwrap();
1818 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1819 let resp = app
1820 .clone()
1821 .oneshot(post_json("/measures/102/eval", "{}".into()))
1822 .await
1823 .unwrap();
1824 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1825
1826 let resp = app
1827 .clone()
1828 .oneshot(get("/measures/100/values"))
1829 .await
1830 .unwrap();
1831 let cells = body_json(resp).await;
1832 assert!(
1833 cells
1834 .as_array()
1835 .unwrap()
1836 .iter()
1837 .any(|c| c["coord"] == json!([["category 9", "item 99"]])),
1838 "{cells}"
1839 );
1840
1841 let resp = app
1842 .oneshot(post_json("/nl/describe", json!({"formula": 5}).to_string()))
1843 .await
1844 .unwrap();
1845 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1846 }
1847}