Files
hope-voice-api/Tests/AuthTests.swift
T

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)
}
}