commit ddab572e2868872596c234df3952c3f7fdc56c80 parent 276aed5f681c0a192840b52ef7a95886eeaf349a Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 23:08:50 +0000 test: qualify the hardened product shell - Admit identity actions while locally usable degradation keeps the shell open. - Prove atomic revision interleavings and rendered system, light, and dark themes. - Exercise canonical generated-FFI parsing and private-reference redaction. - Drive degraded identity removal through the native-backed modal acceptance path. Diffstat:
7 files changed, 416 insertions(+), 48 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 @@ -7,6 +7,7 @@ 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.assertIsEnabled import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag @@ -15,11 +16,14 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.printToString import androidx.compose.ui.test.v2.runComposeUiTest +import kotlinx.coroutines.runBlocking import org.harvestcircle.application.ApplicationClock +import org.harvestcircle.application.ApplicationCommand import org.harvestcircle.application.HarvestCircleApplicationWithDependencies import org.harvestcircle.application.HarvestCirclePresenter import org.harvestcircle.application.OperationId import org.harvestcircle.application.OperationIdSource +import org.harvestcircle.application.RequestContext import org.harvestcircle.application.SecretClipboardController import org.harvestcircle.application.TextClipboard import org.harvestcircle.application.UnixSeconds @@ -204,6 +208,78 @@ class IdentityBootstrapAcceptanceTest { } } + @Test + fun nativeDegradationStaysInTheShellAndIdentityRemovalUsesTheModal() = + runComposeUiTest { + val dataRoot = Files.createTempDirectory("harvestcircle-degraded-removal-") + val runtime = TestBridgeHarvestCircleRuntime.open(dataRoot.toString()) + val operationIds = SequentialOperationIds() + try { + val identityId = + runBlocking { + val initial = runtime.bootstrap() + val recovery = runtime.prepareLocalIdentity() + val created = + runtime + .execute( + ApplicationCommand.AcknowledgeGeneratedIdentity( + recovery.requestId, + RequestContext(operationIds.next(), initial.revision, 2_000UL), + ), + ).snapshot + recovery.backup.clear() + val identityId = assertNotNull(created.selectedIdentityId) + runtime.execute(ApplicationCommand.ActivateIdentity(identityId)) + identityId + } + runtime.setNetworkDegraded(true) + + setContent { + HarvestCircleApplicationWithDependencies( + closeRequested = false, + onExitApproved = {}, + clipboardFactory = { scope -> + SecretClipboardController(scope, RecordingClipboard(), clearDelayMillis = 60_000) + }, + ) { scope -> + HarvestCirclePresenter( + runtime = runtime, + scope = scope, + clock = ApplicationClock { UnixSeconds(FIXED_TIME_SECONDS) }, + operationIds = operationIds, + ) + } + } + + waitForTag("foundation-today") + onNodeWithTag("global-status-banner").assertIsDisplayed() + onNodeWithTag("sidebar-Network").performClick() + waitForTag("network-overview") + onNodeWithTag("network-tab-identity").performClick() + onNodeWithTag("sign-out").performClick() + waitForTag("saved-identity-list") + onNodeWithTag("global-status-banner").assertIsDisplayed() + + onNodeWithTag("remove-identity:${identityId.value}").performClick() + waitForEnabled("overlay-confirm") + onNodeWithText("Remove this saved identity?").assertIsDisplayed() + onNodeWithTag("overlay-cancel").performClick() + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithTag("foundation-overlay").fetchSemanticsNodes().isEmpty() + } + assertTrue(runtime.currentSnapshot().identities.any { it.id == identityId }) + + onNodeWithTag("remove-identity:${identityId.value}").performClick() + waitForEnabled("overlay-confirm") + onNodeWithTag("overlay-confirm").performClick() + waitForTag("bootstrap-welcome") + assertTrue(runtime.currentSnapshot().identities.isEmpty()) + } finally { + runtime.close() + deleteAcceptanceTree(dataRoot) + } + } + private fun androidx.compose.ui.test.ComposeUiTest.waitForTag(tag: String) { try { waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { @@ -213,6 +289,12 @@ class IdentityBootstrapAcceptanceTest { throw AssertionError("Timed out waiting for semantic tag: $tag\n${onRoot().printToString()}", error) } } + + private fun androidx.compose.ui.test.ComposeUiTest.waitForEnabled(tag: String) { + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + runCatching { onNodeWithTag(tag).assertIsEnabled() }.isSuccess + } + } } private class RecordingClipboard : TextClipboard { 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,6 +8,8 @@ import org.harvestcircle.application.SecretKeyInput import org.harvestcircle.application.SessionLifecycle import org.harvestcircle.application.SnapshotRevision import org.harvestcircle.application.verifyNativeCompatibility +import org.harvestcircle.ffi.NostrReferenceKindDto +import org.harvestcircle.ffi.classifyNostrReference import org.harvestcircle.ffi.compatibilityDescriptor import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge import org.harvestcircle.testbridge.ffi.TestBridgeException @@ -24,6 +26,20 @@ import kotlin.test.fail class NativeRuntimeIntegrationTest { @Test + fun generatedFfiClassifiesCanonicalReferencesAndRedactsPrivateKeys() { + val eventId = "d94a3f4dd87b9a3b0bed183b32e916fa29c8020107845d1752d72697fe5309a5" + val event = classifyNostrReference(eventId) + assertEquals(NostrReferenceKindDto.EVENT_ID, event.classification) + assertEquals(eventId, event.canonicalReference) + + val nsec = "nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99" + val privateReference = classifyNostrReference(nsec) + assertEquals(NostrReferenceKindDto.PRIVATE_KEY_REJECTED, privateReference.classification) + assertEquals(null, privateReference.canonicalReference) + assertFalse(privateReference.toString().contains(nsec)) + } + + @Test fun generatedRecoveryRequestIsOpaqueOneUseAndResolvedByIdentity() { val dataRoot = Files.createTempDirectory("harvestcircle-recovery-request-") val bridge = HarvestCircleTestBridge.open(dataRoot.toString()) diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt @@ -23,7 +23,6 @@ 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 @@ -43,6 +42,7 @@ import org.harvestcircle.testbridge.ffi.TestBridgeException import org.harvestcircle.testbridge.ffi.TestGeneratedRecoveryRequest import org.harvestcircle.testbridge.ffi.TestIdentity import org.harvestcircle.testbridge.ffi.TestLifecycle +import org.harvestcircle.testbridge.ffi.TestRemovalRequest import org.harvestcircle.testbridge.ffi.TestSession import org.harvestcircle.testbridge.ffi.TestSnapshot @@ -53,6 +53,7 @@ internal class TestBridgeHarvestCircleRuntime private constructor( override val buildInfo: BuildInfo = BuildInfo.unknown() private var generatedRequest: PendingGeneratedRequest? = null + private var removalRequest: PendingRemovalRequest? = null private var closed = false private var shutdownReceipt: ShutdownReceipt? = null @@ -137,7 +138,24 @@ internal class TestBridgeHarvestCircleRuntime private constructor( ApplicationCommand.RefreshActiveProfile -> ApplicationCommandResult.Updated(callBridge { bridge.refreshActiveProfile().toApplicationSnapshot() }) - is ApplicationCommand.ConfirmIdentityRemoval -> throw unsupportedRemoval(command.context.operationId) + is ApplicationCommand.ConfirmIdentityRemoval -> { + val pending = takeRemovalRequest(command.requestId) + try { + val snapshot = + callBridge { + bridge + .confirmIdentityRemoval( + 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() + } + } } override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery { @@ -162,14 +180,32 @@ internal class TestBridgeHarvestCircleRuntime private constructor( } } - override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = throw unsupportedRemoval() + override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest { + removalRequest?.cancel(bridge) + val native = callBridge { bridge.requestIdentityRemoval(identityId.value) } + val requestId = RemovalRequestId.from("bridge-removal:${native.publicKeyHex()}:${native.expiresAtSeconds()}") + removalRequest = PendingRemovalRequest(requestId, native) + return IdentityRemovalRequest( + requestId = requestId, + identityId = IdentityId.fromPublicKeyHex(native.publicKeyHex()), + deletesLocalCredential = native.deletesLocalCredential(), + signsOut = native.signsOut(), + expiresAt = UnixSeconds(native.expiresAtSeconds()), + ) + } - override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean = false + override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean { + val pending = removalRequest?.takeIf { it.requestId == requestId } ?: return false + removalRequest = null + return pending.cancel(bridge) + } override suspend fun shutdown(): ShutdownReceipt { shutdownReceipt?.let { return it } generatedRequest?.close() generatedRequest = null + removalRequest?.cancel(bridge) + removalRequest = null val snapshot = callBridge { bridge.shutdown().toApplicationSnapshot() } closed = true return ShutdownReceipt(snapshot.revision, snapshot.lifecycle == ApplicationLifecycle.Closed).also { @@ -179,9 +215,13 @@ internal class TestBridgeHarvestCircleRuntime private constructor( fun seedSelectedProfile(displayName: String) = callBridge { bridge.seedSelectedProfile(displayName) } + fun setNetworkDegraded(degraded: Boolean) = bridge.setNetworkDegraded(degraded) + fun restart(): ApplicationSnapshot { generatedRequest?.close() generatedRequest = null + removalRequest?.cancel(bridge) + removalRequest = null val snapshot = callBridge { bridge.restart().toApplicationSnapshot() } closed = false shutdownReceipt = null @@ -193,23 +233,13 @@ internal class TestBridgeHarvestCircleRuntime private constructor( override fun close() { generatedRequest?.close() generatedRequest = null + removalRequest?.cancel(bridge) + removalRequest = null 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.", - ), - ) - private fun takeGeneratedRequest(requestId: RecoveryRequestId): PendingGeneratedRequest { val pending = generatedRequest require(pending?.requestId == requestId) { "Generated recovery request does not match" } @@ -217,12 +247,33 @@ internal class TestBridgeHarvestCircleRuntime private constructor( return pending } + private fun takeRemovalRequest(requestId: RemovalRequestId): PendingRemovalRequest { + val pending = removalRequest + require(pending?.requestId == requestId) { "Identity removal request does not match" } + removalRequest = null + return pending + } + companion object { fun open(dataDirectory: String): TestBridgeHarvestCircleRuntime = TestBridgeHarvestCircleRuntime(HarvestCircleTestBridge.open(dataDirectory)) } } +private class PendingRemovalRequest( + val requestId: RemovalRequestId, + val native: TestRemovalRequest, +) { + fun cancel(bridge: HarvestCircleTestBridge): Boolean = + try { + callBridge { bridge.cancelIdentityRemoval(native) } + } finally { + close() + } + + fun close() = native.close() +} + private class PendingGeneratedRequest( val requestId: RecoveryRequestId, val native: TestGeneratedRecoveryRequest, @@ -260,7 +311,15 @@ private fun TestSnapshot.toApplicationSnapshot(): ApplicationSnapshot { val identity = mappedIdentities.single { it.id == selected } ActiveIdentity( identity = identity, - relays = RelaySummary(emptyList(), RelayConnectionState.Connected), + relays = + RelaySummary( + emptyList(), + if (lifecycle == TestLifecycle.DEGRADED) { + RelayConnectionState.Degraded + } else { + RelayConnectionState.Connected + }, + ), profileState = if (profileDisplayName == null) ProfileLoadState.Empty else ProfileLoadState.Fresh, profile = profileDisplayName?.let { ProfileSummary(null, it, null, null, null) }, ) @@ -273,10 +332,23 @@ private fun TestSnapshot.toApplicationSnapshot(): ApplicationSnapshot { when (lifecycle) { TestLifecycle.BOOTING -> ApplicationLifecycle.Opening TestLifecycle.READY -> ApplicationLifecycle.Ready + TestLifecycle.DEGRADED -> ApplicationLifecycle.Degraded TestLifecycle.FATAL -> ApplicationLifecycle.Fatal TestLifecycle.CLOSED -> ApplicationLifecycle.Closed }, - lifecycleProblem = null, + lifecycleProblem = + if (lifecycle == TestLifecycle.DEGRADED) { + ApplicationProblem( + code = ApplicationErrorCode.RelayConnectionFailed, + category = ApplicationErrorCategory.Network, + retryable = true, + recoveryAction = RecoveryAction.Retry, + operationId = null, + safeMessage = "The local integration relay is unavailable.", + ) + } else { + null + }, configuredRelays = emptyList(), identities = mappedIdentities, selectedIdentityId = selected, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -452,6 +452,11 @@ private fun Throwable.toProblem(operationId: OperationId?): ApplicationProblem = safeMessage = "The application command failed.", ) -private val READY_ROUTES = setOf(HarvestCircleRoute.IDENTITIES, HarvestCircleRoute.ACTIVE_IDENTITY) +private val READY_ROUTES = + setOf( + HarvestCircleRoute.IDENTITIES, + HarvestCircleRoute.ACTIVE_IDENTITY, + HarvestCircleRoute.DEGRADED, + ) private const val EFFECT_BUFFER_CAPACITY = 8 private const val COMMAND_DEADLINE_MILLIS = 5_000UL diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -57,25 +57,43 @@ class ShellReducerTest { val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) runCurrent() - listOf( - HarvestCircleShellIntent.Navigate(ScreenKey.Network), - HarvestCircleShellIntent.SetTheme(ThemePreference.Dark), - HarvestCircleShellIntent.SetTextSize(TextSizePreference.VeryLarge), - HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced), - HarvestCircleShellIntent.EnterReadOnly, - HarvestCircleShellIntent.Overlay( - OverlayIntent.Open(FoundationOverlay.OpenNostrReference()), - ), - ).map { intent -> async { presenter.dispatch(intent) } } - .awaitAll() + buildList { + add(async { identity.state.value = activePresenterState(4UL) }) + add(async { presenter.dispatch(HarvestCircleShellIntent.Navigate(ScreenKey.Network)) }) + add(async { presenter.dispatch(HarvestCircleShellIntent.SetTheme(ThemePreference.Dark)) }) + add(async { presenter.dispatch(HarvestCircleShellIntent.SetTextSize(TextSizePreference.VeryLarge)) }) + add(async { presenter.dispatch(HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced)) }) + add( + async { + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.OpenNostrReference()), + ), + ) + }, + ) + }.awaitAll() + runCurrent() val state = presenter.state.value + assertEquals(4UL, state.identity.snapshot.revision.value) assertEquals(AppRoute.Network, state.currentRoute) assertEquals(ThemePreference.Dark, state.appearance.theme) assertEquals(TextSizePreference.VeryLarge, state.appearance.textSize) assertEquals(MotionPreference.Reduced, state.appearance.motion) - assertTrue(state.session.readOnly) assertTrue(state.overlays.current is FoundationOverlay.OpenNostrReference) + + listOf( + async { identity.state.value = signedOutPresenterState(5UL) }, + async { presenter.dispatch(HarvestCircleShellIntent.EnterReadOnly) }, + ).awaitAll() + runCurrent() + + val signedOut = presenter.state.value + assertEquals(5UL, signedOut.identity.snapshot.revision.value) + assertTrue(signedOut.session.readOnly) + assertEquals(ThemePreference.Dark, signedOut.appearance.theme) + assertTrue(signedOut.overlays.current is FoundationOverlay.OpenNostrReference) presenter.close() } @@ -135,3 +153,15 @@ private fun activePresenterState(revision: ULong): HarvestCirclePresenterState { ), ) } + +private fun signedOutPresenterState(revision: ULong): HarvestCirclePresenterState { + val active = activePresenterState(revision) + return active.copy( + snapshot = + active.snapshot.copy( + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + activeIdentity = null, + ), + ) +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeRenderTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeRenderTest.kt @@ -0,0 +1,62 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.harvestcircle.design.AppearanceState +import org.harvestcircle.design.HarvestCircleDesign +import org.harvestcircle.design.ThemePreference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleThemeRenderTest { + @Test + fun lightDarkAndSystemPreferencesChangeRenderedPixels() = + runComposeUiTest { + var preference by mutableStateOf(ThemePreference.Light) + var systemDark by mutableStateOf(false) + setContent { + HarvestCircleTheme(AppearanceState(theme = preference), systemDark = systemDark) { + Box( + Modifier + .size(32.dp) + .background(LocalHarvestCirclePalette.current.background.toComposeColor()) + .testTag("theme-render-swatch"), + ) + } + } + + fun renderedColor() = onNodeWithTag("theme-render-swatch").captureToImage().toPixelMap()[16, 16] + + val explicitLight = renderedColor() + preference = ThemePreference.Dark + waitForIdle() + val explicitDark = renderedColor() + preference = ThemePreference.System + systemDark = false + waitForIdle() + val systemLight = renderedColor() + systemDark = true + waitForIdle() + val systemDarkColor = renderedColor() + + assertEquals(HarvestCircleDesign.light.background.toComposeColor(), explicitLight) + assertEquals(HarvestCircleDesign.dark.background.toComposeColor(), explicitDark) + assertEquals(explicitLight, systemLight) + assertEquals(explicitDark, systemDarkColor) + assertNotEquals(explicitLight, explicitDark) + } +} diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs @@ -10,7 +10,8 @@ use std::time::Duration; use harvestcircle_application::{ AppLifecycle, AppSnapshot, Clock, DurableRequestId, GeneratedKeyRecoveryHandle, - InMemorySecretStore, RelayConfiguration, SecretStore, SessionState, SnapshotRevision, + InMemorySecretStore, RelayConfiguration, RemovalConfirmationToken, SecretStore, SessionState, + SnapshotRevision, }; use harvestcircle_domain::{ PublicKey, RelayDestinationPolicy, RelayEndpoint, SafeError, SecretKeyInput, UnixTimestamp, @@ -42,6 +43,7 @@ pub struct TestIdentity { pub enum TestLifecycle { Booting, Ready, + Degraded, Fatal, Closed, } @@ -71,6 +73,34 @@ pub struct TestGeneratedRecoveryRequest { resolved: AtomicBool, } +#[derive(uniffi::Object)] +pub struct TestRemovalRequest { + token: Mutex<Option<RemovalConfirmationToken>>, + public_key_hex: String, + deletes_local_credential: bool, + signs_out: bool, + expires_at_seconds: i64, +} + +#[uniffi::export] +impl TestRemovalRequest { + pub fn public_key_hex(&self) -> String { + self.public_key_hex.clone() + } + + pub fn deletes_local_credential(&self) -> bool { + self.deletes_local_credential + } + + pub fn signs_out(&self) -> bool { + self.signs_out + } + + pub fn expires_at_seconds(&self) -> i64 { + self.expires_at_seconds + } +} + #[uniffi::export] impl TestGeneratedRecoveryRequest { pub fn identity(&self) -> TestIdentity { @@ -147,6 +177,7 @@ pub struct HarvestCircleTestBridge { relay: Mutex<Option<MockRelay>>, relay_url: String, database_path: PathBuf, + network_degraded: AtomicBool, } #[uniffi::export] @@ -185,16 +216,17 @@ impl HarvestCircleTestBridge { relay: Mutex::new(Some(relay)), relay_url, database_path, + network_degraded: AtomicBool::new(false), })) } pub fn bootstrap(&self) -> Result<TestSnapshot, TestBridgeError> { let actor = self.actor()?; - Ok(to_snapshot(self.runtime.block_on(actor.bootstrap())?)) + Ok(self.to_test_snapshot(self.runtime.block_on(actor.bootstrap())?)) } pub fn snapshot(&self) -> Result<TestSnapshot, TestBridgeError> { - Ok(to_snapshot(self.actor()?.snapshot())) + Ok(self.to_test_snapshot(self.actor()?.snapshot())) } pub fn begin_generated_identity( @@ -227,7 +259,7 @@ impl HarvestCircleTestBridge { SnapshotRevision::from_value(expected_revision), Duration::from_millis(timeout_millis), ))?; - Ok(to_snapshot(snapshot)) + Ok(self.to_test_snapshot(snapshot)) } pub fn cancel_generated_identity( @@ -258,14 +290,15 @@ impl HarvestCircleTestBridge { Duration::from_millis(timeout_millis), ))?; let _ = receipt.identity(); - Ok(to_snapshot(actor.snapshot())) + Ok(self.to_test_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)?), - )?)) + Ok(self.to_test_snapshot( + self.runtime + .block_on(actor.select_identity(PublicKey::from_hex(&public_key_hex)?))?, + )) } pub fn activate_identity( @@ -273,14 +306,15 @@ impl HarvestCircleTestBridge { 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)?), - )?)) + Ok(self.to_test_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())?)) + Ok(self.to_test_snapshot(self.runtime.block_on(actor.sign_out())?)) } pub fn seed_selected_profile(&self, display_name: String) -> Result<(), TestBridgeError> { @@ -316,9 +350,63 @@ impl HarvestCircleTestBridge { pub fn refresh_active_profile(&self) -> Result<TestSnapshot, TestBridgeError> { let actor = self.actor()?; - Ok(to_snapshot( - self.runtime.block_on(actor.refresh_active_profile())?, - )) + Ok(self.to_test_snapshot(self.runtime.block_on(actor.refresh_active_profile())?)) + } + + pub fn request_identity_removal( + &self, + public_key_hex: String, + ) -> Result<Arc<TestRemovalRequest>, TestBridgeError> { + let token = self.runtime.block_on( + self.actor()? + .request_identity_removal(PublicKey::from_hex(&public_key_hex)?), + )?; + let impact = token.impact(); + let expires_at_seconds = token.expires_at().as_seconds(); + Ok(Arc::new(TestRemovalRequest { + token: Mutex::new(Some(token)), + public_key_hex, + deletes_local_credential: impact.deletes_local_credential(), + signs_out: impact.signs_out(), + expires_at_seconds, + })) + } + + pub fn cancel_identity_removal(&self, request: Arc<TestRemovalRequest>) -> bool { + request + .token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .is_some() + } + + pub fn confirm_identity_removal( + &self, + request_id: String, + expected_revision: u64, + timeout_millis: u64, + request: Arc<TestRemovalRequest>, + ) -> Result<TestSnapshot, TestBridgeError> { + let token = request + .token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(request_unavailable)?; + let snapshot = self + .runtime + .block_on(self.actor()?.confirm_identity_removal( + token, + DurableRequestId::parse(request_id)?, + SnapshotRevision::from_value(expected_revision), + Duration::from_millis(timeout_millis), + ))?; + Ok(self.to_test_snapshot(snapshot)) + } + + pub fn set_network_degraded(&self, degraded: bool) { + self.network_degraded.store(degraded, Ordering::Release); } pub fn start_observer(&self) -> Result<(), TestBridgeError> { @@ -351,7 +439,7 @@ impl HarvestCircleTestBridge { .await }); match change { - Ok(Some(change)) => Ok(Some(to_snapshot(change.snapshot().clone()))), + Ok(Some(change)) => Ok(Some(self.to_test_snapshot(change.snapshot().clone()))), Ok(None) => Ok(None), Err(_) => Ok(None), } @@ -387,7 +475,7 @@ impl HarvestCircleTestBridge { .actor .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(actor); - Ok(to_snapshot(snapshot)) + Ok(self.to_test_snapshot(snapshot)) } pub fn shutdown(&self) -> Result<TestSnapshot, TestBridgeError> { @@ -415,6 +503,19 @@ impl Drop for HarvestCircleTestBridge { } impl HarvestCircleTestBridge { + fn to_test_snapshot(&self, snapshot: AppSnapshot) -> TestSnapshot { + let mut snapshot = to_snapshot(snapshot); + if self.network_degraded.load(Ordering::Acquire) + && !matches!( + snapshot.lifecycle, + TestLifecycle::Closed | TestLifecycle::Fatal + ) + { + snapshot.lifecycle = TestLifecycle::Degraded; + } + snapshot + } + fn actor(&self) -> Result<RuntimeActorHandle, TestBridgeError> { self.actor .lock()