feat(plantare): PlantareService — orchestrate the signed handshake
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.
This commit is contained in:
parent
01fba40ec2
commit
b607ddde41
5 changed files with 529 additions and 3 deletions
211
apps/app_seeds/test/services/plantare_service_test.dart
Normal file
211
apps/app_seeds/test/services/plantare_service_test.dart
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue