Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b405ef4e | |||
| 829118233b | |||
| 58f1e6fb82 | |||
| 58430a0903 | |||
| c5fcf27367 | |||
| 09e7224c0a | |||
| 34cd20786b | |||
| bf9fcf7b0b | |||
| 4eaf1480a2 | |||
| 4a29d653bc | |||
| 77f71973a7 | |||
| 85600bfaed | |||
| 42e4b5c78c | |||
| b265f3fadb | |||
| e8ccab4f00 | |||
| 76bf0f16ec | |||
| 52a95e2b54 | |||
| 08f24aadd5 | |||
| 732c5b73c0 | |||
| f25596563c | |||
| b5ef793513 |
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
|
||||
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
||||
abgeschlossenen Dokumentationen PDF-Dateien.
|
||||
|
||||
> **Projektstatus:** beta1.1
|
||||
> **Projektstatus:** beta1.4
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
@@ -398,12 +398,12 @@ werden kann.
|
||||
|
||||
### Phase 6 -- Qualität und Stabilität
|
||||
|
||||
- [ ] Unit Tests
|
||||
- [ ] Room Tests
|
||||
- [ ] UI Tests
|
||||
- [ ] Fehlerbehandlung
|
||||
- [x] Unit Tests
|
||||
- [x] Room Tests
|
||||
- [x] UI Tests
|
||||
- [x] Fehlerbehandlung
|
||||
- [ ] Tablet-Optimierung
|
||||
- [ ] Datenschutz-Review
|
||||
- [x] Datenschutz-Review
|
||||
|
||||
### Phase 7 -- Multi-Device-Synchronisation
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "de.medapp.wunddoku",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 26
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.blockStateEntity
|
||||
import de.medapp.wunddoku.testutil.checkItemStateEntity
|
||||
import de.medapp.wunddoku.testutil.expectThrows
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */
|
||||
class BlockAndCheckItemDaoTest : RoomTestBase() {
|
||||
|
||||
// --- BlockStateDao -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest {
|
||||
seedDocumentation()
|
||||
val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L)
|
||||
|
||||
blockStateDao.insertAll(listOf(block))
|
||||
|
||||
assertEquals(block, blockStateDao.getById("b-1"))
|
||||
assertNull(blockStateDao.getById("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3),
|
||||
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1),
|
||||
blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2),
|
||||
blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id })
|
||||
assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id })
|
||||
assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest {
|
||||
seedDocumentation()
|
||||
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart")))
|
||||
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart")))
|
||||
}
|
||||
|
||||
assertEquals(1, countRows("block_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"),
|
||||
blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, countRows("block_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest {
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht")))
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("block_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest {
|
||||
blockStateDao.insertAll(emptyList())
|
||||
|
||||
assertEquals(0, countRows("block_states"))
|
||||
}
|
||||
|
||||
// --- CheckItemStateDao ---------------------------------------------------------------
|
||||
|
||||
private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") {
|
||||
if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId)
|
||||
blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest {
|
||||
seedBlock()
|
||||
val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1)
|
||||
val full = checkItemStateEntity(
|
||||
id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true,
|
||||
freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC",
|
||||
)
|
||||
|
||||
checkItemStateDao.insertAll(listOf(plain, full))
|
||||
|
||||
val loaded = checkItemStateDao.getForBlockState("b-1")
|
||||
assertEquals(listOf(plain, full), loaded)
|
||||
assertFalse(loaded[0].checked)
|
||||
assertFalse(loaded[0].isFreeText)
|
||||
assertEquals("", loaded[0].freeTextValue)
|
||||
assertFalse(loaded[0].isStaticHint)
|
||||
assertEquals("", loaded[0].inputSpec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest {
|
||||
seedBlock(id = "b-1", definitionId = "a")
|
||||
seedBlock(id = "b-2", definitionId = "b")
|
||||
checkItemStateDao.insertAll(
|
||||
listOf(
|
||||
checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3),
|
||||
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1),
|
||||
checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2),
|
||||
checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id })
|
||||
assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id })
|
||||
assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest {
|
||||
seedBlock()
|
||||
val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch")
|
||||
checkItemStateDao.insertAll(listOf(item))
|
||||
|
||||
checkItemStateDao.setChecked("c-1", true)
|
||||
assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single())
|
||||
|
||||
checkItemStateDao.setChecked("c-1", false)
|
||||
assertEquals(item, checkItemStateDao.getForBlockState("b-1").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest {
|
||||
seedBlock()
|
||||
checkItemStateDao.insertAll(
|
||||
listOf(
|
||||
checkItemStateEntity(id = "c-1", definitionId = "a", order = 1),
|
||||
checkItemStateEntity(id = "c-2", definitionId = "b", order = 2),
|
||||
)
|
||||
)
|
||||
|
||||
checkItemStateDao.setChecked("c-1", true)
|
||||
|
||||
val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id }
|
||||
assertTrue(byId.getValue("c-1").checked)
|
||||
assertFalse(byId.getValue("c-2").checked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest {
|
||||
seedBlock()
|
||||
val item = checkItemStateEntity()
|
||||
checkItemStateDao.insertAll(listOf(item))
|
||||
|
||||
checkItemStateDao.setChecked("unbekannt", true)
|
||||
checkItemStateDao.updateFreeTextValue("unbekannt", "x")
|
||||
|
||||
assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest {
|
||||
seedBlock()
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true)))
|
||||
|
||||
checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand")
|
||||
|
||||
val loaded = checkItemStateDao.getForBlockState("b-1").single()
|
||||
assertEquals("Rötung am Wundrand", loaded.freeTextValue)
|
||||
assertTrue(loaded.checked)
|
||||
assertTrue(loaded.isFreeText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_freitext_laesst_sich_leeren() = runDbTest {
|
||||
seedBlock()
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt")))
|
||||
|
||||
checkItemStateDao.updateFreeTextValue("c-1", "")
|
||||
|
||||
assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest {
|
||||
seedBlock()
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true)))
|
||||
val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_"
|
||||
|
||||
checkItemStateDao.updateFreeTextValue("c-1", text)
|
||||
|
||||
assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
|
||||
assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest {
|
||||
// InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen.
|
||||
seedBlock()
|
||||
val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey"
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec)))
|
||||
|
||||
assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest {
|
||||
seedBlock()
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup")))
|
||||
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup")))
|
||||
}
|
||||
|
||||
assertEquals(1, countRows("check_item_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest {
|
||||
seedBlock(id = "b-1", definitionId = "a")
|
||||
seedBlock(id = "b-2", definitionId = "b")
|
||||
|
||||
checkItemStateDao.insertAll(
|
||||
listOf(
|
||||
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"),
|
||||
checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, countRows("check_item_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkItems_mit_unbekanntem_block_wirft() = runDbTest {
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht")))
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("check_item_states"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.awaitMatching
|
||||
import de.medapp.wunddoku.testutil.documentationEntity
|
||||
import de.medapp.wunddoku.testutil.expectThrows
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */
|
||||
class DocumentationDaoTest : RoomTestBase() {
|
||||
|
||||
// --- insert / getById ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest {
|
||||
seedPatient()
|
||||
val entity = documentationEntity(
|
||||
status = DocumentationStatus.COMPLETED,
|
||||
createdAt = 1_000L,
|
||||
updatedAt = 2_000L,
|
||||
pdfLastGeneratedAt = 3_000L,
|
||||
catalogVersion = 3,
|
||||
firstNameSnapshot = "Erika",
|
||||
lastNameSnapshot = "Musterfrau",
|
||||
birthDateSnapshot = "1975-12-01",
|
||||
streetSnapshot = "Hauptstraße 5",
|
||||
postalCodeSnapshot = "54321",
|
||||
citySnapshot = "Beispielstadt",
|
||||
)
|
||||
|
||||
documentationDao.insert(entity)
|
||||
|
||||
assertEquals(entity, documentationDao.getById("d-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullable_felder_bleiben_null() = runDbTest {
|
||||
seedDocumentation()
|
||||
|
||||
val loaded = documentationDao.getById("d-1")!!
|
||||
|
||||
assertNull(loaded.continuedFromDocumentationId)
|
||||
assertNull(loaded.pdfLastGeneratedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
|
||||
assertNull(documentationDao.getById("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest {
|
||||
seedPatient()
|
||||
DocumentationStatus.values().forEachIndexed { index, status ->
|
||||
documentationDao.insert(documentationEntity(id = "d-$index", status = status))
|
||||
}
|
||||
|
||||
DocumentationStatus.values().forEachIndexed { index, status ->
|
||||
assertEquals(status, documentationDao.getById("d-$index")!!.status)
|
||||
assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Foreign Keys --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun insert_mit_unbekanntem_patienten_wirft() = runDbTest {
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht"))
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("documentations"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht"))
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("documentations"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest {
|
||||
val source = seedDocumentation(id = "d-source")
|
||||
documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source"))
|
||||
assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId)
|
||||
|
||||
documentationDao.delete(source)
|
||||
|
||||
assertNull(documentationDao.getById("d-source"))
|
||||
val copy = documentationDao.getById("d-copy")
|
||||
assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy)
|
||||
assertNull(copy!!.continuedFromDocumentationId)
|
||||
}
|
||||
|
||||
// --- update / delete -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest {
|
||||
val first = seedDocumentation(id = "d-1")
|
||||
val second = seedDocumentation(id = "d-2")
|
||||
|
||||
documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L))
|
||||
|
||||
val changed = documentationDao.getById("d-1")!!
|
||||
assertEquals(DocumentationStatus.COMPLETED, changed.status)
|
||||
assertEquals(9_000L, changed.updatedAt)
|
||||
assertEquals(9_500L, changed.pdfLastGeneratedAt)
|
||||
assertEquals(second, documentationDao.getById("d-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
documentationDao.update(documentationEntity(id = "geist"))
|
||||
|
||||
assertNull(documentationDao.getById("geist"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest {
|
||||
val first = seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
|
||||
documentationDao.delete(first)
|
||||
|
||||
assertNull(documentationDao.getById("d-1"))
|
||||
assertNotNull(documentationDao.getById("d-2"))
|
||||
assertNotNull(patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
// --- observeAll / countForPatient ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest {
|
||||
seedDocumentation(id = "d-alt", createdAt = 1_000L)
|
||||
seedDocumentation(id = "d-neu", createdAt = 3_000L)
|
||||
seedDocumentation(id = "d-mitte", createdAt = 2_000L)
|
||||
|
||||
val ids = documentationDao.observeAll().first().map { it.id }
|
||||
|
||||
assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest {
|
||||
assertTrue(documentationDao.observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } }
|
||||
val entity = documentationEntity(id = "d-1")
|
||||
documentationDao.insert(entity)
|
||||
assertEquals("d-1", afterInsert.await().single().id)
|
||||
|
||||
val afterUpdate = async {
|
||||
documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED }
|
||||
}
|
||||
documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED))
|
||||
afterUpdate.await()
|
||||
|
||||
val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } }
|
||||
documentationDao.delete(entity)
|
||||
assertTrue(afterDelete.await().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest {
|
||||
seedDocumentation(id = "d-1", patientId = "p-1")
|
||||
seedDocumentation(id = "d-2", patientId = "p-1")
|
||||
seedDocumentation(id = "d-3", patientId = "p-2")
|
||||
|
||||
assertEquals(2, documentationDao.countForPatient("p-1"))
|
||||
assertEquals(1, documentationDao.countForPatient("p-2"))
|
||||
assertEquals(0, documentationDao.countForPatient("unbekannt"))
|
||||
}
|
||||
|
||||
// --- getLatestForPatient -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest {
|
||||
seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L)
|
||||
seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L)
|
||||
seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L)
|
||||
|
||||
assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest {
|
||||
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L)
|
||||
seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L)
|
||||
|
||||
assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
|
||||
assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
assertNull(documentationDao.getLatestForPatient("p-1"))
|
||||
assertNull(documentationDao.getLatestForPatient("unbekannt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.blockStateEntity
|
||||
import de.medapp.wunddoku.testutil.checkItemStateEntity
|
||||
import de.medapp.wunddoku.testutil.photoEntity
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.signatureEntity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen,
|
||||
* Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys.
|
||||
*/
|
||||
class DocumentationRelationsTest : RoomTestBase() {
|
||||
|
||||
/**
|
||||
* Dokumentation [docId] mit 2 Blöcken ("<prefix>-b1" order 2, "<prefix>-b2" order 1),
|
||||
* 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen.
|
||||
*/
|
||||
private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) {
|
||||
seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt)
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2),
|
||||
blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1),
|
||||
)
|
||||
)
|
||||
checkItemStateDao.insertAll(
|
||||
listOf(
|
||||
checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1),
|
||||
checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2),
|
||||
checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1),
|
||||
)
|
||||
)
|
||||
photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1))
|
||||
signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0))
|
||||
signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1))
|
||||
}
|
||||
|
||||
private fun DocumentationWithDetails.itemIdsByBlock(): Map<String, Set<String>> =
|
||||
blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() }
|
||||
|
||||
// --- Laden ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
val full = documentationDao.getFullDocumentation("d-a")!!
|
||||
|
||||
assertEquals("d-a", full.documentation.id)
|
||||
assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet())
|
||||
assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet())
|
||||
assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jede_option_haengt_am_richtigen_block() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
val full = documentationDao.getFullDocumentation("d-a")!!
|
||||
|
||||
assertEquals(
|
||||
mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")),
|
||||
full.itemIdsByBlock(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
val full = documentationDao.getFullDocumentation("d-a")!!
|
||||
|
||||
assertEquals(documentationDao.getById("d-a"), full.documentation)
|
||||
assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState)
|
||||
assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" })
|
||||
assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" })
|
||||
assertEquals(
|
||||
checkItemStateDao.getForBlockState("a-b1"),
|
||||
full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
seedWithChildren("d-b", 2_000L, "b")
|
||||
|
||||
val a = documentationDao.getFullDocumentation("d-a")!!
|
||||
val b = documentationDao.getFullDocumentation("d-b")!!
|
||||
|
||||
assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" })
|
||||
assertTrue(a.photos.all { it.documentationId == "d-a" })
|
||||
assertTrue(a.signatures.all { it.documentationId == "d-a" })
|
||||
assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet())
|
||||
assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest {
|
||||
seedDocumentation(id = "d-leer")
|
||||
|
||||
val full = documentationDao.getFullDocumentation("d-leer")!!
|
||||
|
||||
assertTrue(full.blocks.isEmpty())
|
||||
assertTrue(full.photos.isEmpty())
|
||||
assertTrue(full.signatures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest {
|
||||
seedDocumentation()
|
||||
blockStateDao.insertAll(listOf(blockStateEntity()))
|
||||
|
||||
val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single()
|
||||
|
||||
assertTrue(block.checkItems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
|
||||
assertNull(documentationDao.getFullDocumentation("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
seedWithChildren("d-b", 2_000L, "b")
|
||||
|
||||
val latest = documentationDao.getLatestForPatient("p-1")!!
|
||||
|
||||
assertEquals("d-b", latest.documentation.id)
|
||||
assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet())
|
||||
assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet())
|
||||
assertEquals(2, latest.photos.size)
|
||||
assertEquals(2, latest.signatures.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest {
|
||||
seedDocumentation()
|
||||
val blocks = (1..25).map { b ->
|
||||
blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b)
|
||||
}
|
||||
blockStateDao.insertAll(blocks)
|
||||
val items = (1..25).flatMap { b ->
|
||||
(1..20).map { i ->
|
||||
checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i)
|
||||
}
|
||||
}
|
||||
checkItemStateDao.insertAll(items)
|
||||
|
||||
val full = documentationDao.getFullDocumentation("d-1")!!
|
||||
|
||||
assertEquals(25, full.blocks.size)
|
||||
assertEquals(500, full.blocks.sumOf { it.checkItems.size })
|
||||
for (block in full.blocks) {
|
||||
assertEquals(20, block.checkItems.size)
|
||||
assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cascade / Restrict --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest {
|
||||
assertEquals(1, queryInt("PRAGMA foreign_keys"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
documentationDao.delete(documentationDao.getById("d-a")!!)
|
||||
|
||||
assertEquals(0, countRows("documentations"))
|
||||
assertEquals(0, countRows("block_states"))
|
||||
assertEquals(0, countRows("check_item_states"))
|
||||
assertEquals(0, countRows("photos"))
|
||||
assertEquals(0, countRows("signatures"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
seedWithChildren("d-b", 2_000L, "b")
|
||||
|
||||
documentationDao.delete(documentationDao.getById("d-a")!!)
|
||||
|
||||
assertNotNull(patientDao.getById("p-1"))
|
||||
val remaining = documentationDao.getFullDocumentation("d-b")!!
|
||||
assertEquals(2, remaining.blocks.size)
|
||||
assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size })
|
||||
assertEquals(2, remaining.photos.size)
|
||||
assertEquals(2, remaining.signatures.size)
|
||||
assertEquals(2, countRows("block_states"))
|
||||
assertEquals(3, countRows("check_item_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
execSql("DELETE FROM block_states WHERE id = 'a-b1'")
|
||||
|
||||
assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id })
|
||||
assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty())
|
||||
assertEquals(1, countRows("check_item_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest {
|
||||
seedWithChildren("d-a", 1_000L, "a")
|
||||
|
||||
val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) }
|
||||
|
||||
assertTrue(failed.isFailure)
|
||||
assertNotNull(documentationDao.getFullDocumentation("d-a"))
|
||||
assertEquals(3, countRows("check_item_states"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.awaitMatching
|
||||
import de.medapp.wunddoku.testutil.expectThrows
|
||||
import de.medapp.wunddoku.testutil.patientEntity
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** DAO-Tests für [PatientDao]. */
|
||||
class PatientDaoTest : RoomTestBase() {
|
||||
|
||||
// --- insert / getById ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest {
|
||||
val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123")
|
||||
|
||||
patientDao.insert(entity)
|
||||
|
||||
assertEquals(entity, patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionale_telefonnummern_bleiben_null() = runDbTest {
|
||||
patientDao.insert(patientEntity(phone2 = null, phone3 = null))
|
||||
|
||||
val loaded = patientDao.getById("p-1")!!
|
||||
|
||||
assertNull(loaded.phone2)
|
||||
assertNull(loaded.phone3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest {
|
||||
val entity = patientEntity(
|
||||
firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld",
|
||||
)
|
||||
|
||||
patientDao.insert(entity)
|
||||
|
||||
assertEquals(entity, patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
|
||||
assertNull(patientDao.getById("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest {
|
||||
val original = patientEntity(lastName = "Original")
|
||||
patientDao.insert(original)
|
||||
|
||||
expectThrows<SQLiteConstraintException> { patientDao.insert(patientEntity(lastName = "Duplikat")) }
|
||||
|
||||
assertEquals(original, patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest {
|
||||
// Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index.
|
||||
patientDao.insert(patientEntity(id = "p-1"))
|
||||
patientDao.insert(patientEntity(id = "p-2"))
|
||||
|
||||
assertEquals(2, countRows("patients"))
|
||||
}
|
||||
|
||||
// --- update / delete -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest {
|
||||
patientDao.insert(patientEntity())
|
||||
val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L)
|
||||
|
||||
patientDao.update(changed)
|
||||
|
||||
assertEquals(changed, patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest {
|
||||
patientDao.update(patientEntity(id = "geist"))
|
||||
|
||||
assertNull(patientDao.getById("geist"))
|
||||
assertEquals(0, countRows("patients"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest {
|
||||
patientDao.insert(patientEntity(id = "p-1"))
|
||||
patientDao.insert(patientEntity(id = "p-2"))
|
||||
|
||||
patientDao.delete(patientEntity(id = "p-1"))
|
||||
|
||||
assertNull(patientDao.getById("p-1"))
|
||||
assertNotNull(patientDao.getById("p-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest {
|
||||
patientDao.insert(patientEntity())
|
||||
|
||||
patientDao.delete(patientEntity(id = "unbekannt"))
|
||||
|
||||
assertEquals(1, countRows("patients"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest {
|
||||
seedDocumentation()
|
||||
|
||||
expectThrows<SQLiteConstraintException> { patientDao.delete(patientEntity()) }
|
||||
|
||||
assertNotNull(patientDao.getById("p-1"))
|
||||
assertEquals(1, countRows("documentations"))
|
||||
}
|
||||
|
||||
// --- findExisting --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest {
|
||||
patientDao.insert(patientEntity())
|
||||
|
||||
assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest {
|
||||
patientDao.insert(patientEntity())
|
||||
|
||||
assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14"))
|
||||
assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14"))
|
||||
assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest {
|
||||
// Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT.
|
||||
patientDao.insert(patientEntity())
|
||||
|
||||
assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest {
|
||||
patientDao.insert(patientEntity(id = "p-1"))
|
||||
patientDao.insert(patientEntity(id = "p-2"))
|
||||
|
||||
val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14")
|
||||
|
||||
assertTrue(found?.id == "p-1" || found?.id == "p-2")
|
||||
}
|
||||
|
||||
// --- hasDocumentations ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest {
|
||||
seedPatient("p-1")
|
||||
seedPatient("p-2", lastName = "Musterfrau")
|
||||
seedDocumentation(id = "d-1", patientId = "p-1")
|
||||
|
||||
assertTrue(patientDao.hasDocumentations("p-1"))
|
||||
assertFalse(patientDao.hasDocumentations("p-2"))
|
||||
assertFalse(patientDao.hasDocumentations("unbekannt"))
|
||||
}
|
||||
|
||||
// --- observeAllWithLastDocumentation -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest {
|
||||
seedPatient("p-1", firstName = "Max", lastName = "Müller")
|
||||
seedPatient("p-2", firstName = "Anna", lastName = "Müller")
|
||||
seedPatient("p-3", firstName = "Zoe", lastName = "Adler")
|
||||
|
||||
val result = patientDao.observeAllWithLastDocumentation().first()
|
||||
|
||||
assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest {
|
||||
seedPatient("p-1")
|
||||
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L)
|
||||
seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L)
|
||||
seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L)
|
||||
|
||||
val result = patientDao.observeAllWithLastDocumentation().first().single()
|
||||
|
||||
assertEquals(3_000L, result.lastDocumentationAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest {
|
||||
seedPatient("p-1")
|
||||
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
|
||||
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L)
|
||||
|
||||
val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id }
|
||||
|
||||
assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt)
|
||||
assertNull(byId.getValue("p-2").lastDocumentationAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest {
|
||||
assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest {
|
||||
seedPatient("p-1")
|
||||
val waiting = async {
|
||||
patientDao.observeAllWithLastDocumentation().awaitMatching { list ->
|
||||
list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L }
|
||||
}
|
||||
}
|
||||
|
||||
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
|
||||
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L)
|
||||
|
||||
assertEquals(2, waiting.await().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.expectThrows
|
||||
import de.medapp.wunddoku.testutil.photoEntity
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.signatureEntity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */
|
||||
class PhotoAndSignatureDaoTest : RoomTestBase() {
|
||||
|
||||
// --- PhotoDao ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun photo_insert_und_getById_liefern_alle_felder() = runDbTest {
|
||||
seedDocumentation()
|
||||
val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L)
|
||||
|
||||
photoDao.insert(photo)
|
||||
|
||||
assertEquals(photo, photoDao.getById("ph-1"))
|
||||
assertNull(photoDao.getById("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2))
|
||||
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1))
|
||||
photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0))
|
||||
|
||||
assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id })
|
||||
assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id })
|
||||
assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
assertEquals(-1, photoDao.getMaxOrderIndex("d-1"))
|
||||
|
||||
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4))
|
||||
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9))
|
||||
|
||||
assertEquals(4, photoDao.getMaxOrderIndex("d-1"))
|
||||
assertEquals(9, photoDao.getMaxOrderIndex("d-2"))
|
||||
assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest {
|
||||
seedDocumentation()
|
||||
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
|
||||
photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2))
|
||||
|
||||
photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1))
|
||||
|
||||
assertEquals(2, photoDao.getMaxOrderIndex("d-1"))
|
||||
assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest {
|
||||
seedDocumentation()
|
||||
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
|
||||
|
||||
photoDao.delete(photoEntity(id = "ph-1"))
|
||||
|
||||
assertNull(photoDao.getById("ph-1"))
|
||||
assertEquals("ph-2", photoDao.getById("ph-2")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest {
|
||||
seedDocumentation()
|
||||
|
||||
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
|
||||
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0))
|
||||
|
||||
assertEquals(2, photoDao.getForDocumentation("d-1").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
|
||||
seedDocumentation()
|
||||
photoDao.insert(photoEntity(id = "ph-1"))
|
||||
|
||||
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) }
|
||||
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) }
|
||||
|
||||
assertEquals(1, countRows("photos"))
|
||||
}
|
||||
|
||||
// --- SignatureDao --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun signature_insert_und_getById_liefern_alle_felder() = runDbTest {
|
||||
seedDocumentation()
|
||||
val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L)
|
||||
|
||||
signatureDao.insert(signature)
|
||||
|
||||
assertEquals(signature, signatureDao.getById("s-1"))
|
||||
assertNull(signatureDao.getById("unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2))
|
||||
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
|
||||
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1))
|
||||
signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0))
|
||||
|
||||
assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id })
|
||||
assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id })
|
||||
assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest {
|
||||
seedDocumentation(id = "d-1")
|
||||
seedDocumentation(id = "d-2")
|
||||
assertEquals(-1, signatureDao.getMaxOrderIndex("d-1"))
|
||||
|
||||
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
|
||||
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3))
|
||||
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7))
|
||||
|
||||
assertEquals(3, signatureDao.getMaxOrderIndex("d-1"))
|
||||
assertEquals(7, signatureDao.getMaxOrderIndex("d-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest {
|
||||
seedDocumentation()
|
||||
signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0))
|
||||
signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1))
|
||||
|
||||
signatureDao.delete(signatureEntity(id = "s-1"))
|
||||
|
||||
assertNull(signatureDao.getById("s-1"))
|
||||
assertEquals("s-2", signatureDao.getById("s-2")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
|
||||
seedDocumentation()
|
||||
signatureDao.insert(signatureEntity(id = "s-1"))
|
||||
|
||||
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) }
|
||||
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) }
|
||||
|
||||
assertEquals(1, countRows("signatures"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.awaitMatching
|
||||
import de.medapp.wunddoku.testutil.documentationEntity
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation].
|
||||
*
|
||||
* Szenario (Erstelldatum in Klammern):
|
||||
* - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000)
|
||||
* - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000)
|
||||
* - p-3 Hans Beispiel, 1990-07-30: d-4 (500)
|
||||
* - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation
|
||||
*/
|
||||
class SearchQueryTest : RoomTestBase() {
|
||||
|
||||
private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) {
|
||||
documentationDao.insert(
|
||||
documentationEntity(
|
||||
id = id, patientId = patientId, createdAt = createdAt,
|
||||
firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun seedScenario() {
|
||||
seedPatient("p-1", "Max", "Mustermann", "1980-03-14")
|
||||
seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01")
|
||||
seedPatient("p-3", "Hans", "Beispiel", "1990-07-30")
|
||||
seedPatient("p-4", "Zora", "Zimmer", "2001-01-01")
|
||||
seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14")
|
||||
seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14")
|
||||
seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01")
|
||||
seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30")
|
||||
}
|
||||
|
||||
private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id }
|
||||
|
||||
// --- Dokumentationssuche -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster"))
|
||||
assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("d-3"), searchDocs("erika"))
|
||||
assertEquals(listOf("d-3"), searchDocs("ERIKA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("d-4"), searchDocs("1990-07"))
|
||||
assertEquals(listOf("d-2", "d-1"), searchDocs("1980"))
|
||||
assertEquals(listOf("d-3"), searchDocs("12-01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("d-2", "d-1"), searchDocs("term"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertTrue(searchDocs("Zzz").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
// "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau).
|
||||
val ids = searchDocs("e")
|
||||
|
||||
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids)
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertTrue(searchDocs("Zora").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest {
|
||||
seedScenario()
|
||||
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
|
||||
|
||||
assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname"))
|
||||
assertTrue(searchDocs("Mustermann").isEmpty())
|
||||
// Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen.
|
||||
val hits = documentationDao.search("Neuname").first()
|
||||
assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty())
|
||||
assertTrue(searchDocs("\"").isEmpty())
|
||||
assertTrue(searchDocs("O'Brien").isEmpty())
|
||||
assertEquals(4, countRows("patients"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } }
|
||||
seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14")
|
||||
assertEquals("d-5", afterNewDoc.await().first().id)
|
||||
|
||||
val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } }
|
||||
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
|
||||
assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id })
|
||||
}
|
||||
|
||||
// --- Patientensuche ------------------------------------------------------------------
|
||||
|
||||
private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first()
|
||||
|
||||
@Test
|
||||
fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
val result = searchPatients("Muster")
|
||||
|
||||
assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann
|
||||
assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id })
|
||||
assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id })
|
||||
assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id })
|
||||
assertTrue(searchPatients("Zzz").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
val result = searchPatients("Zora").single()
|
||||
|
||||
assertEquals("p-4", result.patient.id)
|
||||
assertNull(result.lastDocumentationAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
val ids = searchPatients("e").map { it.patient.id }
|
||||
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest {
|
||||
seedPatient("p-1", "Max", "Müller", "1980-01-01")
|
||||
seedPatient("p-2", "Anna", "Müller", "1981-01-01")
|
||||
|
||||
assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty())
|
||||
assertEquals(4, countRows("patients"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest {
|
||||
seedScenario()
|
||||
|
||||
val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } }
|
||||
seedPatient("p-5", "Karl", "Musterkind", "2010-05-05")
|
||||
assertEquals(3, afterNewPatient.await().size)
|
||||
|
||||
val afterNewDoc = async {
|
||||
patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L }
|
||||
}
|
||||
seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05")
|
||||
afterNewDoc.await()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.woundCarerEntity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */
|
||||
class WoundCarerDaoTest : RoomTestBase() {
|
||||
|
||||
// --- insertOrUpdate ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest {
|
||||
val carer = woundCarerEntity(updatedAt = 5_000L)
|
||||
|
||||
woundCarerDao.insertOrUpdate(carer)
|
||||
|
||||
assertEquals(carer, woundCarerDao.getMostRecentlyUsed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L))
|
||||
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L))
|
||||
|
||||
assertEquals(1, countRows("wound_carers"))
|
||||
val loaded = woundCarerDao.getMostRecentlyUsed()!!
|
||||
assertEquals("Anna Schmidt-Neu", loaded.name)
|
||||
assertEquals("neu.png", loaded.signatureFileName)
|
||||
assertEquals(2_000L, loaded.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest {
|
||||
// Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank.
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna"))
|
||||
|
||||
assertEquals(2, countRows("wound_carers"))
|
||||
}
|
||||
|
||||
// --- findByName ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt"))
|
||||
|
||||
assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id)
|
||||
assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id)
|
||||
assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
|
||||
|
||||
assertNull(woundCarerDao.findByName("Anna"))
|
||||
assertNull(woundCarerDao.findByName("Schmidt"))
|
||||
assertNull(woundCarerDao.findByName("Anna Schmidt "))
|
||||
assertNull(woundCarerDao.findByName("unbekannt"))
|
||||
}
|
||||
|
||||
// --- searchByNamePrefix --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L))
|
||||
|
||||
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id })
|
||||
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id })
|
||||
assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
|
||||
|
||||
assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty())
|
||||
assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L))
|
||||
|
||||
assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id })
|
||||
assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id })
|
||||
assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L))
|
||||
|
||||
assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size)
|
||||
}
|
||||
|
||||
// --- getMostRecentlyUsed -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest {
|
||||
assertNull(woundCarerDao.getMostRecentlyUsed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest {
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L))
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L))
|
||||
|
||||
assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id)
|
||||
|
||||
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L))
|
||||
|
||||
assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package de.medapp.wunddoku.data.database
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import androidx.room.withTransaction
|
||||
import de.medapp.wunddoku.testutil.RoomTestBase
|
||||
import de.medapp.wunddoku.testutil.blockStateEntity
|
||||
import de.medapp.wunddoku.testutil.checkItemStateEntity
|
||||
import de.medapp.wunddoku.testutil.documentationEntity
|
||||
import de.medapp.wunddoku.testutil.expectThrows
|
||||
import de.medapp.wunddoku.testutil.photoEntity
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen-
|
||||
* Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() -
|
||||
* atomar machen.
|
||||
*
|
||||
* Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer
|
||||
* Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads.
|
||||
*/
|
||||
class DatabaseTransactionTest : RoomTestBase() {
|
||||
|
||||
// --- Commit --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity())
|
||||
blockStateDao.insertAll(listOf(blockStateEntity()))
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
|
||||
photoDao.insert(photoEntity())
|
||||
}
|
||||
|
||||
assertEquals(1, countRows("documentations"))
|
||||
assertEquals(1, countRows("block_states"))
|
||||
assertEquals(1, countRows("check_item_states"))
|
||||
assertEquals(1, countRows("photos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest {
|
||||
val result = db.withTransaction {
|
||||
seedPatient()
|
||||
42
|
||||
}
|
||||
|
||||
assertEquals(42, result)
|
||||
assertNotNull(patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity())
|
||||
assertNotNull(documentationDao.getById("d-1"))
|
||||
blockStateDao.insertAll(listOf(blockStateEntity()))
|
||||
assertEquals(1, blockStateDao.getForDocumentation("d-1").size)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rollback ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
val error = expectThrows<IllegalStateException> {
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity())
|
||||
blockStateDao.insertAll(listOf(blockStateEntity()))
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
|
||||
error("Abbruch mitten in der Transaktion")
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals("Abbruch mitten in der Transaktion", error.message)
|
||||
assertNull(documentationDao.getById("d-1"))
|
||||
assertEquals(0, countRows("block_states"))
|
||||
assertEquals(0, countRows("check_item_states"))
|
||||
assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity())
|
||||
photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung
|
||||
}
|
||||
}
|
||||
|
||||
assertNull(documentationDao.getById("d-1"))
|
||||
assertEquals(0, countRows("photos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest {
|
||||
seedDocumentation()
|
||||
|
||||
expectThrows<SQLiteConstraintException> {
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
blockStateEntity(id = "b-1", blockDefinitionId = "wundart"),
|
||||
blockStateEntity(id = "b-2", blockDefinitionId = "groesse"),
|
||||
blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("block_states"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest {
|
||||
val doc = seedDocumentation()
|
||||
blockStateDao.insertAll(listOf(blockStateEntity()))
|
||||
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
|
||||
photoDao.insert(photoEntity())
|
||||
|
||||
expectThrows<IllegalStateException> {
|
||||
db.withTransaction {
|
||||
documentationDao.delete(doc)
|
||||
assertNull(documentationDao.getById("d-1"))
|
||||
error("Abbruch nach dem Löschen")
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull(documentationDao.getById("d-1"))
|
||||
assertEquals(1, countRows("block_states"))
|
||||
assertEquals(1, countRows("check_item_states"))
|
||||
assertEquals(1, countRows("photos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest {
|
||||
seedPatient()
|
||||
expectThrows<IllegalStateException> {
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity(id = "d-verloren"))
|
||||
error("Abbruch")
|
||||
}
|
||||
}
|
||||
|
||||
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) }
|
||||
documentationDao.insert(documentationEntity(id = "d-direkt"))
|
||||
|
||||
assertNull(documentationDao.getById("d-verloren"))
|
||||
assertNotNull(documentationDao.getById("d-ok"))
|
||||
assertNotNull(documentationDao.getById("d-direkt"))
|
||||
}
|
||||
|
||||
// --- Verschachtelung -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest {
|
||||
seedPatient()
|
||||
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity(id = "d-aussen"))
|
||||
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) }
|
||||
}
|
||||
|
||||
assertEquals(2, countRows("documentations"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest {
|
||||
// SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet,
|
||||
// wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku).
|
||||
seedPatient()
|
||||
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity(id = "d-aussen"))
|
||||
try {
|
||||
db.withTransaction {
|
||||
documentationDao.insert(documentationEntity(id = "d-innen"))
|
||||
error("Fehler innen")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
// bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(0, countRows("documentations"))
|
||||
}
|
||||
|
||||
// --- Nebenläufigkeit -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest {
|
||||
seedDocumentation()
|
||||
|
||||
coroutineScope {
|
||||
listOf("a", "b", "c").map { prefix ->
|
||||
async(Dispatchers.Default) {
|
||||
repeat(20) { i ->
|
||||
db.withTransaction {
|
||||
photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
assertEquals(60, countRows("photos"))
|
||||
assertEquals(60, photoDao.getForDocumentation("d-1").size)
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.signatureBitmap
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
|
||||
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentationDeletionPrivacyTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
|
||||
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
|
||||
val deleted = mutableListOf<String>()
|
||||
override suspend fun deleteExportsFor(documentationId: String) {
|
||||
deleted += documentationId
|
||||
failure?.let { throw it }
|
||||
}
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
env = TestEnvironment()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
|
||||
env.insertPatient()
|
||||
val id = repo.createEmpty("patient-1").id
|
||||
repo.addPhoto(id, Uri.parse("content://test/1"))
|
||||
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
|
||||
return id
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertEquals(listOf(id), cleaner.deleted)
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(env.photoStorage.deletedAll.contains(id))
|
||||
assertTrue(env.signatureStorage.deletedAll.contains(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
|
||||
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(repo.observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
|
||||
repo.delete("gibt-es-nicht")
|
||||
|
||||
assertTrue(cleaner.deleted.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.MainActivity
|
||||
import de.medapp.wunddoku.R
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
|
||||
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivacyConfigurationTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val packageName = context.packageName
|
||||
private val id = "test-${UUID.randomUUID()}"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
File(context.filesDir, "photos/$id").mkdirs()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
|
||||
File(File(context.filesDir, it), id).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
|
||||
|
||||
// --- Berechtigungen ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
val forbidden = listOf(
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.MANAGE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
)
|
||||
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
|
||||
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||
val unexpected = requested.filterNot { it in allowed }
|
||||
|
||||
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
|
||||
}
|
||||
|
||||
// --- Backup --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun backup_ist_deaktiviert() {
|
||||
val flags = context.applicationInfo.flags
|
||||
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alles_aus() {
|
||||
val parser = context.resources.getXml(R.xml.data_extraction_rules)
|
||||
var section: String? = null
|
||||
val excluded = mutableMapOf<String, MutableSet<String>>()
|
||||
var includeFound = false
|
||||
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||
when (parser.name) {
|
||||
"cloud-backup", "device-transfer" -> {
|
||||
section = parser.name
|
||||
excluded[parser.name] = mutableSetOf()
|
||||
}
|
||||
"exclude" -> {
|
||||
val domain = parser.getAttributeValue(null, "domain")
|
||||
val current = section
|
||||
if (domain != null && current != null) excluded[current]?.add(domain)
|
||||
}
|
||||
"include" -> includeFound = true
|
||||
}
|
||||
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
|
||||
section = null
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse("Kein <include> erlaubt", includeFound)
|
||||
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
|
||||
excluded.forEach { (name, domains) ->
|
||||
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exportierte Komponenten ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
|
||||
val info = packageInfo(
|
||||
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
)
|
||||
|
||||
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
|
||||
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
|
||||
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
|
||||
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
|
||||
|
||||
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
|
||||
|
||||
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
|
||||
|
||||
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
|
||||
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
|
||||
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
|
||||
val provider = context.packageManager.getProviderInfo(
|
||||
ComponentName(packageName, FileProvider::class.java.name),
|
||||
PackageManager.GET_META_DATA,
|
||||
)
|
||||
|
||||
assertFalse(provider.exported)
|
||||
assertTrue(provider.grantUriPermissions)
|
||||
assertEquals("$packageName.fileprovider", provider.authority)
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
|
||||
|
||||
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
|
||||
|
||||
@Test
|
||||
fun pdf_und_bildexporte_sind_freigegeben() {
|
||||
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
|
||||
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
for (file in listOf(photo, signature)) {
|
||||
try {
|
||||
uriFor(file)
|
||||
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet: nicht in file_paths.xml konfiguriert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
|
||||
val database = context.getDatabasePath("irgendeine.db")
|
||||
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
|
||||
|
||||
try {
|
||||
uriFor(other)
|
||||
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
} finally {
|
||||
other.delete()
|
||||
}
|
||||
try {
|
||||
uriFor(database)
|
||||
fail("Die Datenbank darf nie freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Speicherort ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
|
||||
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
|
||||
|
||||
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
|
||||
val external = context.getExternalFilesDir(null)?.canonicalPath
|
||||
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
|
||||
|
||||
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
|
||||
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
|
||||
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
|
||||
}
|
||||
|
||||
// --- Bildschirmschutz ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
|
||||
var flags = 0
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
|
||||
}
|
||||
|
||||
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.medapp.wunddoku.testutil
|
||||
|
||||
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||
import de.medapp.wunddoku.data.entity.PhotoEntity
|
||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||
import de.medapp.wunddoku.data.entity.WoundCarerEntity
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
|
||||
/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */
|
||||
|
||||
fun patientEntity(
|
||||
id: String = "p-1",
|
||||
firstName: String = "Max",
|
||||
lastName: String = "Mustermann",
|
||||
birthDate: String = "1980-03-14",
|
||||
street: String = "Musterstraße 1",
|
||||
postalCode: String = "12345",
|
||||
city: String = "Musterstadt",
|
||||
phone1: String = "0123 456789",
|
||||
phone2: String? = null,
|
||||
phone3: String? = null,
|
||||
createdAt: Long = 1_000L,
|
||||
updatedAt: Long = createdAt,
|
||||
) = PatientEntity(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
birthDate = birthDate,
|
||||
street = street,
|
||||
postalCode = postalCode,
|
||||
city = city,
|
||||
phone1 = phone1,
|
||||
phone2 = phone2,
|
||||
phone3 = phone3,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
fun documentationEntity(
|
||||
id: String = "d-1",
|
||||
patientId: String = "p-1",
|
||||
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
|
||||
createdAt: Long = 1_000L,
|
||||
updatedAt: Long = createdAt,
|
||||
continuedFromDocumentationId: String? = null,
|
||||
pdfLastGeneratedAt: Long? = null,
|
||||
catalogVersion: Int = 4,
|
||||
firstNameSnapshot: String = "Max",
|
||||
lastNameSnapshot: String = "Mustermann",
|
||||
birthDateSnapshot: String = "1980-03-14",
|
||||
streetSnapshot: String = "Musterstraße 1",
|
||||
postalCodeSnapshot: String = "12345",
|
||||
citySnapshot: String = "Musterstadt",
|
||||
) = DocumentationEntity(
|
||||
id = id,
|
||||
patientId = patientId,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
continuedFromDocumentationId = continuedFromDocumentationId,
|
||||
pdfLastGeneratedAt = pdfLastGeneratedAt,
|
||||
catalogVersion = catalogVersion,
|
||||
patientFirstNameSnapshot = firstNameSnapshot,
|
||||
patientLastNameSnapshot = lastNameSnapshot,
|
||||
patientBirthDateSnapshot = birthDateSnapshot,
|
||||
patientStreetSnapshot = streetSnapshot,
|
||||
patientPostalCodeSnapshot = postalCodeSnapshot,
|
||||
patientCitySnapshot = citySnapshot,
|
||||
)
|
||||
|
||||
fun blockStateEntity(
|
||||
id: String = "b-1",
|
||||
documentationId: String = "d-1",
|
||||
blockDefinitionId: String = "wundart",
|
||||
title: String = "Wundart",
|
||||
order: Int = 1,
|
||||
updatedAt: Long = 1_000L,
|
||||
) = BlockStateEntity(
|
||||
id = id,
|
||||
documentationId = documentationId,
|
||||
blockDefinitionId = blockDefinitionId,
|
||||
titleSnapshot = title,
|
||||
blockOrder = order,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
fun checkItemStateEntity(
|
||||
id: String = "c-1",
|
||||
blockStateId: String = "b-1",
|
||||
definitionId: String = "item_1",
|
||||
label: String = "Eintrag 1",
|
||||
order: Int = 1,
|
||||
checked: Boolean = false,
|
||||
isFreeText: Boolean = false,
|
||||
freeTextValue: String = "",
|
||||
isStaticHint: Boolean = false,
|
||||
inputSpec: String = "",
|
||||
) = CheckItemStateEntity(
|
||||
id = id,
|
||||
blockStateId = blockStateId,
|
||||
checkItemDefinitionId = definitionId,
|
||||
labelSnapshot = label,
|
||||
itemOrder = order,
|
||||
checked = checked,
|
||||
isFreeText = isFreeText,
|
||||
freeTextValue = freeTextValue,
|
||||
isStaticHint = isStaticHint,
|
||||
inputSpec = inputSpec,
|
||||
)
|
||||
|
||||
fun photoEntity(
|
||||
id: String = "ph-1",
|
||||
documentationId: String = "d-1",
|
||||
fileName: String = "foto.jpg",
|
||||
orderIndex: Int = 0,
|
||||
createdAt: Long = 1_000L,
|
||||
) = PhotoEntity(
|
||||
id = id,
|
||||
documentationId = documentationId,
|
||||
fileName = fileName,
|
||||
orderIndex = orderIndex,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
fun signatureEntity(
|
||||
id: String = "s-1",
|
||||
documentationId: String = "d-1",
|
||||
signerName: String = "Anna Schmidt",
|
||||
fileName: String = "unterschrift.png",
|
||||
orderIndex: Int = 0,
|
||||
createdAt: Long = 1_000L,
|
||||
) = SignatureEntity(
|
||||
id = id,
|
||||
documentationId = documentationId,
|
||||
signerName = signerName,
|
||||
fileName = fileName,
|
||||
orderIndex = orderIndex,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
fun woundCarerEntity(
|
||||
id: String = "w-1",
|
||||
name: String = "Anna Schmidt",
|
||||
signatureFileName: String = "w-sig.png",
|
||||
updatedAt: Long = 1_000L,
|
||||
) = WoundCarerEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
signatureFileName = signatureFileName,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
@@ -146,6 +146,7 @@ fun TestEnvironment.buildRepository(
|
||||
photoDao: PhotoDao = db.photoDao(),
|
||||
signatureDao: SignatureDao = db.signatureDao(),
|
||||
documentationDao: DocumentationDao = db.documentationDao(),
|
||||
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
|
||||
): DocumentationRepository = DocumentationRepository(
|
||||
documentationDao = documentationDao,
|
||||
blockStateDao = db.blockStateDao(),
|
||||
@@ -159,6 +160,7 @@ fun TestEnvironment.buildRepository(
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = db,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
|
||||
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.medapp.wunddoku.testutil
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.data.dao.BlockStateDao
|
||||
import de.medapp.wunddoku.data.dao.CheckItemStateDao
|
||||
import de.medapp.wunddoku.data.dao.DocumentationDao
|
||||
import de.medapp.wunddoku.data.dao.PatientDao
|
||||
import de.medapp.wunddoku.data.dao.PhotoDao
|
||||
import de.medapp.wunddoku.data.dao.SignatureDao
|
||||
import de.medapp.wunddoku.data.dao.WoundCarerDao
|
||||
import de.medapp.wunddoku.data.database.AppDatabase
|
||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema,
|
||||
* denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine
|
||||
* frische, leere Datenbank.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
abstract class RoomTestBase {
|
||||
|
||||
protected lateinit var db: AppDatabase
|
||||
|
||||
protected val patientDao: PatientDao get() = db.patientDao()
|
||||
protected val documentationDao: DocumentationDao get() = db.documentationDao()
|
||||
protected val blockStateDao: BlockStateDao get() = db.blockStateDao()
|
||||
protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao()
|
||||
protected val photoDao: PhotoDao get() = db.photoDao()
|
||||
protected val signatureDao: SignatureDao get() = db.signatureDao()
|
||||
protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao()
|
||||
|
||||
@Before
|
||||
fun createDatabase() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
AppDatabase::class.java,
|
||||
).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDatabase() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
// --- Seed-Helfer ---------------------------------------------------------------------
|
||||
|
||||
protected suspend fun seedPatient(
|
||||
id: String = "p-1",
|
||||
firstName: String = "Max",
|
||||
lastName: String = "Mustermann",
|
||||
birthDate: String = "1980-03-14",
|
||||
): PatientEntity {
|
||||
val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate)
|
||||
patientDao.insert(entity)
|
||||
return entity
|
||||
}
|
||||
|
||||
/** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */
|
||||
protected suspend fun seedDocumentation(
|
||||
id: String = "d-1",
|
||||
patientId: String = "p-1",
|
||||
createdAt: Long = 1_000L,
|
||||
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
|
||||
): DocumentationEntity {
|
||||
if (patientDao.getById(patientId) == null) seedPatient(id = patientId)
|
||||
val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status)
|
||||
documentationDao.insert(entity)
|
||||
return entity
|
||||
}
|
||||
|
||||
// --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ----
|
||||
|
||||
protected fun queryInt(sql: String): Int =
|
||||
db.openHelper.readableDatabase.query(sql).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
protected fun queryString(sql: String): String =
|
||||
db.openHelper.readableDatabase.query(sql).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0)
|
||||
}
|
||||
|
||||
protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table")
|
||||
|
||||
protected fun execSql(sql: String) {
|
||||
db.openHelper.writableDatabase.execSQL(sql)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt.
|
||||
* Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert.
|
||||
*/
|
||||
suspend fun <T> Flow<T>.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T =
|
||||
withTimeout(timeoutMs) { first { predicate(it) } }
|
||||
@@ -1,10 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
|
||||
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
|
||||
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
|
||||
|
||||
<!-- Die App arbeitet vollständig offline. Falls eine Bibliothek (jetzt oder nach einem
|
||||
Update) die INTERNET-Berechtigung mitbringt, wird sie beim Zusammenführen der Manifeste
|
||||
entfernt - Patientendaten können das Gerät so nicht über das Netzwerk verlassen. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:name=".WunddokuApplication"
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -2,14 +2,25 @@ package de.medapp.wunddoku
|
||||
|
||||
import android.app.Application
|
||||
import de.medapp.wunddoku.di.AppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class WunddokuApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
|
||||
// Datenschutz: veraltete PDF-/Bildexporte und liegengebliebene Kameraaufnahmen entfernen
|
||||
// (Aufbewahrungsfristen siehe InternalExportedFilesCleaner). Läuft im Hintergrund,
|
||||
// wirft nie und verzögert den Start nicht.
|
||||
applicationScope.launch { container.exportedFilesCleaner.deleteStaleFiles() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import de.medapp.wunddoku.domain.model.Signature
|
||||
import de.medapp.wunddoku.media.ImageDownsampler
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||
import de.medapp.wunddoku.pdf.PdfExportError
|
||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||
@@ -69,6 +70,7 @@ class DocumentationRepository(
|
||||
private val pdfExporter: DocumentationPdfExporter,
|
||||
private val pdfToImagesConverter: PdfToImagesConverter,
|
||||
private val db: AppDatabase,
|
||||
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
|
||||
) {
|
||||
|
||||
fun observeAll(): Flow<List<DocumentationSummary>> =
|
||||
@@ -434,6 +436,9 @@ class DocumentationRepository(
|
||||
// geladen werden.
|
||||
runCatching { photoStorage.deleteAll(documentationId) }
|
||||
runCatching { signatureStorage.deleteAll(documentationId) }
|
||||
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
|
||||
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
|
||||
runCatching { exportedFilesCleaner.deleteExportsFor(documentationId) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,8 @@ import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
@@ -42,6 +44,11 @@ class AppContainer(context: Context) {
|
||||
}
|
||||
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
|
||||
|
||||
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
|
||||
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
|
||||
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
|
||||
}
|
||||
|
||||
val patientRepository: PatientRepository by lazy {
|
||||
PatientRepository(patientDao = database.patientDao())
|
||||
}
|
||||
@@ -67,6 +74,7 @@ class AppContainer(context: Context) {
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = database,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ object DocumentationCatalog {
|
||||
title = "Lokalisation",
|
||||
order = 2,
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
|
||||
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "Multiple Wunden im Bereich", order = 1),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -120,9 +120,13 @@ object DocumentationCatalog {
|
||||
),
|
||||
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
|
||||
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
|
||||
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
|
||||
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
|
||||
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
|
||||
// Bewusst OHNE separate Hinweis-Überschrift (anders als bei "Maße" oben): Die
|
||||
// Checkbox selbst trägt mit "Zu versorgendes Areal" bereits den fachlich
|
||||
// gewünschten Namen - eine zusätzliche, wortgleiche Überschrift direkt darüber
|
||||
// würde im PDF als zwei identische, nicht unterscheidbare Zeilen hintereinander
|
||||
// erscheinen (Hinweistext und Checkbox-Label werden dort gleich dargestellt).
|
||||
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 5),
|
||||
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 6, isFreeText = true),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -344,8 +348,8 @@ object DocumentationCatalog {
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
|
||||
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5),
|
||||
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
|
||||
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "Per Doppler (Taschendoppler)", order = 5),
|
||||
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "Per Palpation", order = 6),
|
||||
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
|
||||
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
|
||||
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
|
||||
|
||||
@@ -117,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
}
|
||||
|
||||
private fun documentationDirPath(documentationId: String): File =
|
||||
File(photosRootDir, documentationId)
|
||||
SafePaths.resolveChild(photosRootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
File(documentationDirPath(documentationId), fileName)
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Schutz vor Path-Traversal (`../`) beim Auflösen von Datei-/Ordnernamen im privaten App-Speicher.
|
||||
*
|
||||
* Datei- und Ordnernamen stammen normalerweise aus der eigenen Datenbank (UUIDs). Als
|
||||
* Defense-in-Depth wird trotzdem sichergestellt, dass ein aufgelöster Pfad nie aus seinem
|
||||
* Wurzelordner ausbrechen kann - etwa bei einer manipulierten Datenbank oder einem Programmfehler.
|
||||
*/
|
||||
object SafePaths {
|
||||
|
||||
/**
|
||||
* Liefert `File(parent, name)`, wenn [name] ein einzelner, harmloser Pfadbestandteil ist.
|
||||
*
|
||||
* @throws IllegalArgumentException bei leerem Namen, Pfadtrennzeichen, "." / ".." oder wenn
|
||||
* der kanonische Pfad nicht unterhalb von [parent] liegt.
|
||||
*/
|
||||
fun resolveChild(parent: File, name: String): File {
|
||||
require(name.isNotBlank()) { "Name darf nicht leer sein." }
|
||||
require(!name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) {
|
||||
"Name darf kein Pfadtrennzeichen enthalten."
|
||||
}
|
||||
require(name != "." && name != "..") { "Ungültiger Name." }
|
||||
val child = File(parent, name)
|
||||
val parentPath = parent.canonicalFile.path
|
||||
val childPath = child.canonicalFile.path
|
||||
require(childPath.startsWith(parentPath + File.separator)) { "Pfad liegt außerhalb des erlaubten Ordners." }
|
||||
return child
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
|
||||
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
|
||||
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
|
||||
* (`cacheDir/camera/`).
|
||||
*
|
||||
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
|
||||
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
|
||||
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
|
||||
* Daten der Dokumentation neu erzeugen.
|
||||
*/
|
||||
interface ExportedFilesCleaner {
|
||||
|
||||
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
|
||||
suspend fun deleteExportsFor(documentationId: String)
|
||||
|
||||
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
|
||||
suspend fun deleteStaleFiles()
|
||||
|
||||
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
|
||||
object None : ExportedFilesCleaner {
|
||||
override suspend fun deleteExportsFor(documentationId: String) = Unit
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
class InternalExportedFilesCleaner(
|
||||
filesDir: File,
|
||||
cacheDir: File,
|
||||
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
|
||||
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ExportedFilesCleaner {
|
||||
|
||||
private val pdfRoot = File(filesDir, "pdf")
|
||||
private val imagesRoot = File(filesDir, "pdf_images")
|
||||
private val captureDir = File(cacheDir, "camera")
|
||||
|
||||
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
|
||||
listOf(pdfRoot, imagesRoot).forEach { root ->
|
||||
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
|
||||
val currentTime = now()
|
||||
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
|
||||
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
|
||||
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
|
||||
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
|
||||
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
|
||||
Unit
|
||||
}
|
||||
|
||||
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
|
||||
private fun deleteOlderThan(dir: File, cutoff: Long) {
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
deleteOlderThan(child, cutoff)
|
||||
if (child.listFiles().isNullOrEmpty()) child.delete()
|
||||
} else if (child.lastModified() < cutoff) {
|
||||
child.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
|
||||
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.medapp.wunddoku.signature
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -92,13 +93,13 @@ class InternalSignatureStorage(
|
||||
}
|
||||
|
||||
private fun documentationDirPath(documentationId: String): File =
|
||||
File(rootDir, documentationId)
|
||||
SafePaths.resolveChild(rootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
File(documentationDirPath(documentationId), fileName)
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+4
-3
@@ -331,11 +331,12 @@ class DocumentationEditViewModel(
|
||||
failureCount++
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
|
||||
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||
// Datenschutz: Es wird bewusst NUR der Exception-Typ geloggt. Die
|
||||
// Exception-Nachricht (e.message) enthält bei Dateifehlern häufig den
|
||||
// Dateipfad bzw. die Bild-Uri und darf deshalb nie in Logs landen.
|
||||
android.util.Log.w(
|
||||
"DocumentationEdit",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
||||
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
||||
für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich
|
||||
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
|
||||
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
|
||||
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
|
||||
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
||||
intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="pdf_exports" path="pdf/" />
|
||||
<files-path name="pdf_images" path="pdf_images/" />
|
||||
<files-path name="photos" path="photos/" />
|
||||
<files-path name="signatures" path="signatures/" />
|
||||
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
|
||||
Dokumentation, danach sofort gelöscht. -->
|
||||
<cache-path name="camera_captures" path="camera/" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/** Datenschutz/Sicherheit: kein Ausbrechen aus dem privaten Ordner per Path-Traversal. */
|
||||
class SafePathsTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private fun assertRejected(name: String) {
|
||||
val root = temp.newFolder()
|
||||
try {
|
||||
SafePaths.resolveChild(root, name)
|
||||
fail("'$name' hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normale_namen_werden_unterhalb_des_wurzelordners_aufgeloest() {
|
||||
val root = temp.newFolder()
|
||||
|
||||
val result = SafePaths.resolveChild(root, "3f2a-uuid.jpg")
|
||||
|
||||
assertEquals(File(root, "3f2a-uuid.jpg"), result)
|
||||
assertTrue(result.canonicalPath.startsWith(root.canonicalPath + File.separator))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uebliche_namen_mit_punkten_und_bindestrichen_sind_erlaubt() {
|
||||
val root = temp.newFolder()
|
||||
SafePaths.resolveChild(root, "2026-08-19_Max.Mustermann.v2.pdf")
|
||||
SafePaths.resolveChild(root, "..hidden") // beginnt mit Punkten, ist aber kein Traversal
|
||||
}
|
||||
|
||||
@Test
|
||||
fun path_traversal_wird_abgelehnt() {
|
||||
assertRejected("..")
|
||||
assertRejected("../geheim")
|
||||
assertRejected("../../etc/passwd")
|
||||
assertRejected("a/../../b")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pfadtrenner_und_leere_namen_werden_abgelehnt() {
|
||||
assertRejected("")
|
||||
assertRejected(" ")
|
||||
assertRejected(".")
|
||||
assertRejected("unter/ordner.jpg")
|
||||
assertRejected("unter\\ordner.jpg")
|
||||
assertRejected("/absolut.jpg")
|
||||
assertRejected("nullbyte\u0000.jpg")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun symbolischer_link_aus_dem_ordner_heraus_wird_abgelehnt() {
|
||||
val root = temp.newFolder()
|
||||
val outside = temp.newFolder()
|
||||
val link = File(root, "link")
|
||||
try {
|
||||
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
|
||||
} catch (e: Exception) {
|
||||
return // Dateisystem ohne Symlink-Unterstützung: Test nicht anwendbar
|
||||
}
|
||||
|
||||
try {
|
||||
SafePaths.resolveChild(root, "link")
|
||||
fail("Symlink aus dem Ordner heraus hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: PDF-/Bildexporte (enthalten Patientendaten) und liegengebliebene
|
||||
* Kameraaufnahmen dürfen nicht dauerhaft im Speicher bleiben.
|
||||
*/
|
||||
class ExportedFilesCleanerTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private lateinit var filesDir: File
|
||||
private lateinit var cacheDir: File
|
||||
private val day = 24L * 60 * 60 * 1000
|
||||
private val now = 1_800_000_000_000L // fester "Jetzt"-Zeitpunkt
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
filesDir = temp.newFolder("files")
|
||||
cacheDir = temp.newFolder("cache")
|
||||
}
|
||||
|
||||
private fun cleaner() = InternalExportedFilesCleaner(filesDir, cacheDir, now = { now })
|
||||
|
||||
private fun file(path: File, ageMillis: Long = 0): File {
|
||||
path.parentFile!!.mkdirs()
|
||||
path.writeText("x")
|
||||
path.setLastModified(now - ageMillis)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun pdf(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf/$docId/bericht.pdf"), ageMillis)
|
||||
private fun image(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf_images/$docId/seite_01.jpg"), ageMillis)
|
||||
private fun capture(name: String, ageMillis: Long) = file(File(cacheDir, "camera/$name"), ageMillis)
|
||||
|
||||
// --- Löschen mit der Dokumentation ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_entfernt_pdf_und_bildexporte_der_dokumentation() = runBlocking {
|
||||
val p = pdf("doc-1")
|
||||
val i = image("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertFalse(p.exists())
|
||||
assertFalse(i.exists())
|
||||
assertFalse(File(filesDir, "pdf/doc-1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_laesst_andere_dokumentationen_unberuehrt() = runBlocking {
|
||||
val other = pdf("doc-2")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue(other.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteExportsFor("gibt-es-nicht")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_loescht_nie_ausserhalb_der_exportordner() = runBlocking {
|
||||
val secret = file(File(filesDir, "secret/patientendaten.db"))
|
||||
val photos = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
File(filesDir, "pdf").mkdirs()
|
||||
|
||||
cleaner().deleteExportsFor("../secret")
|
||||
cleaner().deleteExportsFor("..")
|
||||
cleaner().deleteExportsFor("../photos/doc-1")
|
||||
|
||||
assertTrue(secret.exists())
|
||||
assertTrue(photos.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_beruehrt_fotos_und_signaturen_der_dokumentation_nicht() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"))
|
||||
pdf("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue("Löschen der Exporte ist nicht Löschen der Dokumentation", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
// --- Aufbewahrungsfristen ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_exporte_aelter_als_7_tage_und_behaelt_frische() = runBlocking {
|
||||
val old = pdf("doc-old", ageMillis = 8 * day)
|
||||
val oldImage = image("doc-old", ageMillis = 8 * day)
|
||||
val fresh = pdf("doc-fresh", ageMillis = 1 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(old.exists())
|
||||
assertFalse(oldImage.exists())
|
||||
assertTrue(fresh.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_leere_unterordner() = runBlocking {
|
||||
pdf("doc-old", ageMillis = 30 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(File(filesDir, "pdf/doc-old").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_liegengebliebene_kameraaufnahmen_nach_24_stunden() = runBlocking {
|
||||
val stale = capture("capture_old.jpg", ageMillis = day + 60_000)
|
||||
val recent = capture("capture_new.jpg", ageMillis = 2 * 60 * 60 * 1000)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse("Wundfoto darf nicht dauerhaft im Cache liegen bleiben", stale.exists())
|
||||
assertTrue("aktuelle Aufnahme wird eventuell noch importiert", recent.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_beruehrt_dokumentationsdaten_nie() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"), ageMillis = 365 * day)
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"), ageMillis = 365 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertTrue("Dokumentationsfotos/-signaturen sind keine Exporte", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteStaleFiles()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun none_cleaner_tut_nichts() = runBlocking {
|
||||
val p = pdf("doc-1", ageMillis = 100 * day)
|
||||
|
||||
ExportedFilesCleaner.None.deleteExportsFor("doc-1")
|
||||
ExportedFilesCleaner.None.deleteStaleFiles()
|
||||
|
||||
assertTrue(p.exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
|
||||
*
|
||||
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
|
||||
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
|
||||
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
|
||||
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
|
||||
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
|
||||
*/
|
||||
class PrivacySourceScanTest {
|
||||
|
||||
private lateinit var mainDir: File
|
||||
|
||||
@Before
|
||||
fun locateSources() {
|
||||
mainDir = listOf(File("src/main"), File("app/src/main"))
|
||||
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
|
||||
?: File("")
|
||||
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen -----------------------------------------------------------------
|
||||
|
||||
private fun kotlinSources(): List<Pair<String, String>> =
|
||||
File(mainDir, "java").walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
|
||||
.toList()
|
||||
|
||||
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
|
||||
private fun stripComments(source: String): String =
|
||||
source
|
||||
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
|
||||
.replace(Regex("//[^\\n]*"), "")
|
||||
|
||||
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
|
||||
sources.flatMap { (path, text) ->
|
||||
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
|
||||
}
|
||||
|
||||
private fun assertNone(description: String, pattern: Regex) {
|
||||
val found = findings(pattern)
|
||||
assertTrue("$description - gefunden: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
private fun parseXml(relativePath: String) =
|
||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
|
||||
|
||||
// --- Logging -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
|
||||
assertNone(
|
||||
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
|
||||
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
|
||||
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
|
||||
val forbidden = Regex(
|
||||
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
|
||||
)
|
||||
val violations = mutableListOf<String>()
|
||||
|
||||
for ((path, text) in kotlinSources()) {
|
||||
for (match in callStart.findAll(text)) {
|
||||
val openIndex = match.range.last
|
||||
var depth = 0
|
||||
var end = openIndex
|
||||
for (i in openIndex until text.length) {
|
||||
when (text[i]) {
|
||||
'(' -> depth++
|
||||
')' -> { depth--; if (depth == 0) { end = i; break } }
|
||||
}
|
||||
}
|
||||
val call = text.substring(match.range.first, end + 1)
|
||||
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
|
||||
}
|
||||
|
||||
// --- Speicherorte --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
|
||||
assertNone(
|
||||
"Patientendaten dürfen nur im privaten internen Speicher liegen",
|
||||
Regex(
|
||||
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_nutzung_der_zwischenablage() {
|
||||
assertNone(
|
||||
"Die Zwischenablage ist für andere Apps lesbar",
|
||||
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
|
||||
assertNone(
|
||||
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
|
||||
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Netzwerk ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_netzwerkcode_im_quelltext() {
|
||||
assertNone(
|
||||
"Die App arbeitet offline - kein Netzwerkzugriff",
|
||||
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
|
||||
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
|
||||
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
|
||||
val dependencies = gradle!!.readLines()
|
||||
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
|
||||
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
|
||||
val found = dependencies.filter { forbidden.containsMatchIn(it) }
|
||||
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
// --- Manifest ------------------------------------------------------------------------
|
||||
|
||||
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
|
||||
|
||||
@Test
|
||||
fun manifest_fordert_keine_berechtigungen_an() {
|
||||
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
|
||||
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
|
||||
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_entfernt_die_internet_berechtigung_explizit() {
|
||||
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
|
||||
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
|
||||
val text = manifest()
|
||||
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
|
||||
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
|
||||
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
|
||||
assertTrue(!text.contains("android:debuggable"))
|
||||
assertTrue(!text.contains("""android:allowBackup="true""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
|
||||
val text = manifest()
|
||||
assertEquals(
|
||||
"Genau eine Komponente (MainActivity) darf exportiert sein",
|
||||
1,
|
||||
Regex("""android:exported="true"""").findAll(text).count(),
|
||||
)
|
||||
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
|
||||
assertTrue(provider.contains("""android:exported="false""""))
|
||||
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
|
||||
}
|
||||
|
||||
// --- Backup-Regeln -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
|
||||
val doc = parseXml("res/xml/data_extraction_rules.xml")
|
||||
for (section in listOf("cloud-backup", "device-transfer")) {
|
||||
val nodes = doc.getElementsByTagName(section)
|
||||
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
|
||||
val element = nodes.item(0) as org.w3c.dom.Element
|
||||
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
|
||||
val excluded = (0 until element.getElementsByTagName("exclude").length)
|
||||
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
|
||||
.toSet()
|
||||
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
|
||||
val doc = parseXml("res/xml/file_paths.xml")
|
||||
val paths = doc.getElementsByTagName("paths").item(0).childNodes
|
||||
val entries = (0 until paths.length).map { paths.item(it) }
|
||||
.filterIsInstance<org.w3c.dom.Element>()
|
||||
.map { it.tagName to it.getAttribute("path") }
|
||||
|
||||
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
|
||||
val unexpected = entries.filterNot { it in allowed }
|
||||
assertTrue(
|
||||
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
|
||||
unexpected.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# VulneraDoc – Datenschutz- und Datensicherheits-Dokumentation
|
||||
|
||||
Stand: Datenschutz-Review QA-006 (Projektstand `dev-QA004` + Maßnahmen aus diesem Review)
|
||||
|
||||
> **Hinweis:** Dieses Dokument beschreibt die *technische* Umsetzung des Datenschutzes in der App
|
||||
> (Datenflüsse, Speicherorte, Schutzmaßnahmen). Es ist keine Rechtsberatung. Die rechtliche
|
||||
> Bewertung (Rechtsgrundlage, Verzeichnis der Verarbeitungstätigkeiten, ggf. Datenschutz-
|
||||
> Folgenabschätzung, Auftragsverarbeitung, Einwilligungen) muss durch die verantwortliche Stelle
|
||||
> bzw. deren Datenschutzbeauftragte erfolgen. Die Abschnitte 9 und 10 liefern dafür die
|
||||
> technischen Eingangsdaten.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck und Einordnung
|
||||
|
||||
VulneraDoc ist eine **Offline-Android-App zur Wunddokumentation**. Pflegekräfte erfassen Patienten,
|
||||
dokumentieren Wunden (Checklisten, Freitext, Fotos, Unterschriften) und erzeugen daraus ein PDF,
|
||||
das sie öffnen oder teilen können.
|
||||
|
||||
Verarbeitet werden **Gesundheitsdaten** (Wundbefunde, Wundfotos) und weitere personenbezogene Daten.
|
||||
Gesundheitsdaten zählen nach Art. 9 DSGVO zu den besonderen Kategorien personenbezogener Daten und
|
||||
verlangen ein erhöhtes Schutzniveau.
|
||||
|
||||
**Grundprinzip der App:** Die Daten verlassen das Gerät **ausschließlich auf ausdrückliche
|
||||
Handlung des Nutzers** (PDF/Bilder öffnen oder teilen). Es gibt keinen Server, keine Cloud, kein
|
||||
Konto, keine Analyse- oder Absturzberichts-Dienste.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verarbeitete Daten
|
||||
|
||||
| Datenart | Felder / Inhalt | Speicherort (App-intern) |
|
||||
|---|---|---|
|
||||
| Patientenstammdaten | Vorname, Nachname, Geburtsdatum, Straße, PLZ, Ort, bis zu 3 Telefonnummern | Datenbank `wunddoku.db` |
|
||||
| Dokumentation | Erstelldatum, Checklisten-Zustände, Freitexte (Wundbefund, Maßnahmen) | Datenbank |
|
||||
| Wundfotos | Fotos aus Kamera bzw. Photo Picker | `files/photos/<Dokumentations-ID>/` |
|
||||
| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures/<Dokumentations-ID>/` |
|
||||
| Wundversorger-Register | Name und Unterschrift der Pflegekraft (für Wiederverwendung) | Datenbank + `files/wound_carer_signatures/` |
|
||||
| PDF-Export | Vollständiger Bericht inkl. Stammdaten, Befund, Fotos, Unterschriften | `files/pdf/<Dokumentations-ID>/` |
|
||||
| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images/<Dokumentations-ID>/` |
|
||||
| Kameraaufnahme (temporär) | Foto bis zum Import in die Dokumentation | `cache/camera/` |
|
||||
|
||||
Alle Speicherorte liegen im **privaten internen App-Speicher**. Es wird kein öffentlicher
|
||||
Speicher (Galerie, Downloads, SD-Karte) beschrieben.
|
||||
|
||||
**Hinweis Dateiname:** Das PDF heißt `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf` und enthält
|
||||
damit personenbezogene Daten. Der Name wird beim Teilen/Öffnen an die gewählte Ziel-App übergeben
|
||||
(siehe Abschnitt 6, Restrisiko R4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Berechtigungen
|
||||
|
||||
Die App fordert **keine einzige Berechtigung** an.
|
||||
|
||||
| Funktion | Technik | Berechtigung nötig? |
|
||||
|---|---|---|
|
||||
| Fotos auswählen | System-Photo-Picker | Nein |
|
||||
| Foto aufnehmen | System-Kamera-App über `ACTION_IMAGE_CAPTURE` (die App greift nicht selbst auf die Kamera zu) | Nein (`CAMERA` nicht deklariert) |
|
||||
| PDF speichern | Privater App-Speicher | Nein |
|
||||
| PDF öffnen/teilen | `FileProvider` + Android-Sharesheet | Nein |
|
||||
| Netzwerk | nicht vorhanden | `INTERNET` ist im Manifest **ausdrücklich entfernt** (`tools:node="remove"`) |
|
||||
|
||||
Durchsetzung: Das Manifest entfernt `INTERNET` und `ACCESS_NETWORK_STATE` beim Zusammenführen mit
|
||||
Bibliotheks-Manifesten. Der Test `PrivacyConfigurationTest` prüft das fertig zusammengeführte
|
||||
Manifest auf dem Gerät.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenflüsse nach außen
|
||||
|
||||
Daten verlassen das Gerät nur in diesen Fällen – jeweils durch eine bewusste Aktion des Nutzers:
|
||||
|
||||
1. **PDF öffnen** – Übergabe der PDF per `content://`-URI mit *Lese*recht an die gewählte PDF-App.
|
||||
2. **PDF teilen / Bilder teilen** – Übergabe per Android-Sharesheet mit *Lese*recht, nur
|
||||
`EXTRA_STREAM` (die Datei). Es wird **kein** Text, Betreff oder Empfänger mit Patientendaten
|
||||
vorbelegt.
|
||||
3. **Fotoimport** – Fotos werden *in* die App kopiert (Photo Picker / Kamera); die Originale
|
||||
verbleiben dort, wo sie der Nutzer gespeichert hat. Beim Import wird das Foto neu als JPEG
|
||||
kodiert; Metadaten des Originals (z. B. GPS-Position) werden dadurch **nicht** übernommen.
|
||||
|
||||
Nicht vorhanden: Netzwerkzugriff, Cloud-Backup, Geräte-zu-Geräte-Transfer, Analyse/Tracking,
|
||||
Absturzberichte, Werbung, Zwischenablage, Weitergabe an Dritte durch die App selbst.
|
||||
|
||||
**Verantwortung nach dem Teilen:** Sobald eine PDF an eine andere App übergeben wurde, liegt der
|
||||
weitere Umgang außerhalb der Kontrolle von VulneraDoc (z. B. E-Mail-Programm, Messenger,
|
||||
Cloud-Speicher). Das ist organisatorisch zu regeln (Abschnitt 9).
|
||||
|
||||
---
|
||||
|
||||
## 5. Technische und organisatorische Schutzmaßnahmen in der App
|
||||
|
||||
### 5.1 Speicherung
|
||||
- Ausschließlich privater interner Speicher (`filesDir`, `cacheDir`); kein `getExternalFilesDir`,
|
||||
keine `MediaStore`-Nutzung, keine `MODE_WORLD_*`-Dateien.
|
||||
- Dateien sind für andere Apps nicht lesbar (Android-App-Sandbox, Dateirechte).
|
||||
- **Pfadsicherheit:** Datei-/Ordnernamen werden über `SafePaths` aufgelöst (kein `../`, keine
|
||||
Pfadtrenner, keine Symlink-Ausbrüche).
|
||||
- **Atomares Schreiben der PDF:** Erst temporäre Datei, dann Umbenennen – kein halbfertiges PDF,
|
||||
eine vorhandene PDF geht bei einem Fehler nicht verloren.
|
||||
|
||||
### 5.2 Freigabe über FileProvider (Prinzip der minimalen Freigabe)
|
||||
Nur diese Ordner können nach außen gegeben werden:
|
||||
`files/pdf/`, `files/pdf_images/` (Exporte) und `cache/camera/` (Kameraaufnahme).
|
||||
**Nicht** freigegeben: Originalfotos (`photos/`), Unterschriften (`signatures/`), Datenbank,
|
||||
Wundversorger-Register. Der `FileProvider` ist nicht exportiert (`exported="false"`), Rechte werden
|
||||
nur pro Vorgang als Leserecht vergeben.
|
||||
|
||||
### 5.3 Backup
|
||||
- `android:allowBackup="false"` – kein Cloud-/Auto-Backup.
|
||||
- `data_extraction_rules.xml` schließt für `cloud-backup` **und** `device-transfer` die Bereiche
|
||||
`database`, `file` und `sharedpref` aus; es gibt kein `<include>`.
|
||||
- Folge: Beim Gerätewechsel oder nach Deinstallation sind die Daten **nicht** automatisch
|
||||
wiederherstellbar. Das ist gewollt (Datenschutz), muss aber organisatorisch bekannt sein
|
||||
(z. B. PDFs als Archiv in der Einrichtungsdokumentation ablegen).
|
||||
|
||||
### 5.4 Oberfläche
|
||||
- `FLAG_SECURE` im Hauptfenster: keine Screenshots, keine Bildschirmaufnahme, keine Vorschau im
|
||||
App-Wechsler.
|
||||
|
||||
### 5.5 Protokollierung (Logs)
|
||||
- Es gibt keine Konsolenausgaben und keinen `printStackTrace`-Aufruf.
|
||||
- Es existiert genau ein Log-Aufruf (Fehler beim Fotoimport). Er schreibt **nur den Typ** der
|
||||
Exception, nie die Fehlermeldung (diese kann Dateipfade oder URIs enthalten), nie Namen oder
|
||||
Patientendaten.
|
||||
- Fehlermeldungen in der Oberfläche sind allgemein formuliert und enthalten keine Pfade oder
|
||||
Exception-Texte.
|
||||
|
||||
### 5.6 Löschkonzept
|
||||
| Auslöser | Was wird gelöscht |
|
||||
|---|---|
|
||||
| Dokumentation löschen | Datenbankzeilen (inkl. Checklisten, Foto- und Signatur-Einträge), Fotos, Signaturen **und** zugehörige PDF- und Bildexporte |
|
||||
| Einzelnes Foto / einzelne Signatur löschen | Datenbankzeile und Datei |
|
||||
| Patient löschen | Nur möglich, wenn keine Dokumentation mehr existiert |
|
||||
| Automatisch beim App-Start | PDF-/Bildexporte älter als **7 Tage**; liegengebliebene Kameraaufnahmen älter als **24 Stunden** |
|
||||
| Deinstallation der App | Alle App-Daten (Android entfernt den privaten Speicher) |
|
||||
|
||||
Die automatisch gelöschten Exporte lassen sich jederzeit aus der Dokumentation neu erzeugen.
|
||||
Die Fristen stehen als Konstanten in `InternalExportedFilesCleaner` und können angepasst werden.
|
||||
|
||||
### 5.7 Fehlerbehandlung als Datenschutz-/Integritätsmaßnahme
|
||||
Fehlerfälle (Speicher voll, defekte Bilder, PDF-Fehler) führen nicht zu Datenverlust oder zu
|
||||
verwaisten Dateien (siehe Tests aus QA-004): fehlgeschlagene Importe hinterlassen keine
|
||||
Halbdateien, Datenbank und Dateien bleiben konsistent, Autosave-Fehler werden dem Nutzer gemeldet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bekannte Restrisiken und Empfehlungen
|
||||
|
||||
| Nr. | Restrisiko | Bewertung | Empfehlung |
|
||||
|---|---|---|---|
|
||||
| R1 | **Datenbank und Dateien sind nicht zusätzlich verschlüsselt** (nur Android-Sandbox + Gerätespeicher-Verschlüsselung). Auf gerooteten Geräten oder bei kompromittiertem Gerät sind Daten lesbar. | mittel | Gerät mit Bildschirmsperre und aktivierter Speicherverschlüsselung betreiben (Standard seit Android 10). Bei erhöhtem Schutzbedarf Datenbankverschlüsselung (z. B. SQLCipher mit Schlüssel im Android Keystore) einplanen. |
|
||||
| R2 | **Keine App-eigene Sperre** (PIN/Biometrie). Wer das entsperrte Gerät hat, sieht die Daten. | mittel | Gerätesperre verbindlich vorschreiben; optional App-Sperre mit `BiometricPrompt` einführen. |
|
||||
| R3 | **Tastatur-Lernfunktion/Autofill:** Eingaben (Namen, Befund-Freitexte) können von der Bildschirmtastatur oder einem Autofill-Dienst mitgelernt werden. | gering–mittel | Geräte-Richtlinie: nur vertrauenswürdige Tastatur, Personalisierung/Cloud-Funktionen der Tastatur deaktivieren. |
|
||||
| R4 | **Dateiname des PDFs enthält Name und Geburtsdatum** und ist in Ziel-Apps (Mailanhang, Messenger) sichtbar. | gering–mittel | Fachliche Entscheidung: neutralen Dateinamen (z. B. nur Dokumentations-ID/Datum) anbieten. |
|
||||
| R5 | **Weitergabe über andere Apps** liegt nach dem Teilen außerhalb der App-Kontrolle. | organisatorisch | Zulässige Übertragungswege festlegen (z. B. nur dienstliches Postfach/Messenger); Mitarbeiter schulen. |
|
||||
| R6 | **Release-Build ohne Code-Verkleinerung/Obfuskation** (`isMinifyEnabled = false`). | gering | Für die Veröffentlichung R8 aktivieren (bessere Härtung gegen Reverse Engineering; kein Ersatz für Datenschutzmaßnahmen). |
|
||||
| R7 | **Kein automatischer Abgleich/Backup**: Geräteverlust oder Defekt = Datenverlust. | Verfügbarkeit | Organisatorisches Archiv (z. B. PDFs in die Patientenakte der Einrichtung). |
|
||||
| R8 | **Kamera-App des Herstellers** speichert Aufnahmen evtl. zusätzlich in der Galerie/Cloud (bei `ACTION_IMAGE_CAPTURE` mit Ziel-URI normalerweise nicht, herstellerabhängig). | gering | Auf Testgeräten prüfen, ob Aufnahmen zusätzlich in der Galerie erscheinen. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Automatisierte Datenschutz-Tests
|
||||
|
||||
| Test | Art | Prüft |
|
||||
|---|---|---|
|
||||
| `PrivacySourceScanTest` | JVM | Keine Netzwerk-/Tracking-Bibliotheken, keine `println`/`printStackTrace`, Log-Aufrufe ohne Nachricht/Pfad/Personendaten, kein externer Speicher, keine Zwischenablage, keine Texte in Share-Intents, Manifest ohne Berechtigungen, `allowBackup=false`, nur eine exportierte Komponente, Backup-Regeln, FileProvider-Freigaben |
|
||||
| `SafePathsTest` | JVM | Schutz vor Path-Traversal |
|
||||
| `ExportedFilesCleanerTest` | JVM | Löschen der Exporte, Aufbewahrungsfristen, kein Löschen außerhalb der Exportordner |
|
||||
| `PrivacyConfigurationTest` | Gerät/Emulator | Zusammengeführtes Manifest (Berechtigungen, exportierte Komponenten), Backup, FileProvider-Freigaben, private Dateirechte, `FLAG_SECURE` |
|
||||
| `DocumentationDeletionPrivacyTest` | Gerät/Emulator | Löschen einer Dokumentation entfernt auch die Exporte |
|
||||
|
||||
Diese Tests dienen als Regressionsschutz: Sie schlagen an, wenn künftig z. B. eine Berechtigung,
|
||||
eine Netzwerkbibliothek oder eine zu weite Freigabe hinzukommt.
|
||||
|
||||
---
|
||||
|
||||
## 8. Checkliste für Releases
|
||||
|
||||
- [ ] Alle Tests grün (insbesondere die Datenschutz-Tests aus Abschnitt 7)
|
||||
- [ ] Merged Manifest geprüft (Android Studio → `AndroidManifest.xml` → Tab *Merged Manifest*): keine neuen Berechtigungen/exportierten Komponenten
|
||||
- [ ] Neue Abhängigkeiten auf Netzwerk-/Tracking-Funktionen geprüft
|
||||
- [ ] Neue Log-Ausgaben enthalten keine Patientendaten, Pfade, URIs oder Exception-Nachrichten
|
||||
- [ ] Neue Speicherorte liegen im privaten Speicher; neue Freigaben sind in `file_paths.xml` minimal
|
||||
- [ ] Neue Datenfelder sind in Abschnitt 2 dieses Dokuments ergänzt und im Löschkonzept berücksichtigt
|
||||
- [ ] Release-Build signiert, nicht debuggable
|
||||
|
||||
---
|
||||
|
||||
## 9. Organisatorische Hinweise (für die verantwortliche Stelle)
|
||||
|
||||
Diese Punkte kann die App nicht technisch lösen:
|
||||
|
||||
- **Rechtsgrundlage** der Verarbeitung (Behandlungsvertrag / Dokumentationspflicht, Art. 9 Abs. 2
|
||||
lit. h DSGVO) und **Verzeichnis der Verarbeitungstätigkeiten** durch die verantwortliche Stelle
|
||||
- Prüfung, ob eine **Datenschutz-Folgenabschätzung** (Art. 35 DSGVO) erforderlich ist
|
||||
(Gesundheitsdaten, ggf. in größerem Umfang)
|
||||
- **Geräterichtlinie:** Bildschirmsperre, Geräteverschlüsselung, Updates, ggf. MDM, Regelung
|
||||
privater vs. dienstlicher Geräte
|
||||
- **Übertragungswege** für das Teilen von PDFs, Aufbewahrungs- und Löschfristen im Archiv
|
||||
- **Betroffenenrechte** (Auskunft, Berichtigung, Löschung): Löschung ist je Dokumentation in der
|
||||
App möglich; Auskunft über den PDF-Export
|
||||
- Schulung der Anwender, Meldeweg bei Geräteverlust (Meldepflicht bei Datenpannen, Art. 33 DSGVO)
|
||||
|
||||
## 10. Technische Eckdaten für Datenschutzdokumente
|
||||
|
||||
- Verarbeitung ausschließlich lokal auf dem Gerät; keine Empfänger durch die App
|
||||
- Keine Auftragsverarbeiter durch die App (kein Server, keine Cloud, keine Analyse-/Crash-Dienste)
|
||||
- Keine Datenübermittlung in Drittländer durch die App
|
||||
- Keine Cookies, kein Tracking, keine Werbe-ID
|
||||
- Speicherdauer: bis zur Löschung durch den Nutzer; Exporte automatisch nach 7 Tagen
|
||||
- Zugriffsschutz: Android-App-Sandbox, Gerätesperre; `FLAG_SECURE`
|
||||
@@ -0,0 +1,50 @@
|
||||
# QA-006 – Datenschutz-Review: Befunde und Maßnahmen
|
||||
|
||||
Geprüft wurde der Projektstand `dev-QA004` (Manifest, Backup-Regeln, FileProvider, Quelltext,
|
||||
Abhängigkeiten, Speicher-, Lösch- und Exportabläufe). Die App wurde **nicht** auf einem Gerät
|
||||
dynamisch untersucht (kein Netzwerk-Mitschnitt, kein Test der Hersteller-Kamera-App).
|
||||
|
||||
## Bereits gut umgesetzt (keine Änderung nötig)
|
||||
|
||||
| Prüfpunkt | Ergebnis |
|
||||
|---|---|
|
||||
| Berechtigungen | Keine deklariert; Photo Picker und System-Kamera-App statt eigener Kamera-/Speicherzugriffe |
|
||||
| Netzwerk | Kein Netzwerkcode, keine Netzwerk-/Tracking-Bibliotheken |
|
||||
| Backup | `allowBackup=false` und `data_extraction_rules` (Cloud-Backup + Geräte-Transfer) schließen alles aus |
|
||||
| Speicherort | Nur privater interner Speicher, kein öffentlicher/externer Speicher |
|
||||
| Bildschirm | `FLAG_SECURE` (keine Screenshots, keine App-Wechsler-Vorschau) |
|
||||
| Komponenten | Nur `MainActivity` exportiert; `FileProvider` nicht exportiert |
|
||||
| Share-Intents | Nur Datei (`EXTRA_STREAM`), kein Text/Betreff mit Patientendaten |
|
||||
| Datenbank | Keine destruktive Migration (kein stiller Datenverlust bei Updates) |
|
||||
| Metadaten in Fotos | Beim Import neu kodiert → GPS/EXIF des Originals wird nicht übernommen |
|
||||
| Fehlermeldungen in der UI | Ohne Pfade/Exception-Texte |
|
||||
|
||||
## Befunde und umgesetzte Maßnahmen
|
||||
|
||||
| Nr. | Befund | Schwere | Maßnahme | Datei(en) |
|
||||
|---|---|---|---|---|
|
||||
| B1 | **Log beim Fotoimport schrieb `e.message`.** Bei Dateifehlern enthält die Meldung häufig Pfad bzw. Bild-URI – entgegen dem Kommentar im Code. | mittel | Es wird nur noch der Exception-Typ geloggt. | `DocumentationEditViewModel.kt` |
|
||||
| B2 | **PDF- und Bildexporte blieben nach dem Löschen einer Dokumentation im Speicher** (enthalten Name, Geburtsdatum, Anschrift, Befund, Fotos). | hoch | Löschen der Dokumentation entfernt jetzt auch `pdf/<id>/` und `pdf_images/<id>/`. | `ExportedFilesCleaner.kt` (neu), `DocumentationRepository.kt`, `AppContainer.kt` |
|
||||
| B3 | **Exporte hatten keine Aufbewahrungsfrist.** Alte PDFs lagen unbegrenzt im App-Speicher. | mittel | Beim App-Start werden Exporte älter als 7 Tage gelöscht (jederzeit neu erzeugbar). | `ExportedFilesCleaner.kt`, `WunddokuApplication.kt` |
|
||||
| B4 | **Kameraaufnahmen konnten im Cache liegen bleiben**, wenn die App zwischen Aufnahme und Import beendet wurde. | gering–mittel | Aufnahmen älter als 24 h werden beim App-Start gelöscht (Frist bewusst lang, damit eine noch ausstehende Aufnahme nach einem Neustart importierbar bleibt). | `ExportedFilesCleaner.kt` |
|
||||
| B5 | **FileProvider gab `photos/` und `signatures/` frei**, obwohl nie darüber geteilt wird. | gering (Prinzip der minimalen Freigabe) | Freigaben auf `pdf/`, `pdf_images/`, `cache/camera/` reduziert. | `file_paths.xml` |
|
||||
| B6 | **`INTERNET` konnte über eine künftige Bibliothek unbemerkt ins Manifest gelangen.** | gering (Vorsorge) | `INTERNET` und `ACCESS_NETWORK_STATE` werden beim Manifest-Merge explizit entfernt. | `AndroidManifest.xml` |
|
||||
| B7 | **Pfadauflösung ohne Traversal-Schutz** (`File(dir, name)`). Namen stammen aus der eigenen DB (UUIDs), daher kein akutes Risiko. | gering (Defense-in-Depth) | `SafePaths.resolveChild` in Foto-/Signatur-Speicher und Cleaner. | `SafePaths.kt` (neu), `PhotoStorage.kt`, `SignatureStorage.kt` |
|
||||
|
||||
## Offene Punkte (bewusst NICHT geändert – Produktentscheidung nötig)
|
||||
|
||||
| Nr. | Thema | Empfehlung |
|
||||
|---|---|---|
|
||||
| O1 | Datenbank/Dateien nicht zusätzlich verschlüsselt (Restrisiko R1) | Bei erhöhtem Schutzbedarf SQLCipher + Android Keystore; sonst Geräteverschlüsselung + Sperre vorschreiben |
|
||||
| O2 | Keine App-Sperre (PIN/Biometrie) (R2) | `BiometricPrompt` beim Start/Wiederöffnen erwägen |
|
||||
| O3 | PDF-Dateiname enthält Name + Geburtsdatum (R4) | Neutralen Dateinamen als Option; würde `PdfFileNameGenerator` und dessen Tests ändern |
|
||||
| O4 | Tastatur-Lernfunktion/Autofill (R3) | Geräterichtlinie; in Compose nur eingeschränkt steuerbar |
|
||||
| O5 | `isMinifyEnabled = false` (R6) | R8 vor Veröffentlichung aktivieren und Release-Build testen |
|
||||
| O6 | Dynamische Prüfung auf echten Geräten (Netzwerkmitschnitt, Hersteller-Kamera, Galerie-Kopien) | Vor Produktivbetrieb einmal durchführen |
|
||||
|
||||
## Wichtig zu den Änderungen B2/B3
|
||||
|
||||
B2 und B3 ändern das Verhalten: Exportierte PDFs verschwinden nach 7 Tagen und beim Löschen der
|
||||
Dokumentation. Wenn die App PDFs länger vorhalten soll, `DEFAULT_EXPORT_MAX_AGE_MILLIS` in
|
||||
`InternalExportedFilesCleaner` erhöhen. Bereits **geteilte** Kopien in anderen Apps sind davon
|
||||
natürlich nicht betroffen.
|
||||
Reference in New Issue
Block a user