610 lines
24 KiB
Swift
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 == 300)
|
|
#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)
|
|
}
|
|
}
|