89 lines
5.5 KiB
Swift
89 lines
5.5 KiB
Swift
import Foundation
|
|||
|
|
import Testing
|
||
|
|
@testable import Sybil
|
||
|
|
|
||
|
|
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
|
||
|
|
static let lock = NSLock()
|
||
|
|
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
|
||
|
|
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
|
||
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||
|
|
override func startLoading() {
|
||
|
|
do {
|
||
|
|
let handler = Self.lock.withLock { Self.handler! }
|
||
|
|
let (status, data) = try handler(request)
|
||
|
|
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
|
||
|
|
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
|
||
|
|
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||
|
|
}
|
||
|
|
override func stopLoading() {}
|
||
|
|
}
|
||
|
|
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
|
||
|
|
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
|
||
|
|
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
|
||
|
|
}
|
||
|
|
private func pushRequestBody(_ request: URLRequest) -> Data {
|
||
|
|
if let body = request.httpBody { return body }
|
||
|
|
guard let stream = request.httpBodyStream else { return Data() }
|
||
|
|
stream.open(); defer { stream.close() }
|
||
|
|
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
|
||
|
|
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||
|
|
return bytes
|
||
|
|
}
|
||
|
|
|
||
|
|
@Suite(.serialized) struct AgentPushAPITests {
|
||
|
|
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
|
||
|
|
let id = UUID()
|
||
|
|
PushURLProtocol.install { request in
|
||
|
|
#expect(request.url?.host == "example.test")
|
||
|
|
#expect(request.url?.query == nil)
|
||
|
|
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
|
||
|
|
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
|
||
|
|
#expect(request.timeoutInterval == 8)
|
||
|
|
if request.httpMethod == "GET" {
|
||
|
|
#expect(request.url?.path == "/api/v1/agent/push/config")
|
||
|
|
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
|
||
|
|
}
|
||
|
|
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
|
||
|
|
if request.httpMethod == "DELETE" { return (204, Data()) }
|
||
|
|
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
|
||
|
|
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
|
||
|
|
#expect(object["token"] as? String == "00010aff")
|
||
|
|
#expect(object["environment"] as? String == "production")
|
||
|
|
#expect(object["topic"] as? String == "net.buzzert.sybil2")
|
||
|
|
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
|
||
|
|
}
|
||
|
|
let client = pushClient()
|
||
|
|
#expect(try await client.agentPushConfig().available)
|
||
|
|
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
|
||
|
|
try await client.agentRevokePush(id: id)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
|
||
|
|
for status in [302, 401, 404, 409, 503] {
|
||
|
|
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
|
||
|
|
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
|
||
|
|
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
|
||
|
|
}
|
||
|
|
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
|
||
|
|
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
|
||
|
|
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
|
||
|
|
catch { }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
|
||
|
|
let source = "11111111-1111-4111-8111-111111111111"
|
||
|
|
for expectedSource: String? in [nil, source] {
|
||
|
|
PushURLProtocol.install { request in
|
||
|
|
#expect(request.url?.path == "/api/v1/agent/read-state")
|
||
|
|
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
|
||
|
|
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
|
||
|
|
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
|
||
|
|
}
|
||
|
|
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|