app

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

commit d79070cf10ef3c99d0728475d76b82f4d9c7dbbc
parent 31fcb41e07985754663f42e4e80fd7f18564f286
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 18:24:23 +0000

accounts: enforce account state invariants

- normalize supported HTTP and HTTPS server addresses
- reject malformed account identifiers and display names
- require selection and removal targets to reference accounts
- cover valid and invalid state construction deterministically

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 155 insertions(+), 0 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt @@ -1,5 +1,7 @@ package org.radroots.studio.accounts.model +import java.net.URI + data class AddAccountDraft( val displayName: String = "", val serverUrl: String = "", @@ -12,3 +14,61 @@ data class AccountsState( val pendingRemovalAccountId: AccountId? = null, val problem: AccountsProblem? = null, ) + +internal fun normalizeDisplayName(value: String): String? = + value.trim().takeIf(String::isNotEmpty) + +internal fun normalizeServerUrl(value: String): String? { + val parsed = runCatching { URI(value.trim()) }.getOrNull() ?: return null + val scheme = parsed.scheme?.lowercase() ?: return null + val host = parsed.host?.lowercase() ?: return null + if (scheme != "http" && scheme != "https") return null + if (host.isEmpty() || parsed.userInfo != null || parsed.fragment != null) return null + + return runCatching { + URI( + scheme, + null, + host, + parsed.port, + parsed.path, + parsed.query, + null, + ).normalize().toASCIIString() + }.getOrNull() +} + +internal fun AccountsState.requireValid(): AccountsState { + require(accounts.map(Account::id).distinct().size == accounts.size) { + "Account IDs must be unique" + } + accounts.forEach { account -> + require(account.id.value.isNotBlank() && account.id.value == account.id.value.trim()) { + "Account IDs must be trimmed and nonblank" + } + require(normalizeDisplayName(account.displayName) == account.displayName) { + "Account display names must be trimmed and nonblank" + } + require(normalizeServerUrl(account.serverUrl) == account.serverUrl) { + "Account server URLs must be normalized and valid" + } + } + + if (accounts.isEmpty()) { + require(selectedAccountId == null) { + "Selection must be empty when there are no accounts" + } + } else { + require(selectedAccountId != null && accounts.any { it.id == selectedAccountId }) { + "Selection must identify an existing account" + } + } + require( + pendingRemovalAccountId == null || + accounts.any { it.id == pendingRemovalAccountId }, + ) { + "Pending removal must identify an existing account" + } + + return this +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt @@ -2,6 +2,7 @@ package org.radroots.studio.accounts.model import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class AccountsStateTest { @@ -30,4 +31,98 @@ class AccountsStateTest { assertEquals("https://farm.example.test", account.serverUrl) assertEquals(LoginStatus.LoggedOut, account.loginStatus) } + + @Test + fun validStatePassesInvariantValidation() { + val account = validAccount() + + assertEquals( + AccountsState( + accounts = listOf(account), + selectedAccountId = account.id, + ), + AccountsState( + accounts = listOf(account), + selectedAccountId = account.id, + ).requireValid(), + ) + } + + @Test + fun serverUrlNormalizationIsStableAndBounded() { + assertEquals( + "https://farm.example.test/b?mode=local", + normalizeServerUrl(" HTTPS://FARM.EXAMPLE.TEST/a/../b?mode=local "), + ) + assertEquals("http://localhost:8080", normalizeServerUrl("http://localhost:8080")) + assertNull(normalizeServerUrl("ftp://farm.example.test")) + assertNull(normalizeServerUrl("https:///missing-host")) + assertNull(normalizeServerUrl("https://user@farm.example.test")) + assertNull(normalizeServerUrl("https://farm.example.test/#fragment")) + assertNull(normalizeServerUrl("not a url")) + } + + @Test + fun invalidAccountValuesAreRejected() { + invalidAccounts().forEach { account -> + assertFailsWith<IllegalArgumentException> { + AccountsState( + accounts = listOf(account), + selectedAccountId = account.id, + ).requireValid() + } + } + } + + @Test + fun duplicateIdsAreRejected() { + val account = validAccount() + + assertFailsWith<IllegalArgumentException> { + AccountsState( + accounts = listOf(account, account.copy(displayName = "Second Account")), + selectedAccountId = account.id, + ).requireValid() + } + } + + @Test + fun danglingSelectionAndRemovalTargetsAreRejected() { + val account = validAccount() + val missingId = AccountId("missing") + + listOf( + AccountsState(accounts = listOf(account)), + AccountsState( + accounts = listOf(account), + selectedAccountId = missingId, + ), + AccountsState(selectedAccountId = missingId), + AccountsState( + accounts = listOf(account), + selectedAccountId = account.id, + pendingRemovalAccountId = missingId, + ), + ).forEach { state -> + assertFailsWith<IllegalArgumentException> { + state.requireValid() + } + } + } + + private fun validAccount() = Account( + id = AccountId("account-1"), + displayName = "Farm Account", + serverUrl = "https://farm.example.test", + loginStatus = LoginStatus.LoggedOut, + ) + + private fun invalidAccounts() = listOf( + validAccount().copy(id = AccountId("")), + validAccount().copy(id = AccountId(" account-1")), + validAccount().copy(displayName = ""), + validAccount().copy(displayName = " Farm Account"), + validAccount().copy(serverUrl = "ftp://farm.example.test"), + validAccount().copy(serverUrl = "HTTPS://FARM.EXAMPLE.TEST"), + ) }