Signal-iOS/SignalServiceKit/tests/Storage/Database/GRDBSchemaMigratorTest.swift
2024-04-05 21:50:00 -05:00

349 lines
15 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import GRDB
import XCTest
@testable import SignalServiceKit
class GRDBSchemaMigratorTest: XCTestCase {
func testMigrateFromScratch() throws {
let databaseStorage = try SDSDatabaseStorage(
databaseFileUrl: OWSFileSystem.temporaryFileUrl(),
keychainStorage: MockKeychainStorage()
)
try GRDBSchemaMigrator.migrateDatabase(
databaseStorage: databaseStorage,
isMainDatabase: false
)
databaseStorage.read { transaction in
let db = transaction.unwrapGrdbRead.database
let sql = "SELECT name FROM sqlite_schema WHERE type IS 'table'"
let allTableNames = (try? String.fetchAll(db, sql: sql)) ?? []
XCTAssert(allTableNames.contains(TSThread.table.tableName))
}
}
private func keyedArchiverData(rootObject: Any) -> Data {
try! NSKeyedArchiver.archivedData(withRootObject: rootObject, requiringSecureCoding: true)
}
func testMigrateVoiceMessageDrafts() throws {
let collection = "DraftVoiceMessage"
let baseUrl = URL(fileURLWithPath: "/not/a/real/path", isDirectory: true)
let initialEntries: [(String, String, Data)] = [
(collection, "00000000-0000-4000-8000-000000000001", keyedArchiverData(rootObject: NSNumber(true))),
(collection, "00000000-0000-4000-8000-000000000002", keyedArchiverData(rootObject: NSNumber(true))),
(collection, "00000000-0000-4000-8000-000000000003", keyedArchiverData(rootObject: NSNumber(false))),
(collection, "00000000-0000-4000-8000-000000000004", keyedArchiverData(rootObject: [6, 7, 8, 9, 10])),
(collection, "abc1+/==", keyedArchiverData(rootObject: NSNumber(true))),
("UnrelatedCollection", "SomeKey", Data(count: 3))
]
// Set up the database with sample data that may have existed.
let databaseQueue = DatabaseQueue()
try databaseQueue.write { db in
// A snapshot of the key value store as it existed when this migration was
// added. If the key value store's schema is updated in the future, don't
// update this call site. It must remain as a snapshot.
try db.execute(
sql: "CREATE TABLE keyvalue (key TEXT NOT NULL, collection TEXT NOT NULL, value BLOB NOT NULL, PRIMARY KEY (key, collection))"
)
for (collection, key, value) in initialEntries {
try db.execute(
sql: "INSERT INTO keyvalue (collection, key, value) VALUES (?, ?, ?)",
arguments: [collection, key, value]
)
}
}
// Run the test.
var copyResults: [Result<Void, Error>] = [.success(()), .failure(CocoaError(.fileNoSuchFile)), .success(())]
var copyRequests = [(URL, URL)]()
let copyItem = { (src: URL, dst: URL) throws in
copyRequests.append((src, dst))
return try copyResults.removeFirst().get()
}
try databaseQueue.write { db in
let transaction = GRDBWriteTransaction(database: db)
defer { transaction.finalizeTransaction() }
try GRDBSchemaMigrator.migrateVoiceMessageDrafts(
transaction: transaction,
appSharedDataUrl: baseUrl,
copyItem: copyItem
)
}
// Validate the ending state.
let rows = try databaseQueue.read {
try Row.fetchAll($0, sql: "SELECT collection, key, value FROM keyvalue ORDER BY collection, key")
}
let migratedFilenames = Dictionary(uniqueKeysWithValues: copyRequests.map { ($0.0.lastPathComponent, $0.1.lastPathComponent) })
XCTAssertEqual(rows.count, 3)
XCTAssertEqual(rows[0]["collection"], collection)
XCTAssertEqual(rows[0]["key"], "00000000-0000-4000-8000-000000000001")
XCTAssertEqual(
rows[0]["value"],
keyedArchiverData(rootObject: migratedFilenames["00000000%2D0000%2D4000%2D8000%2D000000000001"]!)
)
XCTAssertEqual(rows[1]["collection"], collection)
XCTAssertEqual(rows[1]["key"], "abc1+/==")
XCTAssertEqual(
rows[1]["value"],
keyedArchiverData(rootObject: migratedFilenames["abc1%2B%2F%3D%3D"]!)
)
XCTAssertEqual(rows[2]["collection"], "UnrelatedCollection")
XCTAssertEqual(rows[2]["key"], "SomeKey")
XCTAssertEqual(rows[2]["value"], Data(count: 3))
}
func testMigrateThreadReplyInfos() throws {
let collection = "TSThreadReplyInfo"
let initialEntries: [(String, String, String)] = [
(collection, "00000000-0000-4000-8000-000000000001", #"{"author":{"backingUuid":"00000000-0000-4000-8000-00000000000A","backingPhoneNumber":null},"timestamp":1683201600000}"#),
(collection, "00000000-0000-4000-8000-000000000002", #"{"author":{"backingUuid":null,"backingPhoneNumber":"+16505550100"},"timestamp":1683201600000}"#),
(collection, "00000000-0000-4000-8000-000000000003", "ABC123"),
("UnrelatedCollection", "00000000-0000-4000-8000-000000000001", #"{"author":{"backingUuid":"00000000-0000-4000-8000-00000000000A","backingPhoneNumber":null},"timestamp":1683201600000}"#)
]
// Set up the database with sample data that may have existed.
let databaseQueue = DatabaseQueue()
try databaseQueue.write { db in
// A snapshot of the key value store as it existed when this migration was
// added. If the key value store's schema is updated in the future, don't
// update this call site. It must remain as a snapshot.
try db.execute(
sql: "CREATE TABLE keyvalue (key TEXT NOT NULL, collection TEXT NOT NULL, value BLOB NOT NULL, PRIMARY KEY (key, collection))"
)
for (collection, key, value) in initialEntries {
try db.execute(
sql: "INSERT INTO keyvalue (collection, key, value) VALUES (?, ?, ?)",
arguments: [collection, key, try XCTUnwrap(value.data(using: .utf8))]
)
}
}
// Run the test.
try databaseQueue.write { db in
let transaction = GRDBWriteTransaction(database: db)
defer { transaction.finalizeTransaction() }
try GRDBSchemaMigrator.migrateThreadReplyInfos(transaction: transaction)
}
// Validate the ending state.
let rows = try databaseQueue.read {
try Row.fetchAll($0, sql: "SELECT collection, key, value FROM keyvalue ORDER BY collection, key")
}
XCTAssertEqual(rows.count, 3)
XCTAssertEqual(rows[0]["collection"], collection)
XCTAssertEqual(rows[0]["key"], "00000000-0000-4000-8000-000000000001")
XCTAssertEqual(rows[0]["value"], #"{"author":"00000000-0000-4000-8000-00000000000A","timestamp":1683201600000}"#)
XCTAssertEqual(rows[1]["collection"], collection)
XCTAssertEqual(rows[1]["key"], "00000000-0000-4000-8000-000000000003")
XCTAssertEqual(rows[1]["value"], "ABC123")
XCTAssertEqual(rows[2]["collection"], "UnrelatedCollection")
XCTAssertEqual(rows[2]["key"], "00000000-0000-4000-8000-000000000001")
XCTAssertEqual(rows[2]["value"], #"{"author":{"backingUuid":"00000000-0000-4000-8000-00000000000A","backingPhoneNumber":null},"timestamp":1683201600000}"#)
}
func testMigrateEditRecords() throws {
let tableName = EditRecord.databaseTableName
let tempTableName = "\(EditRecord.databaseTableName)_temp"
let databaseQueue = DatabaseQueue()
let initialValues: [(Int64, Int64, Int64)] = [
(0, 0, 1),
(1, 0, 2),
(2, 3, 4),
(3, 4, 5),
(4, 6, 7),
(5, 6, 8)
]
try setupEditRecordMigrationTables(
databaseQueue: databaseQueue,
initialRecords: initialValues,
initialInteractionIds: Array(0...8)
)
try databaseQueue.write { db in
let tx = GRDBWriteTransaction(database: db)
defer { tx.finalizeTransaction() }
try GRDBSchemaMigrator.migrateEditRecordTable(tx: tx)
}
let exists = checkTableExists(tableName: tableName, databaseQueue: databaseQueue)
let tempExists = checkTableExists(tableName: tempTableName, databaseQueue: databaseQueue)
let count = try databaseQueue.read({ db in
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(tableName)")
})
XCTAssertTrue(exists)
XCTAssertFalse(tempExists)
XCTAssertEqual(count, initialValues.count)
}
func testMigrateEditRecordsEmptyExisting() throws {
let tableName = EditRecord.databaseTableName
let tempTableName = "\(EditRecord.databaseTableName)_temp"
let databaseQueue = DatabaseQueue()
try setupEditRecordMigrationTables(
databaseQueue: databaseQueue,
initialRecords: [],
initialInteractionIds: Array(0...8)
)
try databaseQueue.write { db in
let tx = GRDBWriteTransaction(database: db)
defer { tx.finalizeTransaction() }
try GRDBSchemaMigrator.migrateEditRecordTable(tx: tx)
}
let exists = checkTableExists(tableName: tableName, databaseQueue: databaseQueue)
let tempExists = checkTableExists(tableName: tempTableName, databaseQueue: databaseQueue)
XCTAssertTrue(exists)
XCTAssertFalse(tempExists)
}
}
extension GRDBSchemaMigratorTest {
fileprivate func checkTableExists(tableName: String, databaseQueue: DatabaseQueue) -> Bool {
do {
try databaseQueue.read({ db in
try db.execute(sql: "SELECT EXISTS (SELECT 1 FROM \(tableName));")
})
return true
} catch {
return false
}
}
fileprivate func setupEditRecordMigrationTables(
databaseQueue: DatabaseQueue,
initialRecords: [(Int64, Int64, Int64)],
initialInteractionIds: [Int64]
) throws {
try databaseQueue.write { db in
try db.execute(
sql: """
CREATE TABLE model_TSInteraction (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
);
"""
)
for x in initialInteractionIds {
try db.execute(
sql: "INSERT INTO model_TSInteraction (id) VALUES (?)",
arguments: [x]
)
}
let tx = GRDBWriteTransaction(database: db)
try GRDBSchemaMigrator.createEditRecordTable(tx: tx)
tx.finalizeTransaction()
for (id, latest, past) in initialRecords {
try db.execute(
sql: "INSERT INTO EditRecord (id, latestRevisionId, pastRevisionId) VALUES (?, ?, ?)",
arguments: [id, latest, past]
)
}
}
}
func testMigrateRemovePhoneNumbers() throws {
// Set up the database with sample data that may have existed.
let databaseQueue = DatabaseQueue()
try databaseQueue.write { db in
try db.execute(sql: """
CREATE TABLE "model_SignalRecipient" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"recipientPhoneNumber" TEXT,
"recipientUUID" TEXT
);
CREATE UNIQUE INDEX "RecipientAciIndex" ON "model_SignalRecipient" ("recipientUUID");
CREATE UNIQUE INDEX "RecipientPhoneNumberIndex" ON "model_SignalRecipient" ("recipientPhoneNumber");
INSERT INTO "model_SignalRecipient" (
"recipientPhoneNumber", "recipientUUID"
) VALUES
('+17635550100', '00000000-0000-4000-A000-000000000000'),
('+17635550101', NULL),
('kLocalProfileUniqueId', '00000000-0000-4000-A000-000000000FFF');
CREATE TABLE "SampleTable" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"phoneNumber" TEXT,
"serviceIdString" TEXT
);
CREATE INDEX "ProfileServiceIdIndex" ON "SampleTable" ("serviceIdString");
CREATE INDEX "ProfilePhoneNumberIndex" ON "SampleTable" ("phoneNumber");
INSERT INTO "SampleTable" (
"phoneNumber", "serviceIdString"
) VALUES
(NULL, '00000000-0000-4000-A000-000000000000'),
(NULL, '00000000-0000-4000-B000-000000000000'),
('+17635550100', '00000000-0000-4000-A000-000000000000'),
('+17635550100', 'PNI:00000000-0000-4000-A000-000000000000'),
('+17635550100', NULL),
('+17635550101', NULL),
('+17635550102', NULL),
('kLocalProfileUniqueId', NULL),
('kLocalProfileUniqueId', '00000000-0000-4000-A000-000000000EEE');
""")
try GRDBSchemaMigrator.removeLocalProfileSignalRecipient(in: db)
try GRDBSchemaMigrator.removeRedundantPhoneNumbers(
in: db,
tableName: "SampleTable",
serviceIdColumn: "serviceIdString",
phoneNumberColumn: "phoneNumber"
)
let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM SampleTable")
var row: Row
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, nil)
XCTAssertEqual(row[2] as String?, "00000000-0000-4000-A000-000000000000")
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, nil)
XCTAssertEqual(row[2] as String?, "00000000-0000-4000-B000-000000000000")
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, nil)
XCTAssertEqual(row[2] as String?, "00000000-0000-4000-A000-000000000000")
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, "+17635550100")
XCTAssertEqual(row[2] as String?, "PNI:00000000-0000-4000-A000-000000000000")
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, nil)
XCTAssertEqual(row[2] as String?, "00000000-0000-4000-A000-000000000000")
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, "+17635550101")
XCTAssertEqual(row[2] as String?, nil)
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, "+17635550102")
XCTAssertEqual(row[2] as String?, nil)
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, "kLocalProfileUniqueId")
XCTAssertEqual(row[2] as String?, nil)
row = try cursor.next()!
XCTAssertEqual(row[1] as String?, "kLocalProfileUniqueId")
XCTAssertEqual(row[2] as String?, nil)
XCTAssertNil(try cursor.next())
}
}
}