- Add Apple Sign-In backend (JWKS verification, account linking, session management) - Implement push notification deep linking with NotificationDeepLinkRouter - Add jailbreak detection, runtime integrity monitoring, secure enclave service - Implement OAuth social login, token refresh, and secure logout flows - Add image caching (memory/disk), optimizer, upload queue, async semaphore - Implement notification analytics, type preferences, and category setup - Expand UI test suite with UITestBase, accessibility, auth flow, performance tests - Add CI pipeline for iOS UI tests (3 device sizes) and performance benchmarks - Restructure Xcode project to manual groups with KordantWidgets target - Add SwiftLint, Swift Collections/Algorithms/GoogleSignIn dependencies - Update project.yml for XcodeGen with new targets and configurations
520 lines
18 KiB
Swift
520 lines
18 KiB
Swift
//
|
|
// UnitPerformanceTests.swift
|
|
// KordantTests
|
|
//
|
|
// Unit-level performance tests using manual timing and XCTMetric
|
|
// for measuring view model operations, API serialization, cache
|
|
// operations, and cryptographic primitives with mocked data.
|
|
//
|
|
|
|
import Testing
|
|
@testable import Kordant
|
|
import Foundation
|
|
|
|
// MARK: - JSON Deserialization Performance
|
|
|
|
struct DeserializationPerformanceTests {
|
|
/// Measures the time to decode a full Alert array from JSON.
|
|
/// Baseline: < 50ms for 1000 alerts on iPhone 12.
|
|
@Test("Decode 1000 alerts under 50ms")
|
|
func decodeAlerts() throws {
|
|
let alertsJSON = generateAlertsJSON(count: 1000)
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
let start = Date()
|
|
let data = try #require(alertsJSON.data(using: .utf8))
|
|
let alerts = try decoder.decode([Alert].self, from: data)
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(alerts.count == 1000, "Should decode 1000 alerts")
|
|
#expect(elapsed < 0.05, "Decoding 1000 alerts took \(elapsed)s (expected < 50ms)")
|
|
}
|
|
|
|
/// Measures the time to decode a full Exposure array from JSON.
|
|
/// Baseline: < 50ms for 1000 exposures on iPhone 12.
|
|
@Test("Decode 1000 exposures under 50ms")
|
|
func decodeExposures() throws {
|
|
let exposuresJSON = generateExposuresJSON(count: 1000)
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
let start = Date()
|
|
let data = try #require(exposuresJSON.data(using: .utf8))
|
|
let exposures = try decoder.decode([Exposure].self, from: data)
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(exposures.count == 1000, "Should decode 1000 exposures")
|
|
#expect(elapsed < 0.05, "Decoding 1000 exposures took \(elapsed)s (expected < 50ms)")
|
|
}
|
|
|
|
/// Measures User JSON decoding performance.
|
|
@Test("Decode user object under 5ms")
|
|
func decodeUser() throws {
|
|
let userJSON = """
|
|
{
|
|
"id": "user-1",
|
|
"name": "Test User",
|
|
"email": "test@kordant.com",
|
|
"subscriptionTier": "premium",
|
|
"createdAt": "2026-01-15T00:00:00Z",
|
|
"updatedAt": "2026-06-01T00:00:00Z"
|
|
}
|
|
"""
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
let start = Date()
|
|
let data = try #require(userJSON.data(using: .utf8))
|
|
let user = try decoder.decode(User.self, from: data)
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(user.id == "user-1")
|
|
#expect(elapsed < 0.005, "User deserialization took \(elapsed)s (expected < 5ms)")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func generateAlertsJSON(count: Int) -> String {
|
|
let alerts = (0..<count).map { i in
|
|
"""
|
|
{
|
|
"id": "alert-\(i)",
|
|
"userId": "user-1",
|
|
"type": \(i % 3 == 0 ? "\"exposure\"" : i % 3 == 1 ? "\"breach\"" : "\"login\""),
|
|
"severity": \(i % 4 == 0 ? "\"critical\"" : i % 4 == 1 ? "\"high\"" : i % 4 == 2 ? "\"medium\"" : "\"low\""),
|
|
"title": "Alert \(i)",
|
|
"message": "This is alert number \(i)",
|
|
"read": \(i % 2 == 0),
|
|
"createdAt": "2026-06-0\(i % 9 + 1)T10:00:0\(i % 60)Z"
|
|
}
|
|
"""
|
|
}
|
|
return "[\(alerts.joined(separator: ","))]"
|
|
}
|
|
|
|
private func generateExposuresJSON(count: Int) -> String {
|
|
let exposures = (0..<count).map { i in
|
|
"""
|
|
{
|
|
"id": "exp-\(i)",
|
|
"userId": "user-1",
|
|
"source": \(i % 5 == 0 ? "\"darkWeb\"" : i % 5 == 1 ? "\"dataBreach\"" : i % 5 == 2 ? "\"socialMedia\"" : "\"publicRecord\""),
|
|
"dataType": "Email Address",
|
|
"exposedData": "user\(i)@example.com",
|
|
"severity": \(i % 3 == 0 ? "\"high\"" : "\"medium\""),
|
|
"status": \(i % 3 == 0 ? "\"new\"" : "\"reviewed\""),
|
|
"discoveredAt": "2026-06-0\(i % 9 + 1)T00:00:00Z"
|
|
}
|
|
"""
|
|
}
|
|
return "[\(exposures.joined(separator: ","))]"
|
|
}
|
|
}
|
|
|
|
// MARK: - ViewModel Performance Tests
|
|
|
|
@MainActor
|
|
struct ViewModelPerformanceTests {
|
|
/// Measures DashboardViewModel data loading time with mocked API.
|
|
/// Baseline: < 100ms for full data load with 50 alerts, 50 exposures, 50 watchlist items.
|
|
@Test("DashboardViewModel loads data under 100ms")
|
|
func dashboardViewModelLoadTime() async throws {
|
|
let mock = MockTRPCalling()
|
|
mock.stubbedAlerts = generateMockAlerts(count: 50)
|
|
mock.stubbedExposures = generateMockExposures(count: 50)
|
|
mock.stubbedWatchlist = generateMockWatchlistItems(count: 50)
|
|
|
|
let vm = DashboardViewModel(api: mock)
|
|
|
|
let start = Date()
|
|
await vm.loadDashboard()
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(vm.alerts.count == 50)
|
|
#expect(vm.exposures.count == 50)
|
|
#expect(vm.watchlistItems.count == 50)
|
|
#expect(elapsed < 0.1, "DashboardViewModel load took \(elapsed)s (expected < 100ms)")
|
|
}
|
|
|
|
/// Measures DarkWatchViewModel data loading time.
|
|
/// Baseline: < 100ms for full data load.
|
|
@Test("DarkWatchViewModel loads data under 100ms")
|
|
func darkWatchViewModelLoadTime() async throws {
|
|
let mock = MockTRPCalling()
|
|
mock.stubbedWatchlist = generateMockWatchlistItems(count: 30)
|
|
mock.stubbedExposures = generateMockExposures(count: 30)
|
|
|
|
let vm = DarkWatchViewModel(api: mock)
|
|
|
|
let start = Date()
|
|
await vm.loadData()
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(vm.watchlistItems.count == 30)
|
|
#expect(vm.exposures.count == 30)
|
|
#expect(elapsed < 0.1, "DarkWatchViewModel load took \(elapsed)s (expected < 100ms)")
|
|
}
|
|
|
|
/// Measures threat score calculation performance with large datasets.
|
|
/// Baseline: < 10ms for 500 alerts + 500 exposures.
|
|
@Test("Threat score calculation under 10ms with 1000 items")
|
|
func threatScoreCalculationPerformance() async throws {
|
|
let mock = MockTRPCalling()
|
|
mock.stubbedAlerts = generateMockAlerts(count: 500)
|
|
mock.stubbedExposures = generateMockExposures(count: 500)
|
|
|
|
let vm = DashboardViewModel(api: mock)
|
|
await vm.loadDashboard()
|
|
|
|
// Measure threat score computation
|
|
let start = Date()
|
|
let _ = vm.threatScore
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(elapsed < 0.01, "Threat score calculation took \(elapsed)s (expected < 10ms)")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func generateMockAlerts(count: Int) -> [Alert] {
|
|
(0..<count).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: i % 3 == 0 ? .exposure : i % 3 == 1 ? .breach : .login,
|
|
severity: i % 4 == 0 ? .critical : i % 4 == 1 ? .high : i % 4 == 2 ? .medium : .low,
|
|
title: "Alert \(i)",
|
|
message: "Alert message \(i)",
|
|
read: i % 2 == 0,
|
|
createdAt: Date().addingTimeInterval(-Double(i) * 3600)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func generateMockExposures(count: Int) -> [Exposure] {
|
|
(0..<count).map { i in
|
|
Exposure(
|
|
id: "exp-\(i)",
|
|
userId: "user-1",
|
|
source: i % 5 == 0 ? .darkWeb : i % 5 == 1 ? .dataBreach : i % 5 == 2 ? .socialMedia : .publicRecord,
|
|
dataType: "Email",
|
|
exposedData: "user\(i)@example.com",
|
|
severity: i % 3 == 0 ? "high" : "medium",
|
|
status: i % 3 == 0 ? .new : .reviewed,
|
|
discoveredAt: Date().addingTimeInterval(-Double(i) * 7200)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func generateMockWatchlistItems(count: Int) -> [WatchlistItem] {
|
|
(0..<count).map { i in
|
|
WatchlistItem(
|
|
id: "watch-\(i)",
|
|
userId: "user-1",
|
|
term: "item\(i)@example.com",
|
|
type: .email,
|
|
status: "active",
|
|
createdAt: Date().addingTimeInterval(-Double(i) * 86400)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Keychain Performance Tests
|
|
|
|
struct KeychainPerformanceTests {
|
|
/// Measures mock keychain store/retrieve performance.
|
|
/// Baseline: < 0.1ms per operation.
|
|
@Test("Keychain store/retrieve under 0.1ms per operation")
|
|
func keychainStoreRetrievePerformance() throws {
|
|
let keychain = MockKeychainService()
|
|
let value = Data(repeating: 0x41, count: 1024) // 1KB value
|
|
|
|
let start = Date()
|
|
let count = 100
|
|
for i in 0..<count {
|
|
try keychain.store(key: "key-\(i)", value: value)
|
|
let _ = try keychain.retrieve(key: "key-\(i)")
|
|
}
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
let perOp = elapsed / Double(count * 2) // store + retrieve per iteration
|
|
|
|
#expect(perOp < 0.0001, "Keychain op took \(perOp * 1000)ms (expected < 0.1ms)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Security Manager Performance Tests
|
|
|
|
struct SecurityPerformanceTests {
|
|
/// Measures jailbreak detector checks runtime.
|
|
/// Baseline: < 10ms for full detection suite.
|
|
@Test("Jailbreak detection completes under 10ms")
|
|
func jailbreakDetectionPerformance() {
|
|
let detector = JailbreakDetector.shared
|
|
|
|
let start = Date()
|
|
let _ = detector.isJailbroken
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
// The check should be fast since it's checking file system paths
|
|
#expect(elapsed < 0.01, "Jailbreak detection took \(elapsed)s (expected < 10ms)")
|
|
}
|
|
|
|
/// Measures RuntimeIntegrityMonitor checks performance.
|
|
@Test("Runtime integrity check under 10ms")
|
|
func runtimeIntegrityPerformance() {
|
|
let monitor = RuntimeIntegrityMonitor.shared
|
|
|
|
let start = Date()
|
|
let _ = monitor.isRuntimeIntegrityCompromised
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(elapsed < 0.01, "Runtime integrity check took \(elapsed)s (expected < 10ms)")
|
|
}
|
|
|
|
/// Measures SecureEnclave availability check performance.
|
|
@Test("SecureEnclave availability check under 10ms")
|
|
func secureEnclaveCheckPerformance() {
|
|
let service = SecureEnclaveService.shared
|
|
|
|
let start = Date()
|
|
let _ = service.isAvailable
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(elapsed < 0.01, "Secure Enclave check took \(elapsed)s (expected < 10ms)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Cache Metadata Performance Tests
|
|
|
|
struct ImageCacheMetadataPerformanceTests {
|
|
/// Measures ImageCacheMetadata creation performance for bulk operations.
|
|
@Test("ImageCacheMetadata creation under 0.1ms per item")
|
|
func metadataCreationPerformance() {
|
|
let start = Date()
|
|
let count = 1000
|
|
for i in 0..<count {
|
|
_ = ImageCacheMetadata(
|
|
url: "https://images.kordant.com/photo-\(i).jpg",
|
|
contentType: "image/jpeg",
|
|
fileSize: 1024 * 50,
|
|
cachedAt: Date(),
|
|
expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60)
|
|
)
|
|
}
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
let perItem = elapsed / Double(count)
|
|
|
|
#expect(perItem < 0.0001, "Metadata creation took \(perItem * 1000)ms (expected < 0.1ms)")
|
|
}
|
|
|
|
/// Measures CacheStats value type creation performance.
|
|
@Test("CacheStats values created efficiently")
|
|
func cacheStatsCreationPerformance() {
|
|
let start = Date()
|
|
let count = 10000
|
|
for _ in 0..<count {
|
|
_ = CacheStats(
|
|
memoryUsage: 1024 * 1024,
|
|
memoryCapacity: 50 * 1024 * 1024,
|
|
diskUsage: 5 * 1024 * 1024,
|
|
diskCapacity: 100 * 1024 * 1024,
|
|
cachedEntries: 100,
|
|
diskFiles: 50
|
|
)
|
|
}
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
let perItem = elapsed / Double(count)
|
|
|
|
#expect(perItem < 0.00001, "CacheStats creation took \(perItem * 1000)ms (expected < 0.01ms)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Sort Performance Tests
|
|
|
|
struct SortPerformanceTests {
|
|
/// Measures alert sorting performance (alerts are sorted by createdAt).
|
|
/// This is called every time the dashboard loads.
|
|
@Test("Alert sorting under 5ms for 500 alerts")
|
|
func alertSortingPerformance() {
|
|
let alerts = (0..<500).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: .exposure,
|
|
severity: .medium,
|
|
title: "Alert",
|
|
message: "Message",
|
|
read: i % 2 == 0,
|
|
createdAt: Date().addingTimeInterval(Double(i) * 3600 - 500 * 3600)
|
|
)
|
|
}
|
|
|
|
let start = Date()
|
|
let sorted = alerts.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) }
|
|
let elapsed = -start.timeIntervalSinceNow
|
|
|
|
#expect(sorted.count == 500)
|
|
#expect(elapsed < 0.005, "Sorting 500 alerts took \(elapsed)s (expected < 5ms)")
|
|
}
|
|
}
|
|
|
|
// MARK: - XCTMetric-Based Performance Tests (XCTestCase)
|
|
|
|
/// XCTestCase-based performance tests using XCTMetric for baseline recording.
|
|
/// These tests use `measure(metrics:)` which supports automatic baseline comparison
|
|
/// and 10% regression detection in Xcode.
|
|
///
|
|
/// Note: XCTMetric requires XCTestCase, not the Testing framework.
|
|
/// These tests are run via the KordantTests target alongside Testing-based tests.
|
|
final class XCTMetricPerformanceTests: XCTestCase {
|
|
private static let encoder: JSONEncoder = {
|
|
let enc = JSONEncoder()
|
|
enc.dateEncodingStrategy = .iso8601
|
|
return enc
|
|
}()
|
|
|
|
private static let decoder: JSONDecoder = {
|
|
let dec = JSONDecoder()
|
|
dec.dateDecodingStrategy = .iso8601
|
|
return dec
|
|
}()
|
|
|
|
// MARK: - JSON Encoding Performance
|
|
|
|
/// Measures JSON encoder performance for encoding Alert objects.
|
|
/// XCTMetric captures clock, CPU, and memory.
|
|
func testJSONEncodingPerformance() throws {
|
|
let alerts = (0..<500).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: .exposure,
|
|
severity: .medium,
|
|
title: "Test Alert \(i)",
|
|
message: "This is a test alert message with some detail text that might appear in the actual app.",
|
|
read: i % 2 == 0,
|
|
createdAt: Date()
|
|
)
|
|
}
|
|
|
|
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
|
let encoder = Self.encoder
|
|
for alert in alerts {
|
|
_ = try? encoder.encode(alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON Decoding Performance
|
|
|
|
/// Measures JSON decoder performance for decoding Alert array.
|
|
func testJSONDecodingPerformance() throws {
|
|
let alerts = (0..<500).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: .exposure,
|
|
severity: .medium,
|
|
title: "Test Alert \(i)",
|
|
message: "Test message",
|
|
read: i % 2 == 0,
|
|
createdAt: Date()
|
|
)
|
|
}
|
|
|
|
let data = try Self.encoder.encode(alerts)
|
|
|
|
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
|
_ = try? Self.decoder.decode([Alert].self, from: data)
|
|
}
|
|
}
|
|
|
|
// MARK: - ViewModel Data Processing Performance
|
|
|
|
/// Measures threat score calculation performance with XCTMetric.
|
|
func testThreatScoreCalculationPerformance() throws {
|
|
let alerts = (0..<200).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: i % 3 == 0 ? .breach : .exposure,
|
|
severity: i % 5 == 0 ? .critical : .low,
|
|
title: "Alert",
|
|
message: "Message",
|
|
read: i % 2 == 0,
|
|
createdAt: Date().addingTimeInterval(-Double(i) * 3600)
|
|
)
|
|
}
|
|
let exposures = (0..<100).map { i in
|
|
Exposure(
|
|
id: "exp-\(i)",
|
|
userId: "user-1",
|
|
source: .darkWeb,
|
|
dataType: "Email",
|
|
exposedData: "test@example.com",
|
|
severity: i % 3 == 0 ? "high" : "medium",
|
|
status: i % 2 == 0 ? .new : .reviewed,
|
|
discoveredAt: Date()
|
|
)
|
|
}
|
|
|
|
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
|
|
let unreadCritical = alerts.filter { !$0.read && $0.isCritical }.count
|
|
let newExposures = exposures.filter { $0.status == .new }.count
|
|
let score = min(Double(unreadCritical * 25 + newExposures * 10), 100)
|
|
_ = score / 100.0
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Cache Metadata Persistence Performance
|
|
|
|
/// Measures metadata JSON persistence encoding/decoding performance.
|
|
func testImageCacheMetadataPersistencePerformance() throws {
|
|
let metadata = (0..<500).map { i in
|
|
(
|
|
key: "https://images.kordant.com/photo-\(i).jpg",
|
|
value: ImageCacheMetadata(
|
|
url: "https://images.kordant.com/photo-\(i).jpg",
|
|
contentType: "image/jpeg",
|
|
fileSize: 1024 * (50 + i % 100),
|
|
cachedAt: Date(),
|
|
expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60)
|
|
)
|
|
)
|
|
}
|
|
|
|
let dict = Dictionary(uniqueKeysWithValues: metadata)
|
|
|
|
measure(metrics: [XCTClockMetric()]) {
|
|
if let encoded = try? Self.encoder.encode(dict) {
|
|
_ = try? Self.decoder.decode([String: ImageCacheMetadata].self, from: encoded)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Alert Sorting Performance
|
|
|
|
/// Measures the sorting performance of alerts (used by DashboardViewModel).
|
|
func testAlertSortingPerformance() throws {
|
|
let alerts = (0..<500).map { i in
|
|
Alert(
|
|
id: "alert-\(i)",
|
|
userId: "user-1",
|
|
type: .exposure,
|
|
severity: .medium,
|
|
title: "Alert",
|
|
message: "Message",
|
|
read: i % 2 == 0,
|
|
createdAt: Date().addingTimeInterval(Double(i) * 3600 - 500 * 3600)
|
|
)
|
|
}
|
|
|
|
measure(metrics: [XCTClockMetric()]) {
|
|
let sorted = alerts.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) }
|
|
_ = sorted
|
|
}
|
|
}
|
|
}
|