mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			342 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			342 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
 | 
						|
 | 
						|
import Foundation
 | 
						|
import GRDB
 | 
						|
import Quick
 | 
						|
import Nimble
 | 
						|
import SessionUIKit
 | 
						|
import SessionSnodeKit
 | 
						|
import SessionMessagingKit
 | 
						|
 | 
						|
@testable import Session
 | 
						|
@testable import SessionUtilitiesKit
 | 
						|
 | 
						|
class DatabaseSpec: QuickSpec {
 | 
						|
    fileprivate static let ignoredTables: Set<String> = [
 | 
						|
        "sqlite_sequence", "grdb_migrations", "*_fts*"
 | 
						|
    ]
 | 
						|
    
 | 
						|
    override class func spec() {
 | 
						|
        // MARK: Configuration
 | 
						|
        @TestState var dependencies: Dependencies! = Dependencies()
 | 
						|
        @TestState var mockStorage: Storage! = SynchronousStorage(customWriter: try! DatabaseQueue())
 | 
						|
        @TestState var initialResult: Result<Void, Error>! = nil
 | 
						|
        @TestState var finalResult: Result<Void, Error>! = nil
 | 
						|
        
 | 
						|
        let allMigrations: [Storage.KeyedMigration] = SynchronousStorage.sortedMigrationInfo(
 | 
						|
            migrationTargets: [
 | 
						|
                SNUtilitiesKit.self,
 | 
						|
                SNSnodeKit.self,
 | 
						|
                SNMessagingKit.self,
 | 
						|
                SNUIKit.self
 | 
						|
            ]
 | 
						|
        )
 | 
						|
        let dynamicTests: [MigrationTest] = MigrationTest.extractTests(allMigrations)
 | 
						|
        let allDatabaseTypes: [(TableRecord & FetchableRecord).Type] = MigrationTest.extractDatabaseTypes(allMigrations)
 | 
						|
        MigrationTest.explicitValues = [
 | 
						|
            // Specific enum values needed
 | 
						|
            TableColumn(SessionThread.self, .notificationSound): 1000,
 | 
						|
            TableColumn(ConfigDump.self, .variant): "userProfile",
 | 
						|
            
 | 
						|
            // libSession will throw if we try to insert a community with an invalid
 | 
						|
            // 'server' value or a room that is too long
 | 
						|
            TableColumn(OpenGroup.self, .server): "https://www.oxen.io",
 | 
						|
            TableColumn(OpenGroup.self, .roomToken): "testRoom",
 | 
						|
            
 | 
						|
            // libSession will fail to load state if the ConfigDump data is invalid
 | 
						|
            TableColumn(ConfigDump.self, .data): Data()
 | 
						|
        ]
 | 
						|
        
 | 
						|
        // MARK: - a Database
 | 
						|
        describe("a Database") {
 | 
						|
            beforeEach {
 | 
						|
                // FIXME: These should be mocked out instead of set this way
 | 
						|
                dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = "05\(TestConstants.publicKey)" }
 | 
						|
                LibSession.clearMemoryState()
 | 
						|
            }
 | 
						|
            
 | 
						|
            // MARK: -- can be created from an empty state
 | 
						|
            it("can be created from an empty state") {
 | 
						|
                mockStorage.perform(
 | 
						|
                    migrationTargets: [
 | 
						|
                        SNUtilitiesKit.self,
 | 
						|
                        SNSnodeKit.self,
 | 
						|
                        SNMessagingKit.self,
 | 
						|
                        SNUIKit.self
 | 
						|
                    ],
 | 
						|
                    async: false,
 | 
						|
                    onProgressUpdate: nil,
 | 
						|
                    onMigrationRequirement: { _, _ in },
 | 
						|
                    onComplete: { result, _ in initialResult = result }
 | 
						|
                )
 | 
						|
                
 | 
						|
                expect(initialResult).to(beSuccess())
 | 
						|
            }
 | 
						|
            
 | 
						|
            // MARK: -- can still parse the database types
 | 
						|
            it("can still parse the database types") {
 | 
						|
                mockStorage.perform(
 | 
						|
                    sortedMigrations: allMigrations,
 | 
						|
                    async: false,
 | 
						|
                    onProgressUpdate: nil,
 | 
						|
                    onMigrationRequirement: { _, _ in },
 | 
						|
                    onComplete: { result, _ in initialResult = result }
 | 
						|
                )
 | 
						|
                expect(initialResult).to(beSuccess())
 | 
						|
                
 | 
						|
                // Generate dummy data (fetching below won't do anything)
 | 
						|
                expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)).toNot(throwError())
 | 
						|
                
 | 
						|
                // Fetch the records which are required by the migrations or were modified by them to
 | 
						|
                // ensure the decoding is also still working correctly
 | 
						|
                mockStorage.read { db in
 | 
						|
                    allDatabaseTypes.forEach { table in
 | 
						|
                        expect { try table.fetchAll(db) }.toNot(throwError())
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
            
 | 
						|
            // MARK: -- can still parse the database types setting null where possible
 | 
						|
            it("can still parse the database types setting null where possible") {
 | 
						|
                mockStorage.perform(
 | 
						|
                    sortedMigrations: allMigrations,
 | 
						|
                    async: false,
 | 
						|
                    onProgressUpdate: nil,
 | 
						|
                    onMigrationRequirement: { _, _ in },
 | 
						|
                    onComplete: { result, _ in initialResult = result }
 | 
						|
                )
 | 
						|
                expect(initialResult).to(beSuccess())
 | 
						|
                
 | 
						|
                // Generate dummy data (fetching below won't do anything)
 | 
						|
                expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: true)).toNot(throwError())
 | 
						|
                
 | 
						|
                // Fetch the records which are required by the migrations or were modified by them to
 | 
						|
                // ensure the decoding is also still working correctly
 | 
						|
                mockStorage.read { db in
 | 
						|
                    allDatabaseTypes.forEach { table in
 | 
						|
                        expect { try table.fetchAll(db) }.toNot(throwError())
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
            
 | 
						|
            // MARK: -- can migrate from X to Y
 | 
						|
            dynamicTests.forEach { test in
 | 
						|
                it("can migrate from \(test.initialMigrationKey) to \(test.finalMigrationKey)") {
 | 
						|
                    mockStorage.perform(
 | 
						|
                        sortedMigrations: test.initialMigrations,
 | 
						|
                        async: false,
 | 
						|
                        onProgressUpdate: nil,
 | 
						|
                        onMigrationRequirement: { _, _ in },
 | 
						|
                        onComplete: { result, _ in initialResult = result }
 | 
						|
                    )
 | 
						|
                    expect(initialResult).to(beSuccess())
 | 
						|
                    
 | 
						|
                    // Generate dummy data (otherwise structural issues or invalid foreign keys won't error)
 | 
						|
                    expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)).toNot(throwError())
 | 
						|
                    
 | 
						|
                    // Peform the target migrations to ensure the migrations themselves worked correctly
 | 
						|
                    mockStorage.perform(
 | 
						|
                        sortedMigrations: test.migrationsToTest,
 | 
						|
                        async: false,
 | 
						|
                        onProgressUpdate: nil,
 | 
						|
                        onMigrationRequirement: { _, _ in },
 | 
						|
                        onComplete: { result, _ in finalResult = result }
 | 
						|
                    )
 | 
						|
                    expect(finalResult).to(beSuccess())
 | 
						|
                    
 | 
						|
                    /// Ensure all of the `fetchedTables` records can still be decoded correctly after the migrations have completed (since
 | 
						|
                    /// we perform multiple migrations above it's possible these won't work after the `initialMigrations` but actually will
 | 
						|
                    /// work when required as an intermediate migration could have satisfied the data requirements)
 | 
						|
                    mockStorage.read { db in
 | 
						|
                        test.migrationsToTest.forEach { _, _, migration in
 | 
						|
                            migration.fetchedTables.forEach { table in
 | 
						|
                                expect { try table.fetchAll(db) }.toNot(throwError())
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - Convenience
 | 
						|
 | 
						|
private extension Database.ColumnType {
 | 
						|
    init(rawValue: Any) {
 | 
						|
        switch rawValue as? String {
 | 
						|
            case .some(let value): self = Database.ColumnType(rawValue: value)
 | 
						|
            case .none: self = Database.ColumnType.any
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
private struct TableColumn: Hashable {
 | 
						|
    let tableName: String
 | 
						|
    let columnName: String
 | 
						|
    
 | 
						|
    init<T: TableRecord & ColumnExpressible>(_ type: T.Type, _ column: T.Columns) {
 | 
						|
        self.tableName = T.databaseTableName
 | 
						|
        self.columnName = column.name
 | 
						|
    }
 | 
						|
    
 | 
						|
    init?(_ tableName: String, _ columnName: Any?) {
 | 
						|
        guard let finalColumnName: String = columnName as? String else { return nil }
 | 
						|
        
 | 
						|
        self.tableName = tableName
 | 
						|
        self.columnName = finalColumnName
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
private class MigrationTest {
 | 
						|
    static var explicitValues: [TableColumn: (any DatabaseValueConvertible)] = [:]
 | 
						|
    
 | 
						|
    let initialMigrations: [Storage.KeyedMigration]
 | 
						|
    let migrationsToTest: [Storage.KeyedMigration]
 | 
						|
    
 | 
						|
    var initialMigrationKey: String { return (initialMigrations.last?.key ?? "an empty database") }
 | 
						|
    var finalMigrationKey: String { return (migrationsToTest.last?.key ?? "invalid") }
 | 
						|
 | 
						|
    private init(
 | 
						|
        initialMigrations: [Storage.KeyedMigration],
 | 
						|
        migrationsToTest: [Storage.KeyedMigration]
 | 
						|
    ) {
 | 
						|
        self.initialMigrations = initialMigrations
 | 
						|
        self.migrationsToTest = migrationsToTest
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Test Data
 | 
						|
    
 | 
						|
    static func extractTests(_ allMigrations: [Storage.KeyedMigration]) -> [MigrationTest] {
 | 
						|
        return (0..<(allMigrations.count - 1))
 | 
						|
            .flatMap { index -> [MigrationTest] in
 | 
						|
                ((index + 1)..<allMigrations.count).map { targetMigrationIndex -> MigrationTest in
 | 
						|
                    MigrationTest(
 | 
						|
                        initialMigrations: Array(allMigrations[0..<index]),
 | 
						|
                        migrationsToTest: Array(allMigrations[index..<targetMigrationIndex])
 | 
						|
                    )
 | 
						|
                }
 | 
						|
            }
 | 
						|
    }
 | 
						|
    
 | 
						|
    static func extractDatabaseTypes(_ allMigrations: [Storage.KeyedMigration]) -> [(TableRecord & FetchableRecord).Type] {
 | 
						|
        return allMigrations
 | 
						|
            .reduce(into: [:]) { result, next in
 | 
						|
                next.migration.fetchedTables.forEach { table in
 | 
						|
                    result[ObjectIdentifier(table).hashValue] = table
 | 
						|
                }
 | 
						|
                
 | 
						|
                next.migration.createdOrAlteredTables.forEach { table in
 | 
						|
                    result[ObjectIdentifier(table).hashValue] = table
 | 
						|
                }
 | 
						|
            }
 | 
						|
            .values
 | 
						|
            .asArray()
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Mock Data
 | 
						|
    
 | 
						|
    static func generateDummyData(_ storage: Storage, nullsWherePossible: Bool) throws {
 | 
						|
        var generationError: Error? = nil
 | 
						|
        
 | 
						|
        // The `PRAGMA foreign_keys` is a no-op within a transaction so we have to do it outside of one
 | 
						|
        try storage.testDbWriter?.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA foreign_keys = OFF") }
 | 
						|
        storage.write { db in
 | 
						|
            do {
 | 
						|
                try MigrationTest.generateDummyData(db, nullsWherePossible: nullsWherePossible)
 | 
						|
                try db.checkForeignKeys()
 | 
						|
            }
 | 
						|
            catch { generationError = error }
 | 
						|
        }
 | 
						|
        try storage.testDbWriter?.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA foreign_keys = ON") }
 | 
						|
        
 | 
						|
        // Throw the error if there was one
 | 
						|
        if let error: Error = generationError { throw error }
 | 
						|
    }
 | 
						|
    
 | 
						|
    private static func generateDummyData(_ db: Database, nullsWherePossible: Bool) throws {
 | 
						|
        // Fetch table schema information
 | 
						|
        let disallowedPrefixes: Set<String> = DatabaseSpec.ignoredTables
 | 
						|
            .filter { $0.hasPrefix("*") && !$0.hasSuffix("*") }
 | 
						|
            .map { String($0[$0.index(after: $0.startIndex)...]) }
 | 
						|
            .asSet()
 | 
						|
        let disallowedSuffixes: Set<String> = DatabaseSpec.ignoredTables
 | 
						|
            .filter { $0.hasSuffix("*") && !$0.hasPrefix("*") }
 | 
						|
            .map { String($0[$0.startIndex..<$0.index(before: $0.endIndex)]) }
 | 
						|
            .asSet()
 | 
						|
        let disallowedContains: Set<String> = DatabaseSpec.ignoredTables
 | 
						|
            .filter { $0.hasPrefix("*") && $0.hasSuffix("*") }
 | 
						|
            .map { String($0[$0.index(after: $0.startIndex)..<$0.index(before: $0.endIndex)]) }
 | 
						|
            .asSet()
 | 
						|
        let tables: [Row] = try Row
 | 
						|
            .fetchAll(db, sql: "SELECT * from sqlite_schema WHERE type = 'table'")
 | 
						|
            .filter { tableInfo -> Bool in
 | 
						|
                guard let name: String = tableInfo["name"] else { return false }
 | 
						|
                
 | 
						|
                return (
 | 
						|
                    !DatabaseSpec.ignoredTables.contains(name) &&
 | 
						|
                    !disallowedPrefixes.contains(where: { name.hasPrefix($0) }) &&
 | 
						|
                    !disallowedSuffixes.contains(where: { name.hasSuffix($0) }) &&
 | 
						|
                    !disallowedContains.contains(where: { name.contains($0) })
 | 
						|
                )
 | 
						|
            }
 | 
						|
        
 | 
						|
        // Generate data via schema inspection for all other tables
 | 
						|
        try tables.forEach { tableInfo in
 | 
						|
            switch tableInfo["name"] as? String {
 | 
						|
                case .none: throw StorageError.generic
 | 
						|
                
 | 
						|
                case Identity.databaseTableName:
 | 
						|
                    // If there is an 'Identity' table then insert "proper" identity info (otherwise mock
 | 
						|
                    // data might get deleted as invalid in libSession migrations)
 | 
						|
                    try [
 | 
						|
                        Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!),
 | 
						|
                        Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!),
 | 
						|
                        Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)!),
 | 
						|
                        Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!)
 | 
						|
                    ].forEach { try $0.insert(db) }
 | 
						|
                    
 | 
						|
                case .some(let name):
 | 
						|
                    // No need to insert dummy data if it already exists in the table
 | 
						|
                    guard try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM '\(name)'") == 0 else { return }
 | 
						|
                    
 | 
						|
                    let columnInfo: [Row] = try Row.fetchAll(db, sql: "PRAGMA table_info('\(name)');")
 | 
						|
                    let validNames: [String] = columnInfo.compactMap { $0["name"].map { "'\($0)'" } }
 | 
						|
                    let columnNames: String = validNames.joined(separator: ", ")
 | 
						|
                    let columnArgs: String = validNames.map { _ in "?" }.joined(separator: ", ")
 | 
						|
                    
 | 
						|
                    try db.execute(
 | 
						|
                        sql: "INSERT INTO \(name) (\(columnNames)) VALUES (\(columnArgs))",
 | 
						|
                        arguments: StatementArguments(columnInfo.map { column in
 | 
						|
                            // If we want to allow setting nulls (and the column is nullable but not a primary
 | 
						|
                            // key) then use null for it's value
 | 
						|
                            guard !nullsWherePossible || column["notnull"] != 0 || column["pk"] == 1 else {
 | 
						|
                                return nil
 | 
						|
                            }
 | 
						|
                            
 | 
						|
                            // If this column has an explicitly defined value then use that
 | 
						|
                            if
 | 
						|
                                let key: TableColumn = TableColumn(name, column["name"]),
 | 
						|
                                let explicitValue: (any DatabaseValueConvertible) = MigrationTest.explicitValues[key]
 | 
						|
                            {
 | 
						|
                                return explicitValue
 | 
						|
                            }
 | 
						|
                            
 | 
						|
                            // Otherwise generate some mock data (trying to use potentially real values in case
 | 
						|
                            // something is a primary/foreign key)
 | 
						|
                            switch Database.ColumnType(rawValue: column["type"]) {
 | 
						|
                                case .text: return "05\(TestConstants.publicKey)"
 | 
						|
                                case .blob: return Data([1, 2, 3])
 | 
						|
                                case .boolean: return false
 | 
						|
                                case .integer, .numeric, .double, .real: return 1
 | 
						|
                                case .date, .datetime: return Date(timeIntervalSince1970: 1234567890)
 | 
						|
                                case .any: return nil
 | 
						|
                                default: return nil
 | 
						|
                            }
 | 
						|
                        })
 | 
						|
                    )
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |