app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b008ee14f9815afeb347f985ac053ea26d87176c
parent 91052d6816766603ce4e589b2ae874ff552ca603
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 11:51:05 -0700

store: fail closed on workflow revision keys

- replace infallible revision storage parsing with a strict fallible parser
- propagate invalid order workflow revision values as SQLite enum decode errors
- cover seller list and detail projections for valid and invalid revision storage
- cover buyer list and detail projections for valid and invalid revision storage

Diffstat:
Mcrates/store/src/repo/buyer.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/store/src/repo/mod.rs | 12++++++++++++
Mcrates/store/src/repo/orders.rs | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/view/src/lib.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 284 insertions(+), 22 deletions(-)

diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -9,12 +9,15 @@ use radroots_app_view::{ OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, RepeatDemandEligibility, RepeatDemandHandoffProjection, TradePaymentDisplayStatus, - TradeRevisionStatus, TradeWorkflowProjection, + TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; -use super::order_detail::{order_detail_economics, order_detail_item_row}; +use super::{ + order_detail::{order_detail_economics, order_detail_item_row}, + parse_trade_revision_status, +}; use crate::AppSqliteError; const BUYER_LOW_STOCK_THRESHOLD: u32 = 3; @@ -793,6 +796,8 @@ impl<'a> AppBuyerRepository<'a> { let order_id = parse_typed_id("orders.id", order_id)?; let farm_id = parse_typed_id("orders.farm_id", farm_id)?; let buyer_status = BuyerOrderStatus::from(parse_order_status("orders.status", status)?); + let revision = + parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; orders.push(BuyerOrdersListRow { order_id, @@ -812,9 +817,7 @@ impl<'a> AppBuyerRepository<'a> { ), status: buyer_status, workflow: TradeWorkflowProjection::from_buyer_order_status(order_id, buyer_status) - .with_revision(TradeRevisionStatus::from_storage_key( - workflow_revision.as_str(), - )), + .with_revision(revision), }); } @@ -888,14 +891,14 @@ impl<'a> AppBuyerRepository<'a> { let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; let status = BuyerOrderStatus::from(parse_order_status("orders.status", status)?); + let revision = + parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; let items = self.load_order_detail_items(order_id.to_string())?; let economics = order_detail_economics(&items)?; let payment = TradePaymentDisplayStatus::NotRecorded; let workflow = TradeWorkflowProjection::from_buyer_order_status(order_id, status) - .with_revision(TradeRevisionStatus::from_storage_key( - workflow_revision.as_str(), - )) + .with_revision(revision) .with_economics_and_payment(economics.clone(), payment); Ok(BuyerOrderDetailProjection { order_id, @@ -2793,7 +2796,7 @@ mod tests { use radroots_app_view::{ BuyerCheckoutDisabledReason, BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId, - OrderId, PickupLocationId, ProductId, TradePaymentDisplayStatus, + OrderId, PickupLocationId, ProductId, TradePaymentDisplayStatus, TradeRevisionStatus, }; use rusqlite::{Connection, params}; use serde_json::json; @@ -3363,6 +3366,59 @@ mod tests { } #[test] + fn buyer_order_projections_fail_closed_for_invalid_workflow_revision() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let repository = AppBuyerRepository::new(connection); + let context = BuyerContext::Guest; + let farm_id = insert_farm(connection, "Willow Farm", "ready"); + let order_id = OrderId::new(); + + insert_order( + connection, + order_id, + farm_id, + "R-100", + "scheduled", + Some("guest"), + "guest@example.com", + "", + "", + ); + set_order_workflow_revision( + connection, + order_id, + TradeRevisionStatus::KeptAsPlaced.storage_key(), + ); + + let list = repository + .load_buyer_orders(&context) + .expect("valid revision should load in buyer list"); + let detail = repository + .load_buyer_order_detail(&context, order_id) + .expect("valid revision should load in buyer detail") + .expect("buyer detail should exist"); + + assert_eq!( + list.rows[0].workflow.revision, + TradeRevisionStatus::KeptAsPlaced + ); + assert_eq!(detail.workflow.revision, TradeRevisionStatus::KeptAsPlaced); + + corrupt_order_workflow_revision(connection, order_id, "future_revision"); + + let list_error = repository + .load_buyer_orders(&context) + .expect_err("invalid revision should fail buyer list projection"); + let detail_error = repository + .load_buyer_order_detail(&context, order_id) + .expect_err("invalid revision should fail buyer detail projection"); + + assert_decode_enum(list_error, "orders.workflow_revision", "future_revision"); + assert_decode_enum(detail_error, "orders.workflow_revision", "future_revision"); + } + + #[test] fn buyer_cart_rejects_cross_farm_lines() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let farm_id = FarmId::new(); @@ -3615,6 +3671,43 @@ mod tests { .expect("order insert should succeed"); } + fn set_order_workflow_revision( + connection: &Connection, + order_id: OrderId, + workflow_revision: &str, + ) { + connection + .execute( + "update orders set workflow_revision = ?1 where id = ?2", + params![workflow_revision, order_id.to_string()], + ) + .expect("order workflow revision update should succeed"); + } + + fn corrupt_order_workflow_revision( + connection: &Connection, + order_id: OrderId, + workflow_revision: &str, + ) { + connection + .execute_batch("pragma ignore_check_constraints = on") + .expect("check constraints should disable"); + set_order_workflow_revision(connection, order_id, workflow_revision); + connection + .execute_batch("pragma ignore_check_constraints = off") + .expect("check constraints should re-enable"); + } + + fn assert_decode_enum(error: AppSqliteError, expected_field: &str, expected_value: &str) { + match error { + AppSqliteError::DecodeEnum { field, value } => { + assert_eq!(field, expected_field); + assert_eq!(value, expected_value); + } + other => panic!("expected DecodeEnum error, got {other:?}"), + } + } + fn row_count(connection: &Connection, table_name: &str) -> i64 { let sql = format!("SELECT COUNT(*) FROM {table_name}"); diff --git a/crates/store/src/repo/mod.rs b/crates/store/src/repo/mod.rs @@ -9,6 +9,10 @@ pub(crate) mod products; pub(crate) mod reminders; pub(crate) mod today; +use radroots_app_view::TradeRevisionStatus; + +use crate::AppSqliteError; + pub use activation::AppActivationRepository; pub use activity::{ APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository, @@ -25,3 +29,11 @@ pub use reminders::AppRemindersRepository; pub use today::{ AppTodayAgendaRepository, TODAY_AGENDA_LIST_LIMIT, TODAY_AGENDA_LOW_STOCK_THRESHOLD, }; + +pub(crate) fn parse_trade_revision_status( + field: &'static str, + value: String, +) -> Result<TradeRevisionStatus, AppSqliteError> { + TradeRevisionStatus::try_from_storage_key(value.as_str()) + .map_err(|_| AppSqliteError::DecodeEnum { field, value }) +} diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -12,7 +12,10 @@ use radroots_app_view::{ }; use rusqlite::{Connection, OptionalExtension, params}; -use super::order_detail::{order_detail_economics, order_detail_item_row}; +use super::{ + order_detail::{order_detail_economics, order_detail_item_row}, + parse_trade_revision_status, +}; use crate::AppSqliteError; pub struct AppOrdersRepository<'a> { @@ -120,13 +123,13 @@ impl<'a> AppOrdersRepository<'a> { let order_id: OrderId = parse_typed_id("orders.id", order_id)?; let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; let status = parse_order_status("orders.status", status)?; + let revision = + parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; let items = self.load_order_detail_items(order_id.to_string())?; let economics = order_detail_economics(&items)?; let payment = TradePaymentDisplayStatus::NotRecorded; let workflow = TradeWorkflowProjection::from_order_status(order_id, status) - .with_revision(TradeRevisionStatus::from_storage_key( - workflow_revision.as_str(), - )) + .with_revision(revision) .with_economics_and_payment(economics.clone(), payment); Ok(OrderDetailProjection { order_id, @@ -360,7 +363,10 @@ impl<'a> AppOrdersRepository<'a> { fulfillment_window_label: empty_string_to_none(fulfillment_window_label), pickup_location_label: empty_string_to_none(pickup_location_label), status: parse_order_status("orders.status", status)?, - revision: TradeRevisionStatus::from_storage_key(workflow_revision.as_str()), + revision: parse_trade_revision_status( + "orders.workflow_revision", + workflow_revision, + )?, }); } @@ -1274,11 +1280,11 @@ mod tests { use radroots_app_view::{ FarmId, FulfillmentWindowId, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersScreenQueryState, PackDayOutputOrderState, PackDayProductTotalRow, - PackDayScreenQueryState, PickupLocationId, TradePaymentDisplayStatus, + PackDayScreenQueryState, PickupLocationId, TradePaymentDisplayStatus, TradeRevisionStatus, }; use rusqlite::{Connection, params}; - use crate::{AppSqliteStore, DatabaseTarget}; + use crate::{AppSqliteError, AppSqliteStore, DatabaseTarget}; #[test] fn orders_list_loads_summary_rows_and_window_filter_truthfully() { @@ -1497,6 +1503,72 @@ mod tests { } #[test] + fn seller_order_projections_fail_closed_for_invalid_workflow_revision() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let farm_id = FarmId::new(); + let order_id = OrderId::new(); + + insert_farm( + connection, + farm_id, + "Willow farm", + "ready", + "2026-04-17T08:00:00Z", + ); + insert_order( + connection, + order_id, + farm_id, + None, + "R-100", + "Casey", + "scheduled", + "2026-04-17T10:00:00Z", + ); + set_order_workflow_revision( + connection, + order_id, + TradeRevisionStatus::Updated.storage_key(), + ); + + let list = store + .load_orders_list( + farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("valid revision should load in seller list"); + let detail = store + .load_order_detail(farm_id, order_id) + .expect("valid revision should load in seller detail") + .expect("seller detail should exist"); + + assert_eq!(list.rows[0].workflow.revision, TradeRevisionStatus::Updated); + assert_eq!(detail.workflow.revision, TradeRevisionStatus::Updated); + + corrupt_order_workflow_revision(connection, order_id, "future_revision"); + + let list_error = store + .load_orders_list( + farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect_err("invalid revision should fail seller list projection"); + let detail_error = store + .load_order_detail(farm_id, order_id) + .expect_err("invalid revision should fail seller detail projection"); + + assert_decode_enum(list_error, "orders.workflow_revision", "future_revision"); + assert_decode_enum(detail_error, "orders.workflow_revision", "future_revision"); + } + + #[test] fn pack_day_defaults_to_next_window_and_projects_totals_pack_list_and_roster() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let connection = store.connection(); @@ -2027,6 +2099,43 @@ mod tests { .expect("order insert should succeed"); } + fn set_order_workflow_revision( + connection: &Connection, + order_id: OrderId, + workflow_revision: &str, + ) { + connection + .execute( + "update orders set workflow_revision = ?1 where id = ?2", + params![workflow_revision, order_id.to_string()], + ) + .expect("order workflow revision update should succeed"); + } + + fn corrupt_order_workflow_revision( + connection: &Connection, + order_id: OrderId, + workflow_revision: &str, + ) { + connection + .execute_batch("pragma ignore_check_constraints = on") + .expect("check constraints should disable"); + set_order_workflow_revision(connection, order_id, workflow_revision); + connection + .execute_batch("pragma ignore_check_constraints = off") + .expect("check constraints should re-enable"); + } + + fn assert_decode_enum(error: AppSqliteError, expected_field: &str, expected_value: &str) { + match error { + AppSqliteError::DecodeEnum { field, value } => { + assert_eq!(field, expected_field); + assert_eq!(value, expected_value); + } + other => panic!("expected DecodeEnum error, got {other:?}"), + } + } + fn insert_order_line( connection: &Connection, line_id: &str, diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1169,6 +1169,25 @@ pub enum TradeRevisionStatus { KeptAsPlaced, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseTradeRevisionStatusError { + value: String, +} + +impl ParseTradeRevisionStatusError { + pub fn value(&self) -> &str { + self.value.as_str() + } +} + +impl fmt::Display for ParseTradeRevisionStatusError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid trade revision status `{}`", self.value) + } +} + +impl Error for ParseTradeRevisionStatusError {} + impl TradeRevisionStatus { pub const fn storage_key(self) -> &'static str { match self { @@ -1197,12 +1216,15 @@ impl TradeRevisionStatus { } } - pub fn from_storage_key(value: &str) -> Self { - match value.trim() { - "change_proposed" => Self::ChangeProposed, - "updated" => Self::Updated, - "kept_as_placed" => Self::KeptAsPlaced, - _ => Self::None, + pub fn try_from_storage_key(value: &str) -> Result<Self, ParseTradeRevisionStatusError> { + match value { + "none" => Ok(Self::None), + "change_proposed" => Ok(Self::ChangeProposed), + "updated" => Ok(Self::Updated), + "kept_as_placed" => Ok(Self::KeptAsPlaced), + _ => Err(ParseTradeRevisionStatusError { + value: value.to_owned(), + }), } } } @@ -2801,6 +2823,32 @@ mod tests { TradeRevisionStatus::from_reducer_status(TradeReducerRevisionStatus::Declined), TradeRevisionStatus::KeptAsPlaced ); + assert_eq!( + TradeRevisionStatus::try_from_storage_key("none"), + Ok(TradeRevisionStatus::None) + ); + assert_eq!( + TradeRevisionStatus::try_from_storage_key("change_proposed"), + Ok(TradeRevisionStatus::ChangeProposed) + ); + assert_eq!( + TradeRevisionStatus::try_from_storage_key("updated"), + Ok(TradeRevisionStatus::Updated) + ); + assert_eq!( + TradeRevisionStatus::try_from_storage_key("kept_as_placed"), + Ok(TradeRevisionStatus::KeptAsPlaced) + ); + assert_eq!( + TradeRevisionStatus::try_from_storage_key("proposed") + .expect_err("reducer key should not parse as app revision key") + .value(), + "proposed" + ); + assert!( + TradeRevisionStatus::try_from_storage_key(" none ").is_err(), + "storage keys must parse exactly" + ); let order_id = OrderId::new(); let projection = TradeWorkflowProjection::from_reducer_status(