VaultRepositoryTest.kt

13.3 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test910/** In-memory [VaultStorage] so the repository can be exercised without Android. */11private class FakeStorage : VaultStorage {12    var slotA: StoredVault? = null13    var slotB: String? = null14    private var ver: Long = 015    private var settings = VaultSettings()16    override fun loadSlotA() = slotA17    override fun hasVault() = slotA != null18    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }20    var decoy: StoredVault? = null21    override fun loadDecoySlot() = decoy22    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }23    override fun hasDecoySlot() = decoy != null24    override fun clearDecoySlot() { decoy = null }25    override fun version() = ver26    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }27    override fun loadSettings() = settings28    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }29    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }30}3132class VaultRepositoryTest {33    private fun login(title: String, password: String = "pw") =34        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)3536    @Test fun create_persists_and_unlocks() = runBlocking {37        val storage = FakeStorage()38        val repo = VaultRepository(storage)39        assertFalse(repo.hasVault())40        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))41        assertTrue(repo.hasVault())42        assertTrue(repo.isUnlocked.value)43        // Both slots written, version stamped.44        assertTrue(storage.slotA != null && storage.slotB != null)45        assertTrue(repo.getVaultVersion() > 0)46    }4748    @Test fun entries_persist_across_lock_unlock() = runBlocking {49        val storage = FakeStorage()50        val repo = VaultRepository(storage)51        repo.createVault("pw")52        val added = repo.addEntry(login("GitHub"))53        assertTrue(added.id.isNotBlank())54        assertEquals(1, repo.getEntries().size)55        repo.lock()56        assertFalse(repo.isUnlocked.value)57        assertEquals(UnlockResult.Success, repo.unlock("pw"))58        assertEquals(1, repo.getEntries().size)59        assertEquals("GitHub", repo.getEntries().first().title)60    }6162    @Test fun wrong_password_is_rejected() = runBlocking {63        val storage = FakeStorage()64        val repo = VaultRepository(storage)65        repo.createVault("right")66        repo.lock()67        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))68        assertFalse(repo.isUnlocked.value)69    }7071    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {72        val repo = VaultRepository(FakeStorage())73        repo.createVault("pw")74        val e = repo.addEntry(login("Bank"))75        repo.deleteEntry(e.id)76        assertEquals(0, repo.getEntries().size)77        assertEquals(1, repo.getTombstones().size)78        assertEquals(e.id, repo.getTombstones().first().id)79        repo.restoreEntry(e.id)80        assertEquals(1, repo.getEntries().size)81        repo.deleteEntry(e.id)82        repo.purgeEntry(e.id)83        assertEquals(0, repo.getTombstones().size)84    }8586    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {87        val repo = VaultRepository(FakeStorage())88        repo.createVault("pw")89        val e = repo.addEntry(login("Mail", password = "old"))90        val firstChanged = e.passwordChangedAt!!91        Thread.sleep(2)92        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))93        assertEquals(firstChanged, metaOnly.passwordChangedAt)94        Thread.sleep(2)95        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))96        assertTrue(pwChange.passwordChangedAt!! > firstChanged)97    }9899    @Test fun password_history_captures_old_password_newest_first() = runBlocking {100        val repo = VaultRepository(FakeStorage())101        repo.createVault("pw")102        val e = repo.addEntry(login("H", password = "first"))103        assertNull(e.passwordHistory)104        val v2 = repo.updateEntry(e.copy(password = "second"))105        val v3 = repo.updateEntry(v2.copy(password = "third"))106        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })107        assertEquals("third", v3.password)108    }109110    @Test fun password_history_ignores_metadata_only_update() = runBlocking {111        val repo = VaultRepository(FakeStorage())112        repo.createVault("pw")113        val e = repo.addEntry(login("M", password = "keepme"))114        val r = repo.updateEntry(e.copy(title = "Renamed"))115        assertNull(r.passwordHistory)116    }117118    @Test fun password_history_item_carries_prior_age() = runBlocking {119        val repo = VaultRepository(FakeStorage())120        repo.createVault("pw")121        val e = repo.addEntry(login("S", password = "v1"))122        val firstChanged = e.passwordChangedAt!!123        Thread.sleep(2)124        val r = repo.updateEntry(e.copy(password = "v2"))125        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)126    }127128    @Test fun password_history_skips_identical_resave() = runBlocking {129        val repo = VaultRepository(FakeStorage())130        repo.createVault("pw")131        val e = repo.addEntry(login("N", password = "same"))132        val r = repo.updateEntry(e.copy(password = "same"))133        assertNull(r.passwordHistory)134    }135136    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {137        val repo = VaultRepository(FakeStorage())138        repo.createVault("pw")139        val e = repo.addEntry(login("R", password = "old"))140        val v2 = repo.updateEntry(e.copy(password = "new"))141        val restored = repo.updateEntry(v2.copy(password = "old"))142        assertEquals("old", restored.password)143        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })144    }145146    @Test fun password_history_persists_across_lock_unlock() = runBlocking {147        val storage = FakeStorage()148        val repo = VaultRepository(storage)149        repo.createVault("pw")150        val e = repo.addEntry(login("P", password = "p1"))151        repo.updateEntry(e.copy(password = "p2"))152        repo.lock()153        assertEquals(UnlockResult.Success, repo.unlock("pw"))154        val reloaded = repo.getEntries().first { it.id == e.id }155        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })156    }157158    @Test fun password_history_caps_at_fifty() = runBlocking {159        val repo = VaultRepository(FakeStorage())160        repo.createVault("pw")161        var e = repo.addEntry(login("C", password = "pw-0"))162        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))163        val hist = e.passwordHistory!!164        assertEquals(50, hist.size)165        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.166        assertEquals("pw-54", hist.first().password)167        assertEquals("pw-5", hist.last().password)168    }169170    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {171        val repo = VaultRepository(FakeStorage())172        repo.createVault("pw")173        repo.addEntry(login("Local"))174        val versionBefore = repo.getVaultVersion()175        val remote = VaultEntry(176            id = "remote-1", title = "Remote", type = "login", password = "x",177            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,178        )179        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())180        assertEquals(1, applied)181        assertEquals(2, repo.getEntries().size)182        // The whole point: a sync apply must NOT advance the local version.183        assertEquals(versionBefore, repo.getVaultVersion())184    }185186    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {187        val repo = VaultRepository(FakeStorage())188        repo.createVault("pw")189        val local = repo.addEntry(login("Shared"))190        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)191        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))192        assertEquals(1, applied)193        assertEquals(0, repo.getEntries().size)194    }195196    @Test fun change_password_reencrypts_under_new_password() = runBlocking {197        val storage = FakeStorage()198        val repo = VaultRepository(storage)199        repo.createVault("old-pass")200        repo.addEntry(login("Acct"))201        assertTrue(repo.changePassword("old-pass", "new-pass"))202        repo.lock()203        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))204        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))205        assertEquals(1, repo.getEntries().size)206    }207208    // ---- A1: trash purge + retention auto-purge ---------------------------209210    @Test fun purge_deleted_entries_empties_trash() = runBlocking {211        val repo = VaultRepository(FakeStorage())212        repo.createVault("pw")213        val a = repo.addEntry(login("A"))214        val b = repo.addEntry(login("B"))215        repo.addEntry(login("C"))216        repo.deleteEntry(a.id)217        repo.deleteEntry(b.id)218        assertEquals(2, repo.getDeletedEntries().size)219        assertEquals(2, repo.purgeDeletedEntries())220        assertEquals(0, repo.getDeletedEntries().size)221        assertEquals(1, repo.getEntries().size)222    }223224    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {225        val storage = FakeStorage()226        val repo = VaultRepository(storage)227        repo.createVault("pw")228        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))229        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).230        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",231            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)232        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())233        assertEquals(1, repo.getDeletedEntries().size)234        repo.lock()235        assertEquals(UnlockResult.Success, repo.unlock("pw"))236        assertEquals(0, repo.getDeletedEntries().size)237    }238239    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {240        val storage = FakeStorage()241        val repo = VaultRepository(storage)242        repo.createVault("pw") // default trashRetentionDays == null → "Never"243        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",244            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)245        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())246        repo.lock()247        assertEquals(UnlockResult.Success, repo.unlock("pw"))248        assertEquals(1, repo.getDeletedEntries().size)249    }250251    // ---- A2: tags ---------------------------------------------------------252253    @Test fun tags_distinct_sorted_lowercased() = runBlocking {254        val repo = VaultRepository(FakeStorage())255        repo.createVault("pw")256        repo.addEntry(login("A").copy(tags = listOf("work", "email")))257        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))258        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())259    }260261    @Test fun get_active_entries_filters_by_tag() = runBlocking {262        val repo = VaultRepository(FakeStorage())263        repo.createVault("pw")264        repo.addEntry(login("A").copy(tags = listOf("work")))265        repo.addEntry(login("B").copy(tags = listOf("home")))266        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))267        repo.deleteEntry(gone.id)268        val work = repo.getActiveEntries("work")269        assertEquals(1, work.size)270        assertEquals("A", work.first().title)271        assertEquals(2, repo.getActiveEntries(null).size)272    }273274    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {275        val storage = FakeStorage()276        val repo = VaultRepository(storage)277        repo.createVault("pw")278        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))279        repo.lock()280        repo.unlock("pw")281        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)282    }283284    // ---- A5: recovery-word metadata ---------------------------------------285286    @Test fun recovery_words_set_verify_and_persist() = runBlocking {287        val storage = FakeStorage()288        val repo = VaultRepository(storage)289        repo.createVault("pw")290        assertFalse(repo.isRecoveryWordsSet())291        val words = rootKeyToWords(ByteArray(32) { it.toByte() })292        assertTrue(repo.setRecoveryWords(words))293        assertTrue(repo.isRecoveryWordsSet())294        assertTrue(repo.verifyRecoveryWords(words))295        assertFalse(repo.verifyRecoveryWords(words.reversed()))296        repo.lock()297        assertEquals(UnlockResult.Success, repo.unlock("pw"))298        assertTrue(repo.isRecoveryWordsSet())299        assertTrue(repo.verifyRecoveryWords(words))300    }301302    @Test fun destroy_clears_everything() = runBlocking {303        val storage = FakeStorage()304        val repo = VaultRepository(storage)305        repo.createVault("pw")306        repo.addEntry(login("X"))307        repo.destroyVault()308        assertFalse(repo.isUnlocked.value)309        assertNull(storage.slotA)310        assertFalse(repo.hasVault())311    }312}