diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-04 18:51:35 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-04 18:51:35 +0100 |
| commit | ba2a60a14717fd7c82cd2c4b1778673d1bb6388f (patch) | |
| tree | 45fc389cbafedcfef0f88ae8b7e789af39690c84 /app/src/test/java | |
| parent | 0ad0b1ca6a7f7fa814a92dd6eaf9da88948ee377 (diff) | |
| parent | 136ee43d6080da288afae8ec133727962ea60640 (diff) | |
| download | HardenedPass-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')
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)) + } +} |
