app

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

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

test: add desktop native integration coverage

Diffstat:
MMakefile | 2+-
Aapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt | 13++++++++++++-
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt | 40++++++++++++++++++++++++++++++++++++++++
Mcore/Cargo.lock | 15+++++++++++++++
Mcore/Cargo.toml | 2++
Acore/crates/harvestcircle_test_bridge/Cargo.toml | 29+++++++++++++++++++++++++++++
Acore/crates/harvestcircle_test_bridge/src/lib.rs | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/harvestcircle_test_bridge/uniffi.toml | 3+++
11 files changed, 1047 insertions(+), 2 deletions(-)

diff --git a/Makefile b/Makefile @@ -118,7 +118,7 @@ package-check: source-check $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:packageReadiness integration-check: check - $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:compileIntegrationTestKotlin + $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:integrationTest :app:desktop:verifyTestBridgeIsolation governed-integration-check: $(MAKE) --no-print-directory BUILD_MODE=governed integration-check diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt @@ -0,0 +1,145 @@ +package org.harvestcircle.integration + +import kotlinx.coroutines.runBlocking +import org.harvestcircle.application.ApplicationCommand +import org.harvestcircle.application.OperationId +import org.harvestcircle.application.RequestContext +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.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.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class NativeRuntimeIntegrationTest { + @Test + fun nativeBridgeCoversIdentityRelayRestartObserverTimeoutAndRedaction() = + runBlocking { + val dataRoot = Files.createTempDirectory("harvestcircle-native-integration-") + try { + TestBridgeHarvestCircleRuntime.open(dataRoot.toString()).use { runtime -> + assertEquals( + "c7a84960e53cd9df35d676bab28294eb048a8b86c766d81cded2635b64a7f3d6", + compatibilityDescriptor().contractHash, + ) + val initial = runtime.bootstrap() + assertTrue(initial.revision.value > 0UL) + assertTrue(initial.identities.isEmpty()) + + val bridge = runtime.nativeBridge() + bridge.startObserver() + assertEquals(initial.revision.value, assertNotNull(bridge.nextObservedSnapshot(2_000UL)).revision) + + val generated = runtime.prepareLocalIdentity() + val recoverySecret = generated.backup.revealNsec() + assertTrue(recoverySecret.startsWith("nsec1")) + val created = + runtime + .execute( + ApplicationCommand.AcknowledgeGeneratedIdentity( + generated.requestId, + request("00000000-0000-7000-8000-000000000001", initial.revision), + ), + ).snapshot + assertEquals(1, created.identities.size) + assertEquals(created.revision.value, assertNotNull(bridge.nextObservedSnapshot(2_000UL)).revision) + assertTrue(bridge.stopObserver()) + + runtime.seedProfile(recoverySecret, "Farm Identity") + val generatedId = assertNotNull(created.selectedIdentityId) + runtime.execute(ApplicationCommand.ActivateIdentity(generatedId)) + val refreshed = runtime.execute(ApplicationCommand.RefreshActiveProfile).snapshot + assertEquals(SessionLifecycle.Active, refreshed.session) + assertEquals("Farm Identity", refreshed.activeIdentity?.profile?.displayName) + + runtime.execute(ApplicationCommand.SignOut) + val restarted = runtime.restart() + assertEquals(generatedId, restarted.selectedIdentityId) + assertEquals(SessionLifecycle.SignedOut, restarted.session) + assertEquals( + SessionLifecycle.Active, + runtime.execute(ApplicationCommand.ActivateIdentity(generatedId)).snapshot.session, + ) + + val signedOut = runtime.execute(ApplicationCommand.SignOut).snapshot + val importRecovery = runtime.prepareLocalIdentity() + val importedSecret = importRecovery.backup.revealNsec() + runtime.execute(ApplicationCommand.CancelGeneratedIdentity(importRecovery.requestId)) + val imported = + runtime + .execute( + ApplicationCommand.ImportLocalIdentity( + SecretKeyInput.from(importedSecret), + request("00000000-0000-7000-8000-000000000002", signedOut.revision), + ), + ).snapshot + assertEquals(2, imported.identities.size) + + val timeoutRecovery = runtime.prepareLocalIdentity() + val timeoutSecret = timeoutRecovery.backup.revealNsec() + runtime.execute(ApplicationCommand.CancelGeneratedIdentity(timeoutRecovery.requestId)) + val timeoutBytes = timeoutSecret.encodeToByteArray() + val timeout = + try { + bridge.importIdentity( + "00000000-0000-7000-8000-000000000003", + imported.revision.value, + timeoutBytes, + 0UL, + ) + fail("zero-deadline command unexpectedly succeeded") + } catch (error: TestBridgeException.Failure) { + error.safeMessage + } finally { + timeoutBytes.fill(0) + } + assertFalse(timeout.contains(timeoutSecret)) + assertEquals(2, bridge.snapshot().identities.size) + + val databaseBytes = dataRoot.resolve("harvestcircle-integration.sqlite3").readBytes() + assertFalse(databaseBytes.containsBytes(importedSecret.encodeToByteArray())) + assertFalse(databaseBytes.containsBytes(timeoutSecret.encodeToByteArray())) + assertFalse(databaseBytes.containsBytes("nsec1".encodeToByteArray())) + val publicEvidence = bridge.snapshot().toString() + timeout + assertFalse(publicEvidence.contains(importedSecret)) + assertFalse(publicEvidence.contains(timeoutSecret)) + assertFalse(publicEvidence.contains(recoverySecret)) + + val shutdown = runtime.shutdown() + assertTrue(shutdown.closed) + } + } finally { + deleteTree(dataRoot) + } + } +} + +private fun request( + operationId: String, + revision: SnapshotRevision, +): RequestContext = + RequestContext( + operationId = OperationId.from(operationId), + expectedRevision = revision, + deadlineMillis = 2_000UL, + ) + +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 deleteTree(root: Path) { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } +} diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt @@ -0,0 +1,259 @@ +package org.harvestcircle.integration + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import org.harvestcircle.application.ActiveIdentity +import org.harvestcircle.application.ApplicationChange +import org.harvestcircle.application.ApplicationCommand +import org.harvestcircle.application.ApplicationCommandResult +import org.harvestcircle.application.ApplicationErrorCategory +import org.harvestcircle.application.ApplicationErrorCode +import org.harvestcircle.application.ApplicationFailure +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationProblem +import org.harvestcircle.application.ApplicationSnapshot +import org.harvestcircle.application.BuildInfo +import org.harvestcircle.application.GeneratedIdentityRecovery +import org.harvestcircle.application.GeneratedKeyBackup +import org.harvestcircle.application.HarvestCircleRuntime +import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.IdentityRemovalRequest +import org.harvestcircle.application.IdentitySummary +import org.harvestcircle.application.OperationId +import org.harvestcircle.application.ProfileLoadState +import org.harvestcircle.application.ProfileSummary +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RecoveryRequestId +import org.harvestcircle.application.RelayConnectionState +import org.harvestcircle.application.RelaySummary +import org.harvestcircle.application.RemovalRequestId +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.ShutdownReceipt +import org.harvestcircle.application.SignerAvailability +import org.harvestcircle.application.SignerBindingKind +import org.harvestcircle.application.SignerBindingSummary +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.TestIdentity +import org.harvestcircle.testbridge.ffi.TestSnapshot + +internal class TestBridgeHarvestCircleRuntime private constructor( + private val bridge: HarvestCircleTestBridge, +) : HarvestCircleRuntime, + AutoCloseable { + override val buildInfo: BuildInfo = BuildInfo.unknown() + + private var generatedRequest: RecoveryRequestId? = null + private var closed = false + private var shutdownReceipt: ShutdownReceipt? = null + + override suspend fun bootstrap(): ApplicationSnapshot = callBridge { bridge.bootstrap().toApplicationSnapshot() } + + override fun currentSnapshot(): ApplicationSnapshot = callBridge { bridge.snapshot().toApplicationSnapshot() } + + override fun changes(): Flow<ApplicationChange> = + flow { + callBridge { bridge.startObserver() } + var previous: SnapshotRevision? = null + try { + while (currentCoroutineContext().isActive) { + val snapshot = callBridge { bridge.nextObservedSnapshot(250UL) } ?: continue + val mapped = snapshot.toApplicationSnapshot() + emit(ApplicationChange(mapped, previous)) + previous = mapped.revision + } + } finally { + runCatching { bridge.stopObserver() } + } + }.flowOn(Dispatchers.IO) + + 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) + } + + is ApplicationCommand.CancelGeneratedIdentity -> { + require(command.requestId == generatedRequest) { "Generated recovery request does not match" } + generatedRequest = null + callBridge { bridge.cancelGeneratedIdentity() } + ApplicationCommandResult.Updated(currentSnapshot()) + } + + is ApplicationCommand.ImportLocalIdentity -> { + val secret = command.secretKey.take().encodeToByteArray() + try { + val snapshot = + callBridge { + bridge + .importIdentity( + command.context.operationId.value, + command.context.expectedRevision.value, + secret, + command.context.deadlineMillis, + ).toApplicationSnapshot() + } + ApplicationCommandResult.Committed(command.context.operationId, snapshot.revision, snapshot) + } finally { + secret.fill(0) + command.secretKey.clear() + } + } + + is ApplicationCommand.SelectIdentity -> + ApplicationCommandResult.Updated(callBridge { bridge.selectIdentity(command.identityId.value).toApplicationSnapshot() }) + + is ApplicationCommand.ActivateIdentity -> + ApplicationCommandResult.Updated(callBridge { bridge.activateIdentity(command.identityId.value).toApplicationSnapshot() }) + + ApplicationCommand.SignOut -> ApplicationCommandResult.Updated(callBridge { bridge.signOut().toApplicationSnapshot() }) + ApplicationCommand.RefreshActiveProfile -> + ApplicationCommandResult.Updated(callBridge { bridge.refreshActiveProfile().toApplicationSnapshot() }) + + is ApplicationCommand.ConfirmIdentityRemoval -> throw unsupportedRemoval(command.context.operationId) + } + + 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), + ) + } + + override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = throw unsupportedRemoval() + + override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean = false + + override suspend fun shutdown(): ShutdownReceipt { + shutdownReceipt?.let { return it } + val snapshot = callBridge { bridge.shutdown().toApplicationSnapshot() } + closed = true + return ShutdownReceipt(snapshot.revision, snapshot.lifecycle == ApplicationLifecycle.Closed).also { + shutdownReceipt = it + } + } + + fun seedProfile( + secret: String, + displayName: String, + ) = callBridge { bridge.seedProfile(secret, displayName) } + + fun restart(): ApplicationSnapshot = callBridge { bridge.restart().toApplicationSnapshot() } + + fun nativeBridge(): HarvestCircleTestBridge = bridge + + override fun close() { + if (!closed) runCatching { bridge.shutdown() } + closed = true + bridge.close() + } + + private fun unsupportedRemoval(operationId: OperationId? = null): ApplicationFailure = + ApplicationFailure( + ApplicationProblem( + code = ApplicationErrorCode.InvalidApplicationState, + category = ApplicationErrorCategory.Lifecycle, + retryable = false, + recoveryAction = RecoveryAction.None, + operationId = operationId, + safeMessage = "Identity removal is outside the integration bridge contract.", + ), + ) + + companion object { + fun open(dataDirectory: String): TestBridgeHarvestCircleRuntime = + TestBridgeHarvestCircleRuntime(HarvestCircleTestBridge.open(dataDirectory)) + } +} + +private fun TestIdentity.toIdentitySummary(): IdentitySummary = + IdentitySummary( + id = IdentityId.fromPublicKeyHex(publicKeyHex), + npub = npub, + displayLabel = displayLabel, + signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + createdAt = UnixSeconds(1_700_000_000), + lastUsedAt = null, + ) + +private fun TestSnapshot.toApplicationSnapshot(): ApplicationSnapshot { + val mappedIdentities = identities.map(TestIdentity::toIdentitySummary) + val selected = selectedPublicKeyHex?.let(IdentityId::fromPublicKeyHex) + val mappedSession = + when (session) { + "signed_out" -> SessionLifecycle.SignedOut + "activating" -> SessionLifecycle.Activating + "active" -> SessionLifecycle.Active + "signing_out" -> SessionLifecycle.SigningOut + else -> SessionLifecycle.Failed + } + val activeIdentity = + if (mappedSession == SessionLifecycle.Active) { + val identity = mappedIdentities.single { it.id == selected } + ActiveIdentity( + identity = identity, + relays = RelaySummary(emptyList(), RelayConnectionState.Connected), + profileState = if (profileDisplayName == null) ProfileLoadState.Empty else ProfileLoadState.Fresh, + profile = profileDisplayName?.let { ProfileSummary(null, it, null, null, null) }, + ) + } else { + null + } + return ApplicationSnapshot( + revision = SnapshotRevision(revision), + lifecycle = + when (lifecycle) { + "ready" -> ApplicationLifecycle.Ready + "closed" -> ApplicationLifecycle.Closed + "fatal" -> ApplicationLifecycle.Fatal + else -> ApplicationLifecycle.Opening + }, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = mappedIdentities, + selectedIdentityId = selected, + session = mappedSession, + sessionSubjectIdentityId = if (mappedSession == SessionLifecycle.Active) selected else null, + sessionProblem = null, + activeIdentity = activeIdentity, + recoverableProblem = null, + ) +} + +private inline fun <T> callBridge(operation: () -> T): T = + try { + operation() + } catch (error: TestBridgeException.Failure) { + throw ApplicationFailure( + ApplicationProblem( + code = ApplicationErrorCode.Internal, + category = ApplicationErrorCategory.Internal, + retryable = false, + recoveryAction = RecoveryAction.None, + operationId = null, + safeMessage = error.safeMessage, + ), + ) + } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt @@ -147,13 +147,24 @@ public class HarvestCircleDesktopAppPlugin : Plugin<Project> { private fun configureIntegrationContract(target: Project) { val sourceSets = target.extensions.getByType(SourceSetContainer::class.java) - sourceSets.maybeCreate("integrationTest") + val integration = sourceSets.maybeCreate("integrationTest") + val main = sourceSets.getByName("main") + integration.compileClasspath += main.output + integration.runtimeClasspath += main.output target.configurations.named("integrationTestImplementation") { it.extendsFrom(target.configurations.getByName("testImplementation")) } target.configurations.named("integrationTestRuntimeOnly") { it.extendsFrom(target.configurations.getByName("testRuntimeOnly")) } + target.tasks.register("integrationTest", Test::class.java) { task -> + task.description = "Runs the isolated desktop native integration suite." + task.group = "verification" + task.testClassesDirs = integration.output.classesDirs + task.classpath = integration.runtimeClasspath + task.dependsOn("ktlintIntegrationTestSourceSetCheck", "detektIntegrationTest") + task.shouldRunAfter(target.tasks.named("test")) + } } private fun configureTests(target: Project) { 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 @@ -17,6 +17,7 @@ import org.harvestcircle.buildlogic.plugins.tasks.GenerateUniFfiKotlinTask import org.harvestcircle.buildlogic.plugins.tasks.StageReleaseNativeLibrary import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedCompatibilityExpectations import org.harvestcircle.buildlogic.plugins.tasks.VerifyReleaseNativeLibrary +import org.harvestcircle.buildlogic.plugins.tasks.VerifyTestBridgeIsolation import org.harvestcircle.buildlogic.plugins.tasks.VerifyUniFfiBindings import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.security.MessageDigest @@ -63,6 +64,8 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { val nativeTarget = resolveNativeTarget(osName, architecture, productCoordinates["ffi.cdylib_name"]) val debugLibraryFile = target.file(cargoTargetRoot).resolve("debug/${nativeTarget.libraryName}") val releaseLibraryFile = target.file(cargoTargetRoot).resolve("release/${nativeTarget.libraryName}") + val testBridgeTarget = resolveNativeTarget(osName, architecture, "harvestcircle_test_bridge") + val testBridgeLibraryFile = target.file(cargoTargetRoot).resolve("debug/${testBridgeTarget.libraryName}") val environmentPrefix = productCoordinates["environment.prefix"] fun productEnvironment(suffix: String): String = environmentPrefix + suffix val sourceCommit = target.providers.environmentVariable(productEnvironment("BUILD_SOURCE_COMMIT")).orElse("unknown") @@ -122,6 +125,17 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { task.buildEnvironment.set(buildEnvironment) task.libraryFile.set(releaseLibraryFile) } + val buildTestBridge = + target.tasks.register("buildRustTestBridgeDebug", CargoBuildTask::class.java) { task -> + task.workingDirectory.set(rustRoot) + task.manifestFile.set(rustManifest) + task.rustSources.from(rustSources) + task.packageName.set("harvestcircle_test_bridge") + task.release.set(false) + task.immutableArguments.set(immutableArguments) + task.buildEnvironment.set(buildEnvironment) + task.libraryFile.set(testBridgeLibraryFile) + } val generatedUniFfi = target.layout.buildDirectory.dir("generated/uniffi/kotlin") val generatedCompatibility = target.layout.buildDirectory.dir("generated/compatibility/kotlin") val compatibilityFile = @@ -155,6 +169,23 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { task.immutableArguments.set(immutableArguments) task.outputDirectory.set(generatedUniFfi) } + val generatedTestUniFfi = target.layout.buildDirectory.dir("generated/test-bridge/uniffi/kotlin") + val generateTestUniFfi = + target.tasks.register("generateTestBridgeUniFfiKotlin", GenerateUniFfiKotlinTask::class.java) { task -> + task.dependsOn(buildTestBridge) + task.workingDirectory.set(rustRoot) + task.manifestFile.set(rustManifest) + task.configFile.set(rustRoot.file("crates/harvestcircle_test_bridge/uniffi.toml")) + task.nativeLibrary.set(testBridgeLibraryFile) + task.immutableArguments.set(immutableArguments) + task.outputDirectory.set(generatedTestUniFfi) + } + val verifyTestBindings = + target.tasks.register("verifyTestBridgeUniFfiBindings", VerifyUniFfiBindings::class.java) { task -> + task.dependsOn(generateTestUniFfi) + task.generatedDirectory.set(generatedTestUniFfi) + task.expectedPackage.set("org.harvestcircle.testbridge.ffi") + } val verifyBindings = target.tasks.register("verifyUniFfiBindings", VerifyUniFfiBindings::class.java) { task -> task.dependsOn(generateUniFfi) @@ -177,6 +208,15 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { task.expectedName.set(nativeTarget.libraryName) task.expectedBuildEvidence.set(provenanceDigest.map(::listOf)) } + val verifyTestIsolation = + target.tasks.register("verifyTestBridgeIsolation", VerifyTestBridgeIsolation::class.java) { task -> + task.dependsOn(verifyBindings, verifyTestBindings, verifyRelease) + task.productionBindings.set(generatedUniFfi) + task.testBindings.set(generatedTestUniFfi) + task.releaseNativeResources.set(stagedRelease) + task.productionLibraryName.set(nativeTarget.libraryName) + task.testLibraryName.set(testBridgeTarget.libraryName) + } target.tasks.register("releaseNativeResourcesJar", Jar::class.java) { task -> task.dependsOn(verifyRelease) task.archiveClassifier.set("release-native-resources") @@ -188,6 +228,14 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { target.tasks.named("compileKotlin", KotlinCompile::class.java) { task -> task.dependsOn(generateUniFfi, generateCompatibility) } + target.tasks.named("compileIntegrationTestKotlin", KotlinCompile::class.java) { task -> + task.dependsOn(generateTestUniFfi) + task.source(generatedTestUniFfi) + } + target.tasks.named("integrationTest", Test::class.java) { task -> + task.dependsOn(verifyTestIsolation) + task.systemProperty("jna.library.path", testBridgeLibraryFile.parentFile.absolutePath) + } target.tasks.withType(Test::class.java).configureEach { task -> task.dependsOn(buildDebug) val nativeTestData = target.layout.buildDirectory.dir("native-test-data").get().asFile.absolutePath diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt @@ -257,3 +257,43 @@ public abstract class VerifyReleaseNativeLibrary : DefaultTask() { } } } + +@CacheableTask +public abstract class VerifyTestBridgeIsolation : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val productionBindings: DirectoryProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val testBindings: DirectoryProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val releaseNativeResources: DirectoryProperty + + @get:Input + public abstract val productionLibraryName: Property<String> + + @get:Input + public abstract val testLibraryName: Property<String> + + @TaskAction + public fun verify() { + val productionSources = productionBindings.asFileTree.files.filter { it.extension == "kt" } + val testSources = testBindings.asFileTree.files.filter { it.extension == "kt" } + require(productionSources.size == 1) { "Production UniFFI binding inventory is invalid" } + require(testSources.size == 1) { "Test bridge UniFFI binding inventory is invalid" } + val testStem = "harvestcircle_test_bridge" + require(productionSources.none { it.readText().contains(testStem) }) { + "Production bindings contain integration-only bridge exports" + } + require(testSources.single().readText().contains(testStem)) { + "Integration bindings do not identify the isolated test bridge" + } + val releaseFiles = releaseNativeResources.asFileTree.files.filter { it.isFile } + require(releaseFiles.map { it.name } == listOf(productionLibraryName.get())) { + "Release native resources contain an integration-only bridge artifact" + } + } +} diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1162,6 +1162,21 @@ dependencies = [ ] [[package]] +name = "harvestcircle_test_bridge" +version = "0.1.0-alpha" +dependencies = [ + "harvestcircle_application", + "harvestcircle_domain", + "harvestcircle_nostr", + "harvestcircle_runtime", + "nostr", + "nostr-relay-builder", + "nostr-sdk", + "tokio", + "uniffi", +] + +[[package]] name = "harvestcircle_uniffi_bindgen" version = "0.1.0-alpha" dependencies = [ diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/harvestcircle_product", "crates/harvestcircle_runtime", "crates/harvestcircle_storage", + "crates/harvestcircle_test_bridge", "crates/harvestcircle_uniffi_bindgen", ] resolver = "3" @@ -40,6 +41,7 @@ harvestcircle_nostr = { path = "crates/harvestcircle_nostr", version = "=0.1.0-a harvestcircle_product = { path = "crates/harvestcircle_product", version = "=0.1.0-alpha" } harvestcircle_runtime = { path = "crates/harvestcircle_runtime", version = "=0.1.0-alpha" } harvestcircle_storage = { path = "crates/harvestcircle_storage", version = "=0.1.0-alpha" } +harvestcircle_test_bridge = { path = "crates/harvestcircle_test_bridge", version = "=0.1.0-alpha" } harvestcircle_uniffi_bindgen = { path = "crates/harvestcircle_uniffi_bindgen", version = "=0.1.0-alpha" } radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } getrandom = { version = "0.2", default-features = false } diff --git a/core/crates/harvestcircle_test_bridge/Cargo.toml b/core/crates/harvestcircle_test_bridge/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "harvestcircle_test_bridge" +description = "Integration-only native test bridge for HarvestCircle" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "uniffi.toml", "Cargo.toml"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +harvestcircle_application.workspace = true +harvestcircle_domain.workspace = true +harvestcircle_nostr.workspace = true +harvestcircle_runtime.workspace = true +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +tokio = { version = "=1.47.1", features = ["rt-multi-thread", "sync", "time"] } +uniffi = "=0.32.0" + +[lints] +workspace = true diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs @@ -0,0 +1,493 @@ +#![doc = "Integration-only native test bridge for HarvestCircle."] + +use std::fmt::{self, Display, Formatter}; +use std::fs; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use harvestcircle_application::{ + AppLifecycle, AppSnapshot, Clock, DurableRequestId, GeneratedKeyRecoveryHandle, + InMemorySecretStore, RelayConfiguration, SessionState, SnapshotRevision, +}; +use harvestcircle_domain::{ + PublicKey, RelayDestinationPolicy, RelayEndpoint, SafeError, SecretKeyInput, UnixTimestamp, +}; +use harvestcircle_nostr::SdkNostrClient; +use harvestcircle_runtime::{ + InstallationIdentity, InstallationIdentitySource, RuntimeActorHandle, + RuntimeChangeSubscription, RuntimeDependencies, +}; +use nostr::{EventBuilder, Keys, Metadata}; +use nostr_relay_builder::MockRelay; +use nostr_sdk::Client; +use tokio::runtime::{Builder, Runtime}; + +const ACTOR_CAPACITY: usize = 16; +const OBSERVER_CAPACITY: usize = 16; +const DEFAULT_TIMEOUT_MILLIS: u64 = 2_000; +const FIXED_TIME_SECONDS: i64 = 1_700_000_000; +const FIXED_INSTALLATION_ID: &str = "0123456789abcdef0123456789abcdef"; + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct TestIdentity { + pub public_key_hex: String, + pub npub: String, + pub display_label: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct TestSnapshot { + pub revision: u64, + pub lifecycle: String, + pub identities: Vec<TestIdentity>, + pub selected_public_key_hex: Option<String>, + pub session: String, + 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(Debug, uniffi::Error)] +pub enum TestBridgeError { + Failure { safe_message: String }, +} + +impl Display for TestBridgeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Failure { safe_message } => formatter.write_str(safe_message), + } + } +} + +impl std::error::Error for TestBridgeError {} + +impl From<SafeError> for TestBridgeError { + fn from(error: SafeError) -> Self { + Self::Failure { + safe_message: error.message().as_str().to_owned(), + } + } +} + +impl From<std::io::Error> for TestBridgeError { + fn from(_error: std::io::Error) -> Self { + Self::Failure { + safe_message: "The integration test data directory is unavailable.".to_owned(), + } + } +} + +#[derive(Default)] +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(FIXED_TIME_SECONDS).expect("fixed test timestamp") + } +} + +struct FixedInstallationIdentity; + +impl InstallationIdentitySource for FixedInstallationIdentity { + fn generate(&self) -> Result<InstallationIdentity, SafeError> { + InstallationIdentity::parse(FIXED_INSTALLATION_ID) + } +} + +#[derive(uniffi::Object)] +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>>, + relay_url: String, + database_path: PathBuf, +} + +#[uniffi::export] +impl HarvestCircleTestBridge { + #[uniffi::constructor] + pub fn open(data_directory: String) -> Result<Arc<Self>, TestBridgeError> { + let runtime = Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|_| TestBridgeError::Failure { + safe_message: "The integration test runtime could not start.".to_owned(), + })?; + let data_root = prepare_data_root(Path::new(&data_directory))?; + let database_path = data_root.join("harvestcircle-integration.sqlite3"); + let relay = runtime + .block_on(MockRelay::run()) + .map_err(|_| TestBridgeError::Failure { + safe_message: "The local integration relay could not start.".to_owned(), + })?; + let relay_url = runtime.block_on(relay.url()).to_string(); + let secrets = Arc::new(InMemorySecretStore::default()); + let clock = Arc::new(FixedClock); + let actor = runtime.block_on(open_actor( + &database_path, + &relay_url, + Arc::clone(&secrets), + Arc::clone(&clock), + runtime.handle(), + ))?; + Ok(Arc::new(Self { + runtime, + actor: Mutex::new(Some(actor)), + observer: Mutex::new(None), + pending_generation: Mutex::new(None), + secrets, + clock, + relay: Mutex::new(Some(relay)), + relay_url, + database_path, + })) + } + + pub fn bootstrap(&self) -> Result<TestSnapshot, TestBridgeError> { + let actor = self.actor()?; + Ok(to_snapshot(self.runtime.block_on(actor.bootstrap())?)) + } + + pub fn snapshot(&self) -> Result<TestSnapshot, TestBridgeError> { + Ok(to_snapshot(self.actor()?.snapshot())) + } + + pub fn begin_generated_identity(&self) -> Result<TestGeneratedRecovery, 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) + } + + pub fn acknowledge_generated_identity( + &self, + request_id: String, + expected_revision: u64, + timeout_millis: u64, + ) -> Result<TestSnapshot, TestBridgeError> { + let handle = self + .pending_generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(request_unavailable)?; + let actor = self.actor()?; + let snapshot = self + .runtime + .block_on(actor.acknowledge_generated_key_stage( + handle.id(), + DurableRequestId::parse(request_id)?, + SnapshotRevision::from_value(expected_revision), + Duration::from_millis(timeout_millis), + ))?; + 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(); + let actor = self.actor()?; + Ok(self.runtime.block_on(actor.cancel_generated_key_stage())?) + } + + pub fn import_identity( + &self, + request_id: String, + expected_revision: u64, + mut secret: Vec<u8>, + timeout_millis: u64, + ) -> Result<TestSnapshot, TestBridgeError> { + let input = SecretKeyInput::parse_bytes(std::mem::take(&mut secret))?; + secret.fill(0); + let actor = self.actor()?; + let receipt = self.runtime.block_on(actor.import_secret_key( + DurableRequestId::parse(request_id)?, + SnapshotRevision::from_value(expected_revision), + input, + Duration::from_millis(timeout_millis), + ))?; + let _ = receipt.identity(); + Ok(to_snapshot(actor.snapshot())) + } + + pub fn select_identity(&self, public_key_hex: String) -> Result<TestSnapshot, TestBridgeError> { + let actor = self.actor()?; + Ok(to_snapshot(self.runtime.block_on( + actor.select_identity(PublicKey::from_hex(&public_key_hex)?), + )?)) + } + + pub fn activate_identity( + &self, + public_key_hex: String, + ) -> Result<TestSnapshot, TestBridgeError> { + let actor = self.actor()?; + Ok(to_snapshot(self.runtime.block_on( + actor.activate_identity(PublicKey::from_hex(&public_key_hex)?), + )?)) + } + + pub fn sign_out(&self) -> Result<TestSnapshot, TestBridgeError> { + let actor = self.actor()?; + Ok(to_snapshot(self.runtime.block_on(actor.sign_out())?)) + } + + pub fn seed_profile( + &self, + secret_hex: String, + display_name: String, + ) -> Result<(), TestBridgeError> { + self.runtime.block_on(async { + let keys = Keys::parse(&secret_hex).map_err(|_| invalid_secret())?; + let publisher = Client::new(keys); + publisher + .add_relay(&self.relay_url) + .await + .map_err(|_| relay_failed())?; + publisher.connect().await; + publisher + .wait_for_connection(Duration::from_millis(DEFAULT_TIMEOUT_MILLIS)) + .await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new().display_name(display_name), + )) + .await + .map_err(|_| relay_failed())?; + publisher.shutdown().await; + Ok(()) + }) + } + + pub fn refresh_active_profile(&self) -> Result<TestSnapshot, TestBridgeError> { + let actor = self.actor()?; + Ok(to_snapshot( + self.runtime.block_on(actor.refresh_active_profile())?, + )) + } + + pub fn start_observer(&self) -> Result<(), TestBridgeError> { + let actor = self.actor()?; + let subscription = + self.runtime.block_on(actor.subscribe_changes( + NonZeroUsize::new(OBSERVER_CAPACITY).expect("observer capacity"), + ))?; + *self + .observer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(subscription); + Ok(()) + } + + pub fn next_observed_snapshot( + &self, + timeout_millis: u64, + ) -> Result<Option<TestSnapshot>, TestBridgeError> { + let mut observer = self + .observer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let subscription = observer.as_mut().ok_or_else(request_unavailable)?; + let change = self.runtime.block_on(async { + tokio::time::timeout( + Duration::from_millis(timeout_millis), + subscription.receive(), + ) + .await + }); + match change { + Ok(Some(change)) => Ok(Some(to_snapshot(change.snapshot().clone()))), + Ok(None) => Ok(None), + Err(_) => Ok(None), + } + } + + pub fn stop_observer(&self) -> Result<bool, TestBridgeError> { + let subscription = self + .observer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let Some(subscription) = subscription else { + return Ok(false); + }; + let actor = self.actor()?; + Ok(self + .runtime + .block_on(actor.unsubscribe_changes(subscription.id()))?) + } + + pub fn restart(&self) -> Result<TestSnapshot, TestBridgeError> { + let _ = self.stop_observer(); + self.close_actor()?; + let actor = self.runtime.block_on(open_actor( + &self.database_path, + &self.relay_url, + Arc::clone(&self.secrets), + Arc::clone(&self.clock), + self.runtime.handle(), + ))?; + let snapshot = actor.snapshot(); + *self + .actor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(actor); + Ok(to_snapshot(snapshot)) + } + + pub fn shutdown(&self) -> Result<TestSnapshot, TestBridgeError> { + let snapshot = self.snapshot()?; + let _ = self.stop_observer(); + self.close_actor()?; + if let Some(relay) = self + .relay + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + relay.shutdown(); + } + Ok(TestSnapshot { + lifecycle: "closed".to_owned(), + ..snapshot + }) + } +} + +impl HarvestCircleTestBridge { + fn actor(&self) -> Result<RuntimeActorHandle, TestBridgeError> { + self.actor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(request_unavailable) + } + + fn close_actor(&self) -> Result<(), TestBridgeError> { + let actor = self + .actor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(actor) = actor { + self.runtime.block_on( + actor.close_with_timeout(Duration::from_millis(DEFAULT_TIMEOUT_MILLIS)), + )?; + } + Ok(()) + } +} + +async fn open_actor( + database_path: &Path, + relay_url: &str, + secrets: Arc<InMemorySecretStore>, + clock: Arc<FixedClock>, + runtime: &tokio::runtime::Handle, +) -> Result<RuntimeActorHandle, TestBridgeError> { + let relay = RelayEndpoint::parse(relay_url, RelayDestinationPolicy::Local, true, true)?; + let dependencies = RuntimeDependencies::new( + secrets, + clock, + Arc::new(SdkNostrClient::new(Duration::from_millis( + DEFAULT_TIMEOUT_MILLIS, + ))), + Arc::new(FixedInstallationIdentity), + ); + Ok(RuntimeActorHandle::open( + database_path, + RelayConfiguration::new(vec![relay])?, + dependencies, + NonZeroUsize::new(ACTOR_CAPACITY).expect("actor capacity"), + runtime, + ) + .await?) +} + +fn prepare_data_root(path: &Path) -> Result<PathBuf, TestBridgeError> { + fs::create_dir_all(path)?; + Ok(path.canonicalize()?) +} + +fn to_identity(identity: &harvestcircle_domain::NostrIdentity) -> TestIdentity { + TestIdentity { + public_key_hex: identity.public_key().to_hex(), + npub: identity.npub().as_str().to_owned(), + display_label: identity.display_label(), + } +} + +fn to_snapshot(snapshot: AppSnapshot) -> TestSnapshot { + let lifecycle = match snapshot.lifecycle() { + AppLifecycle::Booting => "booting", + AppLifecycle::Ready => "ready", + AppLifecycle::Fatal(_) => "fatal", + }; + let session = match snapshot.session() { + SessionState::SignedOut => "signed_out", + SessionState::Activating(_) => "activating", + SessionState::Active => "active", + SessionState::SigningOut => "signing_out", + SessionState::Failed(_) => "failed", + }; + let profile_display_name = snapshot + .active_identity() + .and_then(|active| active.profile()) + .and_then(|profile| profile.display_name()) + .map(ToOwned::to_owned); + TestSnapshot { + revision: snapshot.revision().value(), + lifecycle: lifecycle.to_owned(), + identities: snapshot.identities().iter().map(to_identity).collect(), + selected_public_key_hex: snapshot.selected_identity().map(PublicKey::to_hex), + session: session.to_owned(), + profile_display_name, + } +} + +fn request_unavailable() -> TestBridgeError { + TestBridgeError::Failure { + safe_message: "The integration test request is no longer available.".to_owned(), + } +} + +fn invalid_secret() -> TestBridgeError { + TestBridgeError::Failure { + safe_message: "The integration test secret key is invalid.".to_owned(), + } +} + +fn relay_failed() -> TestBridgeError { + TestBridgeError::Failure { + safe_message: "The local integration relay operation failed.".to_owned(), + } +} + +uniffi::setup_scaffolding!(); diff --git a/core/crates/harvestcircle_test_bridge/uniffi.toml b/core/crates/harvestcircle_test_bridge/uniffi.toml @@ -0,0 +1,3 @@ +[crates.harvestcircle_test_bridge.bindings.kotlin] +package_name = "org.harvestcircle.testbridge.ffi" +cdylib_name = "harvestcircle_test_bridge"