Archived
151 lines
8.0 KiB
Swift
151 lines
8.0 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 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)
|
|
}
|
|
}
|