package com.peachpasswords.vault import com.peachpasswords.ui.vault.CsvExport import com.peachpasswords.ui.vault.CsvImport import com.peachpasswords.ui.vault.ImportParseResult import com.peachpasswords.ui.vault.PeachScriptExport import com.peachpasswords.ui.vault.PeachScriptImport import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** * PeachScript + CSV round-trip suite (brief Suite 7): large-vault fidelity, * ciphertext tamper detection, size-bucket padding, and CSV column/injection * behavior. The cross-platform browser KAT lives in [PeachScriptTest]. */ class PeachScriptRoundTripTest { private fun login(i: Int) = VaultEntry( id = java.util.UUID.nameUUIDFromBytes("acct-$i".toByteArray()).toString(), type = "login", title = "Acct $i", username = "user$i", password = "pw-$i-Ω", url = "https://site$i.example.com", notes = "note $i", createdAt = 1_500_000_000_000 + i, modifiedAt = 1_500_000_000_000 + i, ) // ── PeachScript binary envelope ─────────────────────────────────────────── @Test fun large_vault_round_trips() { val input = (0 until 500).map { login(it) } val out = PeachPayloadCodec.buildPayload(input, "backup-pass") val back = PeachPayloadCodec.parsePayload(out, "backup-pass") assertEquals(500, back.size) assertEquals("Acct 0", back.first().title) assertEquals("Acct 499", back.last().title) assertEquals("pw-250-Ω", back[250].password) } @Test fun tampered_ciphertext_fails() { val out = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw").copyOf() // Flip a byte well past the salt(16)+nonce(12) header, inside the ciphertext. val i = 40.coerceAtMost(out.size - 1) out[i] = (out[i].toInt() xor 0xFF).toByte() var threw = false try { PeachPayloadCodec.parsePayload(out, "pw") } catch (_: Exception) { threw = true } assertTrue("tampered ciphertext must fail auth", threw) } @Test fun size_bucket_padding_hides_exact_entry_count() { // An empty vault and a small one-entry vault land in the same size bucket, // so the envelope size does not reveal how many entries are inside. val empty = PeachPayloadCodec.buildPayload(emptyList(), "pw") val one = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw") assertEquals(empty.size, one.size) } @Test fun each_export_uses_a_fresh_nonce() { val a = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw") val b = PeachPayloadCodec.buildPayload(listOf(login(1)), "pw") assertFalse("salt/nonce randomization makes envelopes differ", a.contentEquals(b)) } // ── PeachScript text wrapper ────────────────────────────────────────────── @Test fun text_export_is_peachscript_and_round_trips() { val text = PeachScriptExport.buildEncrypted(listOf(login(1), login(2)), "bp") assertTrue(PeachScriptImport.looksLikePeachScript(text)) val r = PeachScriptImport.decryptAndParse(text, "bp") assertTrue(r is PeachScriptImport.DecryptResult.Success) r as PeachScriptImport.DecryptResult.Success assertEquals(2, r.entries.size) } @Test fun text_export_wrong_password_does_not_yield_entries() { val text = PeachScriptExport.buildEncrypted(listOf(login(1)), "right") val r = PeachScriptImport.decryptAndParse(text, "wrong") assertFalse(r is PeachScriptImport.DecryptResult.Success) } // ── CSV export ──────────────────────────────────────────────────────────── @Test fun csv_header_columns_are_fixed_order() { val csv = CsvExport.toCsv(emptyList()) assertEquals("title,username,password,url,notes", csv.lineSequence().first()) } @Test fun csv_uses_email_when_username_blank() { val e = VaultEntry(id = "", title = "X", type = "login", email = "e@example.com", createdAt = 0, modifiedAt = 0) val csv = CsvExport.toCsv(listOf(e)) assertTrue(csv.contains("e@example.com")) } @Test fun csv_formula_injection_is_guarded() { assertEquals("'=SUM(A1)", CsvExport.encodeField("=SUM(A1)")) assertEquals("'+1", CsvExport.encodeField("+1")) assertEquals("'-2", CsvExport.encodeField("-2")) assertEquals("'@cmd", CsvExport.encodeField("@cmd")) } @Test fun csv_plain_field_is_unquoted() { assertEquals("hello", CsvExport.encodeField("hello")) } @Test fun csv_quotes_fields_with_commas_and_quotes() { assertEquals("\"a,b\"", CsvExport.encodeField("a,b")) assertEquals("\"he\"\"llo\"", CsvExport.encodeField("he\"llo")) } @Test fun csv_injection_guard_lands_inside_quotes_when_also_special() { // "=a,b" triggers both the guard (leading =) and RFC quoting (comma). assertEquals("\"'=a,b\"", CsvExport.encodeField("=a,b")) } // ── CSV round-trip (export → re-import) ──────────────────────────────────── @Test fun csv_round_trips_login_fields() { val entries = listOf(login(1), login(2)) val csv = CsvExport.toCsv(entries) val parsed = CsvImport.detectAndParse(csv) assertTrue(parsed is ImportParseResult.Success) parsed as ImportParseResult.Success assertEquals(2, parsed.entries.size) val first = parsed.entries.first() assertEquals("Acct 1", first.title) assertEquals("user1", first.username) assertEquals("pw-1-Ω", first.password) assertEquals("https://site1.example.com", first.url) assertEquals("note 1", first.notes) } @Test fun csv_round_trips_fields_needing_quotes() { val tricky = VaultEntry( id = "", title = "Comma, Inc", type = "login", username = "u", password = "p\"q", url = "https://x.example.com", notes = "line1\nline2", createdAt = 0, modifiedAt = 0, ) val csv = CsvExport.toCsv(listOf(tricky)) val parsed = CsvImport.detectAndParse(csv) as ImportParseResult.Success val e = parsed.entries.single() assertEquals("Comma, Inc", e.title) assertEquals("p\"q", e.password) assertEquals("line1\nline2", e.notes) } }