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
This commit is contained in:
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
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() == [])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user