You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/MusicTests/RemoteDBIntegrityTests.swift

123 lines
6.6 KiB

import Testing
import Foundation
import GRDB
@testable import Music
/// Tests for the remote-DB transfer integrity guard. The bug under test: connecting to
/// a remote host downloaded a database that opened with "database disk image is malformed"
/// (SQLITE_CORRUPT) on `SELECT * FROM sqlite_master`. The fix validates the database with
/// `PRAGMA quick_check` on both ends (host before serving, client after writing) so a
/// malformed image is rejected with a clear error instead of crashing the UI.
@MainActor
struct RemoteDBIntegrityTests {
/// Build a real DatabaseService (DatabasePool/WAL, like the running app) at a fresh
/// temp path, populated with `count` tracks, and return (tempDir, dbPath).
private func makePopulatedDB(count: Int) throws -> (dir: URL, path: String) {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let path = tempDir.appendingPathComponent("src.sqlite").path
let db = try DatabaseService(path: path)
for i in 1...count {
var t = Track.fixture(fileURL: "/track\(i).mp3", title: "Song number \(i) with several words")
try db.insert(&t)
}
return (tempDir, path)
}
@Test
func backupCopyIsWellFormedAndSurvivesHTTPFraming() throws {
// 1. Build a source DB large enough to span many pages, like the real ~5.8MB library.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
// 2. Produce the served copy exactly as the host does (VACUUM INTO via backup()).
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
// 3. The copy must pass the integrity gate the host now applies before serving.
#expect(DatabaseService.isWellFormedDatabase(atPath: copyPath))
// 4. Frame the copy into an HTTP response byte-for-byte like HostServer.sendHTTP,
// then parse it back like RemoteClient.handleDBData (split on the first \r\n\r\n).
let bodyBytes = try Data(contentsOf: URL(fileURLWithPath: copyPath))
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(bodyBytes.count)\r\nConnection: close\r\n\r\n".utf8)
response.append(bodyBytes)
let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A]
let sepRange = try #require(response.range(of: Data(separator)))
let parsedBody = Data(response[sepRange.upperBound...])
// 5. The framed-then-parsed body must be byte-identical to the original copy.
#expect(parsedBody == bodyBytes)
// 6. Writing it out yields a DB that passes the gate, opens cleanly, and has all rows.
let recvPath = tempDir.appendingPathComponent("received.sqlite").path
try parsedBody.write(to: URL(fileURLWithPath: recvPath))
#expect(DatabaseService.isWellFormedDatabase(atPath: recvPath))
let reopened = try DatabaseService(path: recvPath)
let n = try reopened.dbPool.read { db in try Int.fetchOne(db, sql: "SELECT count(*) FROM tracks") }
#expect(n == 2000)
}
@Test
func truncatedDownloadIsRejected() throws {
// Reproduces the user's exact symptom: a page-aligned but incomplete SQLite image.
// 1. Build a valid served copy.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
let full = try Data(contentsOf: URL(fileURLWithPath: copyPath))
// 2. Keep the first half still a whole number of 4096-byte pages with a valid
// SQLite header, exactly the shape of the user's "5828608 bytes" malformed file.
let truncatedLen = (full.count / 2 / 4096) * 4096
let truncated = Data(full.prefix(truncatedLen))
let truncPath = tempDir.appendingPathComponent("truncated.sqlite").path
try truncated.write(to: URL(fileURLWithPath: truncPath))
// 3. The gate must reject it. Before the fix the client handed this straight to
// GRDB, which crashed with "database disk image is malformed".
#expect(DatabaseService.isWellFormedDatabase(atPath: truncPath) == false)
}
@Test
func corruptInteriorBytesAreRejected() throws {
// 1. Build a valid served copy.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
var bytes = try Data(contentsOf: URL(fileURLWithPath: copyPath))
// 2. Leave the SQLite header intact (so it's still recognized as a database this
// yields SQLITE_CORRUPT/error 11, like the user saw, not "not a database"/error 26)
// but smash a stretch of an interior b-tree page.
for i in 4096..<4600 { bytes[i] = 0xFF }
let corruptPath = tempDir.appendingPathComponent("corrupt.sqlite").path
try bytes.write(to: URL(fileURLWithPath: corruptPath))
// 3. The gate must reject the malformed image.
#expect(DatabaseService.isWellFormedDatabase(atPath: corruptPath) == false)
}
@Test
func emptyMissingAndGarbageFilesAreRejected() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
// 1. A missing file is not well-formed.
#expect(DatabaseService.isWellFormedDatabase(atPath: tempDir.appendingPathComponent("nope.sqlite").path) == false)
// 2. An empty file (0 bytes) is not well-formed.
let emptyPath = tempDir.appendingPathComponent("empty.sqlite").path
try Data().write(to: URL(fileURLWithPath: emptyPath))
#expect(DatabaseService.isWellFormedDatabase(atPath: emptyPath) == false)
// 3. A short text body (e.g. an HTTP error message written as if it were a DB) is rejected.
let garbagePath = tempDir.appendingPathComponent("garbage.sqlite").path
try Data("Failed to read database".utf8).write(to: URL(fileURLWithPath: garbagePath))
#expect(DatabaseService.isWellFormedDatabase(atPath: garbagePath) == false)
}
}