test(android): validate real bridge backups
This commit is contained in:
parent
0018edff26
commit
1552dcac1f
3 changed files with 98 additions and 7 deletions
|
|
@ -10,6 +10,10 @@
|
|||
backup bridge produce a signed release APK, allowing a strictly increasing
|
||||
private update chain without changing Trainlog schemas or protocols.
|
||||
|
||||
- Generalized bridge restore/migration validation to preserve every historical
|
||||
SQLite value whether a real backup contains an active draft or legitimately
|
||||
contains none.
|
||||
|
||||
- Closed the native candidate warning gate with checked path and HTTP response
|
||||
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
|
||||
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
|
@ -26,17 +27,19 @@ class BridgeBackupMigrationTest {
|
|||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val verified = FileInputStream(source!!).use { service.verify(it).getOrThrow() }
|
||||
val historical = historicalDump(verified.database)
|
||||
val activeDraft = historical.entries.single { it.key.startsWith("active_session_draft|") }
|
||||
val hadActiveDraft = activeDraft.value.isNotEmpty()
|
||||
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
|
||||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(
|
||||
"21,",
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.form
|
||||
.weightText,
|
||||
hadActiveDraft,
|
||||
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
|
||||
)
|
||||
android.database.sqlite.SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
val migrated = context.getDatabasePath(name)
|
||||
assertEquals(historical, historicalDump(migrated, historical.keys))
|
||||
SQLiteDatabase.openDatabase(
|
||||
migrated.path,
|
||||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
|
|
@ -46,4 +49,81 @@ class BridgeBackupMigrationTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: every pre-migration column and value is compared exactly;
|
||||
* additive v18-v20 tables/columns are outside the historical shape.
|
||||
* INVARIANT: the supplied archive is opened read-only and only the
|
||||
* test-owned restored database is migrated.
|
||||
*/
|
||||
private fun historicalDump(
|
||||
databasePath: File,
|
||||
shape: Set<String>? = null,
|
||||
): Map<String, List<List<String>>> =
|
||||
SQLiteDatabase.openDatabase(
|
||||
databasePath.path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { database ->
|
||||
val tables =
|
||||
database
|
||||
.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' " +
|
||||
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' " +
|
||||
"ORDER BY name;",
|
||||
null,
|
||||
)
|
||||
.use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) add(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
val specifications =
|
||||
shape
|
||||
?: tables
|
||||
.map { table ->
|
||||
val columns =
|
||||
database.rawQuery("PRAGMA table_info(`$table`);", null).use {
|
||||
cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) add(cursor.getString(1))
|
||||
}
|
||||
}
|
||||
"$table|${columns.joinToString(",")}"
|
||||
}
|
||||
.toSet()
|
||||
specifications.associateWith { specification ->
|
||||
val table = specification.substringBefore('|')
|
||||
val columns = specification.substringAfter('|').split(',')
|
||||
val selection = columns.joinToString(",") { "`$it`" }
|
||||
database
|
||||
.rawQuery(
|
||||
"SELECT $selection FROM `$table` ORDER BY rowid;",
|
||||
null,
|
||||
)
|
||||
.use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
add(
|
||||
(0 until cursor.columnCount).map { column ->
|
||||
when (cursor.getType(column)) {
|
||||
android.database.Cursor.FIELD_TYPE_NULL -> "null"
|
||||
android.database.Cursor.FIELD_TYPE_INTEGER ->
|
||||
"integer:${cursor.getLong(column)}"
|
||||
android.database.Cursor.FIELD_TYPE_FLOAT ->
|
||||
"float:${java.lang.Double.toHexString(cursor.getDouble(column))}"
|
||||
android.database.Cursor.FIELD_TYPE_STRING ->
|
||||
"string:${cursor.getString(column)}"
|
||||
android.database.Cursor.FIELD_TYPE_BLOB ->
|
||||
"blob:${cursor.getBlob(column).contentToString()}"
|
||||
else -> error("Unknown SQLite field type")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1354,3 +1354,10 @@ and the version-thirteen and version-twelve methods in
|
|||
bridge backup input, a real v9 fixture, a real v14 fixture/output directory,
|
||||
and real v13/v12 fixtures with explicit output directories. Synthetic bridge
|
||||
and current-schema migration proofs run separately; no user database is read.
|
||||
|
||||
The bridge migration test accepts both legitimate backup states: an active
|
||||
draft may be present or absent. It snapshots every original table, column,
|
||||
SQLite value type and value before restore, migrates through the production
|
||||
repository owner, and compares that complete historical shape afterward. The
|
||||
draft assertion follows the archived state instead of requiring the synthetic
|
||||
fixture's sample draft.
|
||||
|
|
|
|||
Loading…
Reference in a new issue