diff --git a/CHANGELOG.md b/CHANGELOG.md index b852084..7f0c804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index 5ba607b..b9f647e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -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? = null, + ): Map>> = + 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") + } + } + ) + } + } + } + } + } } diff --git a/docs/tests.md b/docs/tests.md index 8dea9f5..e1b3a27 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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.