rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2import Foundation
3import Darwin
4import XCTest
5@testable import RastrilloApple
6
7private 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
14private actor ReceivedLines {
15 var values: [String] = []
16 func append(_ line: String) { values.append(line) }
17}
18
19final 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", #"""
31import http.server
32class 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)
62server = http.server.HTTPServer(('127.0.0.1', 0), Handler)
63print(server.server_port, flush=True)
64server.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