app

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

commit c38c24136d0af82f74ae1360a06c7e35489b2211
parent a47075dc8c2a4ac943dee409a971d4bc4b4d858f
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 23:26:17 +0000

test: add the identity bootstrap acceptance flow

Diffstat:
Aapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt | 2+-
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt | 12+++++++-----
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 18+++++++++++++++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/GeneratedKeyBackup.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 3++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt | 6++++--
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/GeneratedKeyBackupTest.kt | 2++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt | 5+++++
Mcore/crates/harvestcircle_test_bridge/src/lib.rs | 33+++++++++++++++++++++------------
10 files changed, 243 insertions(+), 22 deletions(-)

diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt @@ -0,0 +1,182 @@ +package org.harvestcircle.integration + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString +import androidx.compose.ui.test.v2.runComposeUiTest +import org.harvestcircle.application.ApplicationClock +import org.harvestcircle.application.HarvestCircleApplicationWithDependencies +import org.harvestcircle.application.HarvestCirclePresenter +import org.harvestcircle.application.OperationId +import org.harvestcircle.application.OperationIdSource +import org.harvestcircle.application.SecretClipboardController +import org.harvestcircle.application.TextClipboard +import org.harvestcircle.application.UnixSeconds +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readBytes +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class IdentityBootstrapAcceptanceTest { + @Test + fun semanticUiFlowCreatesActivatesClosesAndReopensPersistedIdentity() = + runComposeUiTest { + val dataRoot = Files.createTempDirectory("harvestcircle-acceptance-") + val runtime = TestBridgeHarvestCircleRuntime.open(dataRoot.toString()) + val clipboard = RecordingClipboard() + val operationIds = SequentialOperationIds() + var showApplication by mutableStateOf(true) + var closeRequested by mutableStateOf(false) + var applicationSession by mutableStateOf(0) + var approvedExits = 0 + try { + setContent { + if (showApplication) { + key(applicationSession) { + HarvestCircleApplicationWithDependencies( + closeRequested = closeRequested, + onExitApproved = { approvedExits += 1 }, + clipboardFactory = { scope -> + SecretClipboardController( + scope = scope, + clipboard = clipboard, + clearDelayMillis = 60_000, + ) + }, + ) { scope -> + HarvestCirclePresenter( + runtime = runtime, + scope = scope, + clock = ApplicationClock { UnixSeconds(FIXED_TIME_SECONDS) }, + operationIds = operationIds, + ) + } + } + } + } + + waitForTag("identities-screen") + onNodeWithTag("choose-create-identity").performClick() + onNodeWithTag("generate-key").performClick() + waitForTag("generated-key-backup") + onNodeWithTag("generated-nsec").assertIsDisplayed() + onNodeWithTag("copy-generated-key").performClick() + val copiedSecret = assertNotNull(clipboard.readText()) + assertTrue(copiedSecret.startsWith("nsec1")) + + onNodeWithTag("acknowledge-key-backup").performClick() + waitForTag("saved-identity-list") + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithText(copiedSecret).assertCountEquals(0) + + runtime.seedSelectedProfile("Farm Identity") + onNodeWithText("Activate").performClick() + waitForTag("home-screen") + onNodeWithTag("refresh-profile").performClick() + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithText("Display name: Farm Identity").fetchSemanticsNodes().size == 1 + } + onNodeWithText("Display name: Farm Identity").assertIsDisplayed() + + onNodeWithTag("sign-out").performClick() + waitForTag("identities-screen") + onNodeWithTag("cancel-identity-entry").performClick() + onNodeWithTag("choose-import-identity").performClick() + onNodeWithTag("import-nsec-input").performTextInput(copiedSecret) + onNodeWithTag("import-key").performClick() + waitForTag("identities-problem") + onNodeWithTag("identities-problem").assertIsDisplayed() + assertFalse(onRoot(useUnmergedTree = true).printToString().contains(copiedSecret)) + closeRequested = true + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 1 } + assertNull(clipboard.readText()) + + showApplication = false + waitForIdle() + runtime.restart() + applicationSession += 1 + closeRequested = false + showApplication = true + + waitForTag("saved-identity-list") + onNodeWithText("Activate").performClick() + waitForTag("home-screen") + onNodeWithText("Display name: Farm Identity").assertIsDisplayed() + onAllNodesWithText(copiedSecret).assertCountEquals(0) + + assertFalse(runtime.currentSnapshot().toString().contains(copiedSecret)) + closeRequested = true + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 2 } + showApplication = false + waitForIdle() + + val secretBytes = copiedSecret.encodeToByteArray() + try { + Files.walk(dataRoot).use { paths -> + paths + .filter(Files::isRegularFile) + .forEach { file -> assertFalse(file.readBytes().containsBytes(secretBytes)) } + } + } finally { + secretBytes.fill(0) + } + } finally { + runtime.close() + deleteAcceptanceTree(dataRoot) + } + } + + private fun androidx.compose.ui.test.ComposeUiTest.waitForTag(tag: String) { + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1 + } + } +} + +private class RecordingClipboard : TextClipboard { + private var value: String? = null + + override fun readText(): String? = value + + override fun writeText(value: String) { + this.value = value.ifEmpty { null } + } +} + +private class SequentialOperationIds : OperationIdSource { + private var next = 1 + + override fun next(): OperationId = OperationId.from("00000000-0000-7000-8000-${next++.toString().padStart(12, '0')}") +} + +private fun ByteArray.containsBytes(needle: ByteArray): Boolean = + needle.isNotEmpty() && + indices.any { start -> + start + needle.size <= size && needle.indices.all { offset -> this[start + offset] == needle[offset] } + } + +private fun deleteAcceptanceTree(root: Path) { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } +} + +private const val FIXED_TIME_SECONDS = 1_700_000_000L +private const val UI_TIMEOUT_MILLIS = 10_000L diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt @@ -53,7 +53,7 @@ class NativeRuntimeIntegrationTest { assertEquals(created.revision.value, assertNotNull(bridge.nextObservedSnapshot(2_000UL)).revision) assertTrue(bridge.stopObserver()) - runtime.seedProfile(recoverySecret, "Farm Identity") + runtime.seedSelectedProfile("Farm Identity") val generatedId = assertNotNull(created.selectedIdentityId) runtime.execute(ApplicationCommand.ActivateIdentity(generatedId)) val refreshed = runtime.execute(ApplicationCommand.RefreshActiveProfile).snapshot diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt @@ -155,12 +155,14 @@ internal class TestBridgeHarvestCircleRuntime private constructor( } } - fun seedProfile( - secret: String, - displayName: String, - ) = callBridge { bridge.seedProfile(secret, displayName) } + fun seedSelectedProfile(displayName: String) = callBridge { bridge.seedSelectedProfile(displayName) } - fun restart(): ApplicationSnapshot = callBridge { bridge.restart().toApplicationSnapshot() } + fun restart(): ApplicationSnapshot { + val snapshot = callBridge { bridge.restart().toApplicationSnapshot() } + closed = false + shutdownReceipt = null + return snapshot + } fun nativeBridge(): HarvestCircleTestBridge = bridge diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -25,6 +25,7 @@ import org.harvestcircle.identities.ui.toUiModel import java.util.concurrent.atomic.AtomicBoolean internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter +internal typealias SecretClipboardFactory = (CoroutineScope) -> SecretClipboardController @Composable fun HarvestCircleApplication( @@ -32,11 +33,26 @@ fun HarvestCircleApplication( onExitApproved: () -> Unit = {}, shutdownTimeoutMillis: Long = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS, presenterFactory: HarvestCirclePresenterFactory = ::createHarvestCirclePresenter, +) = HarvestCircleApplicationWithDependencies( + closeRequested = closeRequested, + onExitApproved = onExitApproved, + shutdownTimeoutMillis = shutdownTimeoutMillis, + clipboardFactory = ::SecretClipboardController, + presenterFactory = presenterFactory, +) + +@Composable +internal fun HarvestCircleApplicationWithDependencies( + closeRequested: Boolean = false, + onExitApproved: () -> Unit = {}, + shutdownTimeoutMillis: Long = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS, + clipboardFactory: SecretClipboardFactory, + presenterFactory: HarvestCirclePresenterFactory, ) { val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } val presenterResult = remember { runCatching { presenterFactory(scope) } } val presenter = presenterResult.getOrNull() - val clipboard = remember(presenter) { presenter?.let { SecretClipboardController(scope) } } + val clipboard = remember(presenter, clipboardFactory) { presenter?.let { clipboardFactory(scope) } } val lifecycle = remember(scope, presenter, clipboard, shutdownTimeoutMillis) { ApplicationLifecycleResources( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/GeneratedKeyBackup.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/GeneratedKeyBackup.kt @@ -11,6 +11,8 @@ class GeneratedKeyBackup( "Generated recovery material is no longer available" } + fun revealNsecOrNull(): String? = recoveryText + fun clear() { recoveryText = null } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -410,9 +410,10 @@ class HarvestCirclePresenter( } private fun releaseRecovery() { - pendingRecovery?.backup?.clear() + val recovery = pendingRecovery pendingRecovery = null updateState { copy(generatedKeyBackup = null) } + recovery?.backup?.clear() } private fun requestContext(operationId: OperationId): RequestContext = diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt @@ -87,8 +87,10 @@ fun HarvestCirclePresenterState.toUiModel(): HarvestCircleUiModel { configuredRelays = snapshot.configuredRelays.map { it.url }, importDraft = importDraft, generatedKeyBackup = - generatedKeyBackup?.let { - GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) + generatedKeyBackup?.let { backup -> + backup.revealNsecOrNull()?.let { nsec -> + GeneratedKeyBackupUiModel(npub = backup.npub, nsec = nsec) + } }, pendingRemovalPublicKeyHex = pendingRemovalIdentityId?.value, removalImpact = removalImpact, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/GeneratedKeyBackupTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/GeneratedKeyBackupTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull class GeneratedKeyBackupTest { @Test @@ -13,6 +14,7 @@ class GeneratedKeyBackupTest { assertFalse(recovery.toString().contains("nsec1generated")) assertEquals("nsec1generated", recovery.revealNsec()) recovery.clear() + assertNull(recovery.revealNsecOrNull()) assertFailsWith<IllegalStateException> { recovery.revealNsec() } } } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt @@ -231,6 +231,11 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { target.tasks.named("compileIntegrationTestKotlin", KotlinCompile::class.java) { task -> task.dependsOn(generateTestUniFfi) task.source(generatedTestUniFfi) + task.compilerOptions.freeCompilerArgs.add( + target.layout.buildDirectory.dir("classes/kotlin/main").map { output -> + "-Xfriend-paths=${output.asFile.absolutePath}" + }, + ) } target.tasks.named("integrationTest", Test::class.java) { task -> task.dependsOn(verifyTestIsolation) diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs @@ -9,7 +9,7 @@ use std::time::Duration; use harvestcircle_application::{ AppLifecycle, AppSnapshot, Clock, DurableRequestId, GeneratedKeyRecoveryHandle, - InMemorySecretStore, RelayConfiguration, SessionState, SnapshotRevision, + InMemorySecretStore, RelayConfiguration, SecretStore, SessionState, SnapshotRevision, }; use harvestcircle_domain::{ PublicKey, RelayDestinationPolicy, RelayEndpoint, SafeError, SecretKeyInput, UnixTimestamp, @@ -259,13 +259,17 @@ impl HarvestCircleTestBridge { Ok(to_snapshot(self.runtime.block_on(actor.sign_out())?)) } - pub fn seed_profile( - &self, - secret_hex: String, - display_name: String, - ) -> Result<(), TestBridgeError> { + pub fn seed_selected_profile(&self, display_name: String) -> Result<(), TestBridgeError> { + let selected = self + .actor()? + .snapshot() + .selected_identity() + .ok_or_else(request_unavailable)?; + let secret = self.secrets.load(selected)?; self.runtime.block_on(async { - let keys = Keys::parse(&secret_hex).map_err(|_| invalid_secret())?; + let keys = secret + .with_exposed_secret(Keys::parse) + .map_err(|_| invalid_secret())?; let publisher = Client::new(keys); publisher .add_relay(&self.relay_url) @@ -366,18 +370,23 @@ impl HarvestCircleTestBridge { let snapshot = self.snapshot()?; let _ = self.stop_observer(); self.close_actor()?; + Ok(TestSnapshot { + lifecycle: "closed".to_owned(), + ..snapshot + }) + } +} + +impl Drop for HarvestCircleTestBridge { + fn drop(&mut self) { if let Some(relay) = self .relay - .lock() + .get_mut() .unwrap_or_else(std::sync::PoisonError::into_inner) .take() { relay.shutdown(); } - Ok(TestSnapshot { - lifecycle: "closed".to_owned(), - ..snapshot - }) } }