Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
// `URLRequest` lives in FoundationNetworking on Linux, as it does in RunnerCore.
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Ways a fixture can be wrong about its own inputs.
|
||||
private enum FixtureFailure: Error {
|
||||
case unexpectedRequestCount(Int)
|
||||
case unusableURL
|
||||
}
|
||||
|
||||
/// A canned ``HTTPTransport`` that records what it was asked to send.
|
||||
///
|
||||
/// An actor rather than a locked class: ``HTTPTransport/send(_:)`` is `async`,
|
||||
/// so actor isolation satisfies the requirement directly and the recorded
|
||||
/// requests need no lock of their own. No `URLProtocol`, no loopback server —
|
||||
/// the seam is the protocol.
|
||||
private actor MockTransport: HTTPTransport {
|
||||
/// Every request handed to ``send(_:)``, in order.
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
private let handler: @Sendable (URLRequest) -> (Data, Int)
|
||||
|
||||
/// - Parameter handler: Produces the canned `(body, status)` for a request.
|
||||
init(handler: @escaping @Sendable (URLRequest) -> (Data, Int)) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
/// Convenience: always answer with one status and body.
|
||||
init(status: Int, body: String = "") {
|
||||
self.handler = { (_: URLRequest) in (Data(body.utf8), status) }
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, Int) {
|
||||
requests.append(request)
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
/// The single request that was sent, or a failure if the count differs.
|
||||
func onlyRequest() throws -> URLRequest {
|
||||
guard requests.count == 1, let only = requests.first else {
|
||||
throw FixtureFailure.unexpectedRequestCount(requests.count)
|
||||
}
|
||||
return only
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for ``GiteaClient`` request shaping and error mapping, driven through a
|
||||
/// fake ``HTTPTransport``.
|
||||
@Suite("GiteaClient")
|
||||
struct GiteaClientTests {
|
||||
|
||||
private let base = URL(string: "https://gitea.example.com")!
|
||||
|
||||
private func components(_ request: URLRequest) throws -> URLComponents {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { throw FixtureFailure.unusableURL }
|
||||
return components
|
||||
}
|
||||
|
||||
private func queryValue(_ request: URLRequest, _ name: String) throws -> String? {
|
||||
try components(request).queryItems?.first(where: { $0.name == name })?.value
|
||||
}
|
||||
|
||||
// MARK: - Construction
|
||||
|
||||
@Test("a client retains the base URL it was constructed with")
|
||||
func clientRetainsBaseURL() throws {
|
||||
let url = try #require(URL(string: "https://gitea.example.com"))
|
||||
let client = GiteaClient(baseURL: url, token: "t")
|
||||
#expect(client.baseURL == url)
|
||||
}
|
||||
|
||||
// MARK: - Headers
|
||||
|
||||
@Test("every request carries Gitea's token auth and a JSON Accept header")
|
||||
func requestHeaders() throws {
|
||||
let client = GiteaClient(baseURL: base, token: "s3cret")
|
||||
let request = try client.makeRequest(method: "GET", path: "/api/v1/admin/actions/runners")
|
||||
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "token s3cret")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
// No body, so no Content-Type.
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == nil)
|
||||
}
|
||||
|
||||
@Test("a request with a body declares JSON content")
|
||||
func requestWithBody() throws {
|
||||
let client = GiteaClient(baseURL: base, token: "t")
|
||||
let request = try client.makeRequest(
|
||||
method: "POST", path: "/api/v1/x", body: Data(#"{"a":1}"#.utf8))
|
||||
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
#expect(request.httpBody == Data(#"{"a":1}"#.utf8))
|
||||
}
|
||||
|
||||
@Test("a trailing slash on the base URL does not double up")
|
||||
func baseURLTrailingSlash() throws {
|
||||
let client = GiteaClient(baseURL: try #require(URL(string: "https://gitea.example.com/")), token: "t")
|
||||
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
|
||||
#expect(request.url?.absoluteString == "https://gitea.example.com/api/v1/version")
|
||||
}
|
||||
|
||||
@Test("an instance served under a subpath keeps that subpath")
|
||||
func baseURLWithSubpath() throws {
|
||||
// `URL(string:relativeTo:)` would drop "/gitea" here; string joining does not.
|
||||
let client = GiteaClient(baseURL: try #require(URL(string: "https://example.com/gitea")), token: "t")
|
||||
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
|
||||
#expect(request.url?.absoluteString == "https://example.com/gitea/api/v1/version")
|
||||
}
|
||||
|
||||
// MARK: - listQueuedJobs
|
||||
|
||||
@Test("listQueuedJobs GETs the admin jobs endpoint with status=queued")
|
||||
func listQueuedJobsRequestShape() async throws {
|
||||
let body = """
|
||||
{"total_count": 1, "jobs": [
|
||||
{"id": 4711, "run_id": 12, "name": "build", "labels": ["macos-arm64"],
|
||||
"status": "queued", "created_at": "2026-08-07T09:15:04Z"}
|
||||
]}
|
||||
"""
|
||||
let transport = MockTransport(status: 200, body: body)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
let jobs = try await client.listQueuedJobs(limit: 25)
|
||||
#expect(jobs.map(\.id) == [4711])
|
||||
#expect(jobs.first?.labels == ["macos-arm64"])
|
||||
|
||||
let request = try await transport.onlyRequest()
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(try components(request).path == "/api/v1/admin/actions/jobs")
|
||||
// "queued" and never "waiting": the latter means blocked on a dependency.
|
||||
#expect(try queryValue(request, "status") == "queued")
|
||||
#expect(try queryValue(request, "limit") == "25")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
|
||||
}
|
||||
|
||||
@Test("listQueuedJobs defaults to a limit of 50")
|
||||
func listQueuedJobsDefaultLimit() async throws {
|
||||
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "jobs": []}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
#expect(try await client.listQueuedJobs().isEmpty)
|
||||
#expect(try await queryValue(transport.onlyRequest(), "limit") == "50")
|
||||
}
|
||||
|
||||
@Test("listQueuedJobs never asks for a limit below 1")
|
||||
func listQueuedJobsClampsLimit() async throws {
|
||||
let transport = MockTransport(status: 200, body: #"{"jobs": []}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
_ = try await client.listQueuedJobs(limit: 0)
|
||||
#expect(try await queryValue(transport.onlyRequest(), "limit") == "1")
|
||||
}
|
||||
|
||||
@Test("listQueuedJobs surfaces a 401 as a gitea error")
|
||||
func listQueuedJobsUnauthorized() async throws {
|
||||
let transport = MockTransport(status: 401, body: #"{"message": "token is invalid", "url": "..."}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "bad", transport: transport)
|
||||
|
||||
await #expect(throws: CoreError.self) {
|
||||
_ = try await client.listQueuedJobs()
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await client.listQueuedJobs()
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, message) {
|
||||
#expect(status == 401)
|
||||
// Gitea's own `message` field, not the raw envelope.
|
||||
#expect(message == "token is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a 500 with a non-JSON body reports a body excerpt")
|
||||
func serverErrorReportsExcerpt() async throws {
|
||||
let transport = MockTransport(status: 500, body: "<html>Internal Server Error</html>")
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
do {
|
||||
_ = try await client.listQueuedJobs()
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, message) {
|
||||
#expect(status == 500)
|
||||
#expect(message.contains("Internal Server Error"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a 2xx with an undecodable body is an error, not a silent empty list")
|
||||
func undecodableBodyIsAnError() async throws {
|
||||
let transport = MockTransport(status: 200, body: "not json at all")
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
await #expect(throws: CoreError.self) {
|
||||
_ = try await client.listQueuedJobs()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - listRunners
|
||||
|
||||
@Test("listRunners GETs the admin runners endpoint and decodes object labels")
|
||||
func listRunnersRequestShape() async throws {
|
||||
let body = """
|
||||
{"total_count": 1, "runners": [
|
||||
{"id": 9, "name": "macos-vm-abc", "status": "online", "busy": false,
|
||||
"ephemeral": true, "labels": [{"id": 3, "name": "macos-arm64", "type": "custom"}]}
|
||||
]}
|
||||
"""
|
||||
let transport = MockTransport(status: 200, body: body)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
let runners = try await client.listRunners()
|
||||
#expect(runners.count == 1)
|
||||
#expect(runners.first?.labels == ["macos-arm64"])
|
||||
#expect(runners.first?.isEphemeral == true)
|
||||
#expect(runners.first?.isBusy == false)
|
||||
|
||||
let request = try await transport.onlyRequest()
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(try components(request).path == "/api/v1/admin/actions/runners")
|
||||
// Paginated: `total_count` says there is nothing past this page, so one
|
||||
// request is all it takes.
|
||||
let query = try components(request).queryItems ?? []
|
||||
#expect(query.contains(URLQueryItem(name: "page", value: "1")))
|
||||
#expect(query.contains(URLQueryItem(name: "limit", value: "50")))
|
||||
}
|
||||
|
||||
@Test("listRunners walks every page rather than returning only the first")
|
||||
func listRunnersPaginates() async throws {
|
||||
// Gitea clamps `limit` to its own maximum, so a page shorter than the
|
||||
// one asked for does not mean the walk is over — only `total_count` does.
|
||||
let transport = MockTransport { request in
|
||||
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first { $0.name == "page" }?.value ?? "1"
|
||||
let id = page == "1" ? 1 : 2
|
||||
let body = """
|
||||
{"total_count": 2, "runners": [
|
||||
{"id": \(id), "name": "macos-vm-\(id)", "status": "online", "busy": false,
|
||||
"ephemeral": true, "labels": ["macos-arm64"]}
|
||||
]}
|
||||
"""
|
||||
return (Data(body.utf8), 200)
|
||||
}
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
let runners = try await client.listRunners()
|
||||
#expect(runners.map(\.id) == [1, 2])
|
||||
await #expect(transport.requests.count == 2)
|
||||
}
|
||||
|
||||
@Test("listRunners stops on an empty page when the server omits total_count")
|
||||
func listRunnersStopsOnEmptyPage() async throws {
|
||||
let transport = MockTransport { request in
|
||||
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first { $0.name == "page" }?.value ?? "1"
|
||||
let body = page == "1"
|
||||
? #"{"runners": [{"id": 1, "name": "macos-vm-1", "ephemeral": true, "labels": []}]}"#
|
||||
: #"{"runners": []}"#
|
||||
return (Data(body.utf8), 200)
|
||||
}
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
let runners = try await client.listRunners()
|
||||
#expect(runners.map(\.id) == [1])
|
||||
await #expect(transport.requests.count == 2)
|
||||
}
|
||||
|
||||
@Test("listRunners surfaces a 403 as a gitea error")
|
||||
func listRunnersForbidden() async throws {
|
||||
let transport = MockTransport(status: 403, body: #"{"message": "not an admin"}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
do {
|
||||
_ = try await client.listRunners()
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, message) {
|
||||
#expect(status == 403)
|
||||
#expect(message == "not an admin")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - deleteRunner
|
||||
|
||||
@Test("deleteRunner DELETEs the runner by id and accepts 204")
|
||||
func deleteRunnerRequestShape() async throws {
|
||||
let transport = MockTransport(status: 204)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
try await client.deleteRunner(id: 9)
|
||||
|
||||
let request = try await transport.onlyRequest()
|
||||
#expect(request.httpMethod == "DELETE")
|
||||
#expect(try components(request).path == "/api/v1/admin/actions/runners/9")
|
||||
}
|
||||
|
||||
@Test("deleteRunner tolerates a 404")
|
||||
func deleteRunnerTolerates404() async throws {
|
||||
// The goal is only that the row be gone. We race Gitea's own midnight
|
||||
// sweep and `--ephemeral` auto-deregistration, so "already absent" is
|
||||
// success, not a failure worth logging every five minutes.
|
||||
let transport = MockTransport(status: 404, body: #"{"message": "runner not found"}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
try await client.deleteRunner(id: 9)
|
||||
}
|
||||
|
||||
@Test("deleteRunner accepts a 200 as well as a 204")
|
||||
func deleteRunnerTolerates200() async throws {
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: MockTransport(status: 200))
|
||||
try await client.deleteRunner(id: 1)
|
||||
}
|
||||
|
||||
@Test("deleteRunner still fails on a 500")
|
||||
func deleteRunnerFailsOn500() async throws {
|
||||
let transport = MockTransport(status: 500, body: #"{"message": "boom"}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
do {
|
||||
try await client.deleteRunner(id: 9)
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, message) {
|
||||
#expect(status == 500)
|
||||
#expect(message == "boom")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("deleteRunner rejects a 401 rather than treating it as done")
|
||||
func deleteRunnerFailsOn401() async throws {
|
||||
let client = GiteaClient(
|
||||
baseURL: base, token: "t", transport: MockTransport(status: 401, body: "unauthorized"))
|
||||
|
||||
await #expect(throws: CoreError.self) {
|
||||
try await client.deleteRunner(id: 9)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - getRegistrationToken
|
||||
|
||||
@Test("getRegistrationToken POSTs and returns the token")
|
||||
func registrationTokenRequestShape() async throws {
|
||||
let transport = MockTransport(status: 200, body: #"{"token": "AABBCC00112233"}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
#expect(try await client.getRegistrationToken() == "AABBCC00112233")
|
||||
|
||||
let request = try await transport.onlyRequest()
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(try components(request).path == "/api/v1/admin/actions/runners/registration-token")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
|
||||
}
|
||||
|
||||
@Test("getRegistrationToken trims surrounding whitespace")
|
||||
func registrationTokenIsTrimmed() async throws {
|
||||
let transport = MockTransport(status: 200, body: "{\"token\": \" AABB \\n\"}")
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
#expect(try await client.getRegistrationToken() == "AABB")
|
||||
}
|
||||
|
||||
@Test("getRegistrationToken rejects an empty token")
|
||||
func registrationTokenRejectsEmpty() async throws {
|
||||
// An empty token would be written into the guest and fail at
|
||||
// `gitea-runner register`, tens of seconds and one VM boot later.
|
||||
let client = GiteaClient(
|
||||
baseURL: base, token: "t", transport: MockTransport(status: 200, body: #"{"token": ""}"#))
|
||||
|
||||
await #expect(throws: CoreError.self) {
|
||||
_ = try await client.getRegistrationToken()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("getRegistrationToken surfaces a 500")
|
||||
func registrationTokenServerError() async throws {
|
||||
let client = GiteaClient(
|
||||
baseURL: base, token: "t", transport: MockTransport(status: 500, body: #"{"message": "nope"}"#))
|
||||
|
||||
do {
|
||||
_ = try await client.getRegistrationToken()
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, _) {
|
||||
#expect(status == 500)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ping
|
||||
|
||||
@Test("ping probes an admin endpoint, so a non-admin token fails")
|
||||
func pingUsesAnAdminEndpoint() async throws {
|
||||
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "runners": []}"#)
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
|
||||
|
||||
try await client.ping()
|
||||
|
||||
let request = try await transport.onlyRequest()
|
||||
// Deliberately not /api/v1/version, which most instances serve
|
||||
// anonymously and would therefore pass with a bad token.
|
||||
#expect(try components(request).path == "/api/v1/admin/actions/runners")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
|
||||
}
|
||||
|
||||
@Test("ping fails when the token is rejected")
|
||||
func pingFailsOnBadToken() async throws {
|
||||
let client = GiteaClient(
|
||||
baseURL: base, token: "bad",
|
||||
transport: MockTransport(status: 401, body: #"{"message": "token is invalid"}"#))
|
||||
|
||||
do {
|
||||
try await client.ping()
|
||||
Issue.record("expected a CoreError.gitea")
|
||||
} catch let CoreError.gitea(status, _) {
|
||||
#expect(status == 401)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport-level failures
|
||||
|
||||
@Test("a transport error propagates unchanged")
|
||||
func transportErrorPropagates() async throws {
|
||||
// A dropped connection is not an API error; the poll loop logs it and
|
||||
// retries on the next tick without touching any state. It must not be
|
||||
// laundered into a `CoreError.gitea` with a made-up status.
|
||||
let client = GiteaClient(baseURL: base, token: "t", transport: ThrowingTransport())
|
||||
|
||||
await #expect(throws: ThrowingTransport.Offline.self) {
|
||||
_ = try await client.listQueuedJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport that always fails, standing in for an unreachable instance.
|
||||
private struct ThrowingTransport: HTTPTransport {
|
||||
struct Offline: Error {}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, Int) {
|
||||
throw Offline()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user