summaryrefslogtreecommitdiffstats
path: root/app/src/test/java
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
committerDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
commitba2a60a14717fd7c82cd2c4b1778673d1bb6388f (patch)
tree45fc389cbafedcfef0f88ae8b7e789af39690c84 /app/src/test/java
parent0ad0b1ca6a7f7fa814a92dd6eaf9da88948ee377 (diff)
parent136ee43d6080da288afae8ec133727962ea60640 (diff)
downloadHardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.tar.gz
HardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.zip
Merge branch 'compose': Compose UI overhaul
Every screen moves from XML layouts, Views and Fragments to Jetpack Compose with Material 3, in one MainActivity with a NavHost: the list with breadcrumbs, search, pull to sync and multi-select; the entry view with OTP countdown; the editor with generator sheet and folder picker; setup (welcome, git server and clone, SSH key, PGP keys); settings, git config, commit log and proxy; and the autofill picker, publisher warning and decryption prompts. LaunchActivity stays the only exported activity. Fixes found on the way reach master with it, among them symlink-safe deletes, the proxy user name, screen lock gated switches, the Android 14 app lock and screen lock prompts, and the autofill passphrase cache rule. The View-era dependencies and view binding are gone; Material Components (Views) stays for the git dialogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/test/java')
-rw-r--r--app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt91
-rw-r--r--app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt27
-rw-r--r--app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt156
-rw-r--r--app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt57
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt105
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt29
-rw-r--r--app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt90
-rw-r--r--app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt150
-rw-r--r--app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt33
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt27
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt25
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt26
12 files changed, 816 insertions, 0 deletions
diff --git a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt
new file mode 100644
index 00000000..39f7f358
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AutofillModelTest {
+
+ private val root = File("/store")
+
+ private fun rows(structure: DirectoryStructure, vararg paths: String) =
+ autofillRows(paths.map { File(root, it) }, root, structure).map {
+ Triple(it.path, it.label, it.subtitle)
+ }
+
+ @Test
+ fun fileBasedNamesTheIdentifier() {
+ assertEquals(
+ listOf(
+ Triple("/store/work/example.org/john@doe.org.gpg", "example.org", "john@doe.org · work"),
+ Triple("/store/example.org/john.gpg", "example.org", "john"),
+ Triple("/store/example.org.gpg", "example.org", null),
+ ),
+ rows(
+ DirectoryStructure.FileBased,
+ "work/example.org/john@doe.org.gpg",
+ "example.org/john.gpg",
+ "example.org.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun directoryBasedFallsBackToTheAccountPart() {
+ assertEquals(
+ listOf(
+ Triple(
+ "/store/example.org/john@doe.org/password.gpg",
+ "example.org",
+ "john@doe.org/password",
+ ),
+ Triple("/store/Temporary PIN.gpg", "Temporary PIN", null),
+ ),
+ rows(
+ DirectoryStructure.DirectoryBased,
+ "example.org/john@doe.org/password.gpg",
+ "Temporary PIN.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun encryptedUsernameShowsTheFolders() {
+ assertEquals(
+ listOf(Triple("/store/work/example.org.gpg", "example.org", "work")),
+ rows(DirectoryStructure.EncryptedUsername, "work/example.org.gpg"),
+ )
+ }
+
+ @Test
+ fun rowsKeepTheRankingOrder() {
+ assertEquals(
+ listOf("b.org", "a.org"),
+ rows(DirectoryStructure.FileBased, "b.org.gpg", "a.org.gpg").map { it.second },
+ )
+ }
+
+ @Test
+ fun passphraseIsCachedOnlyAfterTheScreenLock() {
+ assertTrue(cachesPassphrase(true, BiometricResult.Success(null)))
+ val notConfirmed =
+ listOf(
+ BiometricResult.CanceledByUser,
+ BiometricResult.CanceledBySystem,
+ BiometricResult.HardwareUnavailableOrDisabled,
+ BiometricResult.Failure(null, "failed"),
+ BiometricResult.Retry,
+ )
+ for (auth in notConfirmed) assertFalse(cachesPassphrase(true, auth), auth.toString())
+ assertFalse(cachesPassphrase(false, BiometricResult.Success(null)))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt
new file mode 100644
index 00000000..fee6bf72
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** Pins the path helpers' behavior across their move out of BasePGPActivity. */
+class CryptoExtensionsTest {
+
+ @Test
+ fun parentPathIsRelativeToTheStore() {
+ assertEquals("/social/", getParentPath("/store/social/facebook.gpg", "/store"))
+ assertEquals("/", getParentPath("/store/example.org.gpg", "/store"))
+ }
+
+ @Test
+ fun longNameJoinsFolderAndName() {
+ // StoreActions passes absolute folders, PasswordItem its path relative to the store.
+ assertEquals("work/x", getLongName("/store/work", "/store", "x"))
+ assertEquals("social/facebook", getLongName("/social/", "/store", "facebook"))
+ assertEquals("x", getLongName("/store", "/store", "x"))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
new file mode 100644
index 00000000..160434c9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class EditorModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun editingWithoutChangesKeepsThePassFile() {
+ val files =
+ listOf(
+ "hunter2\nusername: alice\nurl: https://example.org\n",
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n",
+ )
+ for (content in files) assertEquals(content, passContent(editorForm(entry(content), "", "x")))
+ }
+
+ @Test
+ fun formFromAnEntrySplitsItsFields() {
+ val form =
+ editorForm(entry("hunter2\nusername: alice\nurl: https://example.org\n"), "work", "mail")
+ assertEquals(EditorForm("mail", "work", "hunter2", "alice", "url: https://example.org\n"), form)
+ }
+
+ @Test
+ fun newEntryContent() {
+ assertEquals(
+ "pw\nusername: bob\nnote: x",
+ passContent(EditorForm(name = "n", password = "pw", username = "bob", extra = "note: x")),
+ )
+ assertEquals("pw\n", passContent(EditorForm(name = "n", password = "pw")))
+ }
+
+ @Test
+ fun formErrors() {
+ assertEquals(R.string.file_toast_text, formError(EditorForm(name = " ", password = "pw")))
+ assertEquals(
+ R.string.invalid_filename_text,
+ formError(EditorForm(name = "a/b", password = "pw")),
+ )
+ assertEquals(R.string.empty_toast_text, formError(EditorForm(name = "a", username = "bob")))
+ assertNull(formError(EditorForm(name = "a", extra = "note")))
+ assertNull(formError(EditorForm(name = "a", password = "pw")))
+ }
+
+ @Test
+ fun entryFileTrimsTheNameAndUsesTheFolder() {
+ assertEquals(
+ File(root, "work/mail.gpg"),
+ entryFile(root, EditorForm(name = " mail ", dir = "work")),
+ )
+ assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail")))
+ assertEquals(
+ File(root, "b/mail.gpg"),
+ entryFile(root, EditorForm(name = "mail", dir = "./a/../b")),
+ )
+ }
+
+ @Test
+ fun overwritesOnlyAnotherFile() {
+ val taken = File(root, "a.gpg").apply { writeText("x") }
+ assertFalse(overwrites(File(root, "work/b.gpg"), null))
+ assertTrue(overwrites(taken, null))
+ assertFalse(overwrites(taken, taken))
+ assertTrue(overwrites(taken, File(root, "work/b.gpg")))
+ }
+
+ @Test
+ fun writeEntryCreatesTheFolderAndReplacesTheFile() {
+ val target = File(root, "new/dir/a.gpg")
+ writeEntry(target, byteArrayOf(1))
+ writeEntry(target, byteArrayOf(2, 3))
+ assertContentEquals(byteArrayOf(2, 3), target.readBytes())
+ assertEquals(listOf("a.gpg"), target.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun linesGoOnTheirOwnLine() {
+ assertEquals("otpauth://x", withLine("", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a\n", "otpauth://x"))
+ }
+
+ @Test
+ fun manualTotpUriIsReadBackByTheParser() {
+ val uri = totpUri(" JBSW Y3DP EHPK 3PXP ", "Example Co")!!
+ assertEquals("JBSWY3DPEHPK3PXP", UriTotpFinder().findSecret(uri))
+ assertEquals("Example Co", UriTotpFinder().findIssuer(uri))
+ assertTrue(entry("PLACEHOLDER\n$uri").hasTotp())
+ assertEquals("otpauth://totp?secret=JBSWY3DPEHPK3PXP", totpUri("JBSWY3DPEHPK3PXP", " "))
+ assertNull(totpUri(" ", "Example Co"))
+ }
+
+ @Test
+ fun randomOptionsFollowTheCheckboxes() {
+ assertEquals(listOf(PasswordOption.FullyRandom), RandomSpec().options())
+ assertEquals(
+ listOf(
+ PasswordOption.NoDigits,
+ PasswordOption.AtLeastOneSymbol,
+ PasswordOption.NoUppercaseLetters,
+ PasswordOption.NoLowercaseLetters,
+ PasswordOption.NoAmbiguousCharacters,
+ ),
+ RandomSpec(
+ numerals = false,
+ symbols = true,
+ uppercase = false,
+ lowercase = false,
+ ambiguous = false,
+ pronounceable = true,
+ )
+ .options(),
+ )
+ }
+
+ @Test
+ fun randomPasswordOrWhyNot() {
+ assertEquals(20, randomPassword(RandomSpec()).value.length)
+ assertEquals(
+ R.string.pwgen_no_chars_error,
+ randomPassword(RandomSpec(numerals = false, uppercase = false, lowercase = false)).error,
+ )
+ assertEquals(
+ R.string.pwgen_length_too_short_error,
+ randomPassword(RandomSpec(length = 2)).error,
+ )
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt
new file mode 100644
index 00000000..290c51a9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+class EntryModelTest {
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun firstGroupAndExtrasInFileOrder() {
+ val fields =
+ entryFields(entry("hunter2\nusername: alice\nurl: https://example.org\npin: 1234\n"))
+ assertEquals("hunter2", fields.password)
+ assertEquals("alice", fields.username)
+ assertEquals(listOf("url" to "https://example.org", "pin" to "1234"), fields.extras)
+ }
+
+ @Test
+ fun blankPasswordAndMissingUsernameAreLeftOut() {
+ val fields = entryFields(entry("\nnote: none\n"))
+ assertNull(fields.password)
+ assertNull(fields.username)
+ assertEquals(listOf("note" to "none"), fields.extras)
+ }
+
+ @Test
+ fun otpUriIsNotAnExtraField() {
+ val fields =
+ entryFields(
+ entry(
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n"
+ )
+ )
+ assertEquals("s3cret", fields.password)
+ assertTrue(fields.extras.isEmpty())
+ }
+
+ @Test
+ fun fractionLeftOfTheOtpPeriod() {
+ assertEquals(1f, Totp("123456", 30.seconds, 30.seconds).fractionLeft())
+ assertEquals(0.5f, Totp("123456", 15.seconds, 30.seconds).fractionLeft())
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
new file mode 100644
index 00000000..cf12deef
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ListModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun file(path: String) =
+ File(root, path).apply {
+ parentFile!!.mkdirs()
+ writeText("x")
+ }
+
+ private fun folder(path: String) = File(root, path).apply { mkdirs() }
+
+ @Test
+ fun openingAChildFolderPushesIt() {
+ assertEquals(0 to listOf("work"), folderMoves("", "work"))
+ }
+
+ @Test
+ fun breadcrumbJumpsOnlyPop() {
+ assertEquals(1 to emptyList<String>(), folderMoves("work/servers", "work"))
+ assertEquals(2 to emptyList<String>(), folderMoves("work/servers", ""))
+ }
+
+ @Test
+ fun searchResultsRebuildTheStackFromTheSharedParent() {
+ assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail"))
+ assertEquals(
+ 0 to listOf("work/servers", "work/servers/eu"),
+ folderMoves("work", "work/servers/eu"),
+ )
+ }
+
+ @Test
+ fun sameFolderMovesNothing() {
+ assertEquals(0 to emptyList<String>(), folderMoves("work", "work"))
+ }
+
+ @Test
+ fun browsingShowsNamesAndFolderCounts() {
+ val work = folder("work")
+ file("work/mail.gpg")
+ file("work/notes.txt")
+ folder("work/servers")
+ val site = file("site.gpg")
+
+ val rows = listRows(listOf(work, site), root)
+
+ assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders)
+ assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries)
+ }
+
+ @Test
+ fun searchResultsShowWhereTheyAre() {
+ val servers = folder("work/servers")
+ val vpn = file("work/vpn.gpg")
+
+ val rows = listRows(listOf(vpn, servers), root)
+
+ assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders)
+ assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries)
+ }
+
+ @Test
+ fun keepsTheGivenOrderInsideEachGroup() {
+ val b = file("b.gpg")
+ val a = file("a.gpg")
+ val z = folder("z")
+ val y = folder("y")
+
+ val rows = listRows(listOf(b, z, a, y), root)
+
+ assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path })
+ assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path })
+ }
+
+ @Test
+ fun pickerOffersFoldersByName() {
+ folder("b")
+ folder("A")
+ folder(".hidden")
+ folder(".git")
+ file("c.gpg")
+ assertEquals(listOf("A", "b"), subfolders(root, showHidden = false).map { it.name })
+ assertEquals(listOf(".hidden", "A", "b"), subfolders(root, showHidden = true).map { it.name })
+ assertEquals(emptyList<File>(), subfolders(File(root, "missing"), showHidden = false))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt
new file mode 100644
index 00000000..8a1ae1d9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.list
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class StoreActionsTest {
+
+ @Test
+ fun deleteDoesNotFollowSymlinks() {
+ val root = Files.createTempDirectory("aps").toFile()
+ val outsideDir = File(root, "outside").apply { mkdirs() }
+ val outsideFile = File(outsideDir, "secret.gpg").apply { writeText("x") }
+ val folder = File(root, "store/folder").apply { mkdirs() }
+ File(folder, "login.gpg").writeText("x")
+ Files.createSymbolicLink(File(folder, "link").toPath(), outsideDir.toPath())
+
+ assertTrue(folder.deleteWithoutFollowingLinks())
+
+ assertFalse(folder.exists())
+ assertTrue(outsideFile.exists())
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt
new file mode 100644
index 00000000..693e07dd
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import app.passwordstore.util.git.GitCommit
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.lib.PersonIdent
+import org.eclipse.jgit.revwalk.RevCommit
+import org.eclipse.jgit.util.FS
+
+class SettingsModelTest {
+
+ private val root = Files.createTempDirectory("aps-settings").toFile()
+ private val identity = PersonIdent("test", "test@example.org")
+
+ init {
+ // Ignore the developer's ~/.gitconfig (default branch, commit signing).
+ FS.DETECTED.setUserHome(root)
+ }
+
+ @AfterTest fun cleanUp() = root.deleteRecursively().let {}
+
+ private fun repo(): Git =
+ Git.init().setInitialBranch("master").setDirectory(File(root, "store")).call()
+
+ private fun Git.commitFile(name: String, message: String): RevCommit {
+ File(repository.workTree, name).writeText(message)
+ add().addFilepattern(name).call()
+ return commit()
+ .setMessage(message)
+ .setAuthor(identity)
+ .setCommitter(identity)
+ .setSign(false)
+ .call()
+ }
+
+ @Test
+ fun proxyPorts() {
+ assertEquals(-1, proxyPort(""))
+ assertEquals(-1, proxyPort(" "))
+ assertEquals(8080, proxyPort("8080"))
+ assertEquals(8080, proxyPort(" 8080 "))
+ assertEquals(1, proxyPort("1"))
+ assertEquals(65535, proxyPort("65535"))
+ assertNull(proxyPort("0"))
+ assertNull(proxyPort("65536"))
+ assertNull(proxyPort("99999999999"))
+ assertNull(proxyPort("-1"))
+ assertNull(proxyPort("80a"))
+ }
+
+ @Test
+ fun headStatusFollowsHead() {
+ repo().use { git ->
+ // A new repository: HEAD names a branch that has no commit yet.
+ assertEquals(HeadStatus.Branch("master"), headStatus(git.repository))
+ val first = git.commitFile("a.gpg", "Add a")
+ git.commitFile("b.gpg", "Add b")
+ assertEquals(HeadStatus.Branch("master"), headStatus(git.repository))
+ assertFalse(needsAbort(git.repository))
+ git.checkout().setName(first.name).call()
+ assertEquals(HeadStatus.Detached(first.name.take(8)), headStatus(git.repository))
+ }
+ }
+
+ @Test
+ fun gitLogIsNewestFirst() {
+ assertEquals(emptyList<GitCommit>(), gitLog(null))
+ repo().use { git ->
+ // No commit yet: JGit's NoHeadException is an empty log, not a crash.
+ assertEquals(emptyList<GitCommit>(), gitLog(git.repository))
+ val first = git.commitFile("a.gpg", "Add a")
+ val second = git.commitFile("b.gpg", "Add b")
+ val log = gitLog(git.repository)
+ assertEquals(listOf("Add b", "Add a"), log.map(GitCommit::shortMessage))
+ assertEquals(listOf(second.name, first.name), log.map(GitCommit::hash))
+ assertEquals("test", log[0].authorName)
+ }
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt
new file mode 100644
index 00000000..9ccddbd3
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+class SetupModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ @Test
+ fun urlProblems() {
+ assertEquals(UrlProblem.HttpsPort, urlProblem("https://example.org:8443/store.git"))
+ assertEquals(UrlProblem.SshScheme, urlProblem("git@example.org:2222/srv/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org:9418/store.git"))
+ assertNull(urlProblem("ssh://git@example.org:2222/srv/store.git"))
+ assertNull(urlProblem("git@example.org:srv/store.git"))
+ assertNull(urlProblem("https://example.org/store.git"))
+ assertNull(urlProblem("http://example.org:8080/store.git"))
+ }
+
+ @Test
+ fun urlFixes() {
+ assertEquals(
+ "https://example.org/store.git",
+ fixUrl("https://example.org:8443/store.git", UrlProblem.HttpsPort),
+ )
+ assertEquals(
+ "ssh://git@example.org:2222/srv/store.git",
+ fixUrl("git@example.org:2222/srv/store.git", UrlProblem.SshScheme),
+ )
+ assertNull(fixUrl("git://example.org/store.git", UrlProblem.GitScheme))
+ }
+
+ @Test
+ fun authModesFollowTheUrl() {
+ val ssh = listOf(AuthMode.SshKey, AuthMode.Password)
+ val http = listOf(AuthMode.None, AuthMode.Password)
+ assertEquals(ssh, authModes("ssh://git@example.org/store.git"))
+ assertEquals(ssh, authModes("git@example.org:store.git"))
+ assertEquals(ssh, authModes(""))
+ assertEquals(http, authModes("https://example.org/store.git"))
+ assertEquals(http, authModes("http://example.org/store.git"))
+ assertEquals(AuthMode.None, fitAuthMode("https://example.org/x", AuthMode.SshKey))
+ assertEquals(AuthMode.Password, fitAuthMode("https://example.org/x", AuthMode.Password))
+ assertEquals(AuthMode.SshKey, fitAuthMode("git@example.org:x", AuthMode.None))
+ assertEquals(AuthMode.Password, fitAuthMode("git@example.org:x", AuthMode.Password))
+ }
+
+ @Test
+ fun cloneTargets() {
+ val store = File(root, "store")
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ store.mkdirs()
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ File(store, ".git").mkdirs()
+ assertEquals(CloneTarget.OnlyGit, cloneTarget(store))
+ File(store, "a.gpg").writeText("x")
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ File(store, ".git").deleteRecursively()
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ }
+
+ @Test
+ fun storeRecipientsSkipBlankAndInvalidLines() {
+ val ids =
+ storeRecipients(
+ "4882E8257A3700D43119B8D3F8993A789CDFB014\n\n Alice <alice@example.org> \nnot an id\n"
+ )
+ assertEquals(listOf("f8993a789cdfb014", "alice@example.org"), ids.map(PGPIdentifier::toString))
+ assertEquals(emptyList(), storeRecipients(""))
+ }
+
+ @Test
+ fun storeRecipientsDropCommentsAndExactSubkeyMarks() {
+ val ids =
+ storeRecipients(
+ "# store key\nF8993A789CDFB014! # laptop\n79E8208280490C77!\nalice@example.org#work\n # \n"
+ )
+ assertEquals(
+ listOf("f8993a789cdfb014", "79e8208280490c77", "alice@example.org"),
+ ids.map(PGPIdentifier::toString),
+ )
+ }
+
+ @Test
+ fun readAtMostRefusesMoreThanTheLimit() {
+ assertEquals("abc", readAtMost("abc".byteInputStream(), 3)?.decodeToString())
+ assertEquals("", readAtMost("".byteInputStream(), 3)?.decodeToString())
+ assertNull(readAtMost("abcd".byteInputStream(), 3))
+ }
+
+ @Test
+ fun gpgIdTextIsUppercaseKeyIdsOnePerLine() {
+ val ids =
+ listOf("f8993a789cdfb014", "0x79E8208280490C77").map {
+ assertIs<PGPIdentifier.KeyId>(PGPIdentifier.fromString(it))
+ }
+ val text = gpgIdText(ids)
+ assertEquals("F8993A789CDFB014\n79E8208280490C77\n", text)
+ // pass and this app read it back as the same key IDs.
+ assertEquals(ids, storeRecipients(text))
+ }
+
+ @Test
+ fun gpgIdIsReadOnlyFromASmallRegularFile() {
+ assertNull(readGpgId(root))
+ File(root, ".gpg-id").writeText("F8993A789CDFB014\n")
+ assertEquals("F8993A789CDFB014\n", readGpgId(root))
+
+ val linked = File(root, "linked").apply { mkdirs() }
+ val outside = File(root, "outside").apply { writeText("F8993A789CDFB014\n") }
+ Files.createSymbolicLink(File(linked, ".gpg-id").toPath(), outside.toPath())
+ assertNull(readGpgId(linked))
+
+ val big = File(root, "big").apply { mkdirs() }
+ File(big, ".gpg-id").writeText("A".repeat(64 * 1024 + 1))
+ assertNull(readGpgId(big))
+
+ val folder = File(root, "folder").apply { File(this, ".gpg-id").mkdirs() }
+ assertNull(readGpgId(folder))
+ }
+
+ @Test
+ fun createdForMatchesOnlyTheChosenKeysGpgId() {
+ val key = PGPIdentifier.fromString("0xf8993a789cdfb014") as PGPIdentifier.KeyId
+ assertEquals(false, createdFor(root, key))
+ File(root, ".gpg-id").writeText(gpgIdText(listOf(key)))
+ assertEquals(true, createdFor(root, key))
+ File(root, ".gpg-id").writeText("F8993A789CDFB014\nB950AE2813841585\n")
+ assertEquals(false, createdFor(root, key))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt
new file mode 100644
index 00000000..1a9914fb
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.git.sshj
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class SshKeyFileTest {
+
+ private fun pem(type: String) =
+ listOf("-----BEGIN $type-----", "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU", "-----END $type-----")
+
+ @Test
+ fun acceptsSshPrivateKeys() {
+ assertTrue(isSshPrivateKey(pem("OPENSSH PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("RSA PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("EC PRIVATE KEY")))
+ }
+
+ @Test
+ fun refusesOtherFiles() {
+ // The step 4 finding: an OpenPGP key block has the same markers and was accepted.
+ assertFalse(isSshPrivateKey(pem("PGP PRIVATE KEY BLOCK")))
+ assertFalse(isSshPrivateKey(pem("PGP PUBLIC KEY BLOCK")))
+ assertFalse(isSshPrivateKey(listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA test@example.org")))
+ assertFalse(isSshPrivateKey(emptyList()))
+ assertFalse(isSshPrivateKey(pem("OPENSSH PRIVATE KEY").dropLast(1)))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
new file mode 100644
index 00000000..6b30c248
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.settings
+
+import app.passwordstore.util.extensions.getString
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class GitSettingsTest {
+
+ @Test
+ fun proxyUsernameIsReadWhereItIsWritten() {
+ val settings = SPMockBuilder().createSharedPreferences()
+ val proxy = SPMockBuilder().createSharedPreferences()
+ val gitSettings =
+ GitSettings(settings, SPMockBuilder().createSharedPreferences(), proxy, "/nonexistent")
+ gitSettings.proxyUsername = "proxy-user"
+ assertEquals("proxy-user", gitSettings.proxyUsername)
+ assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME))
+ assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
index 62109563..44bd12ba 100644
--- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
+++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
@@ -184,4 +184,29 @@ class MigrationsTest {
assertFalse { sharedPrefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO) }
assertTrue { sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, false) }
}
+
+ @Test
+ fun removedSortOrdersFallBackToFoldersFirst() {
+ val gitSettings =
+ GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir)
+ for (removed in listOf("FILE_FIRST", "INDEPENDENT")) {
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, removed) }
+ runMigrations(filesDir, sharedPrefs, gitSettings)
+ assertEquals("FOLDER_FIRST", sharedPrefs.getString(PreferenceKeys.SORT_ORDER))
+ }
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") }
+ runMigrations(filesDir, sharedPrefs, gitSettings)
+ assertEquals("RECENTLY_USED", sharedPrefs.getString(PreferenceKeys.SORT_ORDER))
+ }
+
+ @Test
+ fun staleOpenKeystoreKeyIdIsRemoved() {
+ sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, "0123456789ABCDEF") }
+ runMigrations(
+ filesDir,
+ sharedPrefs,
+ GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir),
+ )
+ assertFalse(sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID))
+ }
}
diff --git a/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt
new file mode 100644
index 00000000..34348692
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.settings
+
+import androidx.core.content.edit
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PasswordSortOrderTest {
+
+ @Test
+ fun unknownAndRemovedValuesReadAsFoldersFirst() {
+ val prefs = SPMockBuilder().createSharedPreferences()
+ assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs))
+ for (value in listOf("FILE_FIRST", "INDEPENDENT", "bogus")) {
+ prefs.edit { putString(PreferenceKeys.SORT_ORDER, value) }
+ assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs))
+ }
+ prefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") }
+ assertEquals(PasswordSortOrder.RECENTLY_USED, PasswordSortOrder.getSortOrder(prefs))
+ }
+}