Files
sxchnge/frontend/packages/sxchnge_wallet/test/destinations_test.dart

618 lines
20 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_wallet/src/destinations/transport.dart';
const crypto = CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'Untrusted label',
networkLabel: 'Untrusted network',
);
const input = OneTimeDestination(crypto);
const card = CardDestination(
capture: 'demo-card-ready',
brand: 'Untrusted brand',
last4: '9999',
expiryMonth: 1,
expiryYear: 1900,
);
const bank = Bank(
bik: '044525225',
name: 'Demo Bank',
city: 'Moscow',
correspondent: '30101810400000000225',
revision: 'demo-1',
);
const russian = RussianBankDestination(
firstName: 'Anna Maria',
lastName: 'de la Cruz',
bank: bank,
account: '40817810899910004312',
);
Matcher issue(DestinationIssue value) =>
throwsA(isA<DestinationException>().having((e) => e.issue, 'issue', value));
void main() {
late DummyDestinationService service;
setUp(() {
service = DummyDestinationService();
});
test('one-time validation, quotation and acceptance never save', () async {
final lease = await service.prepare(
input,
const PayoutContext('eur', amount: 100),
);
expect(lease.snapshot.display, contains('ETH'));
service.accept(lease);
expect(await service.list(), isEmpty);
});
test('idempotency replays creation and rejects changed payload', () async {
final first = await service.create(
crypto,
'Private name',
'creation-key-000001',
);
expect(first.outcome, SaveOutcome.created);
expect(
(await service.create(
crypto,
'Private name',
'creation-key-000001',
)).destinationId,
first.destinationId,
);
await expectLater(
service.create(crypto, 'Changed', 'creation-key-000001'),
issue(DestinationIssue.conflict),
);
expect((await service.list()).length, 1);
});
test(
'duplicate outcome is stable after rename, removal and concurrent create',
() async {
final results = await Future.wait(
List.generate(
8,
(i) => service.create(crypto, 'Name $i', 'creation-key-00000$i'),
),
);
expect(results.where((r) => r.outcome == SaveOutcome.created).length, 1);
expect(results.map((r) => r.destinationId).toSet().length, 1);
final d = await service.get(results.first.destinationId);
final renamed = await service.rename(
d.id,
'Original stays',
d.metadataRevision,
);
await service.remove(
d.id,
renamed.metadataRevision,
renamed.executionRevision,
);
final replay = await service.create(
crypto,
'Name 1',
'creation-key-000001',
);
expect(replay.outcome, SaveOutcome.duplicate);
expect(replay.destinationId, d.id);
expect(
(await service.creation('creation-key-000001'))!.outcome,
SaveOutcome.duplicate,
);
},
);
test(
'save timeout reconciles a created record separately from duplicate',
() async {
service.loseNextSaveResponse = true;
await expectLater(
service.create(crypto, '', 'creation-key-000001'),
issue(DestinationIssue.uncertain),
);
expect(
(await service.creation('creation-key-000001'))!.outcome,
SaveOutcome.created,
);
service.loseNextSaveResponse = true;
await expectLater(
service.create(crypto, '', 'creation-key-000002'),
issue(DestinationIssue.uncertain),
);
expect(
(await service.creation('creation-key-000002'))!.outcome,
SaveOutcome.duplicate,
);
},
);
test('customer isolation includes duplicates, attempt lookup, reads and mutations', () async {
final memory = DestinationMemory();
final a = DummyDestinationService(customer: 'a', memory: memory);
final b = DummyDestinationService(customer: 'b', memory: memory);
final saved = await a.create(crypto, 'A', 'creation-key-000001');
expect(await b.creation('creation-key-000001'), isNull);
await expectLater(
b.get(saved.destinationId),
issue(DestinationIssue.removed),
);
await expectLater(
b.rename(saved.destinationId, 'B', 1),
issue(DestinationIssue.removed),
);
await expectLater(
b.remove(saved.destinationId, 1, 1),
issue(DestinationIssue.removed),
);
expect(
(await b.create(crypto, 'B', 'creation-key-000001')).outcome,
SaveOutcome.created,
);
});
test('metadata concurrency never changes execution revision or invalidates lease', () async {
final result = await service.create(crypto, '', 'creation-key-000001');
final d = await service.get(result.destinationId);
final lease = await service.prepare(d.input, const PayoutContext('eur'));
final changed = await service.rename(d.id, 'New label', d.metadataRevision);
expect(changed.executionRevision, d.executionRevision);
expect(changed.metadataRevision, d.metadataRevision + 1);
service.accept(lease);
await expectLater(
service.rename(d.id, 'Stale', d.metadataRevision),
issue(DestinationIssue.conflict),
);
});
test(
'removal invalidates draft lease and never mutates previous snapshots',
() async {
final result = await service.create(crypto, '', 'creation-key-000001');
final d = await service.get(result.destinationId);
final lease = await service.prepare(d.input, const PayoutContext('eur'));
await service.remove(d.id, d.metadataRevision, d.executionRevision);
expect(() => service.accept(lease), issue(DestinationIssue.removed));
expect(lease.snapshot.review, contains(crypto.address));
expect(await service.list(), isEmpty);
expect(service.memory.audit.map((a) => a.action), contains('remove'));
},
);
test(
'requirements and contextual availability are rechecked at acceptance',
() async {
final result = await service.create(crypto, '', 'creation-key-000001');
final d = await service.get(result.destinationId);
final lease = await service.prepare(d.input, const PayoutContext('eur'));
service.availability[d.id] = const PayoutEligibility(
EligibilityState.temporarilyUnavailable,
reason: DestinationIssue.unavailable,
);
expect(() => service.accept(lease), issue(DestinationIssue.unavailable));
service.availability.clear();
service.requirementsRevision++;
expect(() => service.accept(lease), issue(DestinationIssue.conflict));
},
);
test(
'network and canonical token identity distinguish duplicate addresses',
() async {
final ids = <String>{};
for (final a in DummyDestinationService.assets.where(
(a) => a.networkId.startsWith('eip155:'),
)) {
final result = await service.create(
CryptoDestination(
assetId: a.id,
networkId: a.networkId,
address: crypto.address,
memo: '',
assetLabel: a.label,
networkLabel: a.networkLabel,
),
'',
destinationKey(),
);
ids.add(result.destinationId);
}
expect(ids.length, 4);
},
);
test(
'address presentation preserves case; normalization is service-owned',
() async {
const upper = CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
);
final first = await service.create(upper, '', 'creation-key-000001');
final lower = CryptoDestination(
assetId: upper.assetId,
networkId: upper.networkId,
address: upper.address.toLowerCase(),
assetLabel: 'ETH',
networkLabel: 'Ethereum',
);
expect(
(await service.create(lower, '', 'creation-key-000002')).destinationId,
first.destinationId,
);
expect(
(await service.get(first.destinationId)).details.review,
contains(upper.address),
);
},
);
test('memo requirements are validated before saving', () async {
const d = CryptoDestination(
assetId: 'demo:xrp:native',
networkId: 'xrpl:mainnet',
address: 'r123456789ABCDEFGHJKLMNPQRSTU',
assetLabel: 'XRP',
networkLabel: 'XRP Ledger',
);
await expectLater(
service.create(d, '', 'creation-key-000001'),
issue(DestinationIssue.invalid),
);
expect(await service.list(), isEmpty);
});
test('card captures never trust user display metadata; recovery preserves record', () async {
final r = await service.create(card, '', 'creation-key-000001');
final saved = await service.get(r.destinationId);
expect(saved.details.display, contains('4242'));
expect(saved.details.display, isNot(contains('9999')));
const recovery = CardDestination(
capture: 'demo-card-recover',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
);
final other = DummyDestinationService();
final pending = await other.create(recovery, '', 'creation-key-000001');
final d = await other.get(pending.destinationId);
expect(
(await other.assess(d.input, const PayoutContext('eur'))).state,
EligibilityState.requiresAction,
);
expect((await other.list()).length, 1);
});
test('unreliable card fingerprint does not merge by last four', () async {
const d = CardDestination(
capture: 'demo-card-unknown',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
);
final a = await service.create(d, '', 'creation-key-000001');
final b = await service.create(d, '', 'creation-key-000002');
expect(a.destinationId, isNot(b.destinationId));
expect(b.outcome, SaveOutcome.created);
});
test(
'bank control key and directory changes are separate from ownership',
() async {
expect(validBankAccount('40817810899910004312', bank.bik), isTrue);
final r = await service.create(russian, '', 'creation-key-000001');
final saved = await service.get(r.destinationId);
expect(saved.ownership.verified, isFalse);
service.directory = [
const Bank(
bik: '044525225',
name: 'New cosmetic name',
city: 'Moscow',
correspondent: '30101810400000000225',
revision: 'demo-2',
),
];
expect(
(await service.assess(
saved.input,
const PayoutContext('eur'),
)).eligible,
isTrue,
);
service.directory = [
const Bank(
bik: '044525225',
name: 'New bank',
city: 'Moscow',
correspondent: 'changed',
revision: 'demo-3',
),
];
expect(
(await service.assess(saved.input, const PayoutContext('eur'))).reason,
DestinationIssue.bankChanged,
);
expect(
(await service.get(saved.id)).details.review,
contains(bank.correspondent),
);
},
);
test(
'paste conflicts are never guessed and full holder names remain unsplit',
() {
final parsed = BankPaste.parse(
'БИК: 044525225\nБИК: 044525999\nНомер счета: 0000 1234\nФИО: Anna Maria de la Cruz',
);
expect(parsed.conflicts, contains('bik'));
expect(parsed.values, isNot(contains('bik')));
expect(parsed.values['account'], '00001234');
expect(parsed.values['holder'], 'Anna Maria de la Cruz');
expect(parsed.values, isNot(contains('firstName')));
},
);
test('all details round trip through DTO mapper', () {
for (final d in <DestinationDetails>[crypto, card, russian]) {
expect(DestinationMapper.clone(d).review, d.review);
}
});
test(
'optional save failure can continue one-time without claiming saved',
() async {
final p = DestinationFormProvider(
service,
DummyAnalyticsService(),
context: const PayoutContext('eur'),
);
addTearDown(p.dispose);
p.change(crypto);
p.preferences(save: true);
service.nextSaveError = DestinationIssue.conflict;
await p.proceed();
expect(p.savingFailed, isTrue);
expect(p.saved, isNull);
await p.proceed(withoutSaving: true);
expect(p.result, isA<OneTimeDestination>());
expect(await service.list(), isEmpty);
},
);
test('saving and contextual eligibility have independent outcomes', () async {
final p = DestinationFormProvider(
service,
DummyAnalyticsService(),
context: const PayoutContext('unsupported'),
);
addTearDown(p.dispose);
p.change(crypto);
p.preferences(save: true);
await p.proceed();
expect(p.saved!.outcome, SaveOutcome.created);
expect(p.eligibility!.state, EligibilityState.unsupported);
expect(p.result, isNull);
expect(p.error, isNull);
});
test('explicit duplicate reuse preserves existing nickname', () async {
final first = await service.create(
crypto,
'Original',
'creation-key-000001',
);
final p = DestinationFormProvider(
service,
DummyAnalyticsService(),
context: const PayoutContext('eur'),
);
addTearDown(p.dispose);
p.change(crypto);
p.preferences(save: true, nickname: 'New');
await p.proceed();
expect(p.saved!.outcome, SaveOutcome.duplicate);
expect(p.result, isNull);
await p.useExisting();
expect((p.result as SavedReference).id, first.destinationId);
expect((await service.get(first.destinationId)).nickname, 'Original');
});
test(
'wallet acceptance rejects removed destination without debiting',
() async {
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
delay: Duration.zero,
destinations: service,
);
final created = await service.create(crypto, '', 'creation-key-000001');
final d = await service.get(created.destinationId);
final q = await wallet.quote(
TransferRequest(
kind: TransferKind.payout,
source: 'eur',
amount: 1000,
payoutDestination: d.input,
),
);
await service.remove(d.id, d.metadataRevision, d.executionRevision);
await expectLater(
wallet.submit(q.id, 'request'),
throwsA(
isA<WalletException>().having(
(e) => e.code,
'code',
WalletError.destinationChanged,
),
),
);
expect((await wallet.fetch()).accounts.first.available, 7500);
expect((await wallet.fetch()).operations, isEmpty);
},
);
test('large collections search safe metadata and sort recent use without choosing', () async {
for (var i = 0; i < 240; i++) {
await service.create(
CryptoDestination(
assetId: crypto.assetId,
networkId: crypto.networkId,
address: '0x' + i.toRadixString(16).padLeft(40, '0'),
assetLabel: 'ETH',
networkLabel: 'Ethereum',
),
'Wallet ' + i.toString(),
destinationKey(),
);
}
final rows = await service.list();
service.accept(
await service.prepare(rows.last.input, const PayoutContext('eur')),
);
final p = DestinationsProvider(service);
addTearDown(p.dispose);
await p.refresh();
expect(p.search('', DestinationType.crypto).first.id, rows.last.id);
expect(
p.search('Wallet 239', DestinationType.crypto).single.id,
rows.last.id,
);
expect(p.search('not found', DestinationType.crypto), isEmpty);
});
test('cards without supported reuse or recovery cannot be saved', () async {
service.reusableCards = false;
service.cardRecovery = false;
expect(
(await service.assess(
const OneTimeDestination(card),
const PayoutContext('eur'),
)).canSave,
isFalse,
);
await expectLater(
service.create(card, '', 'creation-key-000001'),
issue(DestinationIssue.unsupported),
);
expect(await service.list(), isEmpty);
});
test('existing card survives loss of its only reusable binding', () async {
final result = await service.create(card, '', 'creation-key-000001');
final d = await service.get(result.destinationId);
expect((d.details as CardDestination).capture, isEmpty);
final old = await service.prepare(d.input, const PayoutContext('eur'));
service.memory.records[service.customer]![d.id] = SavedDestination(
id: d.id,
details: d.details,
metadataRevision: d.metadataRevision,
executionRevision: d.executionRevision + 1,
bindings: [
const ProviderBinding(
reference: 'binding',
state: BindingState.revoked,
reusable: true,
recoverable: true,
),
],
);
final changed = await service.get(d.id);
expect(
(await service.assess(changed.input, const PayoutContext('eur'))).state,
EligibilityState.requiresAction,
);
expect(() => service.accept(old), issue(DestinationIssue.conflict));
expect((await service.list()).length, 1);
service.cardRecovery = false;
expect(
(await service.assess(changed.input, const PayoutContext('eur'))).state,
EligibilityState.unsupported,
);
});
test(
'accepted payout snapshot survives nickname changes and removal',
() async {
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
delay: Duration.zero,
destinations: service,
);
final result = await service.create(crypto, '', 'creation-key-000001');
final d = await service.get(result.destinationId);
final q = await wallet.quote(
TransferRequest(
kind: TransferKind.payout,
source: 'eur',
amount: 1000,
payoutDestination: d.input,
),
);
final renamed = await service.rename(
d.id,
'After quote',
d.metadataRevision,
);
final accepted = await wallet.submit(q.id, 'payout-key');
await service.remove(
d.id,
renamed.metadataRevision,
renamed.executionRevision,
);
expect((await wallet.fetch()).operations.single.id, accepted.id);
expect(accepted.quote.payoutSnapshot!.review, contains(crypto.address));
},
);
test('card recovery preserves identity, invalidates execution and replays idempotently', () async {
const capture = CardDestination(
capture: 'demo-card-recover',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
);
final created = await service.create(
capture,
'Keep label',
'creation-key-000001',
);
final d = await service.get(created.destinationId);
final restored = await service.restoreCard(
d.id,
d.executionRevision,
card,
'card-recovery-key-0001',
);
expect(restored.id, d.id);
expect(restored.nickname, d.nickname);
expect(restored.metadataRevision, d.metadataRevision);
expect(restored.executionRevision, d.executionRevision + 1);
expect(
(await service.assess(
restored.input,
const PayoutContext('eur'),
)).eligible,
isTrue,
);
expect(
(await service.restoreCard(
d.id,
d.executionRevision,
card,
'card-recovery-key-0001',
)).executionRevision,
restored.executionRevision,
);
expect((await service.list()).length, 1);
const unidentified = CardDestination(
capture: 'demo-card-unknown',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
);
await expectLater(
service.restoreCard(
d.id,
restored.executionRevision,
unidentified,
'card-recovery-key-0002',
),
issue(DestinationIssue.replacementRequired),
);
});
}