2026-06-11 19:20:48 +02:00
|
|
|
package no.naiv.meddetsamme.backup
|
|
|
|
|
|
|
|
|
|
import androidx.test.core.app.ApplicationProvider
|
|
|
|
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
|
|
|
import kotlinx.coroutines.runBlocking
|
|
|
|
|
import no.naiv.meddetsamme.data.InventoryItem
|
|
|
|
|
import no.naiv.meddetsamme.data.MedDatabase
|
|
|
|
|
import no.naiv.meddetsamme.data.MedForm
|
|
|
|
|
import no.naiv.meddetsamme.data.Medication
|
|
|
|
|
import no.naiv.meddetsamme.settings.SettingsStore
|
|
|
|
|
import org.junit.After
|
|
|
|
|
import org.junit.Assert.assertEquals
|
|
|
|
|
import org.junit.Assert.assertNotNull
|
|
|
|
|
import org.junit.Assert.assertNull
|
|
|
|
|
import org.junit.Assert.assertTrue
|
|
|
|
|
import org.junit.Before
|
|
|
|
|
import org.junit.Test
|
|
|
|
|
import org.junit.runner.RunWith
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The Google Auto Backup path end to end, without the OS backup transport
|
|
|
|
|
* (which UI/automation can't drive deterministically): writeCloudBlob encrypts
|
|
|
|
|
* with the stored passphrase, pendingCloudRestore fires only on an empty DB,
|
|
|
|
|
* and import() with the right passphrase brings the data back. This is the
|
|
|
|
|
* "new phone" recovery, and it must keep working — Keystore won't migrate, so
|
|
|
|
|
* the typed passphrase is the only key.
|
|
|
|
|
*/
|
|
|
|
|
@RunWith(AndroidJUnit4::class)
|
|
|
|
|
class CloudBackupRoundTripTest {
|
|
|
|
|
|
|
|
|
|
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
|
|
|
private val db get() = MedDatabase.get(context)
|
|
|
|
|
private val manager = BackupManager(context)
|
|
|
|
|
|
|
|
|
|
@Before fun clear() {
|
|
|
|
|
runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
|
|
|
|
|
BackupManager.cloudBlobFile(context).delete()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@After fun cleanup() {
|
|
|
|
|
runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
|
|
|
|
|
BackupManager.cloudBlobFile(context).delete()
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test fun writeThenRestoreOnEmptyDb() = runBlocking {
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = "korrekt hest"
|
|
|
|
|
val itemId = db.inventoryDao().insert(
|
|
|
|
|
InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0),
|
|
|
|
|
)
|
|
|
|
|
db.medicationDao().insert(Medication(itemId = itemId, notes = "fastende"))
|
|
|
|
|
|
|
|
|
|
manager.writeCloudBlob()
|
|
|
|
|
val blob = BackupManager.cloudBlobFile(context)
|
|
|
|
|
assertTrue("blob written", blob.exists())
|
|
|
|
|
assertTrue("blob is an age v1 file", blob.readBytes().decodeToString(0, 21) == "age-encryption.org/v1")
|
|
|
|
|
|
|
|
|
|
// Non-empty DB → no restore offered.
|
|
|
|
|
assertNull(manager.pendingCloudRestore())
|
|
|
|
|
|
|
|
|
|
// Simulate the new device: empty DB, blob restored by the OS.
|
|
|
|
|
db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList())
|
|
|
|
|
val pending = manager.pendingCloudRestore()
|
|
|
|
|
assertNotNull("restore offered on empty DB", pending)
|
|
|
|
|
|
|
|
|
|
val count = manager.import(pending!!.readBytes(), "korrekt hest".toCharArray())
|
|
|
|
|
assertEquals(1, count)
|
|
|
|
|
assertEquals("Levaxin", db.inventoryDao().getAll().single().name)
|
|
|
|
|
assertEquals("fastende", db.medicationDao().getAllWithItems().single().med.notes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test fun wrongPassphraseFailsAndKeepsDbEmpty() = runBlocking {
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = "riktig"
|
|
|
|
|
val itemId = db.inventoryDao().insert(
|
|
|
|
|
InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0),
|
|
|
|
|
)
|
|
|
|
|
db.medicationDao().insert(Medication(itemId = itemId))
|
|
|
|
|
manager.writeCloudBlob()
|
|
|
|
|
db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList())
|
|
|
|
|
|
|
|
|
|
val blob = manager.pendingCloudRestore()!!
|
|
|
|
|
try {
|
|
|
|
|
manager.import(blob.readBytes(), "feil".toCharArray())
|
|
|
|
|
org.junit.Assert.fail("wrong passphrase must throw")
|
|
|
|
|
} catch (_: Exception) { /* expected */ }
|
|
|
|
|
assertTrue("DB still empty after failed restore", db.inventoryDao().getAll().isEmpty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:20:16 +02:00
|
|
|
@Test fun testBackupWithOnlyPassphraseSucceeds() = runBlocking {
|
|
|
|
|
// The user's report: passphrase set, no S3 → "Test backup" must NOT
|
|
|
|
|
// error with "not configured"; it writes the Google blob and says so.
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = "x"
|
|
|
|
|
db.inventoryDao().insert(InventoryItem(name = "A", strength = "", unit = "stk", form = MedForm.OTHER))
|
|
|
|
|
|
|
|
|
|
val outcome = manager.runConfiguredBackups()
|
|
|
|
|
assertTrue("cloud blob written", outcome.cloudBlobWritten)
|
|
|
|
|
assertNull("no S3 attempted when unconfigured", outcome.s3Key)
|
|
|
|
|
assertTrue(BackupManager.cloudBlobFile(context).exists())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test fun testBackupWithNothingConfiguredThrows() = runBlocking {
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = null
|
|
|
|
|
try {
|
|
|
|
|
manager.runConfiguredBackups()
|
|
|
|
|
org.junit.Assert.fail("must require at least one mechanism")
|
|
|
|
|
} catch (_: IllegalArgumentException) { /* expected */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:20:48 +02:00
|
|
|
@Test fun noPassphraseRemovesBlob() = runBlocking {
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = "x"
|
|
|
|
|
db.inventoryDao().insert(InventoryItem(name = "A", strength = "", unit = "stk", form = MedForm.OTHER))
|
|
|
|
|
manager.writeCloudBlob()
|
|
|
|
|
assertTrue(BackupManager.cloudBlobFile(context).exists())
|
|
|
|
|
|
|
|
|
|
// Clearing the passphrase must stop offering data to Google.
|
|
|
|
|
SettingsStore(context).autoBackupPassphrase = null
|
|
|
|
|
manager.writeCloudBlob()
|
|
|
|
|
assertTrue("blob removed when passphrase cleared", !BackupManager.cloudBlobFile(context).exists())
|
|
|
|
|
}
|
|
|
|
|
}
|