183 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|