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:
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(