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
|
backup bridge produce a signed release APK, allowing a strictly increasing
|
||||||
private update chain without changing Trainlog schemas or protocols.
|
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
|
- 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
|
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
|
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
@ -26,17 +27,19 @@ class BridgeBackupMigrationTest {
|
||||||
var repository = TrainlogRepository(context, name)
|
var repository = TrainlogRepository(context, name)
|
||||||
try {
|
try {
|
||||||
val verified = FileInputStream(source!!).use { service.verify(it).getOrThrow() }
|
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)
|
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"21,",
|
hadActiveDraft,
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
|
||||||
.draft
|
|
||||||
.form
|
|
||||||
.weightText,
|
|
||||||
)
|
)
|
||||||
android.database.sqlite.SQLiteDatabase.openDatabase(
|
val migrated = context.getDatabasePath(name)
|
||||||
context.getDatabasePath(name).path,
|
assertEquals(historical, historicalDump(migrated, historical.keys))
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
migrated.path,
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
|
|
@ -46,4 +49,81 @@ class BridgeBackupMigrationTest {
|
||||||
context.deleteDatabase(name)
|
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,
|
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 real v13/v12 fixtures with explicit output directories. Synthetic bridge
|
||||||
and current-schema migration proofs run separately; no user database is read.
|
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