feat: complete Tasks 21-28 — backend integration, security hardening, UI tests & CI
- 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
This commit is contained in:
519
iOS/KordantTests/UnitPerformanceTests.swift
Normal file
519
iOS/KordantTests/UnitPerformanceTests.swift
Normal file
@@ -0,0 +1,519 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user