Files
2026-08-07 05:11:01 -07:00

610 lines
24 KiB
Swift

import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``RunnerConfig`` decoding, normalization, validation, and token
/// resolution.
@Suite("RunnerConfig")
struct ConfigTests {
// MARK: - Fixtures
/// A configuration that passes ``RunnerConfig/validated()`` unmodified, so
/// each test can break exactly one thing.
private func validConfig() -> RunnerConfig {
RunnerConfig(
gitea: .init(
instanceURL: URL(string: "https://gitea.example.com")!,
adminToken: "abc123",
registrationToken: "REG123"))
}
/// Writes `contents` to a unique file under a fresh temporary directory and
/// returns its path. The directory is left for the OS to reap; these are a
/// handful of bytes per test.
private func temporaryFile(named name: String = "token", contents: String) throws -> String {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let file = dir.appendingPathComponent(name)
try Data(contents.utf8).write(to: file)
return file.path
}
/// Runs `body`, requiring it to throw ``CoreError/configInvalid(_:)``, and
/// returns the detail message so a test can assert *which* rule fired.
@discardableResult
private func configInvalidDetail(
_ body: () throws -> Void,
sourceLocation: SourceLocation = #_sourceLocation
) -> String {
do {
try body()
Issue.record("expected CoreError.configInvalid", sourceLocation: sourceLocation)
return ""
} catch let CoreError.configInvalid(detail) {
return detail
} catch {
Issue.record("expected CoreError.configInvalid, got \(error)", sourceLocation: sourceLocation)
return ""
}
}
// MARK: - Defaults
@Test("the default configuration carries every documented default")
func defaultsArePresent() {
let c = RunnerConfig.default
#expect(c.runner.labels == ["macos-arm64"])
#expect(c.runner.namePrefix == "macos-vm-")
#expect(c.runner.version == "3.0.2")
#expect(c.scheduler.maxConcurrentVMs == 2)
#expect(c.scheduler.pollIntervalSeconds == 5)
#expect(c.scheduler.reconcileIntervalSeconds == 300)
#expect(c.scheduler.jobTimeoutMinutes == 120)
#expect(c.scheduler.bootTimeoutSeconds == 900)
#expect(c.guest.username == "admin")
#expect(c.guest.cpuCount == 4)
#expect(c.guest.memoryGB == 8)
#expect(c.guest.diskGB == 64)
#expect(c.storage.minFreeDiskGB == 20)
// No token of either kind: `config init` writes a template an operator
// must still fill in, and `validated()` says so rather than starting.
#expect(c.gitea.adminToken == nil)
#expect(c.gitea.adminTokenFile == nil)
}
@Test("the default download URL substitutes the configured version")
func downloadURLSubstitutesVersion() throws {
var section = RunnerConfig.RunnerSection()
section.version = "3.1.0"
let url = try section.resolvedDownloadURL
#expect(url.absoluteString.contains("v3.1.0/"))
#expect(url.absoluteString.hasSuffix("gitea-runner-3.1.0-darwin-arm64"))
#expect(!url.absoluteString.contains("{version}"))
}
@Test("a download URL template with no scheme is rejected")
func downloadURLWithoutSchemeIsRejected() {
var section = RunnerConfig.RunnerSection()
section.runnerDownloadURL = "gitea.com/runner-{version}"
configInvalidDetail { _ = try section.resolvedDownloadURL }
}
@Test("the default config path lives under the user's home directory")
func defaultPathIsExpanded() {
#expect(!RunnerConfig.defaultPath.hasPrefix("~"))
#expect(RunnerConfig.defaultPath.hasSuffix("/.config/gitea-macos-runner/config.json"))
}
// MARK: - Decoding
@Test("a minimal config decodes with every other section defaulted")
func minimalConfigDecodes() throws {
let json = """
{"gitea": {"instanceURL": "https://gitea.example.com",
"adminToken": "abc", "registrationToken": "reg"}}
"""
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
#expect(c.runner.labels == ["macos-arm64"])
#expect(c.scheduler.pollIntervalSeconds == 5)
#expect(c.guest.username == "admin")
#expect(c.gitea.fetchRegistrationTokenViaAPI == false)
}
@Test("a partial section keeps defaults for the keys it omits")
func partialSectionKeepsDefaults() throws {
let json = """
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
"guest": {"cpuCount": 8}}
"""
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
#expect(c.guest.cpuCount == 8)
#expect(c.guest.memoryGB == 8) // untouched default
#expect(c.guest.username == "admin")
}
@Test("a config with no gitea section is rejected by name")
func missingGiteaSectionIsReported() throws {
let path = try temporaryFile(named: "config.json", contents: #"{"guest": {"cpuCount": 2}}"#)
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
#expect(detail.contains("gitea"))
}
@Test("loading a file that is not JSON reports it as malformed, not missing")
func malformedJSONIsReported() throws {
let path = try temporaryFile(named: "config.json", contents: "not json {")
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
#expect(detail.contains(path))
#expect(!detail.contains("no configuration file"))
}
@Test("loading a missing file names the expanded path")
func missingFileIsReported() {
let detail = configInvalidDetail {
_ = try RunnerConfig.load(from: "/nonexistent/gmr/config.json")
}
#expect(detail.contains("/nonexistent/gmr/config.json"))
}
// MARK: - load / save round trip
@Test("load applies validation, so the loaded value is already normalized")
func loadNormalizes() throws {
let json = """
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
"scheduler": {"maxConcurrentVMs": 16},
"storage": {"storeDir": "~/gmr-store"}}
"""
let path = try temporaryFile(named: "config.json", contents: json)
let c = try RunnerConfig.load(from: path)
#expect(c.scheduler.maxConcurrentVMs == 2)
#expect(!c.storage.storeDir.hasPrefix("~"))
}
@Test("a saved config reloads equal to what was saved")
func saveRoundTrips() throws {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
// Nested and not yet created: `save` is expected to make the parents.
let path = dir.appendingPathComponent("nested/config.json").path
let original = try validConfig().validated()
try original.save(to: path)
#expect(FileManager.default.fileExists(atPath: path))
#expect(try RunnerConfig.load(from: path) == original)
}
@Test("writeExample does not clobber an existing file unless told to")
func writeExampleRespectsExistingFile() throws {
let path = try temporaryFile(named: "config.json", contents: "ORIGINAL")
let c = try validConfig().validated()
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE") == false)
#expect(try String(contentsOfFile: path, encoding: .utf8) == "ORIGINAL")
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE", overwrite: true) == true)
#expect(try String(contentsOfFile: path, encoding: .utf8) == "EXAMPLE")
}
// MARK: - Tilde expansion
@Test("expandTilde resolves a leading tilde and leaves other paths alone")
func expandTildeBehaviour() {
let home = NSHomeDirectory()
#expect(RunnerConfig.expandTilde("~/x") == home + "/x")
#expect(RunnerConfig.expandTilde("/absolute/x") == "/absolute/x")
#expect(RunnerConfig.expandTilde("relative/x") == "relative/x")
// A tilde anywhere but the front is an ordinary character.
#expect(RunnerConfig.expandTilde("/a/~/b") == "/a/~/b")
}
@Test("validation expands tildes in every path-bearing field")
func validationExpandsPaths() throws {
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = "~/admin.token"
c.gitea.registrationToken = nil
c.gitea.registrationTokenFile = "~/reg.token"
c.storage.storeDir = "~/gmr"
let v = try c.validated()
let home = NSHomeDirectory()
#expect(v.gitea.adminTokenFile == home + "/admin.token")
#expect(v.gitea.registrationTokenFile == home + "/reg.token")
#expect(v.storage.storeDir == home + "/gmr")
#expect(v.storeDirectoryURL.path == home + "/gmr")
}
// MARK: - Validation: instance URL
@Test("a valid configuration validates unchanged")
func validConfigurationSurvivesValidation() throws {
let c = try validConfig().validated()
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
#expect(c.gitea.adminToken == "abc123")
}
@Test("a plain http instance URL is allowed")
func httpInstanceURLIsAllowed() throws {
var c = validConfig()
c.gitea.instanceURL = URL(string: "http://gitea.lan:3000")!
#expect(throws: Never.self) { try c.validated() }
}
@Test("a non-http scheme is rejected")
func nonHTTPSchemeIsRejected() {
var c = validConfig()
c.gitea.instanceURL = URL(string: "ssh://gitea.example.com")!
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
}
@Test("an instance URL with no host is rejected")
func hostlessInstanceURLIsRejected() throws {
// `#require` rather than a force-unwrap: whether an empty authority
// parses at all is a Foundation detail, and a nil here should fail this
// one test rather than trap the whole suite.
var c = validConfig()
c.gitea.instanceURL = try #require(URL(string: "https:///path"))
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
}
// MARK: - Validation: admin token
@Test("no admin token at all names the keys to set")
func missingAdminTokenIsRejected() {
var c = validConfig()
c.gitea.adminToken = nil
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("gitea.adminToken"))
#expect(detail.contains("gitea.adminTokenFile"))
}
@Test("both admin token sources set is rejected as ambiguous")
func bothAdminTokenSourcesRejected() {
// A stale inline token beside a live token file is the shape of a
// baffling 401; better to fail at load with the reason spelled out.
var c = validConfig()
c.gitea.adminTokenFile = "/tmp/admin.token"
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("both"))
}
@Test("a whitespace-only admin token counts as absent")
func whitespaceAdminTokenIsAbsent() {
var c = validConfig()
c.gitea.adminToken = " \n "
#expect(configInvalidDetail { _ = try c.validated() }.contains("adminToken"))
}
@Test("a surrounding-whitespace admin token is trimmed rather than rejected")
func adminTokenIsTrimmed() throws {
var c = validConfig()
c.gitea.adminToken = " abc123\n"
#expect(try c.validated().gitea.adminToken == "abc123")
}
// MARK: - Validation: registration token
@Test("no registration source at all is rejected")
func missingRegistrationTokenIsRejected() {
var c = validConfig()
c.gitea.registrationToken = nil
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("registration"))
}
@Test("the API fallback alone satisfies the registration requirement")
func apiFallbackSatisfiesRegistration() throws {
var c = validConfig()
c.gitea.registrationToken = nil
c.gitea.fetchRegistrationTokenViaAPI = true
#expect(throws: Never.self) { try c.validated() }
}
@Test("a file and an inline registration token together are allowed")
func bothRegistrationSourcesAllowed() throws {
// Unlike the admin token: the file simply wins, and the redundancy is
// how operators migrate from inline to file without downtime.
var c = validConfig()
c.gitea.registrationTokenFile = "/tmp/reg.token"
#expect(throws: Never.self) { try c.validated() }
}
// MARK: - Validation: labels
@Test("an empty label list is rejected")
func emptyLabelsRejected() {
var c = validConfig()
c.runner.labels = []
#expect(configInvalidDetail { _ = try c.validated() }.contains("runner.labels"))
}
@Test("an empty label name is rejected")
func emptyLabelNameRejected() {
var c = validConfig()
c.runner.labels = ["macos-arm64", " "]
#expect(configInvalidDetail { _ = try c.validated() }.contains("empty label"))
}
@Test("a ':schema' suffix in configured labels is rejected")
func schemedLabelRejected() {
// Gitea reports bare names on jobs, so "macos-arm64:host" in config
// would silently match nothing at all — a config error, not a runtime
// mystery.
var c = validConfig()
c.runner.labels = ["macos-arm64:host"]
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("bare names"))
}
@Test("labels are trimmed by validation")
func labelsAreTrimmed() throws {
var c = validConfig()
c.runner.labels = [" macos-arm64 ", "macos"]
#expect(try c.validated().runner.labels == ["macos-arm64", "macos"])
}
@Test("the label set derived from config drives job matching")
func labelSetMatchesJobs() throws {
var c = validConfig()
c.runner.labels = ["macos-arm64", "macos"]
let set = try c.validated().labelSet
#expect(set.matches(jobLabels: ["macos-arm64"]))
#expect(!set.matches(jobLabels: ["ubuntu-latest"]))
}
@Test("an empty name prefix is rejected")
func emptyNamePrefixRejected() {
// An empty prefix would make the reconcile loop treat every runner on
// the instance as ours.
var c = validConfig()
c.runner.namePrefix = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("namePrefix"))
}
@Test("an empty runner version is rejected")
func emptyVersionRejected() {
var c = validConfig()
c.runner.version = ""
#expect(configInvalidDetail { _ = try c.validated() }.contains("version"))
}
// MARK: - Validation: scheduler
@Test("maxConcurrentVMs is clamped to the kernel's limit of 2")
func maxConcurrentVMsClampedHigh() throws {
// Apple's kernel fails the third `start()` with
// VZError.virtualMachineLimitExceeded; that is not negotiable in JSON.
for requested in [3, 8, 64, Int.max] {
var c = validConfig()
c.scheduler.maxConcurrentVMs = requested
#expect(try c.validated().scheduler.maxConcurrentVMs == 2)
}
}
@Test("maxConcurrentVMs is clamped up to 1")
func maxConcurrentVMsClampedLow() throws {
for requested in [0, -1, Int.min] {
var c = validConfig()
c.scheduler.maxConcurrentVMs = requested
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
}
}
@Test("an in-range maxConcurrentVMs is left alone")
func maxConcurrentVMsInRange() throws {
var c = validConfig()
c.scheduler.maxConcurrentVMs = 1
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
}
@Test("the hard cap is 2")
func hardCapIsTwo() {
#expect(RunnerConfig.SchedulerSection.hardMaxConcurrentVMs == 2)
}
@Test("non-positive intervals and timeouts are rejected")
func nonPositiveIntervalsRejected() {
var poll = validConfig()
poll.scheduler.pollIntervalSeconds = 0
#expect(configInvalidDetail { _ = try poll.validated() }.contains("pollIntervalSeconds"))
var reconcile = validConfig()
reconcile.scheduler.reconcileIntervalSeconds = -5
#expect(
configInvalidDetail { _ = try reconcile.validated() }.contains("reconcileIntervalSeconds"))
var job = validConfig()
job.scheduler.jobTimeoutMinutes = 0
#expect(configInvalidDetail { _ = try job.validated() }.contains("jobTimeoutMinutes"))
var boot = validConfig()
boot.scheduler.bootTimeoutSeconds = 0
#expect(configInvalidDetail { _ = try boot.validated() }.contains("bootTimeoutSeconds"))
}
// MARK: - Validation: guest
@Test("an empty guest username is rejected")
func emptyGuestUsernameRejected() {
var c = validConfig()
c.guest.username = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.username"))
}
@Test("an empty guest password is rejected")
func emptyGuestPasswordRejected() {
// SSH password auth is the only channel into the guest; an empty
// password turns every boot into an unexplained authentication hang.
var c = validConfig()
c.guest.password = ""
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.password"))
}
@Test("a guest password of only whitespace is accepted verbatim")
func whitespaceGuestPasswordIsKept() throws {
// Unlike tokens, the password is not trimmed: whitespace is a legal
// part of a password, and silently trimming it would break SSH.
var c = validConfig()
c.guest.password = " "
#expect(try c.validated().guest.password == " ")
}
@Test("under-sized guests are rejected")
func undersizedGuestRejected() {
var cpu = validConfig()
cpu.guest.cpuCount = 0
#expect(configInvalidDetail { _ = try cpu.validated() }.contains("cpuCount"))
var mem = validConfig()
mem.guest.memoryGB = 0
#expect(configInvalidDetail { _ = try mem.validated() }.contains("memoryGB"))
var disk = validConfig()
disk.guest.diskGB = 0
#expect(configInvalidDetail { _ = try disk.validated() }.contains("diskGB"))
}
// MARK: - Validation: storage
@Test("an empty store directory is rejected")
func emptyStoreDirRejected() {
var c = validConfig()
c.storage.storeDir = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("storeDir"))
}
@Test("a negative free-space floor is rejected")
func negativeMinFreeDiskRejected() {
var c = validConfig()
c.storage.minFreeDiskGB = -1
#expect(configInvalidDetail { _ = try c.validated() }.contains("minFreeDiskGB"))
}
@Test("a zero free-space floor is allowed")
func zeroMinFreeDiskAllowed() throws {
var c = validConfig()
c.storage.minFreeDiskGB = 0
#expect(throws: Never.self) { try c.validated() }
}
// MARK: - Token resolution
@Test("an inline admin token resolves to itself")
func resolveInlineAdminToken() throws {
#expect(try validConfig().resolveAdminToken() == "abc123")
}
@Test("an admin token file is read and trimmed")
func resolveAdminTokenFromFile() throws {
// `echo secret > token` always leaves a trailing newline; sending that
// in an Authorization header is an instant 401.
let path = try temporaryFile(contents: " file-token-value\n")
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(try c.resolveAdminToken() == "file-token-value")
}
@Test("the token file wins over an inline value when both are somehow present")
func tokenFileTakesPrecedence() throws {
// `validated()` rejects this combination, but resolution is also called
// on configs assembled in code, so the precedence must be defined.
let path = try temporaryFile(contents: "from-file")
var c = validConfig()
c.gitea.adminTokenFile = path
#expect(try c.resolveAdminToken() == "from-file")
}
@Test("resolving from a missing token file names the key and the path")
func missingTokenFileIsReported() {
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = "/nonexistent/admin.token"
let detail = configInvalidDetail { _ = try c.resolveAdminToken() }
#expect(detail.contains("gitea.adminTokenFile"))
#expect(detail.contains("/nonexistent/admin.token"))
}
@Test("an empty token file is rejected rather than yielding an empty token")
func emptyTokenFileIsRejected() throws {
let path = try temporaryFile(contents: "\n\n \n")
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(configInvalidDetail { _ = try c.resolveAdminToken() }.contains("empty"))
}
@Test("resolving with no admin source configured yields nil, not an error")
func resolveAdminTokenWithNoSource() throws {
var c = validConfig()
c.gitea.adminToken = nil
#expect(try c.resolveAdminToken() == nil)
}
@Test("a static registration token resolves from file, then inline")
func resolveRegistrationToken() throws {
var inline = validConfig()
#expect(try inline.resolveStaticRegistrationToken() == "REG123")
let path = try temporaryFile(named: "reg", contents: "REG-FROM-FILE\n")
inline.gitea.registrationTokenFile = path
#expect(try inline.resolveStaticRegistrationToken() == "REG-FROM-FILE")
}
@Test("no static registration token yields nil so the caller can use the API")
func resolveRegistrationTokenWithNoSource() throws {
var c = validConfig()
c.gitea.registrationToken = nil
c.gitea.fetchRegistrationTokenViaAPI = true
#expect(try c.resolveStaticRegistrationToken() == nil)
}
// MARK: - Token file permissions
@Test("a 0600 token file is not reported as insecure")
func ownerOnlyTokenFileIsSecure() throws {
let path = try temporaryFile(contents: "s")
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == false)
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(c.insecureTokenFilePaths.isEmpty)
}
@Test("a group- or world-readable token file is flagged but not fatal")
func looseTokenFileIsFlagged() throws {
// Deliberately a warning: refusing to start over a 0644 file on a
// single-user CI Mac would be a poor trade.
let path = try temporaryFile(contents: "s")
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: path)
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == true)
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(c.insecureTokenFilePaths == [path])
// Still valid: the permission check never blocks startup.
#expect(throws: Never.self) { try c.validated() }
}
@Test("an unreadable path reports an unknown mode rather than a verdict")
func unknownPermissionsAreNil() {
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable("/nonexistent/token") == nil)
}
}