Files
2026-10-04 07:23:49 -04:00

183 lines
10 KiB
Swift

import Foundation
import Testing
@testable import Voice
private final class AuthURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (Int, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let (status, data) = try Self.handler!(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
@Suite(.serialized)
@MainActor
struct AuthTests {
private func config(_ value: String = "https://example.com") throws -> ServerConfig {
try ServerConfig.parse(value).get()
}
private func session() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [AuthURLProtocol.self]
return URLSession(configuration: config)
}
@Test func englishVoiceCatalogAndPreferenceMigration() async throws {
AuthURLProtocol.handler = { request in
#expect(request.url?.path == "/voices")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (200, Data(#"{"default_voice":"en-US-Ava:DragonHDOmniLatestNeural","catalog_available":true,"voices":[{"id":"en-GB-Sonia:DragonHDOmniLatestNeural","name":"Sonia","locale":"en-GB","gender":"Female","tag":"Omni"}]}"#.utf8))
}
let catalog = try await AuthClient(config: config(), session: session()).voices(token: "test-session")
#expect(catalog.catalogAvailable)
#expect(catalog.voices.first?.details == "en-GB · Female · Omni")
#expect(Voices.restoredID(catalog.voices.first?.id) == "en-GB-Sonia:DragonHDOmniLatestNeural")
// A valid server-catalogued choice must survive SettingsStore's next launch,
// even if it is not among the offline fallback voices.
#expect(Voices.restoredID("en-GB-Sonia:DragonHDOmniLatestNeural") == "en-GB-Sonia:DragonHDOmniLatestNeural")
#expect(Voices.restoredID("en-US-Ava:DragonHDLatestNeural") == Voices.defaultID)
for legacy in ["Jenny", "Guy", "Andrew", "Aria", "Emma", "Eric", "Brian", "Ashley", "Richard", "Tina", "Steffan"] {
let migrated = Voices.restoredID("en-US-\(legacy)Neural")
#expect(migrated.contains(":DragonHDOmniLatestNeural"))
#expect(Voices.display(for: migrated) != nil)
}
#expect(Voices.restoredID("invalid <voice>") == Voices.defaultID)
#expect(Voices.restoredID(nil) == Voices.defaultID)
}
@Test func certificateFailureHasSafeRecovery() throws {
let url = URL(string: "https://192.168.86.2:9178")!
let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url)
#expect(message.contains("192.168.86.2"))
#expect(message.contains("Certificate Trust Settings"))
#expect(!message.contains("disable"))
#expect(ConnectionFailure.message(AuthError.status(401), server: url).contains("username and password"))
}
@Test func canonicalOriginsAndSecureIPv6() throws {
let parsed = try config(" HTTPS://EXAMPLE.COM:443/path ")
#expect(parsed.baseURL.absoluteString == "https://example.com")
#expect(parsed.livekitURL.absoluteString == "wss://example.com/livekit")
#expect(try config("https://[fd00::1]:9178").livekitURL.absoluteString == "wss://[fd00::1]:9178/livekit")
for invalid in ["https://u:[email protected]", "https://example.com?token=x", "https://example.com#fragment", "http://192.168.1.1"] {
if case .success = ServerConfig.parse(invalid) { Issue.record("Accepted unsafe origin") }
}
}
@Test func bearerBootstrapAndMissingAuth() async throws {
let c = try config()
AuthURLProtocol.handler = { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
#expect(request.url?.path == "/token")
return (200, Data(#"{"token":"room-jwt","room":"room","user":{"id":"one","display_name":"One"}}"#.utf8))
}
let response = try await TokenClient(config: c, bearerToken: "test-session", session: session()).fetchToken()
#expect(response.user.id == "one")
do {
_ = try await TokenClient(config: c, session: session()).fetchToken()
Issue.record("Unauthenticated bootstrap accepted")
} catch { #expect(error is AuthError) }
}
@Test func loginMeLogoutWireContract() async throws {
let c = try config()
let user = #"{"id":"one","username":"alice","display_name":"Alice","role":"user","status":"ready"}"#
AuthURLProtocol.handler = { request in
switch request.url!.path {
case "/api/auth/login":
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
return (200, Data("{\"token\":\"test-session\",\"user\":\(user),\"expires_at\":4102444800}".utf8))
case "/api/auth/me":
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (200, Data("{\"user\":\(user)}".utf8))
default:
#expect(request.url!.path == "/api/auth/logout")
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (204, Data())
}
}
let memory = MemoryTokenStore()
let client = AuthClient(config: c, session: session())
let store = AuthStore(config: c, client: client, tokens: memory)
await store.login(username: "alice", password: "never-persist-this")
#expect(store.user?.id == "one")
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
#expect(try memory.token(origin: "https://another.example") == nil)
let restored = AuthStore(config: c, client: client, tokens: memory)
await restored.restore()
#expect(restored.user?.username == "alice")
await store.logout {
#expect(store.token == nil)
#expect(store.user?.id == "one") // old UI survives only until transport disconnect
}
#expect(store.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
}
@Test func transientRestoreFailureKeepsTokenButUnauthorizedDeletesIt() async throws {
let c = try config()
let memory = MemoryTokenStore()
try memory.save("test-session", origin: c.baseURL.absoluteString)
AuthURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
let offline = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
await offline.restore()
#expect(offline.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
AuthURLProtocol.handler = { _ in (401, Data()) }
let revoked = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
await revoked.restore()
#expect(revoked.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
}
@Test func logoutInvalidatesSuspendedRestore() async throws {
let c = try config()
let memory = MemoryTokenStore()
try memory.save("test-session", origin: c.baseURL.absoluteString)
AuthURLProtocol.handler = { _ in
return (200, Data(#"{"user":{"id":"one","username":"alice","role":"user","status":"ready"}}"#.utf8))
}
let store = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
let restore = Task { await store.restore() }
await Task.yield()
await store.logout {}
await restore.value
#expect(store.user == nil)
#expect(store.token == nil)
}
@Test func historyNeverImportsGlobalLegacyAndSeparatesAccounts() throws {
let origin = "https://history-test.example"
let legacy = AppPreferences.store.object(forKey: "voice_history")
defer {
if let legacy { AppPreferences.store.set(legacy, forKey: "voice_history") }
else { AppPreferences.store.removeObject(forKey: "voice_history") }
}
AppPreferences.store.set("[{\"role\":\"user\",\"text\":\"legacy secret\",\"timestamp\":0}]", forKey: "voice_history")
let first = TranscriptHistory(origin: origin, userID: "one")
#expect(!first.entries.contains { $0.text == "legacy secret" })
first.clear()
first.sync(finals: [("user", "private", Date())])
#expect(TranscriptHistory(origin: origin, userID: "one").entries.last?.text == "private")
let other = TranscriptHistory(origin: origin, userID: "two")
#expect(other.entries.isEmpty)
#expect(TranscriptHistory(origin: "https://other.example", userID: "one").entries.isEmpty)
first.clear()
}
@Test func progressCompletesWithoutTranscriptPollution() throws {
let settings = SettingsStore()
let c = try config()
let voice = VoiceSession(config: c, tokenClient: TokenClient(config: c), settings: settings)
let before = voice.transcriptHandler.events
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"working","message":"Checking your calendar…"}"#.utf8))
#expect(voice.assistantActivity == "Checking your calendar…")
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"completed","message":"Done"}"#.utf8))
#expect(voice.assistantActivity == nil)
#expect(voice.transcriptHandler.events == before)
}
}