From 8dad89ec9a37ab53a0de8983afafb0ef82d6d2d5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:36:30 +0100 Subject: feat(app): add the editor model and the picker's folder listing Pure functions for the Compose editor: the form and the pass file it writes, validation, the target file, the overwrite check, an atomic write, OTP URI building and the random generator's options. The folder picker lists subfolders with the list's hidden filter. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/editor/EditorModelTest.kt | 152 +++++++++++++++++++++ .../app/passwordstore/ui/list/ListModelTest.kt | 12 ++ 2 files changed, 164 insertions(+) create mode 100644 app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt (limited to 'app/src/test') 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..81174d05 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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"))) + } + + @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/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt index 60e3020a..cf12deef 100644 --- a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt @@ -90,4 +90,16 @@ class ListModelTest { 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(), subfolders(File(root, "missing"), showHidden = false)) + } } -- cgit v1.2.3