Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/app/admin_cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,94 @@ pub async fn admin_cancel_action(

Ok(())
}

#[cfg(test)]
mod tests {
use mostro_core::error::CantDoReason;
use mostro_core::prelude::MostroError;

const DAEMON_PUBKEY: &str = "b1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
const READ_ONLY_SOLVER: &str =
"c1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
const READ_WRITE_SOLVER: &str =
"d1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";

async fn setup_permission_db() -> sqlx::SqlitePool {
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
pool
}

async fn seed_solver(pool: &sqlx::SqlitePool, pubkey: &str, category: i64) {
sqlx::query(
"INSERT INTO users (pubkey, is_solver, category, created_at) VALUES (?1, 1, ?2, 1700000000)",
)
.bind(pubkey)
.bind(category)
.execute(pool)
.await
.unwrap();
}

async fn seed_dispute(pool: &sqlx::SqlitePool, order_id: uuid::Uuid, solver_pubkey: &str) {
sqlx::query(
"INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at)
VALUES (?1, ?2, 'in-progress', 'dispute', ?3, 1700000000)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(order_id)
.bind(solver_pubkey)
.execute(pool)
.await
.unwrap();
}

/// admin_cancel: read-only solver is rejected with NotAuthorized
#[tokio::test]
async fn admin_cancel_read_only_solver_is_rejected() {
let pool = setup_permission_db().await;
let order_id = uuid::Uuid::new_v4();
seed_solver(&pool, READ_ONLY_SOLVER, 1).await;
seed_dispute(&pool, order_id, READ_ONLY_SOLVER).await;

let result = crate::db::ensure_dispute_finalize_permission(
&pool,
READ_ONLY_SOLVER,
DAEMON_PUBKEY,
order_id,
)
.await;

assert!(
matches!(
result,
Err(MostroError::MostroCantDo(CantDoReason::NotAuthorized))
),
"read-only solver must be rejected with NotAuthorized, got: {:?}",
result
);
}

/// admin_cancel: read-write solver is allowed through the permission gate
#[tokio::test]
async fn admin_cancel_read_write_solver_is_allowed() {
let pool = setup_permission_db().await;
let order_id = uuid::Uuid::new_v4();
seed_solver(&pool, READ_WRITE_SOLVER, 2).await;
seed_dispute(&pool, order_id, READ_WRITE_SOLVER).await;

let result = crate::db::ensure_dispute_finalize_permission(
&pool,
READ_WRITE_SOLVER,
DAEMON_PUBKEY,
order_id,
)
.await;

assert!(
result.is_ok(),
"read-write solver must pass the permission gate, got: {:?}",
result
);
}
}
101 changes: 89 additions & 12 deletions src/app/admin_settle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,28 +237,105 @@ pub async fn admin_settle_action(
#[cfg(test)]
mod tests {
use mostro_core::error::CantDoReason;
use mostro_core::prelude::MostroError;

/// Test that our error handling logic correctly identifies admin takeover vs regular disputes
/// This tests the core business logic of issue #302 without complex database setup
/// Existing structural test — kept for continuity
#[test]
fn test_dispute_error_types() {
// Test that we have the correct error types available
// This ensures our mostro-core dependency includes the new DisputeTakenByAdmin variant

// Original error for regular dispute issues
let regular_error = CantDoReason::IsNotYourDispute;
assert_eq!(format!("{:?}", regular_error), "IsNotYourDispute");

// New error for admin takeover scenarios
let admin_error = CantDoReason::DisputeTakenByAdmin;
assert_eq!(format!("{:?}", admin_error), "DisputeTakenByAdmin");

// New error for authenticated callers lacking enough permissions
let unauthorized_error = CantDoReason::NotAuthorized;
assert_eq!(format!("{:?}", unauthorized_error), "NotAuthorized");

// Verify they are different error types
assert_ne!(regular_error, admin_error);
assert_ne!(admin_error, unauthorized_error);
}

// ---- Solver write-permission gate (issue #709) ----

const DAEMON_PUBKEY: &str = "b1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
const READ_ONLY_SOLVER: &str =
"c1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
const READ_WRITE_SOLVER: &str =
"d1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";

async fn setup_permission_db() -> sqlx::SqlitePool {
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
pool
}

async fn seed_solver(pool: &sqlx::SqlitePool, pubkey: &str, category: i64) {
sqlx::query(
"INSERT INTO users (pubkey, is_solver, category, created_at) VALUES (?1, 1, ?2, 1700000000)",
)
.bind(pubkey)
.bind(category)
.execute(pool)
.await
.unwrap();
}

async fn seed_dispute(pool: &sqlx::SqlitePool, order_id: uuid::Uuid, solver_pubkey: &str) {
sqlx::query(
"INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at)
VALUES (?1, ?2, 'in-progress', 'dispute', ?3, 1700000000)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(order_id)
.bind(solver_pubkey)
.execute(pool)
.await
.unwrap();
}

/// admin_settle: read-only solver is rejected with NotAuthorized
#[tokio::test]
async fn admin_settle_read_only_solver_is_rejected() {
let pool = setup_permission_db().await;
let order_id = uuid::Uuid::new_v4();
seed_solver(&pool, READ_ONLY_SOLVER, 1).await;
seed_dispute(&pool, order_id, READ_ONLY_SOLVER).await;

let result = crate::db::ensure_dispute_finalize_permission(
&pool,
READ_ONLY_SOLVER,
DAEMON_PUBKEY,
order_id,
)
.await;

assert!(
matches!(
result,
Err(MostroError::MostroCantDo(CantDoReason::NotAuthorized))
),
"read-only solver must be rejected with NotAuthorized, got: {:?}",
result
);
}

/// admin_settle: read-write solver is allowed through the permission gate
#[tokio::test]
async fn admin_settle_read_write_solver_is_allowed() {
let pool = setup_permission_db().await;
let order_id = uuid::Uuid::new_v4();
seed_solver(&pool, READ_WRITE_SOLVER, 2).await;
seed_dispute(&pool, order_id, READ_WRITE_SOLVER).await;

let result = crate::db::ensure_dispute_finalize_permission(
&pool,
READ_WRITE_SOLVER,
DAEMON_PUBKEY,
order_id,
)
.await;

assert!(
result.is_ok(),
"read-write solver must pass the permission gate, got: {:?}",
result
);
}
}
4 changes: 2 additions & 2 deletions src/app/admin_take_dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,11 @@ pub async fn admin_take_dispute_action(
// Get order from db using the dispute order id
let order = if let Some(order) = Order::by_id(pool, dispute.order_id)
.await
.map_err(|_| MostroInternalErr(ServiceError::InvalidOrderId))?
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?
{
order
} else {
return Err(MostroInternalErr(ServiceError::InvalidOrderId));
return Err(MostroCantDo(CantDoReason::NotFound));
};

// Update dispute fields
Expand Down
2 changes: 1 addition & 1 deletion src/app/dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ pub async fn dispute_action(
let order_id = if let Some(order_id) = msg.get_inner_message_kind().id {
order_id
} else {
return Err(MostroInternalErr(ServiceError::InvalidOrderId));
return Err(MostroCantDo(CantDoReason::NotFound));
};
// Check dispute for this order id is yet present.
if find_dispute_by_order_id(pool, order_id).await.is_ok() {
Expand Down
65 changes: 61 additions & 4 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1168,16 +1168,14 @@ pub async fn get_dispute(msg: &Message, pool: &Pool<Sqlite>) -> Result<Dispute,

pub async fn get_order(msg: &Message, pool: &Pool<Sqlite>) -> Result<Order, MostroError> {
let order_msg = msg.get_inner_message_kind();
let order_id = order_msg
.id
.ok_or(MostroInternalErr(ServiceError::InvalidOrderId))?;
let order_id = order_msg.id.ok_or(MostroCantDo(CantDoReason::NotFound))?;
let order = Order::by_id(pool, order_id)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
if let Some(order) = order {
Ok(order)
} else {
Err(MostroInternalErr(ServiceError::InvalidOrderId))
Err(MostroCantDo(CantDoReason::NotFound))
}
}

Expand Down Expand Up @@ -1585,6 +1583,65 @@ mod tests {
assert!(orders.is_empty());
}

#[tokio::test]
async fn test_get_order_returns_not_found_when_id_missing() {
initialize();
let pool = setup_orders_pool().await;
let message = Message::Order(MessageKind::new(
None,
None,
None,
Action::AdminSettle,
None,
));

let err = get_order(&message, &pool).await.unwrap_err();
assert!(matches!(
err,
MostroError::MostroCantDo(CantDoReason::NotFound)
));
}

#[tokio::test]
async fn test_get_order_returns_not_found_when_order_absent() {
initialize();
let pool = setup_orders_pool().await;
let missing_id = Uuid::new_v4();
let message = Message::Order(MessageKind::new(
Some(missing_id),
None,
None,
Action::AdminSettle,
None,
));

let err = get_order(&message, &pool).await.unwrap_err();
assert!(matches!(
err,
MostroError::MostroCantDo(CantDoReason::NotFound)
));
}

#[tokio::test]
async fn test_get_order_returns_order_when_found() {
initialize();
let pool = setup_orders_pool().await;
let user_pubkey = "a".repeat(64);
let order_id = Uuid::new_v4();
insert_order(&pool, order_id, Some(&user_pubkey), None, &user_pubkey).await;

let message = Message::Order(MessageKind::new(
Some(order_id),
None,
None,
Action::AdminSettle,
None,
));

let order = get_order(&message, &pool).await.unwrap();
assert_eq!(order.id, order_id);
}

#[test]
fn test_get_dev_fee_basic() {
// 1000 sats Mostro fee at 30% -> 300 sats
Expand Down