File size: 9,775 Bytes
4977db2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | import CryptoKit
import Foundation
import GRDB
import XCTest
@testable import PromptGarageCore
/// Opt-in integration coverage for Samuel's real legacy database.
///
/// Run on the Mac with:
/// `PROMPTGARAGE_SOURCE_DB=/absolute/path/to/prompts.db swift test --filter RealDatabaseMigrationTests`
///
/// The source is hashed before and after, and all SQLite connections opened by
/// the production bootstrap service are read-only. Only a disposable copy below
/// XCTest's temporary directory is migrated.
final class RealDatabaseMigrationTests: XCTestCase {
func testRealSourceInstallsToTemporaryDestinationWithoutSourceMutationOrBackfill() throws {
let environment = ProcessInfo.processInfo.environment
guard let sourcePath = environment["PROMPTGARAGE_SOURCE_DB"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!sourcePath.isEmpty
else {
throw XCTSkip("Set PROMPTGARAGE_SOURCE_DB to opt in to the real legacy DB migration test.")
}
let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
XCTFail("PROMPTGARAGE_SOURCE_DB does not exist: \(sourceURL.path)")
return
}
let sourceFamilyBefore = try databaseFamilySnapshot(at: sourceURL)
let sourceQueue = try readOnlyQueue(
at: sourceURL,
label: "PromptGarageTests.source.readonly"
)
let sourceLegacySnapshot: LegacyRowsSnapshot
do {
sourceLegacySnapshot = try legacyRowsSnapshot(in: sourceQueue)
try sourceQueue.close()
} catch {
try? sourceQueue.close()
throw error
}
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("PromptGarageRealMigration-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
let service = try DatabaseBootstrapService(
applicationSupportRoot: root.appendingPathComponent("ApplicationSupport")
)
let sourceReport = try service.validateSource(at: sourceURL)
XCTAssertFalse(
sourceReport.hasPromptedAt,
"The opt-in source must be the untouched legacy DB, not an already migrated app copy."
)
XCTAssertGreaterThan(sourceReport.promptCount, 0)
let repository = try service.installDatabase(from: sourceURL)
let destinationQueue = try readOnlyQueue(
at: service.databaseURL,
label: "PromptGarageTests.destination.readonly"
)
defer { try? destinationQueue.close() }
let destinationReport = try DatabaseSchema.validateReadOnly(destinationQueue)
let destinationLegacySnapshot = try legacyRowsSnapshot(in: destinationQueue)
let fourParityCounts = [
sourceReport.promptCount,
sourceReport.ftsCount,
destinationReport.promptCount,
destinationReport.ftsCount,
]
XCTAssertEqual(
Set(fourParityCounts).count,
1,
"source prompts/source FTS/destination prompts/destination FTS must stay dynamically equal: \(fourParityCounts)"
)
XCTAssertTrue(destinationReport.hasPromptedAt)
XCTAssertEqual(try repository.count(), sourceReport.promptCount)
XCTAssertEqual(try repository.legacyUndatedCount(), sourceReport.promptCount)
XCTAssertEqual(sourceLegacySnapshot.rowCount, sourceReport.promptCount)
XCTAssertEqual(destinationLegacySnapshot.rowCount, destinationReport.promptCount)
XCTAssertEqual(
destinationLegacySnapshot.columnDigests,
sourceLegacySnapshot.columnDigests,
"Every value in every legacy column must survive the copy exactly."
)
XCTAssertEqual(
destinationLegacySnapshot.orderedRowsDigest,
sourceLegacySnapshot.orderedRowsDigest,
"Legacy row/column associations must remain byte-for-byte equivalent in deterministic id order."
)
let destinationLegacyNullCount = try destinationQueue.read { db in
try Int.fetchOne(
db,
sql: "SELECT COUNT(*) FROM prompts WHERE prompted_at IS NULL"
) ?? -1
}
XCTAssertEqual(
destinationLegacyNullCount,
sourceReport.promptCount,
"Adding prompted_at to the app copy must not infer or backfill dates for any legacy row."
)
XCTAssertEqual(
try databaseFamilySnapshot(at: sourceURL),
sourceFamilyBefore,
"The selected source DB family (main, WAL, SHM, or journal) changed during a supposedly read-only migration."
)
}
private let legacyColumns = [
"id", "title", "content", "source", "is_starred", "tags", "model",
"rating", "use_count", "created_at", "updated_at",
]
private func readOnlyQueue(at url: URL, label: String) throws -> DatabaseQueue {
var configuration = Configuration()
configuration.label = label
configuration.readonly = true
configuration.busyMode = .timeout(5)
return try DatabaseQueue(path: url.path, configuration: configuration)
}
/// Hashes every SQLite value with an explicit storage-class marker and
/// length framing. Per-column digests prove column preservation; the full
/// digest additionally proves that values stayed attached to the same row
/// in deterministic primary-key order.
private func legacyRowsSnapshot(in queue: DatabaseQueue) throws -> LegacyRowsSnapshot {
try queue.read { db in
let selectedColumns = legacyColumns
.map { "\"\($0)\"" }
.joined(separator: ", ")
let cursor = try Row.fetchCursor(
db,
sql: "SELECT \(selectedColumns) FROM prompts ORDER BY id ASC"
)
var rowCount = 0
var orderedRowsHasher = SHA256()
var columnHashers = legacyColumns.map { _ in SHA256() }
while let row = try cursor.next() {
rowCount += 1
update(&orderedRowsHasher, withUnsignedInteger: UInt64(rowCount))
for (index, column) in legacyColumns.enumerated() {
let value: DatabaseValue = row[column]
update(&orderedRowsHasher, with: value)
update(&columnHashers[index], with: value)
}
}
let columnDigests = Dictionary(uniqueKeysWithValues: zip(
legacyColumns,
columnHashers.map { hasher in
var hasher = hasher
return hexadecimal(hasher.finalize())
}
))
return LegacyRowsSnapshot(
rowCount: rowCount,
orderedRowsDigest: hexadecimal(orderedRowsHasher.finalize()),
columnDigests: columnDigests
)
}
}
private func update(_ hasher: inout SHA256, with value: DatabaseValue) {
switch value.storage {
case .null:
hasher.update(data: Data([0]))
case let .int64(value):
hasher.update(data: Data([1]))
update(&hasher, withUnsignedInteger: UInt64(bitPattern: value))
case let .double(value):
hasher.update(data: Data([2]))
update(&hasher, withUnsignedInteger: value.bitPattern)
case let .string(value):
hasher.update(data: Data([3]))
update(&hasher, withLengthPrefixed: Data(value.utf8))
case let .blob(value):
hasher.update(data: Data([4]))
update(&hasher, withLengthPrefixed: value)
}
}
private func update(_ hasher: inout SHA256, withLengthPrefixed data: Data) {
update(&hasher, withUnsignedInteger: UInt64(data.count))
hasher.update(data: data)
}
private func update(_ hasher: inout SHA256, withUnsignedInteger value: UInt64) {
var bigEndian = value.bigEndian
withUnsafeBytes(of: &bigEndian) { bytes in
hasher.update(data: Data(bytes))
}
}
private func databaseFamilySnapshot(at mainURL: URL) throws -> [DatabaseFileSnapshot] {
try ["", "-wal", "-shm", "-journal"].map { suffix in
let url = URL(fileURLWithPath: mainURL.path + suffix)
guard FileManager.default.fileExists(atPath: url.path) else {
return DatabaseFileSnapshot(suffix: suffix, byteCount: nil, sha256: nil)
}
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
let byteCount = (attributes[.size] as? NSNumber)?.uint64Value
return DatabaseFileSnapshot(
suffix: suffix,
byteCount: byteCount,
sha256: try sha256(of: url)
)
}
}
private func sha256(of url: URL) throws -> String {
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
return hexadecimal(SHA256.hash(data: data))
}
private func hexadecimal<D: Sequence>(_ digest: D) -> String where D.Element == UInt8 {
digest.map { String(format: "%02x", $0) }.joined()
}
}
private struct LegacyRowsSnapshot: Equatable {
let rowCount: Int
let orderedRowsDigest: String
let columnDigests: [String: String]
}
private struct DatabaseFileSnapshot: Equatable {
let suffix: String
let byteCount: UInt64?
let sha256: String?
}
|