PeachScriptRoundTripTest.kt

6.5 KB · raw

1package com.peachpasswords.vault23import com.peachpasswords.ui.vault.CsvExport4import com.peachpasswords.ui.vault.CsvImport5import com.peachpasswords.ui.vault.ImportParseResult6import com.peachpasswords.ui.vault.PeachScriptExport7import com.peachpasswords.ui.vault.PeachScriptImport8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertTrue11import org.junit.Test1213/**14 * PeachScript + CSV round-trip suite (brief Suite 7): large-vault fidelity,15 * ciphertext tamper detection, size-bucket padding, and CSV column/injection16 * behavior. The cross-platform browser KAT lives in [PeachScriptTest].17 */18class PeachScriptRoundTripTest {1920    private fun login(i: Int) = VaultEntry(21        id = java.util.UUID.nameUUIDFromBytes("acct-$i".toByteArray()).toString(), type = "login", title = "Acct $i",22        username = "user$i", password = "pw-$i-Ω", url = "https://site$i.example.com",23        notes = "note $i", createdAt = 1_500_000_000_000 + i, modifiedAt = 1_500_000_000_000 + i,24    )2526    // ── PeachScript binary envelope ───────────────────────────────────────────2728    @Test fun large_vault_round_trips() {29        val input = (0 until 500).map { login(it) }30        val out = PeachPayloadCodec.buildPayload(input, "backup-pass")31        val back = PeachPayloadCodec.parsePayload(out, "backup-pass")32        assertEquals(500, back.size)33        assertEquals("Acct 0", back.first().title)34        assertEquals("Acct 499", back.last().title)35        assertEquals("pw-250-Ω", back[250].password)36    }3738    @Test fun tampered_ciphertext_fails() {39        val out = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw").copyOf()40        // Flip a byte well past the salt(16)+nonce(12) header, inside the ciphertext.41        val i = 40.coerceAtMost(out.size - 1)42        out[i] = (out[i].toInt() xor 0xFF).toByte()43        var threw = false44        try { PeachPayloadCodec.parsePayload(out, "pw") } catch (_: Exception) { threw = true }45        assertTrue("tampered ciphertext must fail auth", threw)46    }4748    @Test fun size_bucket_padding_hides_exact_entry_count() {49        // An empty vault and a small one-entry vault land in the same size bucket,50        // so the envelope size does not reveal how many entries are inside.51        val empty = PeachPayloadCodec.buildPayload(emptyList(), "pw")52        val one = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw")53        assertEquals(empty.size, one.size)54    }5556    @Test fun each_export_uses_a_fresh_nonce() {57        val a = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw")58        val b = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw")59        assertFalse("salt/nonce randomization makes envelopes differ", a.contentEquals(b))60    }6162    // ── PeachScript text wrapper ──────────────────────────────────────────────6364    @Test fun text_export_is_peachscript_and_round_trips() {65        val text = PeachScriptExport.buildEncrypted(listOf(login(1), login(2)), "bp")66        assertTrue(PeachScriptImport.looksLikePeachScript(text))67        val r = PeachScriptImport.decryptAndParse(text, "bp")68        assertTrue(r is PeachScriptImport.DecryptResult.Success)69        r as PeachScriptImport.DecryptResult.Success70        assertEquals(2, r.entries.size)71    }7273    @Test fun text_export_wrong_password_does_not_yield_entries() {74        val text = PeachScriptExport.buildEncrypted(listOf(login(1)), "right")75        val r = PeachScriptImport.decryptAndParse(text, "wrong")76        assertFalse(r is PeachScriptImport.DecryptResult.Success)77    }7879    // ── CSV export ────────────────────────────────────────────────────────────8081    @Test fun csv_header_columns_are_fixed_order() {82        val csv = CsvExport.toCsv(emptyList())83        assertEquals("title,username,password,url,notes", csv.lineSequence().first())84    }8586    @Test fun csv_uses_email_when_username_blank() {87        val e = VaultEntry(id = "", title = "X", type = "login", email = "[email protected]",88            createdAt = 0, modifiedAt = 0)89        val csv = CsvExport.toCsv(listOf(e))90        assertTrue(csv.contains("[email protected]"))91    }9293    @Test fun csv_formula_injection_is_guarded() {94        assertEquals("'=SUM(A1)", CsvExport.encodeField("=SUM(A1)"))95        assertEquals("'+1", CsvExport.encodeField("+1"))96        assertEquals("'-2", CsvExport.encodeField("-2"))97        assertEquals("'@cmd", CsvExport.encodeField("@cmd"))98    }99100    @Test fun csv_plain_field_is_unquoted() {101        assertEquals("hello", CsvExport.encodeField("hello"))102    }103104    @Test fun csv_quotes_fields_with_commas_and_quotes() {105        assertEquals("\"a,b\"", CsvExport.encodeField("a,b"))106        assertEquals("\"he\"\"llo\"", CsvExport.encodeField("he\"llo"))107    }108109    @Test fun csv_injection_guard_lands_inside_quotes_when_also_special() {110        // "=a,b" triggers both the guard (leading =) and RFC quoting (comma).111        assertEquals("\"'=a,b\"", CsvExport.encodeField("=a,b"))112    }113114    // ── CSV round-trip (export → re-import) ────────────────────────────────────115116    @Test fun csv_round_trips_login_fields() {117        val entries = listOf(login(1), login(2))118        val csv = CsvExport.toCsv(entries)119        val parsed = CsvImport.detectAndParse(csv)120        assertTrue(parsed is ImportParseResult.Success)121        parsed as ImportParseResult.Success122        assertEquals(2, parsed.entries.size)123        val first = parsed.entries.first()124        assertEquals("Acct 1", first.title)125        assertEquals("user1", first.username)126        assertEquals("pw-1-Ω", first.password)127        assertEquals("https://site1.example.com", first.url)128        assertEquals("note 1", first.notes)129    }130131    @Test fun csv_round_trips_fields_needing_quotes() {132        val tricky = VaultEntry(133            id = "", title = "Comma, Inc", type = "login", username = "u",134            password = "p\"q", url = "https://x.example.com", notes = "line1\nline2",135            createdAt = 0, modifiedAt = 0,136        )137        val csv = CsvExport.toCsv(listOf(tricky))138        val parsed = CsvImport.detectAndParse(csv) as ImportParseResult.Success139        val e = parsed.entries.single()140        assertEquals("Comma, Inc", e.title)141        assertEquals("p\"q", e.password)142        assertEquals("line1\nline2", e.notes)143    }144}