commit 219a5ffd234011799408f31541024e3af94d8c14 parent 49478aa11af4f714ca9c3cf27be39a1d776664e6 Author: triesap <tyson@radroots.org> Date: Wed, 12 Aug 2026 17:27:12 +0000 test: qualify final shell security and interaction - Name an owning regression for every HC-SC finding. - Reject retired confirmation, reference, and selection source shapes. - Exercise native removal authority and bounded reference ingress. - Preserve standalone source, integration, and package acceptance. Diffstat:
10 files changed, 241 insertions(+), 11 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 @@ -14,6 +14,7 @@ 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 kotlinx.coroutines.runBlocking @@ -186,6 +187,22 @@ class IdentityBootstrapAcceptanceTest { onNodeWithText("Read-only session").assertIsDisplayed() assertTrue(runtime.currentSnapshot().identities.isEmpty()) + onNodeWithTag("today-open-reference").performClick() + onNodeWithTag("nostr-reference-input").performTextInput(" \tNoStR:NS" + "EC1partial") + onNodeWithText("Private-key references cannot be opened.").assertIsDisplayed() + onNodeWithTag("overlay-cancel").performClick() + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithTag("foundation-overlay").fetchSemanticsNodes().isEmpty() + } + + onNodeWithTag("today-open-reference").performClick() + onNodeWithTag("nostr-reference-input").performTextInput("é".repeat(1_025)) + onNodeWithText("This reference is not valid.").assertIsDisplayed() + onNodeWithTag("overlay-cancel").performClick() + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithTag("foundation-overlay").fetchSemanticsNodes().isEmpty() + } + closeRequested = true waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 1 } showApplication = false diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt @@ -93,6 +93,73 @@ class NativeRuntimeIntegrationTest { } @Test + fun nativeRemovalRequestsRejectMissingStaleAndRapidDuplicateAuthority() { + val dataRoot = Files.createTempDirectory("harvestcircle-removal-authority-") + val bridge = HarvestCircleTestBridge.open(dataRoot.toString()) + try { + val initial = bridge.bootstrap() + val generated = bridge.beginGeneratedIdentity() + val secret = generated.takeRecoveryNsec() + val created = + bridge.acknowledgeGeneratedIdentity( + "00000000-0000-7000-8000-000000000021", + initial.revision, + 2_000UL, + generated, + ) + val identityId = assertNotNull(created.selectedPublicKeyHex) + generated.close() + + val missing = + assertFailsWith<TestBridgeException.Failure> { + bridge.requestIdentityRemoval("04".repeat(32)) + } + assertFalse(missing.safeMessage.contains(secret)) + + val cancelled = bridge.requestIdentityRemoval(identityId) + assertTrue(bridge.cancelIdentityRemoval(cancelled)) + assertFalse(bridge.cancelIdentityRemoval(cancelled)) + val stale = + assertFailsWith<TestBridgeException.Failure> { + bridge.confirmIdentityRemoval( + "00000000-0000-7000-8000-000000000022", + created.revision, + 2_000UL, + cancelled, + ) + } + assertFalse(stale.safeMessage.contains(secret)) + cancelled.close() + + val admitted = bridge.requestIdentityRemoval(identityId) + val removed = + bridge.confirmIdentityRemoval( + "00000000-0000-7000-8000-000000000023", + created.revision, + 2_000UL, + admitted, + ) + assertTrue(removed.identities.isEmpty()) + val duplicate = + assertFailsWith<TestBridgeException.Failure> { + bridge.confirmIdentityRemoval( + "00000000-0000-7000-8000-000000000024", + removed.revision, + 2_000UL, + admitted, + ) + } + assertFalse(duplicate.safeMessage.contains(secret)) + admitted.close() + assertFalse(bridge.snapshot().toString().contains(secret)) + bridge.shutdown() + } finally { + bridge.close() + deleteTree(dataRoot) + } + } + + @Test fun nativeBridgeCoversIdentityRelayRestartObserverTimeoutAndRedaction() = runBlocking { val dataRoot = Files.createTempDirectory("harvestcircle-native-integration-") diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -258,7 +258,7 @@ class HarvestCirclePresenterTest { } @Test - fun staleRemovalTokenCannotCancelTheAdmittedRequest() = + fun hcSc002StaleRemovalTokenCannotCancelTheAdmittedRequest() = runTest { val runtime = FakePresenterRuntime() val presenter = presenter(runtime) @@ -286,7 +286,7 @@ class HarvestCirclePresenterTest { } @Test - fun failedRemovalRequestExposesNoConfirmation() = + fun hcSc001FailedRemovalRequestExposesNoConfirmation() = runTest { val runtime = FakePresenterRuntime().also { it.removalFailure = problem(retryable = false) } val presenter = presenter(runtime) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -51,7 +51,7 @@ class HarvestCircleShellPresenterTest { } @Test - fun admittedConfirmationDispatchesExactIdentityEffectOnceAndClosesFromIdentityState() = + fun hcSc003HcSc004AdmittedConfirmationDispatchesExactEffectOnceAndClosesFromIdentityState() = runTest { val identityId = IdentityId.fromPublicKeyHex("03".repeat(32)) val requestId = RemovalRequestId.from("removal-shell-1") @@ -120,6 +120,46 @@ class HarvestCircleShellPresenterTest { } @Test + fun replacedConfirmationRejectsThePriorTokenAndAdmitsOnlyTheCurrentToken() = + runTest { + val identityId = IdentityId.fromPublicKeyHex("04".repeat(32)) + val priorRequest = RemovalRequestId.from("removal-shell-prior") + val currentRequest = RemovalRequestId.from("removal-shell-current") + val identity = + FakeIdentityPresentation( + presenterState(HarvestCircleRoute.IDENTITIES).withRemovalConfirmation(identityId, priorRequest), + ) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + + identity.state.value = + identity.state.value.withRemovalConfirmation(identityId, currentRequest) + runCurrent() + assertEquals( + ConfirmationAction.RemoveLocalIdentity(identityId, currentRequest), + (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).action, + ) + + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Confirm(ConfirmationAction.RemoveLocalIdentity(identityId, priorRequest)), + ), + ) + assertTrue(identity.intents.isEmpty()) + + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Confirm(ConfirmationAction.RemoveLocalIdentity(identityId, currentRequest)), + ), + ) + assertEquals( + listOf<HarvestCircleIntent>(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, currentRequest)), + identity.intents, + ) + presenter.close() + } + + @Test fun usableDegradationPreservesDashboardNavigation() = runTest { val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL)) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertSame class ReferenceInputPolicyTest { @Test - fun privateKeyShapesAreRejectedCaseInsensitivelyAfterAsciiWhitespace() { + fun hcSc005PrivateKeyShapesAreRejectedCaseInsensitivelyAfterAsciiWhitespace() { listOf( "nsec1", "nsec1partial", @@ -27,7 +27,7 @@ class ReferenceInputPolicyTest { } @Test - fun characterAndUtf8ByteLimitsAreEnforcedBeforeAdmission() { + fun hcSc006CharacterAndUtf8ByteLimitsAreEnforcedBeforeAdmission() { val asciiLimit = "a".repeat(ReferenceInputPolicy.MAX_REFERENCE_CHARS) val multibyteOverflow = "é".repeat((ReferenceInputPolicy.MAX_REFERENCE_UTF8_BYTES / 2) + 1) @@ -40,7 +40,7 @@ class ReferenceInputPolicyTest { } @Test - fun acceptedAdmissionAndEditIntentDoNotStringifyRawInput() { + fun hcSc007AcceptedAdmissionAndEditIntentDoNotStringifyRawInput() { val distinctive = "distinctive-public-reference" val admission = assertIs<ReferenceInputAdmission.Accepted>(ReferenceInputPolicy.admit(distinctive)) val intent = OverlayIntent.EditReference(distinctive) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -26,7 +26,7 @@ class ShellOverlaysTest { } @Test - fun referenceOpeningIsInputFreeAndGenericPrefilledIngressIsRejected() { + fun hcSc012ReferenceOpeningIsInputFreeAndGenericPrefilledIngressIsRejected() { val initial = shellState() val rejected = OverlayReducer diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertNull class ShellControlsTest { @Test - fun focusChangesOnlyBorderAndRingAcrossEveryEnabledControlFamily() { + fun hcSc008FocusChangesOnlyBorderAndRingAcrossEveryEnabledControlFamily() { listOf(HarvestCircleDesign.light, HarvestCircleDesign.dark).forEach { palette -> ShellButtonKind.entries.forEach { kind -> listOf(false, true).forEach { selected -> @@ -30,7 +30,7 @@ class ShellControlsTest { } @Test - fun interactionPreservesPrimaryAndDestructiveSemanticFamilies() { + fun hcSc009InteractionPreservesPrimaryAndDestructiveSemanticFamilies() { listOf(HarvestCircleDesign.light, HarvestCircleDesign.dark).forEach { palette -> listOf(false, true).forEach { hovered -> listOf(false, true).forEach { pressed -> diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt @@ -96,7 +96,7 @@ class ShellAccessibilityUiTest { } @Test - fun modalRemovesTheBackgroundSemanticsSubtree() = + fun hcSc011ModalRemovesTheBackgroundSemanticsSubtree() = runComposeUiTest { setContent { HarvestCircleShell( diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt @@ -25,7 +25,7 @@ import kotlin.test.assertFalse @OptIn(ExperimentalTestApi::class) class ShellControlsUiTest { @Test - fun controlsExposeTargetsSelectionDisabledStateAndFieldCopy() = + fun hcSc010ControlsExposeTargetsSelectionDisabledStateAndFieldCopy() = runComposeUiTest { setContent { Column { diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -385,6 +385,46 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St findings.push(format!("{path}: required product-shell source is missing")); } } + let regression_matrix: &[(&str, &[&str])] = &[ + ( + "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt", + &["hcSc001", "hcSc002"], + ), + ( + "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt", + &["hcSc003", "HcSc004"], + ), + ( + "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt", + &["hcSc005", "hcSc006", "hcSc007"], + ), + ( + "app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt", + &["hcSc008", "hcSc009"], + ), + ( + "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt", + &["hcSc010"], + ), + ( + "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt", + &["hcSc011"], + ), + ( + "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt", + &["hcSc012"], + ), + ]; + for (path, markers) in regression_matrix { + let source = read_text(root, path); + for marker in *markers { + if !source.contains(marker) { + findings.push(format!( + "{path}: required shell-security regression marker is missing: {marker}" + )); + } + } + } let locked_copy = [ ( "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt", @@ -445,6 +485,32 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St .chars() .filter(|character| !character.is_whitespace()) .collect::<String>(); + for (shape, diagnostic) in [ + ( + "dataobjectConfirmIdentityRemoval", + "retired parameterless confirmation source shape", + ), + ( + "dataobjectCancelIdentityRemoval", + "retired parameterless confirmation source shape", + ), + ( + "isOverlayIntent.EditReference->classifyNostrReference(", + "parser-on-edit source shape", + ), + ( + "OverlayIntent.Open(FoundationOverlay.OpenNostrReference(", + "prefilled reference ingress source shape", + ), + ( + "selected=true,enabled=false", + "selected-as-disabled source shape", + ), + ] { + if compact.contains(shape) { + findings.push(format!("{path}: {diagnostic}")); + } + } if normalized_path.ends_with("/harvestcirclescreen.kt") { findings.push(format!("{path}: superseded product-shell screen path")); } @@ -1052,6 +1118,46 @@ mod tests { } #[test] + fn product_shell_audit_rejects_retired_security_and_selection_shapes() { + let root = fixture("shell-security-shapes"); + write( + &root, + "app/shared/src/commonMain/kotlin/org/harvestcircle/application/LegacyConfirmation.kt", + "data object ConfirmIdentityRemoval\ndata object CancelIdentityRemoval\n", + ); + write( + &root, + "app/shared/src/commonMain/kotlin/org/harvestcircle/application/UnsafeReference.kt", + "fun reduce(intent: OverlayIntent) = when (intent) { is OverlayIntent.EditReference -> classifyNostrReference(intent.value) }\n", + ); + write( + &root, + "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/UnsafeIngress.kt", + "val overlay = OverlayIntent.Open(FoundationOverlay.OpenNostrReference(\"prefilled\"))\n", + ); + write( + &root, + "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/UnsafeSelection.kt", + "ShellTab(selected = true, enabled = false)\n", + ); + let inventory = Inventory::load(&root).expect("security shape inventory"); + let mut findings = Vec::new(); + product_shell_audit(&root, &inventory, &mut findings); + for expected in [ + "retired parameterless confirmation source shape", + "parser-on-edit source shape", + "prefilled reference ingress source shape", + "selected-as-disabled source shape", + ] { + assert!( + findings.iter().any(|finding| finding.contains(expected)), + "missing {expected}: {findings:?}" + ); + } + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] fn repository_policy_rejects_nested_documentation_and_workflow_roots() { let root = fixture("nested-docs"); write(&root, "app/docs/notes.md", "fixture\n");