diff --git a/CLAUDE.md b/CLAUDE.md index f0a67ce70..4bd623d40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,10 @@ When implementing or debugging protocol-related features (order flows, actions, ### Nostr Integration - **NostrService** (`services/nostr_service.dart`) manages relay connections and messaging - All Nostr protocol interactions go through this service -- **MostroFSM** (`core/mostro_fsm.dart`) manages order state transitions +- **MostroFSM** (`core/mostro_fsm.dart`) defines a transition matrix but is **not wired in** — nothing imports it + - Order status is actually derived by `OrderState._getStatusFromAction` (`features/order/models/order_state.dart`), which maps actions to statuses without consulting the matrix + - Do not read `mostro_fsm.dart` as an active validation layer. Its role axis models who performs an action, not the local user's role in the trade (the app never assigns `Role.admin` to a session), so wiring it as-is would reject legitimate admin resolutions + - The only transition guard that runs today is the dispute-evidence check on `admin-*` actions in `OrderState.updateWith` ### Navigation and UI - **GoRouter** for navigation (configured in `core/app_routes.dart`) diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 4f27f8f65..9056e9f9a 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -93,6 +93,51 @@ class OrderState { ); } + /// Dispute statuses in which the dispute is over: a resolution has already + /// been applied and no further admin action is expected for it. + static const _terminalDisputeStatuses = { + 'resolved', + 'seller-refunded', + 'closed', + }; + + static bool _isAdminDisputeAction(Action action) => + action == Action.adminSettled || + action == Action.adminSettle || + action == Action.adminCanceled || + action == Action.adminCancel || + action == Action.adminTookDispute || + action == Action.adminTakeDispute; + + /// Whether local state corroborates that a dispute exists for this order. + /// + /// Evidence is the tracked [Dispute] object and nothing else. In particular + /// [Status.dispute] does not qualify: any action mapping to that status sets + /// it without carrying a dispute, so accepting it would let a bare + /// `dispute` message stand in as the evidence for the `admin-*` message + /// right behind it. + /// + /// The incoming payload does not count either — taking an attacker-supplied + /// Dispute as its own justification is the vector this guards against. An + /// already-resolved dispute is not re-resolved, which blocks replaying an + /// authentic resolution onto a later state. + bool get _acceptsAdminDisputeAction { + final localDispute = dispute; + if (localDispute == null) return false; + + final localDisputeStatus = localDispute.status?.toLowerCase(); + return localDisputeStatus == null || + !_terminalDisputeStatuses.contains(localDisputeStatus); + } + + /// Whether [updateWith] would drop this action for lack of dispute evidence. + /// + /// Callers use this to suppress the message's side effects too. A rejected + /// resolution that still raises a notification or navigates would hand the + /// attacker the user-visible half of the forgery. + bool rejectsAdminDisputeAction(Action action) => + _isAdminDisputeAction(action) && !_acceptsAdminDisputeAction; + OrderState updateWith(MostroMessage message) { logger.i('Updating OrderState with Action: ${message.action}'); @@ -101,6 +146,18 @@ class OrderState { return copyWith(cantDo: message.getPayload()); } + // An admin resolution only means something as the outcome of a dispute that + // already exists. Applied unconditionally, a forged or replayed admin-* + // message flips a live trade to a terminal state and drives the resolution + // UI, so drop it unless local state corroborates the dispute. + if (_isAdminDisputeAction(message.action) && !_acceptsAdminDisputeAction) { + logger.w( + 'Ignoring ${message.action} for order ${message.id}: no dispute ' + 'evidence in local state (status: $status, dispute: ${dispute?.status ?? 'none'})', + ); + return this; + } + // Track whether fiat was sent at any point in this order's lifecycle final bool newFiatWasSent = fiatWasSent || message.action == Action.fiatSent || @@ -155,12 +212,28 @@ class OrderState { newPeer = peer; // Preserve existing } - // Handle dispute status updates based on action - Dispute? updatedDispute = message.getPayload() ?? dispute; - + // Handle dispute status updates based on action. + // A payload dispute never re-points a tracked dispute at a different id: + // only the dispute this order already carries can be updated from the wire. + final localDispute = dispute; + final payloadDispute = message.getPayload(); + final bool payloadDisputeAccepted = payloadDispute != null && + (localDispute == null || + payloadDispute.disputeId == localDispute.disputeId); + + if (payloadDispute != null && !payloadDisputeAccepted) { + logger.w( + 'Ignoring dispute payload ${payloadDispute.disputeId} for order ' + '${message.id}: does not match tracked dispute ${localDispute!.disputeId}', + ); + } + + Dispute? updatedDispute = + payloadDisputeAccepted ? payloadDispute : localDispute; + // If we got a dispute from the message payload, ensure it has the message timestamp // This is critical for correct sorting in the dispute list - if (updatedDispute != null && message.getPayload() != null) { + if (updatedDispute != null && payloadDisputeAccepted) { // Use message timestamp if dispute doesn't have a createdAt or if message has a timestamp // Note: Nostr timestamps are in seconds, so convert to milliseconds if (message.timestamp != null) { @@ -331,12 +404,17 @@ class OrderState { // Actions that should set status to canceled case Action.canceled: case Action.cancel: - case Action.adminCanceled: - case Action.adminCancel: case Action.cooperativeCancelAccepted: case Action.holdInvoicePaymentCanceled: return Status.canceled; + // A dispute resolved by cancelation is its own terminal state: it keeps + // the admin resolution visible to the user and out of the plain-cancel + // cleanup paths, which are built around Action.canceled. + case Action.adminCanceled: + case Action.adminCancel: + return Status.canceledByAdmin; + // Actions that should set status to cooperatively canceled (pending cancellation) case Action.cooperativeCancelInitiatedByYou: case Action.cooperativeCancelInitiatedByPeer: diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 6be5d051b..e9e6b8955 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -107,9 +107,25 @@ class AbstractMostroNotifier extends StateNotifier { final wasUserInitiatedCancel = msg.action == Action.canceled && _userInitiatedCancels.remove(orderId); + // Evaluated before the state update, which is what consumes the + // dispute evidence this depends on. + final rejectedAdminAction = + state.rejectsAdminDisputeAction(msg.action); + if (mounted) { state = state.updateWith(msg); } + + // The state change was dropped; its side effects must go with it. + // Otherwise a forged admin resolution still reaches the user as a + // notification and a jump to the trade detail. + if (rejectedAdminAction) { + logger.w( + 'Dropping side effects for rejected ${msg.action} on order $orderId'); + onAdminResolutionRejected(msg); + return; + } + if (msg.timestamp != null && msg.timestamp! > DateTime.now() @@ -165,6 +181,16 @@ class AbstractMostroNotifier extends StateNotifier { } } + /// Called when an admin resolution was dropped for lack of dispute evidence. + /// + /// The rejection is decided from in-memory state, which during startup may + /// not yet hold the dispute this resolution belongs to. Subclasses that + /// hydrate from storage override this to replay the persisted history, where + /// the dispute and the resolution are applied in order. Replaying is safe: + /// the guard runs on every message of the replay, so a resolution with no + /// dispute ahead of it in the history is still rejected. + void onAdminResolutionRejected(MostroMessage message) {} + Future handleEvent(MostroMessage event, {bool bypassTimestampGate = false, Status? previousStatus, @@ -668,6 +694,16 @@ class AbstractMostroNotifier extends StateNotifier { } break; + // Mirrors adminSettled: an admin resolution is terminal but keeps its + // session, so the user retains the record of how the dispute ended. + // Deliberately not routed through the Action.canceled cleanup, which + // deletes the session and would erase that record. + case Action.adminCanceled: + if (isRecent && !bypassTimestampGate) { + navProvider.go('/trade_detail/$orderId'); + } + break; + case Action.cantDo: final cantDo = event.getPayload(); diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index e30465330..00b85d577 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -8,12 +8,21 @@ import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; ProviderSubscription>>? _publicEventsSubscription; bool _isSyncing = false; // Only for sync() method - + bool _hydrated = false; // A sync() has read the history successfully + bool _resyncRequested = false; // A sync() was asked for while one was running + int _resyncAttempts = 0; + + /// Bounds the chain of replays a single startup may schedule. Rejected + /// resolutions are hostile input, so the chain must not be paced by how fast + /// they arrive. + static const _maxChainedResyncs = 3; + OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); sync(); @@ -35,9 +44,25 @@ class OrderNotifier extends AbstractMostroNotifier { wasUserInitiatedCancel: wasUserInitiatedCancel); } + /// Replays the persisted history when a resolution was rejected only because + /// startup had not loaded its dispute yet. Once hydrated, a rejection is the + /// correct outcome and no replay is needed — which also keeps forged + /// resolutions from each costing a full storage read. + @override + void onAdminResolutionRejected(MostroMessage message) { + if (_hydrated) return; + logger.i( + 'Re-syncing order $orderId: ${message.action} arrived before hydration completed'); + sync(); + } + Future sync() async { - if (_isSyncing) return; + if (_isSyncing) { + _resyncRequested = true; + return; + } + var succeeded = false; try { _isSyncing = true; @@ -45,6 +70,7 @@ class OrderNotifier extends AbstractMostroNotifier { final messages = await storage.getAllMessagesForOrderId(orderId); if (messages.isEmpty) { logger.w('No messages found for order $orderId'); + succeeded = true; return; } @@ -73,6 +99,7 @@ class OrderNotifier extends AbstractMostroNotifier { if (state.status == Status.canceled) { await reconcileCanceledBondedSession(); } + succeeded = true; } catch (e, stack) { logger.e( 'Error syncing order state for $orderId', @@ -81,6 +108,24 @@ class OrderNotifier extends AbstractMostroNotifier { ); } finally { _isSyncing = false; + + final completion = resolveSyncCompletion( + succeeded: succeeded, + resyncRequested: _resyncRequested, + resyncAttempts: _resyncAttempts, + maxChainedResyncs: _maxChainedResyncs, + ); + _resyncRequested = false; + + switch (completion) { + case SyncCompletion.replay: + _resyncAttempts++; + sync(); + case SyncCompletion.hydrated: + _hydrated = true; + case SyncCompletion.unhydrated: + break; + } } } diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index 91a0a33e6..2fade4c53 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -13,6 +13,18 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; // Status filter provider - holds the currently selected status filter final statusFilterProvider = StateProvider((ref) => null); +/// Whether an order's status belongs under the selected filter. +/// +/// The picker offers no separate "canceled by admin" entry, so orders resolved +/// by an admin cancelation stay under "canceled" instead of dropping out of +/// every filter. The distinction is surfaced in the trade detail. +bool matchesStatusFilter(Status status, Status filter) { + if (filter == Status.canceled) { + return status == Status.canceled || status == Status.canceledByAdmin; + } + return status == filter; +} + // New provider that properly handles synthetic status filtering by checking OrderState final filteredTradesWithOrderStateProvider = Provider>>((ref) { @@ -59,10 +71,10 @@ final filteredTradesWithOrderStateProvider = final orderState = orderStates[order.orderId!]; if (orderState != null) { - return orderState.status == selectedStatusFilter; + return matchesStatusFilter(orderState.status, selectedStatusFilter); } else { // Fallback to raw status comparison if OrderState not available - return order.status == selectedStatusFilter; + return matchesStatusFilter(order.status, selectedStatusFilter); } }); } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index a60934bbb..c14d8b523 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Streitfall geschlossen – die Order wurde von den Parteien erfolgreich abgeschlossen.", "disputeClosedCooperativeCancel": "Streitfall geschlossen – die Order wurde von den Parteien kooperativ storniert.", "disputeInProgress": "Dieser Streitfall wird derzeit bearbeitet. Ein Schlichter prüft deinen Fall.", - "disputeSellerRefunded": "Dieser Streitfall wurde gelöst; dem Verkäufer wurde der Betrag zurückerstattet.", + "disputeSellerRefunded": "Der Administrator hat diesen Streitfall zugunsten des Verkäufers entschieden.", "disputeUnknownStatus": "Der Status dieses Streitfalls ist unbekannt.", "disputeChatClosed": "Dieser Streitfall wurde gelöst. Der Chat ist nun geschlossen.", "@_comment_dispute_descriptions": "Beschreibungen der Streitfall-Meldungen", @@ -849,15 +849,15 @@ "disputeDescriptionInitiatedByPeer": "Ein Streitfall gegen dich wurde eröffnet", "disputeDescriptionInitiatedPendingAdmin": "Ein Administrator wird diesen Streitfall bald übernehmen", "disputeDescriptionInProgress": "Noch keine Nachrichten", - "disputeDescriptionResolved": "Order abgeschlossen – Käufer hat die Sats erhalten", - "disputeDescriptionSellerRefunded": "Order abgebrochen – Verkäufer erhielt Rückerstattung", + "disputeDescriptionResolved": "Vom Administrator zugunsten des Käufers abgeschlossen", + "disputeDescriptionSellerRefunded": "Vom Administrator zugunsten des Verkäufers abgebrochen", "disputeDescriptionUnknown": "Unbekannter Status", "disputeAdminSettledMessage": "Der Administrator hat die Order zugunsten einer Partei entschieden. Überprüfe deine Wallet auf Zahlungen.", - "disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und dem Verkäufer den Betrag erstattet. Der Streitfall ist nun geschlossen.", - "disputeSettledBuyerMessage": "Der Streitfall wurde zu deinen Gunsten gelöst. Die Order wurde erfolgreich abgeschlossen und du hast die Sats erhalten. Überprüfe deine Wallet.", - "disputeSettledSellerMessage": "Der Streitfall wurde gelöst. Die Order wurde erfolgreich abgeschlossen und der Käufer hat die Sats erhalten.", - "disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen. Dem Verkäufer wurde der Betrag erstattet und du hast keine Sats erhalten.", - "disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und dir den Betrag erstattet. Der Käufer hat keine Sats erhalten. Überprüfe deine Wallet auf die Rückerstattung.", + "disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Der Streitfall ist nun geschlossen.", + "disputeSettledBuyerMessage": "Der Administrator hat den Streitfall zu deinen Gunsten entschieden und die Order abgeschlossen. Überprüfe deine Wallet, um den Eingang der Sats zu bestätigen.", + "disputeSettledSellerMessage": "Der Administrator hat den Streitfall zugunsten des Käufers entschieden und die Order abgeschlossen.", + "disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Diese Order wird nicht abgeschlossen.", + "disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zu deinen Gunsten entschieden. Überprüfe deine Wallet, um den Eingang der Rückerstattung zu bestätigen.", "disputeOpenedByYou": "Du hast diesen Streitfall gegen den Käufer {counterparty} eröffnet, bitte lies das Folgende sorgfältig durch:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ad67dda2f..d0f258e64 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Dispute closed — the order was successfully completed by the parties.", "disputeClosedCooperativeCancel": "Dispute closed — the order was cooperatively canceled by the parties.", "disputeInProgress": "This dispute is currently in progress. A solver is reviewing your case.", - "disputeSellerRefunded": "This dispute has been resolved with the seller being refunded.", + "disputeSellerRefunded": "The admin resolved this dispute in the seller's favor.", "disputeUnknownStatus": "The status of this dispute is unknown.", "disputeChatClosed": "This dispute has been resolved. The chat is now closed.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,15 +849,15 @@ "disputeDescriptionInitiatedByPeer": "A dispute was opened against you", "disputeDescriptionInitiatedPendingAdmin": "An admin will take this dispute soon", "disputeDescriptionInProgress": "No messages yet", - "disputeDescriptionResolved": "Order was completed - buyer received the sats", - "disputeDescriptionSellerRefunded": "Order was canceled - seller refunded", + "disputeDescriptionResolved": "Settled by the admin in the buyer's favor", + "disputeDescriptionSellerRefunded": "Canceled by the admin in the seller's favor", "disputeDescriptionUnknown": "Unknown status", "disputeAdminSettledMessage": "The admin settled the order in favor of one party. Check your wallet for any payments.", - "disputeSellerRefundedMessage": "The admin canceled the order and refunded the seller. The dispute is now closed.", - "disputeSettledBuyerMessage": "The dispute was resolved in your favor. The order was completed successfully and you received the sats. Check your wallet.", - "disputeSettledSellerMessage": "The dispute was resolved. The order was completed successfully and the buyer received the sats.", - "disputeCanceledBuyerMessage": "The admin canceled the order. The seller was refunded and you did not receive the sats.", - "disputeCanceledSellerMessage": "The admin canceled the order and refunded you. The buyer did not receive the sats. Check your wallet for the refund.", + "disputeSellerRefundedMessage": "The admin canceled the order and resolved the dispute in the seller's favor. The dispute is now closed.", + "disputeSettledBuyerMessage": "The admin resolved the dispute in your favor and settled the order. Check your wallet to confirm the sats arrived.", + "disputeSettledSellerMessage": "The admin resolved the dispute in the buyer's favor and settled the order.", + "disputeCanceledBuyerMessage": "The admin canceled the order and resolved the dispute in the seller's favor. This order will not be completed.", + "disputeCanceledSellerMessage": "The admin canceled the order and resolved the dispute in your favor. Check your wallet to confirm the refund arrived.", "disputeOpenedByYou": "You opened this dispute against the buyer {counterparty}, please read carefully below:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e8c1bcbe1..9a040a100 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -839,7 +839,7 @@ "disputeClosedUserCompleted": "Disputa cerrada — la orden fue completada exitosamente por las partes.", "disputeClosedCooperativeCancel": "Disputa cerrada — la orden fue cancelada cooperativamente por las partes.", "disputeInProgress": "Esta disputa está actualmente en progreso. Un mediador está revisando tu caso.", - "disputeSellerRefunded": "Esta disputa se ha resuelto con el vendedor siendo reembolsado.", + "disputeSellerRefunded": "El administrador resolvió esta disputa a favor del vendedor.", "disputeUnknownStatus": "El estado de esta disputa es desconocido.", "disputeChatClosed": "Esta disputa ha sido resuelta. El chat está cerrado.", "@_comment_dispute_descriptions": "Mensajes de Descripción de Disputas", @@ -847,15 +847,15 @@ "disputeDescriptionInitiatedByPeer": "Se abrió una disputa en tu contra", "disputeDescriptionInitiatedPendingAdmin": "Un administrador tomará esta disputa pronto", "disputeDescriptionInProgress": "Aún no hay mensajes", - "disputeDescriptionResolved": "Orden completada - el comprador recibió los sats", - "disputeDescriptionSellerRefunded": "Orden cancelada - vendedor reembolsado", + "disputeDescriptionResolved": "Liquidada por el administrador a favor del comprador", + "disputeDescriptionSellerRefunded": "Cancelada por el administrador a favor del vendedor", "disputeDescriptionUnknown": "Estado desconocido", "disputeAdminSettledMessage": "El administrador resolvió la orden a favor de una de las partes. Revisa tu billetera para ver los pagos.", - "disputeSellerRefundedMessage": "El administrador canceló la orden y reembolsó al vendedor. La disputa está ahora cerrada.", - "disputeSettledBuyerMessage": "La disputa se resolvió a tu favor. La orden se completó exitosamente y recibiste los sats. Revisa tu billetera.", - "disputeSettledSellerMessage": "La disputa fue resuelta. La orden se completó exitosamente y el comprador recibió los sats.", - "disputeCanceledBuyerMessage": "El administrador canceló la orden. Se reembolsó al vendedor y no recibiste los sats.", - "disputeCanceledSellerMessage": "El administrador canceló la orden y te reembolsó. El comprador no recibió los sats. Revisa tu billetera para ver el reembolso.", + "disputeSellerRefundedMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. La disputa está ahora cerrada.", + "disputeSettledBuyerMessage": "El administrador resolvió la disputa a tu favor y liquidó la orden. Revisa tu billetera para confirmar que recibiste los sats.", + "disputeSettledSellerMessage": "El administrador resolvió la disputa a favor del comprador y liquidó la orden.", + "disputeCanceledBuyerMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. Esta orden no se completará.", + "disputeCanceledSellerMessage": "El administrador canceló la orden y resolvió la disputa a tu favor. Revisa tu billetera para confirmar que recibiste el reembolso.", "disputeOpenedByYou": "Abriste esta disputa contra el comprador {counterparty}, lee atentamente a continuación:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 6d6f1980c..c896425d8 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Différend clos — la commande a été complétée avec succès par les parties.", "disputeClosedCooperativeCancel": "Différend clos — la commande a été annulée de manière coopérative par les parties.", "disputeInProgress": "Ce différend est actuellement en cours. Un résolveur examine votre cas.", - "disputeSellerRefunded": "Ce différend a été résolu avec remboursement du vendeur.", + "disputeSellerRefunded": "L'administrateur a résolu ce différend en faveur du vendeur.", "disputeUnknownStatus": "Le statut de ce différend est inconnu.", "disputeChatClosed": "Ce différend a été résolu. Le chat est maintenant fermé.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,15 +849,15 @@ "disputeDescriptionInitiatedByPeer": "Un différend a été ouvert contre vous", "disputeDescriptionInitiatedPendingAdmin": "Un administrateur prendra ce différend bientôt", "disputeDescriptionInProgress": "Aucun message pour le moment", - "disputeDescriptionResolved": "Commande terminée - l'acheteur a reçu les sats", - "disputeDescriptionSellerRefunded": "Commande annulée - vendeur remboursé", + "disputeDescriptionResolved": "Réglée par l'administrateur en faveur de l'acheteur", + "disputeDescriptionSellerRefunded": "Annulée par l'administrateur en faveur du vendeur", "disputeDescriptionUnknown": "Statut inconnu", "disputeAdminSettledMessage": "L'administrateur a réglé la commande en faveur d'une partie. Vérifiez votre portefeuille pour tout paiement.", - "disputeSellerRefundedMessage": "L'administrateur a annulé la commande et remboursé le vendeur. Le différend est maintenant fermé.", - "disputeSettledBuyerMessage": "Le différend a été résolu en votre faveur. La commande a été terminée avec succès et vous avez reçu les sats. Vérifiez votre portefeuille.", - "disputeSettledSellerMessage": "Le différend a été résolu. La commande a été terminée avec succès et l'acheteur a reçu les sats.", - "disputeCanceledBuyerMessage": "L'administrateur a annulé la commande. Le vendeur a été remboursé et vous n'avez pas reçu les sats.", - "disputeCanceledSellerMessage": "L'administrateur a annulé la commande et vous a remboursé. L'acheteur n'a pas reçu les sats. Vérifiez votre portefeuille pour le remboursement.", + "disputeSellerRefundedMessage": "L'administrateur a annulé la commande et résolu le différend en faveur du vendeur. Le différend est maintenant fermé.", + "disputeSettledBuyerMessage": "L'administrateur a résolu le différend en votre faveur et réglé la commande. Vérifiez votre portefeuille pour confirmer la réception des sats.", + "disputeSettledSellerMessage": "L'administrateur a résolu le différend en faveur de l'acheteur et réglé la commande.", + "disputeCanceledBuyerMessage": "L'administrateur a annulé la commande et résolu le différend en faveur du vendeur. Cette commande ne sera pas complétée.", + "disputeCanceledSellerMessage": "L'administrateur a annulé la commande et résolu le différend en votre faveur. Vérifiez votre portefeuille pour confirmer la réception du remboursement.", "disputeOpenedByYou": "Vous avez ouvert ce différend contre l'acheteur {counterparty}, veuillez lire attentivement ci-dessous :", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 371a1b4a2..0260fc04f 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -901,7 +901,7 @@ "disputeClosedUserCompleted": "Disputa chiusa — l'ordine è stato completato con successo dalle parti.", "disputeClosedCooperativeCancel": "Disputa chiusa — l'ordine è stato annullato in modo cooperativo dalle parti.", "disputeInProgress": "Questa disputa è attualmente in corso. Un risolutore sta esaminando il tuo caso.", - "disputeSellerRefunded": "Questa disputa è stata risolta con il venditore che è stato rimborsato.", + "disputeSellerRefunded": "L'amministratore ha risolto questa disputa a favore del venditore.", "disputeUnknownStatus": "Lo stato di questa disputa è sconosciuto.", "disputeChatClosed": "Questa disputa è stata risolta. La chat è ora chiusa.", "@_comment_dispute_descriptions": "Messaggi di Descrizione delle Dispute", @@ -909,15 +909,15 @@ "disputeDescriptionInitiatedByPeer": "È stata aperta una disputa contro di te", "disputeDescriptionInitiatedPendingAdmin": "Un amministratore prenderà presto questa disputa", "disputeDescriptionInProgress": "Nessun messaggio ancora", - "disputeDescriptionResolved": "Ordine completato - l'acquirente ha ricevuto i sats", - "disputeDescriptionSellerRefunded": "Ordine cancellato - venditore rimborsato", + "disputeDescriptionResolved": "Liquidato dall'amministratore a favore dell'acquirente", + "disputeDescriptionSellerRefunded": "Cancellato dall'amministratore a favore del venditore", "disputeDescriptionUnknown": "Stato sconosciuto", "disputeAdminSettledMessage": "L'amministratore ha risolto l'ordine a favore di una delle parti. Controlla il tuo wallet per eventuali pagamenti.", - "disputeSellerRefundedMessage": "L'amministratore ha cancellato l'ordine e rimborsato il venditore. La disputa è ora chiusa.", - "disputeSettledBuyerMessage": "La disputa è stata risolta a tuo favore. L'ordine è stato completato con successo e hai ricevuto i sats. Controlla il tuo wallet.", - "disputeSettledSellerMessage": "La disputa è stata risolta. L'ordine è stato completato con successo e l'acquirente ha ricevuto i sats.", - "disputeCanceledBuyerMessage": "L'amministratore ha cancellato l'ordine. Il venditore è stato rimborsato e non hai ricevuto i sats.", - "disputeCanceledSellerMessage": "L'amministratore ha cancellato l'ordine e ti ha rimborsato. L'acquirente non ha ricevuto i sats. Controlla il tuo wallet per il rimborso.", + "disputeSellerRefundedMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a favore del venditore. La disputa è ora chiusa.", + "disputeSettledBuyerMessage": "L'amministratore ha risolto la disputa a tuo favore e ha liquidato l'ordine. Controlla il tuo wallet per confermare di aver ricevuto i sats.", + "disputeSettledSellerMessage": "L'amministratore ha risolto la disputa a favore dell'acquirente e ha liquidato l'ordine.", + "disputeCanceledBuyerMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a favore del venditore. Questo ordine non sarà completato.", + "disputeCanceledSellerMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a tuo favore. Controlla il tuo wallet per confermare di aver ricevuto il rimborso.", "disputeOpenedByYou": "Hai aperto questa disputa contro l'acquirente {counterparty}, leggi attentamente di seguito:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 61ef03a77..9bd50f023 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Disputa fechada — a ordem foi concluída com sucesso pelas partes.", "disputeClosedCooperativeCancel": "Disputa fechada — a ordem foi cancelada cooperativamente pelas partes.", "disputeInProgress": "Esta disputa está atualmente em andamento. Um mediador está analisando seu caso.", - "disputeSellerRefunded": "Esta disputa foi resolvida com o reembolso do vendedor.", + "disputeSellerRefunded": "O administrador resolveu esta disputa em favor do vendedor.", "disputeUnknownStatus": "O status desta disputa é desconhecido.", "disputeChatClosed": "Esta disputa foi resolvida. O chat agora está fechado.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,15 +849,15 @@ "disputeDescriptionInitiatedByPeer": "Uma disputa foi aberta contra você", "disputeDescriptionInitiatedPendingAdmin": "Um administrador assumirá esta disputa em breve", "disputeDescriptionInProgress": "Nenhuma mensagem ainda", - "disputeDescriptionResolved": "A ordem foi concluída - o comprador recebeu os sats", - "disputeDescriptionSellerRefunded": "A ordem foi cancelada - vendedor reembolsado", + "disputeDescriptionResolved": "Liquidada pelo administrador em favor do comprador", + "disputeDescriptionSellerRefunded": "Cancelada pelo administrador em favor do vendedor", "disputeDescriptionUnknown": "Status desconhecido", "disputeAdminSettledMessage": "O administrador liquidou a ordem em favor de uma das partes. Verifique sua carteira para pagamentos.", - "disputeSellerRefundedMessage": "O administrador cancelou a ordem e reembolsou o vendedor. A disputa agora está fechada.", - "disputeSettledBuyerMessage": "A disputa foi resolvida a seu favor. A ordem foi concluída com sucesso e você recebeu os sats. Verifique sua carteira.", - "disputeSettledSellerMessage": "A disputa foi resolvida. A ordem foi concluída com sucesso e o comprador recebeu os sats.", - "disputeCanceledBuyerMessage": "O administrador cancelou a ordem. O vendedor foi reembolsado e você não recebeu os sats.", - "disputeCanceledSellerMessage": "O administrador cancelou a ordem e reembolsou você. O comprador não recebeu os sats. Verifique sua carteira para o reembolso.", + "disputeSellerRefundedMessage": "O administrador cancelou a ordem e resolveu a disputa em favor do vendedor. A disputa agora está fechada.", + "disputeSettledBuyerMessage": "O administrador resolveu a disputa a seu favor e liquidou a ordem. Verifique sua carteira para confirmar que você recebeu os sats.", + "disputeSettledSellerMessage": "O administrador resolveu a disputa em favor do comprador e liquidou a ordem.", + "disputeCanceledBuyerMessage": "O administrador cancelou a ordem e resolveu a disputa em favor do vendedor. Esta ordem não será concluída.", + "disputeCanceledSellerMessage": "O administrador cancelou a ordem e resolveu a disputa a seu favor. Verifique sua carteira para confirmar que você recebeu o reembolso.", "disputeOpenedByYou": "Você abriu esta disputa contra o comprador {counterparty}, por favor leia atentamente abaixo:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/shared/utils/order_sync_helpers.dart b/lib/shared/utils/order_sync_helpers.dart new file mode 100644 index 000000000..48ee74b3a --- /dev/null +++ b/lib/shared/utils/order_sync_helpers.dart @@ -0,0 +1,39 @@ +/// How a finished `sync()` pass should end. +enum SyncCompletion { + /// Another pass was requested while this one ran: replay the history again. + replay, + + /// The history was read successfully and nothing is queued behind it. + hydrated, + + /// The read failed, or the replay budget ran out before one succeeded. + /// Recovery stays available. + unhydrated, +} + +/// Decides how a `sync()` pass ends. +/// +/// Hydration is only claimed by a pass that actually read the history with +/// nothing queued behind it. A failed read or a pending replay must leave +/// recovery available, otherwise an admin resolution rejected during startup +/// — before its dispute was loaded — is never revisited. +/// +/// `maxChainedResyncs` bounds how many replays may be chained. Exhausting it +/// yields [SyncCompletion.unhydrated]: the pass neither claims a history it +/// knows may be missing the queued resolution, nor schedules yet another full +/// read. Recovery stays available because the notifier is still unhydrated, so +/// a later rejection can ask for a fresh pass — one read per message, rather +/// than a replay loop that rejected messages alone could keep running. +SyncCompletion resolveSyncCompletion({ + required bool succeeded, + required bool resyncRequested, + required int resyncAttempts, + required int maxChainedResyncs, +}) { + if (resyncRequested) { + return resyncAttempts < maxChainedResyncs + ? SyncCompletion.replay + : SyncCompletion.unhydrated; + } + return succeeded ? SyncCompletion.hydrated : SyncCompletion.unhydrated; +} diff --git a/test/features/disputes/dispute_resolution_message_test.dart b/test/features/disputes/dispute_resolution_message_test.dart new file mode 100644 index 000000000..2fb609164 --- /dev/null +++ b/test/features/disputes/dispute_resolution_message_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_status_content.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +/// The resolution banner is rendered from the message action plus the local +/// role alone: the app never reads the wallet or the escrow. It must therefore +/// attribute the outcome to the admin and ask the user to confirm, rather than +/// state that funds moved. + +Widget _wrap(DisputeData dispute, {Locale locale = const Locale('en')}) => + ProviderScope( + child: MaterialApp( + locale: locale, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.supportedLocales, + home: Scaffold(body: DisputeStatusContent(dispute: dispute)), + ), + ); + +DisputeData _resolved({ + required String action, + required UserRole userRole, + String status = 'resolved', +}) => + DisputeData( + disputeId: 'dispute-1', + status: status, + descriptionKey: DisputeDescriptionKey.resolved, + createdAt: DateTime(2026, 1, 1), + userRole: userRole, + action: action, + ); + +String _renderedText(WidgetTester tester) { + final texts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .toList(); + return texts.join(' '); +} + +void main() { + testWidgets('settled resolution does not tell the buyer the sats arrived', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-settled', userRole: UserRole.buyer)), + ); + await tester.pumpAndSettle(); + + final rendered = _renderedText(tester); + + expect(rendered, contains('admin'), + reason: 'the outcome must be attributed to who decided it'); + expect(rendered, contains('Check your wallet'), + reason: 'the user must be pointed at the only authoritative source'); + expect(rendered, isNot(contains('you received the sats')), + reason: 'the app never observed the funds moving'); + }); + + testWidgets('canceled resolution does not assert the refund to the seller', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved( + action: 'admin-canceled', + userRole: UserRole.seller, + status: 'seller-refunded', + )), + ); + await tester.pumpAndSettle(); + + final rendered = _renderedText(tester); + + expect(rendered, contains('Check your wallet')); + expect(rendered, isNot(contains('refunded you')), + reason: 'the app never observed the refund'); + }); + + testWidgets('settled resolution does not assert what the counterparty got', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-settled', userRole: UserRole.seller)), + ); + await tester.pumpAndSettle(); + + expect(_renderedText(tester), isNot(contains('received the sats'))); + }); + + testWidgets('canceled resolution does not assert the seller was refunded', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-canceled', userRole: UserRole.buyer)), + ); + await tester.pumpAndSettle(); + + expect(_renderedText(tester), isNot(contains('seller was refunded'))); + }); + + // The wording is a security property, not a copy detail: a locale that + // reverts to asserting fund movement puts those users back where they + // started. + group('localized resolutions attribute the outcome instead of asserting it', + () { + // (admin attribution, wallet, verb of confirmation, phrase unique to the + // old wording that announced the order as already completed). + const locales = { + 'en': ('admin', 'Check your wallet', 'confirm', 'completed successfully'), + 'es': ( + 'administrador', + 'Revisa tu billetera', + 'confirmar', + 'se completó exitosamente' + ), + 'it': ( + 'amministratore', + 'Controlla il tuo wallet', + 'confermare', + 'completato con successo' + ), + 'pt': ( + 'administrador', + 'Verifique sua carteira', + 'confirmar', + 'concluída com sucesso' + ), + 'de': ( + 'Administrator', + 'Überprüfe deine Wallet', + 'bestätigen', + 'erfolgreich abgeschlossen' + ), + 'fr': ( + 'administrateur', + 'Vérifiez votre portefeuille', + 'confirmer', + 'terminée avec succès' + ), + }; + + /// The variants where the user is the one owed money. Only these may talk + /// about the wallet, and they must ask rather than announce. + const awaitingFunds = [ + ('admin-settled', UserRole.buyer), + ('admin-canceled', UserRole.seller), + ]; + const notAwaitingFunds = [ + ('admin-settled', UserRole.seller), + ('admin-canceled', UserRole.buyer), + ]; + + locales.forEach((code, expectations) { + final (adminWord, wallet, confirms, oldWording) = expectations; + + testWidgets(code, (tester) async { + Future render(String action, UserRole role) async { + await tester.pumpWidget( + _wrap( + _resolved(action: action, userRole: role), + locale: Locale(code), + ), + ); + await tester.pumpAndSettle(); + return _renderedText(tester); + } + + for (final (action, role) in [...awaitingFunds, ...notAwaitingFunds]) { + final rendered = await render(action, role); + + expect(rendered, contains(adminWord), + reason: '$code/$action/${role.name} must attribute the outcome ' + 'to the admin who decided it'); + expect(rendered, isNot(contains(oldWording)), + reason: '$code/$action/${role.name} must not go back to stating ' + 'the order as settled fact'); + } + + for (final (action, role) in awaitingFunds) { + final rendered = await render(action, role); + + expect(rendered, contains(wallet), + reason: '$code/$action/${role.name} must point the user at ' + 'their wallet'); + expect(rendered, contains(confirms), + reason: '$code/$action/${role.name} must ask the user to ' + 'confirm, not announce receipt'); + } + }); + }); + }); +} diff --git a/test/features/order/models/order_state_admin_resolution_guard_test.dart b/test/features/order/models/order_state_admin_resolution_guard_test.dart new file mode 100644 index 000000000..7f33c52ec --- /dev/null +++ b/test/features/order/models/order_state_admin_resolution_guard_test.dart @@ -0,0 +1,300 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/data/enums.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; + +/// Guards against forged or replayed admin resolutions. +/// +/// `admin-settled` / `admin-canceled` / `admin-took-dispute` are only +/// legitimate as the outcome of an existing dispute. Applying them without +/// dispute evidence lets a counterparty (v1 intake) or a replayed message flip +/// a live trade to a terminal state and drive the resolution UI. + +Order _testOrder({Status status = Status.active}) => Order( + id: 'test-order-id', + kind: OrderType.sell, + status: status, + amount: 50000, + fiatCode: 'USD', + fiatAmount: 500, + paymentMethod: 'SEPA', + premium: 0, + ); + +/// A live trade with no dispute anywhere: the victim's state before the forgery. +OrderState _stateWithoutDispute({ + Status status = Status.fiatSent, + Action action = Action.fiatSentOk, +}) { + return OrderState( + status: status, + action: action, + order: _testOrder(status: status), + dispute: null, + ); +} + +/// A trade with a real dispute under review: the legitimate precondition for +/// an admin resolution. +OrderState _stateWithDispute({String disputeStatus = 'in-progress'}) { + return OrderState( + status: Status.dispute, + action: Action.disputeInitiatedByYou, + order: _testOrder(status: Status.dispute), + dispute: Dispute( + disputeId: 'dispute-1', + orderId: 'test-order-id', + status: disputeStatus, + ), + ); +} + +MostroMessage _message(Action action, {Payload? payload}) { + return MostroMessage(id: 'test-order-id', action: action, payload: payload); +} + +const _adminPubkey = + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + +void main() { + group('Admin resolutions require dispute evidence', () { + test('bare admin-canceled does not flip a fiat-sent order', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith(_message(Action.adminCanceled)); + + expect(updated.status, equals(Status.fiatSent), + reason: 'no dispute exists, so the resolution must not apply'); + expect(updated.action, equals(Action.fiatSentOk)); + expect(updated.dispute, isNull); + }); + + test('bare admin-settled does not flip a fiat-sent order', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.fiatSent)); + expect(updated.action, equals(Action.fiatSentOk)); + expect(updated.dispute, isNull); + }); + + test('bare admin-took-dispute does not move an active order to dispute', + () { + final state = _stateWithoutDispute( + status: Status.active, + action: Action.holdInvoicePaymentAccepted, + ); + + final updated = state.updateWith(_message(Action.adminTookDispute)); + + expect(updated.status, equals(Status.active)); + expect(updated.dispute, isNull); + }); + + test('a one-field dispute payload does not invent a resolved dispute', () { + final state = _stateWithoutDispute( + status: Status.active, + action: Action.holdInvoicePaymentAccepted, + ); + + final updated = state.updateWith( + _message( + Action.adminCanceled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.status, equals(Status.active), + reason: 'an attacker-supplied dispute id is not dispute evidence'); + expect(updated.dispute, isNull, + reason: 'the dispute object must not be materialized from the wire'); + }); + + test('a payload dispute cannot re-point a tracked dispute at another id', + () { + final state = _stateWithDispute(); + + final updated = state.updateWith( + _message( + Action.adminCanceled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.dispute, isNotNull); + expect(updated.dispute!.disputeId, equals('dispute-1'), + reason: 'the tracked dispute id must survive the wire payload'); + }); + + test('a bare dispute message is not evidence for the resolution behind it', + () { + // Two-step forgery: Action.dispute maps to Status.dispute without + // carrying a Dispute, so the status alone must not authorize what + // follows it. + final disputed = + _stateWithoutDispute().updateWith(_message(Action.dispute)); + + expect(disputed.status, equals(Status.dispute)); + expect(disputed.dispute, isNull); + + for (final action in [Action.adminCanceled, Action.adminSettled]) { + final resolved = disputed.updateWith(_message(action)); + + expect(resolved.status, equals(Status.dispute), + reason: '$action must not be applied on a status-only dispute'); + expect(resolved.dispute, isNull); + } + }); + + test('dispute status without a tracked dispute object is not evidence', () { + final state = OrderState( + status: Status.dispute, + action: Action.disputeInitiatedByPeer, + order: _testOrder(status: Status.dispute), + dispute: null, + ); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.dispute), + reason: 'only a tracked Dispute object authorizes a resolution'); + }); + + test('admin-settled payload alone cannot fabricate a settled dispute', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith( + _message( + Action.adminSettled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.status, equals(Status.fiatSent)); + expect(updated.dispute, isNull); + }); + }); + + group('Rejected resolutions are reported so side effects can be dropped', () { + test('reports rejection for admin actions with no dispute evidence', () { + final state = _stateWithoutDispute(); + + expect(state.rejectsAdminDisputeAction(Action.adminCanceled), isTrue); + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isTrue); + expect(state.rejectsAdminDisputeAction(Action.adminTookDispute), isTrue); + }); + + test('reports no rejection when a dispute is under review', () { + final state = _stateWithDispute(); + + expect(state.rejectsAdminDisputeAction(Action.adminCanceled), isFalse); + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isFalse); + }); + + test('reports rejection once the dispute is already resolved', () { + final state = _stateWithDispute(disputeStatus: 'resolved'); + + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isTrue, + reason: 'replaying a resolution onto a settled dispute is rejected'); + }); + + test('never reports rejection for non-admin actions', () { + final state = _stateWithoutDispute(); + + expect(state.rejectsAdminDisputeAction(Action.fiatSentOk), isFalse); + expect(state.rejectsAdminDisputeAction(Action.canceled), isFalse); + expect(state.rejectsAdminDisputeAction(Action.release), isFalse); + }); + + test('agrees with what updateWith actually does', () { + // The predicate drives side-effect suppression, so it must not diverge + // from the state machine it is meant to describe. + final states = [ + _stateWithoutDispute(), + _stateWithDispute(), + _stateWithDispute(disputeStatus: 'resolved'), + _stateWithDispute(disputeStatus: 'seller-refunded'), + ]; + const adminActions = [ + Action.adminCanceled, + Action.adminSettled, + Action.adminTookDispute, + ]; + + for (final state in states) { + for (final action in adminActions) { + final rejected = state.rejectsAdminDisputeAction(action); + final unchanged = identical(state.updateWith(_message(action)), state); + expect(rejected, equals(unchanged), + reason: 'predicate and updateWith disagree for $action on ' + '${state.status}/${state.dispute?.status}'); + } + } + }); + }); + + group('Legitimate admin resolutions still apply', () { + test('admin-settled resolves an existing in-progress dispute', () { + final state = _stateWithDispute(); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.settledByAdmin)); + expect(updated.action, equals(Action.adminSettled)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('resolved')); + expect(updated.dispute!.action, equals('admin-settled')); + }); + + test('admin-canceled resolves an existing in-progress dispute', () { + final state = _stateWithDispute(); + + final updated = state.updateWith(_message(Action.adminCanceled)); + + expect(updated.status, equals(Status.canceledByAdmin), + reason: 'an admin cancelation is its own terminal state'); + expect(updated.action, equals(Action.adminCanceled)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('seller-refunded')); + expect(updated.dispute!.action, equals('admin-canceled')); + }); + + test('admin-canceled is distinguishable from a plain cancelation', () { + final adminCanceled = + _stateWithDispute().updateWith(_message(Action.adminCanceled)); + final plainCanceled = + _stateWithoutDispute().updateWith(_message(Action.canceled)); + + expect(adminCanceled.status, equals(Status.canceledByAdmin)); + expect(plainCanceled.status, equals(Status.canceled)); + expect(adminCanceled.status, isNot(equals(plainCanceled.status)), + reason: 'the user must be able to tell an admin resolution apart ' + 'from a cancelation by either party'); + }); + + test('admin-canceled keeps a terminal status', () { + final updated = + _stateWithDispute().updateWith(_message(Action.adminCanceled)); + + expect(updated.status.isTerminal, isTrue); + }); + + test('admin-took-dispute assigns the admin on an existing dispute', () { + final state = _stateWithDispute(disputeStatus: 'initiated'); + + final updated = state.updateWith( + _message( + Action.adminTookDispute, + payload: Peer(publicKey: _adminPubkey), + ), + ); + + expect(updated.status, equals(Status.dispute)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('in-progress')); + expect(updated.dispute!.adminPubkey, equals(_adminPubkey)); + }); + + }); +} diff --git a/test/features/trades/status_filter_test.dart b/test/features/trades/status_filter_test.dart new file mode 100644 index 000000000..58778ab74 --- /dev/null +++ b/test/features/trades/status_filter_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; + +void main() { + group('matchesStatusFilter', () { + test('canceled filter also matches admin-canceled orders', () { + expect(matchesStatusFilter(Status.canceledByAdmin, Status.canceled), + isTrue, + reason: 'the picker has no separate admin-canceled entry, so these ' + 'orders must not drop out of every filter'); + }); + + test('canceled filter matches plain canceled orders', () { + expect(matchesStatusFilter(Status.canceled, Status.canceled), isTrue); + }); + + test('canceled filter does not match unrelated statuses', () { + expect(matchesStatusFilter(Status.active, Status.canceled), isFalse); + expect(matchesStatusFilter(Status.settledByAdmin, Status.canceled), + isFalse); + expect( + matchesStatusFilter(Status.cooperativelyCanceled, Status.canceled), + isFalse, + reason: 'cooperative cancellation is its own filterable status'); + }); + + test('other filters match exactly', () { + for (final status in Status.values) { + expect(matchesStatusFilter(status, Status.active), + equals(status == Status.active)); + } + }); + }); +} diff --git a/test/shared/utils/order_sync_helpers_test.dart b/test/shared/utils/order_sync_helpers_test.dart new file mode 100644 index 000000000..85f563d89 --- /dev/null +++ b/test/shared/utils/order_sync_helpers_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; + +/// A `sync()` pass may only claim hydration when it actually read the history. +/// Claiming it after a failed read leaves an admin resolution that was rejected +/// during startup permanently dropped, since nothing triggers another replay. + +const _maxChained = 3; + +SyncCompletion _resolve({ + required bool succeeded, + bool resyncRequested = false, + int resyncAttempts = 0, +}) => + resolveSyncCompletion( + succeeded: succeeded, + resyncRequested: resyncRequested, + resyncAttempts: resyncAttempts, + maxChainedResyncs: _maxChained, + ); + +void main() { + group('resolveSyncCompletion', () { + test('a clean pass with nothing queued hydrates', () { + expect(_resolve(succeeded: true), equals(SyncCompletion.hydrated)); + }); + + test('a failed read does not hydrate', () { + expect(_resolve(succeeded: false), equals(SyncCompletion.unhydrated), + reason: 'recovery must stay available after a failed history read'); + }); + + test('a queued replay defers hydration even on a successful pass', () { + expect(_resolve(succeeded: true, resyncRequested: true), + equals(SyncCompletion.replay), + reason: 'the message that queued the replay is not in this pass'); + }); + + test('a queued replay after a failed pass still replays', () { + expect(_resolve(succeeded: false, resyncRequested: true), + equals(SyncCompletion.replay)); + }); + + test('the last attempt within budget still replays', () { + expect( + _resolve( + succeeded: true, + resyncRequested: true, + resyncAttempts: _maxChained - 1), + equals(SyncCompletion.replay)); + }); + + test('an exhausted budget with a replay pending neither hydrates nor ' + 'replays', () { + // Both properties matter and pull against each other. Hydrating would + // declare a history that may be missing the queued resolution, killing + // recovery for every later one. Replaying would let rejected messages — + // the hostile input this guard exists for — pace an unbounded chain of + // full history reads. + for (final succeeded in [true, false]) { + expect( + _resolve( + succeeded: succeeded, + resyncRequested: true, + resyncAttempts: _maxChained), + equals(SyncCompletion.unhydrated), + reason: 'succeeded=$succeeded must leave recovery available ' + 'without scheduling another read'); + } + }); + }); +}