Files
web-terminal/ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Yaojia Wang cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00

80 lines
3.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import HostRegistry
import Security
import Testing
import WireProtocol
/// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7).
///
/// `swift test` binaries are unsigned the data-protection keychain answers
/// `errSecMissingEntitlement` (-34018), so the package layer only tests the
/// store against a fake `SecItemShim`. THIS bundle runs signed inside the
/// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised
/// end-to-end and the §5.3 protection class is asserted on the stored item.
@Suite("KeychainHostStore (real keychain, signed app host)", .serialized)
struct KeychainHostStoreLiveTests {
/// Test-only service/account never collides with the production item.
private static let service = "com.yaojia.webterm.host-registry.livetests"
private static let account = "hosts-live-roundtrip"
private static func makeHost() throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.9:3000"))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint)
}
/// Removes the test item regardless of test outcome.
private static func deleteTestItem() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
SecItemDelete(query as CFDictionary)
}
@Test("真 Keychain 往返upsert/loadAll/remove+ kSecAttrAccessible 属性断言")
func realKeychainRoundtripAndProtectionClass() async throws {
Self.deleteTestItem() // clean slate from any earlier aborted run
defer { Self.deleteTestItem() } // never leave the test item behind
// Arrange: REAL shim (default init), isolated service/account.
let store = KeychainHostStore(service: Self.service, account: Self.account)
let host = try Self.makeHost()
// Act + Assert: upsert visible via loadAll.
let afterUpsert = try await store.upsert(host)
#expect(afterUpsert == [host])
#expect(try await store.loadAll() == [host])
// Assert §5.3: the STORED item's protection class, straight from
// SecItemCopyMatching attributes (not from our own spec constants).
var result: CFTypeRef?
let attributesQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
kSecUseDataProtectionKeychain as String: true,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result)
#expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)")
let attributes = try #require(result as? [String: Any])
let accessible = try #require(attributes[kSecAttrAccessible as String] as? String)
#expect(
accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
"stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)"
)
// §5.3: never iCloud-synced the item must not be synchronizable.
let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false
#expect(!synchronizable)
// Act + Assert: remove empty store (item deleted).
let afterRemove = try await store.remove(id: host.id)
#expect(afterRemove.isEmpty)
#expect(try await store.loadAll() == [])
}
}