| 1 | #if os(macOS) |
| 2 | import Foundation |
| 3 | import Darwin |
| 4 | import XCTest |
| 5 | @testable import RastrilloApple |
| 6 | |
| 7 | private final class ReceivedOutcomes: @unchecked Sendable { |
| 8 | private let lock = NSLock() |
| 9 | private var recorded: [NativeTransportOutcome] = [] |
| 10 | func append(_ outcome: NativeTransportOutcome) { lock.withLock { recorded.append(outcome) } } |
| 11 | var values: [NativeTransportOutcome] { lock.withLock { recorded } } |
| 12 | } |
| 13 | |
| 14 | private actor ReceivedLines { |
| 15 | var values: [String] = [] |
| 16 | func append(_ line: String) { values.append(line) } |
| 17 | } |
| 18 | |
| 19 | final class HTTPTests: XCTestCase { |
| 20 | private func stop(_ process: Process) { |
| 21 | process.terminate() |
| 22 | let deadline = Date().addingTimeInterval(0.5) |
| 23 | while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } |
| 24 | if process.isRunning { kill(process.processIdentifier, SIGKILL) } |
| 25 | } |
| 26 | |
| 27 | private func fixture(observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) throws -> (Process, NativeHTTP) { |
| 28 | let process = Process() |
| 29 | process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| 30 | process.arguments = ["-u", "-c", #""" |
| 31 | import http.server |
| 32 | class Handler(http.server.BaseHTTPRequestHandler): |
| 33 | def log_message(self, *args): pass |
| 34 | def do_GET(self): |
| 35 | if self.path == '/redirect': |
| 36 | self.send_response(302) |
| 37 | self.send_header('Location', '/unexpected-follow') |
| 38 | self.end_headers() |
| 39 | return |
| 40 | if self.path.startswith('/status/'): |
| 41 | body = b'refused' |
| 42 | self.send_response(int(self.path.split('/')[2])) |
| 43 | self.send_header('Content-Length', str(len(body))) |
| 44 | self.send_header('Retry-After', '9') |
| 45 | self.end_headers() |
| 46 | self.wfile.write(body) |
| 47 | return |
| 48 | if self.path == '/stream': |
| 49 | body = b'event: update\r\ndata: 42\r\n\r\n' |
| 50 | content_type = 'text/event-stream; charset=utf-8' |
| 51 | else: |
| 52 | body = b'12345' |
| 53 | content_type = 'application/octet-stream' |
| 54 | self.send_response(200) |
| 55 | self.send_header('Content-Type', content_type) |
| 56 | self.send_header('Content-Length', str(len(body))) |
| 57 | self.send_header('Docs-Seq', '42') |
| 58 | self.send_header('Seen-Origin', self.headers.get('Origin', 'missing')) |
| 59 | self.send_header('Seen-Cookie', self.headers.get('Cookie', 'missing')) |
| 60 | self.end_headers() |
| 61 | self.wfile.write(body) |
| 62 | server = http.server.HTTPServer(('127.0.0.1', 0), Handler) |
| 63 | print(server.server_port, flush=True) |
| 64 | server.serve_forever() |
| 65 | """#] |
| 66 | let output = Pipe() |
| 67 | process.standardOutput = output |
| 68 | try process.run() |
| 69 | var port = Data() |
| 70 | while port.count < 12 { |
| 71 | let byte = output.fileHandleForReading.readData(ofLength: 1) |
| 72 | if byte.isEmpty || byte == Data([10]) { break } |
| 73 | port.append(byte) |
| 74 | } |
| 75 | guard let portText = String(data: port, encoding: .utf8), let number = Int(portText), number > 0, |
| 76 | let origin = URL(string: "http://127.0.0.1:\(number)") else { |
| 77 | process.terminate() |
| 78 | throw NativeClientError.message("The isolated HTTP fixture could not start.") |
| 79 | } |
| 80 | return (process, NativeHTTP(origin: origin, credential: NativeSession(name: "rastrillo_session", value: "fixture"), |
| 81 | observer: observer)) |
| 82 | } |
| 83 | |
| 84 | func testResponseLimitHeadersAndOrigin() async throws { |
| 85 | let (server, client) = try fixture() |
| 86 | defer { stop(server) } |
| 87 | let response = try await client.responseDetails("/bytes", maxBytes: 5) |
| 88 | XCTAssertEqual(response.data, Data("12345".utf8)) |
| 89 | XCTAssertEqual(response.header("Docs-Seq"), "42") |
| 90 | XCTAssertEqual(response.header("Seen-Origin"), client.origin.absoluteString) |
| 91 | XCTAssertEqual(response.header("Seen-Cookie"), "rastrillo_session=fixture") |
| 92 | do { |
| 93 | _ = try await client.response("/bytes", maxBytes: 4) |
| 94 | XCTFail("A truncated response must fail, never succeed with a prefix") |
| 95 | } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) } |
| 96 | } |
| 97 | |
| 98 | /// Real statuses from a real server reach the caller typed, through every |
| 99 | /// entry point, and each arrival tells the observer the server is reachable. |
| 100 | func testStatusesAreTypedAndObserved() async throws { |
| 101 | let outcomes = ReceivedOutcomes() |
| 102 | let (server, client) = try fixture(observer: { outcomes.append($0) }) |
| 103 | defer { stop(server) } |
| 104 | struct Empty: Decodable {} |
| 105 | do { |
| 106 | let _: Empty = try await client.request("/status/401") |
| 107 | XCTFail("A 401 decoded") |
| 108 | } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } |
| 109 | do { |
| 110 | try await client.stream("/status/401") { _ in XCTFail("A refused stream delivered a line") } |
| 111 | XCTFail("A 401 stream opened") |
| 112 | } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } |
| 113 | do { |
| 114 | let _: Empty = try await client.request("/status/409") |
| 115 | XCTFail("A 409 decoded") |
| 116 | } catch { XCTAssertEqual(error as? NativeClientError, .conflict(Data("refused".utf8))) } |
| 117 | do { |
| 118 | let _: Empty = try await client.request("/status/503") |
| 119 | XCTFail("A 503 decoded") |
| 120 | } catch { XCTAssertEqual(error as? NativeClientError, .rateLimited(retryAfter: 9)) } |
| 121 | let details = try await client.responseDetails("/status/404") |
| 122 | XCTAssertEqual(details.status, 404, "responseDetails must keep returning the status") |
| 123 | XCTAssertThrowsError(try details.requireSuccess()) { XCTAssertEqual($0 as? NativeClientError, .notFound) } |
| 124 | XCTAssertEqual(outcomes.values, [401, 401, 409, 503, 404].map { .responded(status: $0) }) |
| 125 | } |
| 126 | |
| 127 | func testRedirectIsReturnedWithoutFollowing() async throws { |
| 128 | let (server, client) = try fixture() |
| 129 | defer { stop(server) } |
| 130 | let response = try await client.responseDetails("/redirect") |
| 131 | XCTAssertEqual(response.status, 302) |
| 132 | XCTAssertTrue(response.data.isEmpty) |
| 133 | } |
| 134 | |
| 135 | func testFileExportIsPrivateAndNeverOverwritesOrFollowsRedirect() async throws { |
| 136 | let (server, client) = try fixture() |
| 137 | defer { stop(server) } |
| 138 | let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| 139 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) |
| 140 | defer { try? FileManager.default.removeItem(at: directory) } |
| 141 | let file = directory.appendingPathComponent("export.xlsx") |
| 142 | try await client.download("/bytes", to: file) |
| 143 | XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8)) |
| 144 | let permissions = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber |
| 145 | XCTAssertEqual(permissions?.intValue, 0o600) |
| 146 | try Data("keep existing file".utf8).write(to: file) |
| 147 | do { |
| 148 | try await client.download("/bytes", to: file) |
| 149 | XCTFail("Export overwrote an existing file") |
| 150 | } catch { XCTAssertEqual(try Data(contentsOf: file), Data("keep existing file".utf8)) } |
| 151 | let redirected = directory.appendingPathComponent("redirect.xlsx") |
| 152 | do { |
| 153 | try await client.download("/redirect", to: redirected) |
| 154 | XCTFail("Export followed or accepted a redirect") |
| 155 | } catch { XCTAssertFalse(FileManager.default.fileExists(atPath: redirected.path)) } |
| 156 | } |
| 157 | |
| 158 | func testLiveTransportAndWrongContentType() async throws { |
| 159 | let (server, client) = try fixture() |
| 160 | defer { stop(server) } |
| 161 | let lines = ReceivedLines() |
| 162 | try await client.stream("/stream") { line in await lines.append(line) } |
| 163 | let received = await lines.values |
| 164 | XCTAssertEqual(received, ["event: update", "data: 42", ""]) |
| 165 | do { |
| 166 | try await client.stream("/bytes") { _ in XCTFail("Non-SSE content reached the live editor") } |
| 167 | XCTFail("Non-SSE response was accepted") |
| 168 | } catch { XCTAssertTrue(error.localizedDescription.contains("refused")) } |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | #endif |
| 173 | |