test(android): validate real bridge backups

This commit is contained in:
fy59 2026-09-17 16:08:36 +02:00
parent 0018edff26
commit 1552dcac1f
3 changed files with 98 additions and 7 deletions

View file

@ -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

View file

@ -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")
}
}
)
}
}
}
}
}
}

View file

@ -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.