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.
123 lines
6.6 KiB
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)
|
|
}
|
|
}
|
|
|