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) } }