#if os(macOS) import Foundation import Darwin import XCTest @testable import RastrilloApple private final class ReceivedOutcomes: @unchecked Sendable { private let lock = NSLock() private var recorded: [NativeTransportOutcome] = [] func append(_ outcome: NativeTransportOutcome) { lock.withLock { recorded.append(outcome) } } var values: [NativeTransportOutcome] { lock.withLock { recorded } } } private actor ReceivedLines { var values: [String] = [] func append(_ line: String) { values.append(line) } } final class HTTPTests: XCTestCase { private func stop(_ process: Process) { process.terminate() let deadline = Date().addingTimeInterval(0.5) while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } if process.isRunning { kill(process.processIdentifier, SIGKILL) } } private func fixture(observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) throws -> (Process, NativeHTTP) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") process.arguments = ["-u", "-c", #""" import http.server class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): if self.path == '/redirect': self.send_response(302) self.send_header('Location', '/unexpected-follow') self.end_headers() return if self.path.startswith('/status/'): body = b'refused' self.send_response(int(self.path.split('/')[2])) self.send_header('Content-Length', str(len(body))) self.send_header('Retry-After', '9') self.end_headers() self.wfile.write(body) return if self.path == '/stream': body = b'event: update\r\ndata: 42\r\n\r\n' content_type = 'text/event-stream; charset=utf-8' else: body = b'12345' content_type = 'application/octet-stream' self.send_response(200) self.send_header('Content-Type', content_type) self.send_header('Content-Length', str(len(body))) self.send_header('Docs-Seq', '42') self.send_header('Seen-Origin', self.headers.get('Origin', 'missing')) self.send_header('Seen-Cookie', self.headers.get('Cookie', 'missing')) self.end_headers() self.wfile.write(body) server = http.server.HTTPServer(('127.0.0.1', 0), Handler) print(server.server_port, flush=True) server.serve_forever() """#] let output = Pipe() process.standardOutput = output try process.run() var port = Data() while port.count < 12 { let byte = output.fileHandleForReading.readData(ofLength: 1) if byte.isEmpty || byte == Data([10]) { break } port.append(byte) } guard let portText = String(data: port, encoding: .utf8), let number = Int(portText), number > 0, let origin = URL(string: "http://127.0.0.1:\(number)") else { process.terminate() throw NativeClientError.message("The isolated HTTP fixture could not start.") } return (process, NativeHTTP(origin: origin, credential: NativeSession(name: "rastrillo_session", value: "fixture"), observer: observer)) } func testResponseLimitHeadersAndOrigin() async throws { let (server, client) = try fixture() defer { stop(server) } let response = try await client.responseDetails("/bytes", maxBytes: 5) XCTAssertEqual(response.data, Data("12345".utf8)) XCTAssertEqual(response.header("Docs-Seq"), "42") XCTAssertEqual(response.header("Seen-Origin"), client.origin.absoluteString) XCTAssertEqual(response.header("Seen-Cookie"), "rastrillo_session=fixture") do { _ = try await client.response("/bytes", maxBytes: 4) XCTFail("A truncated response must fail, never succeed with a prefix") } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) } } /// Real statuses from a real server reach the caller typed, through every /// entry point, and each arrival tells the observer the server is reachable. func testStatusesAreTypedAndObserved() async throws { let outcomes = ReceivedOutcomes() let (server, client) = try fixture(observer: { outcomes.append($0) }) defer { stop(server) } struct Empty: Decodable {} do { let _: Empty = try await client.request("/status/401") XCTFail("A 401 decoded") } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } do { try await client.stream("/status/401") { _ in XCTFail("A refused stream delivered a line") } XCTFail("A 401 stream opened") } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } do { let _: Empty = try await client.request("/status/409") XCTFail("A 409 decoded") } catch { XCTAssertEqual(error as? NativeClientError, .conflict(Data("refused".utf8))) } do { let _: Empty = try await client.request("/status/503") XCTFail("A 503 decoded") } catch { XCTAssertEqual(error as? NativeClientError, .rateLimited(retryAfter: 9)) } let details = try await client.responseDetails("/status/404") XCTAssertEqual(details.status, 404, "responseDetails must keep returning the status") XCTAssertThrowsError(try details.requireSuccess()) { XCTAssertEqual($0 as? NativeClientError, .notFound) } XCTAssertEqual(outcomes.values, [401, 401, 409, 503, 404].map { .responded(status: $0) }) } func testRedirectIsReturnedWithoutFollowing() async throws { let (server, client) = try fixture() defer { stop(server) } let response = try await client.responseDetails("/redirect") XCTAssertEqual(response.status, 302) XCTAssertTrue(response.data.isEmpty) } func testFileExportIsPrivateAndNeverOverwritesOrFollowsRedirect() async throws { let (server, client) = try fixture() defer { stop(server) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) defer { try? FileManager.default.removeItem(at: directory) } let file = directory.appendingPathComponent("export.xlsx") try await client.download("/bytes", to: file) XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8)) let permissions = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber XCTAssertEqual(permissions?.intValue, 0o600) try Data("keep existing file".utf8).write(to: file) do { try await client.download("/bytes", to: file) XCTFail("Export overwrote an existing file") } catch { XCTAssertEqual(try Data(contentsOf: file), Data("keep existing file".utf8)) } let redirected = directory.appendingPathComponent("redirect.xlsx") do { try await client.download("/redirect", to: redirected) XCTFail("Export followed or accepted a redirect") } catch { XCTAssertFalse(FileManager.default.fileExists(atPath: redirected.path)) } } func testLiveTransportAndWrongContentType() async throws { let (server, client) = try fixture() defer { stop(server) } let lines = ReceivedLines() try await client.stream("/stream") { line in await lines.append(line) } let received = await lines.values XCTAssertEqual(received, ["event: update", "data: 42", ""]) do { try await client.stream("/bytes") { _ in XCTFail("Non-SSE content reached the live editor") } XCTFail("Non-SSE response was accepted") } catch { XCTAssertTrue(error.localizedDescription.contains("refused")) } } } #endif