Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import de.davis.keygo.core.item.data.local.entity.LoginEntity
import de.davis.keygo.core.item.data.local.pojo.LightweightLogin
import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection
import de.davis.keygo.core.item.data.local.pojo.LoginProjection
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
Expand All @@ -21,6 +22,17 @@ internal interface LoginDao {
@Query("SELECT * FROM login")
fun observeAll(): Flow<List<LoginProjection>>

@Query(
"""
SELECT l.id,
EXISTS (SELECT 1 FROM password p WHERE p.login_id = l.id) AS has_password,
EXISTS (SELECT 1 FROM passkey pk WHERE pk.login_id = l.id) AS has_passkey,
EXISTS (SELECT 1 FROM totp t WHERE t.login_id = l.id) AS has_totp
FROM login l
"""
)
fun observeCredentials(): Flow<List<LoginCredentialsProjection>>

@Transaction
@Query("SELECT * FROM login WHERE id = :id")
fun observeById(id: ItemId): Flow<LoginProjection?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,6 @@ internal abstract class TagDao {
@Query("SELECT * FROM tag WHERE EXISTS (SELECT 1 FROM tag_cross_ref WHERE tag_id = tag.id)")
abstract fun observeAllTags(): Flow<List<TagEntity>>

@Query(
"""
SELECT DISTINCT x.item_id FROM tag_cross_ref x
JOIN tag t ON t.id = x.tag_id
WHERE t.normalized IN (:normalizedValues)
"""
)
abstract fun observeItemIdsWithAnyTag(normalizedValues: Set<String>): Flow<List<ItemId>>

@Query(
"""
SELECT x.item_id AS itemId, t.value AS value FROM tag_cross_ref x
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package de.davis.keygo.core.item.data.local.pojo

import androidx.room3.ColumnInfo
import de.davis.keygo.core.item.domain.alias.ItemId

internal data class LoginCredentialsProjection(
val id: ItemId,
@ColumnInfo(name = "has_password")
val hasPassword: Boolean,
@ColumnInfo(name = "has_passkey")
val hasPasskey: Boolean,
@ColumnInfo(name = "has_totp")
val hasTotp: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import de.davis.keygo.core.item.data.local.entity.LoginEntity
import de.davis.keygo.core.item.data.local.entity.TagEntity
import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity
import de.davis.keygo.core.item.data.local.pojo.LightweightLogin
import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection
import de.davis.keygo.core.item.data.local.pojo.LoginProjection
import de.davis.keygo.core.item.domain.model.CredentialType
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.PasskeyRef
import de.davis.keygo.core.item.domain.model.PasswordCredential
Expand Down Expand Up @@ -55,3 +57,9 @@ internal fun LightweightLogin.toDomain(): LiteLogin = LiteLogin(
hasPassword = hasPassword,
domains = domains.map(DomainInfoEntity::toDomain),
)

internal fun LoginCredentialsProjection.toCredentialTypes(): Set<CredentialType> = buildSet {
if (hasPassword) add(CredentialType.Password)
if (hasPasskey) add(CredentialType.Passkey)
if (hasTotp) add(CredentialType.Totp)
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ internal class ItemRepositoryImpl(
override fun observeAllTags(): Flow<List<Tag>> =
tagDao.observeAllTags().map { it.map(TagEntity::toDomain) }

override fun observeItemIdsForTags(tags: Set<Tag>): Flow<Set<ItemId>> =
tagDao.observeItemIdsWithAnyTag(tags.mapTo(mutableSetOf()) { it.normalized })
.map { it.toSet() }

override fun observeTagsByItem(): Flow<Map<ItemId, Set<Tag>>> =
tagDao.observeItemTags().map { rows ->
rows.groupBy { it.itemId }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import de.davis.keygo.core.item.data.local.dao.TagDao
import de.davis.keygo.core.item.data.local.dao.TotpDao
import de.davis.keygo.core.item.data.local.pojo.LightweightLogin
import de.davis.keygo.core.item.data.local.pojo.LoginProjection
import de.davis.keygo.core.item.data.mapper.toCredentialTypes
import de.davis.keygo.core.item.data.mapper.toData
import de.davis.keygo.core.item.data.mapper.toDomain
import de.davis.keygo.core.item.data.mapper.toDomainInfoEntities
Expand All @@ -17,6 +18,7 @@ import de.davis.keygo.core.item.data.mapper.toPasswordEntity
import de.davis.keygo.core.item.data.mapper.toTagEntities
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
import de.davis.keygo.core.item.domain.model.CredentialType
import de.davis.keygo.core.item.domain.model.Item
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.Passkey
Expand Down Expand Up @@ -106,4 +108,9 @@ internal class LoginRepositoryImpl(
passwordDao.observeScores().map { entries ->
entries.associate { it.id to it.passwordScore }
}

override fun observeCredentialTypes(): Flow<Map<ItemId, Set<CredentialType>>> =
loginDao.observeCredentials().map { rows ->
rows.associate { it.id to it.toCredentialTypes() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package de.davis.keygo.core.item.domain.model
import java.time.YearMonth

enum class CardExpiryStatus {
Expired,
ExpiresThisMonth,
ExpiresNextMonth,
Expired;
ExpiresNextMonth;

companion object {
fun of(expiration: YearMonth, today: YearMonth): CardExpiryStatus? = when {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package de.davis.keygo.core.item.domain.model

enum class CredentialType { Password, Passkey, Totp }
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ interface ItemRepository {

fun observeAllTags(): Flow<List<Tag>>

fun observeItemIdsForTags(tags: Set<Tag>): Flow<Set<ItemId>>

fun observeTagsByItem(): Flow<Map<ItemId, Set<Tag>>>

fun observeLiteVaultItems(vaultId: VaultId? = null): Flow<List<LiteItem>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package de.davis.keygo.core.item.domain.repository

import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
import de.davis.keygo.core.item.domain.model.CredentialType
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.Passkey
import de.davis.keygo.core.item.domain.model.PasswordScore
Expand Down Expand Up @@ -44,4 +45,6 @@ interface LoginRepository {
fun observeLogins(): Flow<List<Login>>

fun observePasswordScores(): Flow<Map<ItemId, PasswordScore>>

fun observeCredentialTypes(): Flow<Map<ItemId, Set<CredentialType>>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package de.davis.keygo.core.item.data.local.dao

import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import de.davis.keygo.core.item.data.local.datasource.ItemDatabase
import de.davis.keygo.core.item.data.local.entity.ItemEntity
import de.davis.keygo.core.item.data.local.entity.LoginEntity
import de.davis.keygo.core.item.data.local.entity.Timestamp
import de.davis.keygo.core.item.data.local.entity.VaultEntity
import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity
import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity
import de.davis.keygo.core.item.data.local.entity.credential.TotpEntity
import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
import de.davis.keygo.core.item.domain.alias.newItemId
import de.davis.keygo.core.item.domain.alias.newVaultId
import de.davis.keygo.core.item.domain.model.EncryptedPayload
import de.davis.keygo.core.item.domain.model.PasswordScore
import de.davis.keygo.core.item.domain.model.Vault
import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation

internal class LoginDaoTest {

private lateinit var db: ItemDatabase
private lateinit var loginDao: LoginDao

private val vaultId: VaultId = newVaultId()
private val payload = EncryptedPayload(byteArrayOf(1), byteArrayOf(2))

@BeforeTest
fun setUp() = runBlocking {
db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), ItemDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
loginDao = db.loginDao()
db.vaultDao().insert(
VaultEntity(
id = vaultId,
name = "Vault",
icon = Vault.Icon.Person,
createdAt = 0L,
keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()),
)
)
}

@AfterTest
fun tearDown() {
db.close()
}

private suspend fun insertLogin(id: ItemId = newItemId()): ItemId {
db.itemDao().upsert(
ItemEntity(
id = id,
vaultId = vaultId,
name = "Login",
note = null,
itemType = VaultItemType.Login,
pinned = false,
keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()),
timestamp = Timestamp(createdAt = 0L, modifiedAt = null),
)
)
loginDao.upsert(LoginEntity(id = id, username = null))
return id
}

private suspend fun addPassword(loginId: ItemId) = db.passwordDao().upsert(
PasswordEntity(loginId = loginId, passwordScore = PasswordScore.Strong, password = payload)
)

private suspend fun addPasskey(loginId: ItemId, credentialId: Byte) =
db.passkeyDao().insertPasskey(
PasskeyEntity(
credentialId = byteArrayOf(credentialId),
loginId = loginId,
rp = "example.com",
privateKey = payload,
name = "user",
displayName = "User",
)
)

private suspend fun addTotp(loginId: ItemId) = db.totpDao().upsert(
TotpEntity(
loginId = loginId,
secret = payload,
issuer = null,
accountName = null,
algorithm = "SHA1",
digits = 6,
period = 30,
)
)

@Test
fun `observeCredentials reports which credentials each login holds`() = runTest {
val bare = insertLogin()
val passwordOnly = insertLogin().also { addPassword(it) }
val everything = insertLogin().also {
addPassword(it)
addPasskey(it, credentialId = 1)
addPasskey(it, credentialId = 2)
addTotp(it)
}

val rows = loginDao.observeCredentials().first().associateBy { it.id }

assertEquals(
mapOf(
bare to LoginCredentialsProjection(bare, false, false, false),
passwordOnly to LoginCredentialsProjection(passwordOnly, true, false, false),
everything to LoginCredentialsProjection(everything, true, true, true),
),
rows,
)
}

@Test
fun `observeCredentials drops a credential once its row is deleted`() = runTest {
val login = insertLogin().also { addTotp(it) }
assertEquals(true, loginDao.observeCredentials().first().single().hasTotp)

db.totpDao().delete(login)

assertEquals(false, loginDao.observeCredentials().first().single().hasTotp)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,53 +134,6 @@ internal class TagDaoTest {
assertNull(tagDao.findIdByNormalized("temp"))
}

@Test
fun `observeItemIdsWithAnyTag returns ids of items having any of the values`() = runTest {
val a = insertItem()
val b = insertItem()
val c = insertItem()
tagDao.syncTags(a, setOf(tag("Bank")))
tagDao.syncTags(b, setOf(tag("Work"), tag("Bank")))
tagDao.syncTags(c, setOf(tag("Personal")))

val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank")).first().toSet()

assertEquals(setOf(a, b), ids)
}

@Test
fun `observeItemIdsWithAnyTag unions multiple values`() = runTest {
val a = insertItem()
val b = insertItem()
val c = insertItem()
tagDao.syncTags(a, setOf(tag("Bank")))
tagDao.syncTags(b, setOf(tag("Work")))
tagDao.syncTags(c, setOf(tag("Personal")))

val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank", "work")).first().toSet()

assertEquals(setOf(a, b), ids)
}

@Test
fun `observeItemIdsWithAnyTag returns empty when no tag matches`() = runTest {
val a = insertItem()
tagDao.syncTags(a, setOf(tag("Bank")))

assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("nope")).first())
}

@Test
fun `observeItemIdsWithAnyTag matches the stored normalized form regardless of display casing`() =
runTest {
val a = insertItem()
tagDao.syncTags(a, setOf(tag("Bank")))

// Caller is required to pass normalized (lower-cased, trimmed) values.
assertEquals(listOf(a), tagDao.observeItemIdsWithAnyTag(setOf("bank")).first())
assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("Bank")).first())
}

@Test
fun `observeItemTags emits one row per item-tag pair using display value`() = runTest {
val a = insertItem()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,6 @@ class FakeItemRepository(
override fun observeAllTags(): Flow<List<Tag>> =
allStores.map { store -> store.values.flatMap { item -> item.tags }.distinct() }

override fun observeItemIdsForTags(tags: Set<Tag>): Flow<Set<ItemId>> {
val targetNormalized = tags.mapTo(mutableSetOf()) { it.normalized }
return allStores.map { store ->
store.values
.filter { item -> item.tags.any { it.normalized in targetNormalized } }
.map { it.id }
.toSet()
}
}

override fun observeTagsByItem(): Flow<Map<ItemId, Set<Tag>>> =
allStores.map { store ->
store.values
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package de.davis.keygo.core.item

import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
import de.davis.keygo.core.item.domain.model.CredentialType
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.Passkey
import de.davis.keygo.core.item.domain.model.PasswordScore
Expand Down Expand Up @@ -115,4 +116,15 @@ class FakeLoginRepository : LoginRepository {
.mapNotNull { login -> login.passwordCredential?.let { login.id to it.score } }
.toMap()
}

override fun observeCredentialTypes(): Flow<Map<ItemId, Set<CredentialType>>> =
store.map { logins ->
logins.values.associate { login ->
login.id to buildSet {
if (login.passwordCredential != null) add(CredentialType.Password)
if (login.passkeys.isNotEmpty()) add(CredentialType.Passkey)
if (login.totp != null) add(CredentialType.Totp)
}
}
}
}
Loading
Loading