The app-layer driver that turns local intent into a signed proposal, counter-signs or declines incoming ones, and reconciles every move into the local ledger. Cryptography and wire format stay in commons_core; this owns persistence and orchestration. - propose(): builds + self-signs a PlantarePledge, records the local row (remoteState=proposed, my stub) and sends it. Direction maps to debtor/creditor: iReturn = I received and owe, owedToMe = I gave. - accept(): counter-signs the exact in-hand proposal (verified) and sends the doubly-signed copy back, closing the row. - decline(): notifies the proposer and marks the row declined. - ingest(): verifies stubs before storing — a bad-signature proposal or a half-signed "accept" is dropped; proposals not addressed to me are ignored. Pending proposals held in memory keyed by pledge id (relay redelivers on reconnect), so accept signs the verified payload. Wired into DI + Bootstrap alongside the inbox/sync listeners and torn down/rebuilt on identity switch. SocialSession now exposes the transport. Tests: full propose→accept close, decline, and the three drop paths with a records-only transport + real repos. services/data green.
211 lines
7.1 KiB
Dart
211 lines
7.1 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:tane/data/variety_repository.dart';
|
|
import 'package:tane/db/database.dart';
|
|
import 'package:tane/db/enums.dart';
|
|
import 'package:tane/services/plantare_service.dart';
|
|
|
|
import '../support/test_support.dart';
|
|
|
|
/// A records-only transport: captures what a service sends so the test can hand
|
|
/// the resulting move to the other party's [PlantareService.ingest]. No relay.
|
|
class _CapturingTransport implements PlantareTransport {
|
|
final proposed = <PlantarePledge>[];
|
|
final accepted = <PlantarePledge>[];
|
|
final declined = <({String toPubkey, String pledgeId, String reason})>[];
|
|
|
|
@override
|
|
Future<void> propose(PlantarePledge pledge) async => proposed.add(pledge);
|
|
@override
|
|
Future<void> accept(PlantarePledge pledge) async => accepted.add(pledge);
|
|
@override
|
|
Future<void> decline({
|
|
required String toPubkey,
|
|
required String pledgeId,
|
|
String reason = '',
|
|
}) async =>
|
|
declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason));
|
|
@override
|
|
Stream<PlantareEnvelope> incoming() => const Stream.empty();
|
|
@override
|
|
Future<void> close() async {}
|
|
}
|
|
|
|
void main() {
|
|
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
|
|
Uint8List(32)..fillRange(0, 32, fill),
|
|
);
|
|
|
|
late AppDatabase dbA;
|
|
late AppDatabase dbB;
|
|
late VarietyRepository repoA;
|
|
late VarietyRepository repoB;
|
|
late NostrIdentity creditor; // gives seed, proposes
|
|
late NostrIdentity debtor; // receives seed, owes a return
|
|
late PlantareService svcA; // creditor's service
|
|
late PlantareService svcB; // debtor's service
|
|
late _CapturingTransport txA;
|
|
late _CapturingTransport txB;
|
|
|
|
setUp(() async {
|
|
dbA = newTestDatabase();
|
|
dbB = newTestDatabase();
|
|
repoA = newTestRepository(dbA);
|
|
repoB = newTestRepository(dbB);
|
|
creditor = await idFor(1);
|
|
debtor = await idFor(2);
|
|
txA = _CapturingTransport();
|
|
txB = _CapturingTransport();
|
|
svcA = PlantareService(
|
|
repo: repoA,
|
|
selfPubkey: creditor.publicKeyHex,
|
|
selfSecretKey: creditor.privateKeyHex,
|
|
)..bindTransport(txA);
|
|
svcB = PlantareService(
|
|
repo: repoB,
|
|
selfPubkey: debtor.publicKeyHex,
|
|
selfSecretKey: debtor.privateKeyHex,
|
|
)..bindTransport(txB);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await svcA.stop();
|
|
await svcB.stop();
|
|
await dbA.close();
|
|
await dbB.close();
|
|
});
|
|
|
|
PlantareEnvelope envelope(
|
|
PlantareMessageKind kind,
|
|
String from,
|
|
PlantarePledge pledge,
|
|
) =>
|
|
PlantareEnvelope(kind: kind, fromPubkey: from, pledge: pledge);
|
|
|
|
test('full handshake: propose → accept, both end fully signed & accepted',
|
|
() async {
|
|
// Creditor proposes (their view: owedToMe).
|
|
final pledgeId = await svcA.propose(
|
|
direction: PlantareDirection.owedToMe,
|
|
counterpartyKey: debtor.publicKeyHex,
|
|
counterpartyName: 'Ana',
|
|
label: 'Tomate rosa',
|
|
owedDescription: 'un puñado',
|
|
);
|
|
expect(txA.proposed, hasLength(1));
|
|
|
|
// Creditor's local row: proposed, creditor-signed only.
|
|
final aRow = await repoA.plantareByPledgeId(pledgeId);
|
|
expect(aRow!.remoteState, PlantareRemoteState.proposed);
|
|
expect(aRow.creditorSignature, isNotNull);
|
|
expect(aRow.debtorSignature, isNull);
|
|
|
|
// Debtor receives the proposal.
|
|
await svcB.ingest(
|
|
envelope(PlantareMessageKind.proposed, creditor.publicKeyHex,
|
|
txA.proposed.single),
|
|
);
|
|
final bRow = await repoB.plantareByPledgeId(pledgeId);
|
|
expect(bRow, isNotNull);
|
|
expect(bRow!.direction, PlantareDirection.iReturn); // mirror view
|
|
expect(bRow.remoteState, PlantareRemoteState.proposed);
|
|
expect(svcB.pendingProposals, contains(pledgeId));
|
|
|
|
// Debtor accepts → counter-signs and sends back.
|
|
await svcB.accept(pledgeId);
|
|
expect(txB.accepted, hasLength(1));
|
|
final closed = txB.accepted.single;
|
|
expect(await PlantareCrypto.verifyBoth(closed), isTrue);
|
|
final bAfter = await repoB.plantareByPledgeId(pledgeId);
|
|
expect(bAfter!.remoteState, PlantareRemoteState.accepted);
|
|
expect(bAfter.debtorSignature, isNotNull);
|
|
expect(svcB.pendingProposals, isEmpty);
|
|
|
|
// Creditor receives the accept → their row closes too.
|
|
await svcA.ingest(
|
|
envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, closed),
|
|
);
|
|
final aAfter = await repoA.plantareByPledgeId(pledgeId);
|
|
expect(aAfter!.remoteState, PlantareRemoteState.accepted);
|
|
expect(aAfter.debtorSignature, isNotNull);
|
|
expect(aAfter.creditorSignature, isNotNull);
|
|
});
|
|
|
|
test('decline notifies the proposer and marks the row declined', () async {
|
|
final pledgeId = await svcA.propose(
|
|
direction: PlantareDirection.owedToMe,
|
|
counterpartyKey: debtor.publicKeyHex,
|
|
label: 'Maíz',
|
|
);
|
|
await svcB.ingest(
|
|
envelope(PlantareMessageKind.proposed, creditor.publicKeyHex,
|
|
txA.proposed.single),
|
|
);
|
|
|
|
await svcB.decline(pledgeId, reason: 'ya no me caben');
|
|
expect(txB.declined.single.toPubkey, creditor.publicKeyHex);
|
|
expect(txB.declined.single.reason, 'ya no me caben');
|
|
final bRow = await repoB.plantareByPledgeId(pledgeId);
|
|
expect(bRow!.remoteState, PlantareRemoteState.declined);
|
|
|
|
// Creditor sees the decline.
|
|
await svcA.ingest(PlantareEnvelope(
|
|
kind: PlantareMessageKind.declined,
|
|
fromPubkey: debtor.publicKeyHex,
|
|
pledgeId: pledgeId,
|
|
));
|
|
expect(
|
|
(await repoA.plantareByPledgeId(pledgeId))!.remoteState,
|
|
PlantareRemoteState.declined);
|
|
});
|
|
|
|
test('a proposal with a bad signature is dropped, not stored', () async {
|
|
// Build a proposal but corrupt the creditor's stub.
|
|
final good = txA;
|
|
await svcA.propose(
|
|
direction: PlantareDirection.owedToMe,
|
|
counterpartyKey: debtor.publicKeyHex,
|
|
label: 'Judía',
|
|
);
|
|
final tampered = good.proposed.single.copyWith(
|
|
creditorSignature: 'f' * 128, // invalid
|
|
);
|
|
await svcB.ingest(
|
|
envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, tampered),
|
|
);
|
|
expect(await repoB.watchPlantares().first, isEmpty);
|
|
expect(svcB.pendingProposals, isEmpty);
|
|
});
|
|
|
|
test('an accept must carry both valid stubs or it is ignored', () async {
|
|
final pledgeId = await svcA.propose(
|
|
direction: PlantareDirection.owedToMe,
|
|
counterpartyKey: debtor.publicKeyHex,
|
|
label: 'Calabaza',
|
|
);
|
|
// Half-signed "accept" (only the creditor's original stub) → ignored.
|
|
await svcA.ingest(
|
|
envelope(PlantareMessageKind.accepted, debtor.publicKeyHex,
|
|
txA.proposed.single),
|
|
);
|
|
expect(
|
|
(await repoA.plantareByPledgeId(pledgeId))!.remoteState,
|
|
PlantareRemoteState.proposed);
|
|
});
|
|
|
|
test('a proposal addressed to someone else is not stored', () async {
|
|
final stranger = await idFor(9);
|
|
await svcA.propose(
|
|
direction: PlantareDirection.owedToMe,
|
|
counterpartyKey: stranger.publicKeyHex, // not the debtor
|
|
label: 'Pepino',
|
|
);
|
|
await svcB.ingest(
|
|
envelope(PlantareMessageKind.proposed, creditor.publicKeyHex,
|
|
txA.proposed.single),
|
|
);
|
|
expect(await repoB.watchPlantares().first, isEmpty);
|
|
});
|
|
}
|