app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 9811d487b3948cd9d5417995f400ae9fdb576efa
parent bce7f8d924bc33c7009eb98a51dcae425341f9d3
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 04:07:15 +0000

test: make integration recovery custody opaque

- replace the secret-bearing bridge record with a UniFFI request object
- resolve acknowledge and cancel against the exact one-use request
- clear and close Kotlin recovery custody on every terminal path
- prove redaction, duplicate resolution, and package isolation behavior

Diffstat:
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcore/crates/harvestcircle_test_bridge/src/lib.rs | 77++++++++++++++++++++++++++++++++++++++++++-----------------------------------
3 files changed, 165 insertions(+), 61 deletions(-)

diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt @@ -8,12 +8,14 @@ import org.harvestcircle.application.SecretKeyInput import org.harvestcircle.application.SessionLifecycle import org.harvestcircle.application.SnapshotRevision import org.harvestcircle.ffi.compatibilityDescriptor +import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge import org.harvestcircle.testbridge.ffi.TestBridgeException import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.readBytes import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -21,6 +23,59 @@ import kotlin.test.fail class NativeRuntimeIntegrationTest { @Test + fun generatedRecoveryRequestIsOpaqueOneUseAndResolvedByIdentity() { + val dataRoot = Files.createTempDirectory("harvestcircle-recovery-request-") + val bridge = HarvestCircleTestBridge.open(dataRoot.toString()) + try { + val initial = bridge.bootstrap() + val request = bridge.beginGeneratedIdentity() + val secret = request.takeRecoveryNsec() + assertTrue(secret.startsWith("nsec1")) + val secondRead = assertFailsWith<TestBridgeException.Failure> { request.takeRecoveryNsec() } + assertFalse(secondRead.safeMessage.contains(secret)) + assertFalse(request.toString().contains(secret)) + + val committed = + bridge.acknowledgeGeneratedIdentity( + "00000000-0000-7000-8000-000000000011", + initial.revision, + 2_000UL, + request, + ) + assertEquals(request.identity().publicKeyHex, committed.selectedPublicKeyHex) + val duplicateAcknowledge = + assertFailsWith<TestBridgeException.Failure> { + bridge.acknowledgeGeneratedIdentity( + "00000000-0000-7000-8000-000000000012", + committed.revision, + 2_000UL, + request, + ) + } + assertFalse(duplicateAcknowledge.safeMessage.contains(secret)) + request.close() + + val cancelled = bridge.beginGeneratedIdentity() + val cancelledSecret = cancelled.takeRecoveryNsec() + assertTrue(bridge.cancelGeneratedIdentity(cancelled)) + assertFalse(bridge.cancelGeneratedIdentity(cancelled)) + assertFalse(cancelled.toString().contains(cancelledSecret)) + cancelled.close() + + val publicEvidence = bridge.snapshot().toString() + secondRead.safeMessage + duplicateAcknowledge.safeMessage + assertFalse(publicEvidence.contains(secret)) + assertFalse(publicEvidence.contains(cancelledSecret)) + val databaseBytes = dataRoot.resolve("harvestcircle-integration.sqlite3").readBytes() + assertFalse(databaseBytes.containsBytes(secret.encodeToByteArray())) + assertFalse(databaseBytes.containsBytes(cancelledSecret.encodeToByteArray())) + bridge.shutdown() + } finally { + bridge.close() + deleteTree(dataRoot) + } + } + + @Test fun nativeBridgeCoversIdentityRelayRestartObserverTimeoutAndRedaction() = runBlocking { val dataRoot = Files.createTempDirectory("harvestcircle-native-integration-") diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt @@ -40,6 +40,7 @@ import org.harvestcircle.application.SnapshotRevision import org.harvestcircle.application.UnixSeconds import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge import org.harvestcircle.testbridge.ffi.TestBridgeException +import org.harvestcircle.testbridge.ffi.TestGeneratedRecoveryRequest import org.harvestcircle.testbridge.ffi.TestIdentity import org.harvestcircle.testbridge.ffi.TestSnapshot @@ -49,7 +50,7 @@ internal class TestBridgeHarvestCircleRuntime private constructor( AutoCloseable { override val buildInfo: BuildInfo = BuildInfo.unknown() - private var generatedRequest: RecoveryRequestId? = null + private var generatedRequest: PendingGeneratedRequest? = null private var closed = false private var shutdownReceipt: ShutdownReceipt? = null @@ -76,25 +77,32 @@ internal class TestBridgeHarvestCircleRuntime private constructor( override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult = when (command) { is ApplicationCommand.AcknowledgeGeneratedIdentity -> { - require(command.requestId == generatedRequest) { "Generated recovery request does not match" } - generatedRequest = null - val snapshot = - callBridge { - bridge - .acknowledgeGeneratedIdentity( - command.context.operationId.value, - command.context.expectedRevision.value, - command.context.deadlineMillis, - ).toApplicationSnapshot() - } - ApplicationCommandResult.Committed(command.context.operationId, snapshot.revision, snapshot) + val pending = takeGeneratedRequest(command.requestId) + try { + val snapshot = + callBridge { + bridge + .acknowledgeGeneratedIdentity( + command.context.operationId.value, + command.context.expectedRevision.value, + command.context.deadlineMillis, + pending.native, + ).toApplicationSnapshot() + } + ApplicationCommandResult.Committed(command.context.operationId, snapshot.revision, snapshot) + } finally { + pending.close() + } } is ApplicationCommand.CancelGeneratedIdentity -> { - require(command.requestId == generatedRequest) { "Generated recovery request does not match" } - generatedRequest = null - callBridge { bridge.cancelGeneratedIdentity() } - ApplicationCommandResult.Updated(currentSnapshot()) + val pending = takeGeneratedRequest(command.requestId) + try { + callBridge { check(bridge.cancelGeneratedIdentity(pending.native)) } + ApplicationCommandResult.Updated(currentSnapshot()) + } finally { + pending.close() + } } is ApplicationCommand.ImportLocalIdentity -> { @@ -131,15 +139,25 @@ internal class TestBridgeHarvestCircleRuntime private constructor( } override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery { - val generated = callBridge { bridge.beginGeneratedIdentity() } - val requestId = RecoveryRequestId.from("bridge:${generated.stageId}") - generatedRequest = requestId - return GeneratedIdentityRecovery( - requestId = requestId, - identity = generated.identity.toIdentitySummary(), - expiresAt = UnixSeconds(generated.expiresAtSeconds), - backup = GeneratedKeyBackup(generated.identity.npub, generated.recoveryNsec), - ) + val native = callBridge { bridge.beginGeneratedIdentity() } + var backup: GeneratedKeyBackup? = null + return try { + val identity = callBridge { native.identity() }.toIdentitySummary() + val createdBackup = GeneratedKeyBackup(identity.npub, callBridge { native.takeRecoveryNsec() }) + backup = createdBackup + val requestId = RecoveryRequestId.from("bridge:${identity.id.value}") + generatedRequest = PendingGeneratedRequest(requestId, native, createdBackup) + GeneratedIdentityRecovery( + requestId = requestId, + identity = identity, + expiresAt = UnixSeconds(callBridge { native.expiresAtSeconds() }), + backup = createdBackup, + ) + } catch (error: Exception) { + backup?.clear() + native.close() + throw error + } } override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = throw unsupportedRemoval() @@ -148,6 +166,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor( override suspend fun shutdown(): ShutdownReceipt { shutdownReceipt?.let { return it } + generatedRequest?.close() + generatedRequest = null val snapshot = callBridge { bridge.shutdown().toApplicationSnapshot() } closed = true return ShutdownReceipt(snapshot.revision, snapshot.lifecycle == ApplicationLifecycle.Closed).also { @@ -158,6 +178,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor( fun seedSelectedProfile(displayName: String) = callBridge { bridge.seedSelectedProfile(displayName) } fun restart(): ApplicationSnapshot { + generatedRequest?.close() + generatedRequest = null val snapshot = callBridge { bridge.restart().toApplicationSnapshot() } closed = false shutdownReceipt = null @@ -167,6 +189,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor( fun nativeBridge(): HarvestCircleTestBridge = bridge override fun close() { + generatedRequest?.close() + generatedRequest = null if (!closed) runCatching { bridge.shutdown() } closed = true bridge.close() @@ -184,12 +208,30 @@ internal class TestBridgeHarvestCircleRuntime private constructor( ), ) + private fun takeGeneratedRequest(requestId: RecoveryRequestId): PendingGeneratedRequest { + val pending = generatedRequest + require(pending?.requestId == requestId) { "Generated recovery request does not match" } + generatedRequest = null + return pending + } + companion object { fun open(dataDirectory: String): TestBridgeHarvestCircleRuntime = TestBridgeHarvestCircleRuntime(HarvestCircleTestBridge.open(dataDirectory)) } } +private class PendingGeneratedRequest( + val requestId: RecoveryRequestId, + val native: TestGeneratedRecoveryRequest, + private val backup: GeneratedKeyBackup, +) { + fun close() { + backup.clear() + native.close() + } +} + private fun TestIdentity.toIdentitySummary(): IdentitySummary = IdentitySummary( id = IdentityId.fromPublicKeyHex(publicKeyHex), diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs @@ -4,6 +4,7 @@ use std::fmt::{self, Display, Formatter}; use std::fs; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -47,12 +48,28 @@ pub struct TestSnapshot { pub profile_display_name: Option<String>, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct TestGeneratedRecovery { - pub stage_id: u64, - pub identity: TestIdentity, - pub recovery_nsec: String, - pub expires_at_seconds: i64, +#[derive(uniffi::Object)] +pub struct TestGeneratedRecoveryRequest { + handle: GeneratedKeyRecoveryHandle, + resolved: AtomicBool, +} + +#[uniffi::export] +impl TestGeneratedRecoveryRequest { + pub fn identity(&self) -> TestIdentity { + to_identity(self.handle.view().identity()) + } + + pub fn expires_at_seconds(&self) -> i64 { + self.handle.view().expires_at().as_seconds() + } + + pub fn take_recovery_nsec(&self) -> Result<String, TestBridgeError> { + self.handle + .take_recovery_nsec() + .map(|nsec| nsec.with_exposed_secret(ToOwned::to_owned)) + .map_err(TestBridgeError::from) + } } #[derive(Debug, uniffi::Error)] @@ -108,7 +125,6 @@ pub struct HarvestCircleTestBridge { runtime: Runtime, actor: Mutex<Option<RuntimeActorHandle>>, observer: Mutex<Option<RuntimeChangeSubscription>>, - pending_generation: Mutex<Option<GeneratedKeyRecoveryHandle>>, secrets: Arc<InMemorySecretStore>, clock: Arc<FixedClock>, relay: Mutex<Option<MockRelay>>, @@ -147,7 +163,6 @@ impl HarvestCircleTestBridge { runtime, actor: Mutex::new(Some(actor)), observer: Mutex::new(None), - pending_generation: Mutex::new(None), secrets, clock, relay: Mutex::new(Some(relay)), @@ -165,23 +180,15 @@ impl HarvestCircleTestBridge { Ok(to_snapshot(self.actor()?.snapshot())) } - pub fn begin_generated_identity(&self) -> Result<TestGeneratedRecovery, TestBridgeError> { + pub fn begin_generated_identity( + &self, + ) -> Result<Arc<TestGeneratedRecoveryRequest>, TestBridgeError> { let actor = self.actor()?; let handle = self.runtime.block_on(actor.begin_generated_key_stage())?; - let recovery_nsec = handle - .take_recovery_nsec()? - .with_exposed_secret(ToOwned::to_owned); - let recovery = TestGeneratedRecovery { - stage_id: handle.id().value(), - identity: to_identity(handle.view().identity()), - recovery_nsec, - expires_at_seconds: handle.view().expires_at().as_seconds(), - }; - *self - .pending_generation - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(handle); - Ok(recovery) + Ok(Arc::new(TestGeneratedRecoveryRequest { + handle, + resolved: AtomicBool::new(false), + })) } pub fn acknowledge_generated_identity( @@ -189,18 +196,16 @@ impl HarvestCircleTestBridge { request_id: String, expected_revision: u64, timeout_millis: u64, + request: Arc<TestGeneratedRecoveryRequest>, ) -> Result<TestSnapshot, TestBridgeError> { - let handle = self - .pending_generation - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - .ok_or_else(request_unavailable)?; + if request.resolved.swap(true, Ordering::AcqRel) { + return Err(request_unavailable()); + } let actor = self.actor()?; let snapshot = self .runtime .block_on(actor.acknowledge_generated_key_stage( - handle.id(), + request.handle.id(), DurableRequestId::parse(request_id)?, SnapshotRevision::from_value(expected_revision), Duration::from_millis(timeout_millis), @@ -208,11 +213,13 @@ impl HarvestCircleTestBridge { Ok(to_snapshot(snapshot)) } - pub fn cancel_generated_identity(&self) -> Result<bool, TestBridgeError> { - self.pending_generation - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); + pub fn cancel_generated_identity( + &self, + request: Arc<TestGeneratedRecoveryRequest>, + ) -> Result<bool, TestBridgeError> { + if request.resolved.swap(true, Ordering::AcqRel) { + return Ok(false); + } let actor = self.actor()?; Ok(self.runtime.block_on(actor.cancel_generated_key_stage())?) }