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:
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"),
+ )
}