diff --git a/README.md b/README.md index 473754b..156b0a3 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,57 @@ tuist --help swiftlint --help ``` +### Authenticating with private repositories & GitHub Enterprise Server + +If a tool's `url` points at a private repository — on `github.com` or a GitHub Enterprise +Server instance — set an environment variable holding a personal access token before running +`luca install`: + +- `github.com` reads `LUCA_GITHUB_TOKEN`. +- Any other host reads `LUCA_GITHUB_TOKEN_`, where `` is the hostname uppercased + with every non-alphanumeric character replaced by `_`. For example, a Lucafile entry + pointing at `https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip` + is authenticated by setting `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`. + +```bash +export LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM=ghp_xxxxxxxxxxxx +luca install +``` + +The token is only ever sent to the host it was configured for. Note that `github.com` asset +downloads never carry this header, even if `LUCA_GITHUB_TOKEN` is set — GitHub redirects those +downloads to a separate signed-URL storage host, so the token is unused there today; it is +still read and applied to the `api.github.com` release-metadata lookup used by +`luca install org/repo@version`. + +#### GitHub Enterprise Server behind an SSO gateway (e.g. Okta) + +If your GHE instance sits behind a browser-SSO gateway, the plain release-download URL +(`.../releases/download/{version}/{asset}`) may be intercepted before it reaches GHE and served +back as an HTML login page — even with `LUCA_GITHUB_TOKEN_` set — because that URL is +treated as web traffic requiring an interactive session, not a token. The `/api/v3/...` paths are +typically exempt from that gate, since they're designed for token-based access. Point `url` at the +release's **API asset URL** instead of the browser download URL: + +```bash +curl -H "Authorization: Bearer $LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM" \ + https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/tags/2.5.0 +``` + +Find the matching asset's `url` field in the response (not `browser_download_url`) — it looks like +`https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345` — and use that in +your Lucafile: + +```yaml +tools: + - name: ModuleCreator + version: 2.5.0 + url: https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345 +``` + +Luca always sends `Accept: application/octet-stream` on the download request, which the API asset +endpoint requires to return the raw binary instead of JSON metadata. + ### Uninstalling tools Uninstall a specific tool version: diff --git a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md index 1403795..d5cd311 100644 --- a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md +++ b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md @@ -151,6 +151,57 @@ Use checksums for critical tools to verify integrity and protect against tamperi Add `.luca/tools/` to your `.gitignore` file. Luca can manage this automatically with the `--install-git-hook` flag. +## Authenticating with Private Repositories & GitHub Enterprise Server + +If a tool's `url` points at a private repository — on `github.com` or a GitHub Enterprise +Server instance — set an environment variable holding a personal access token before running +`luca install`: + +- `github.com` reads `LUCA_GITHUB_TOKEN`. +- Any other host reads `LUCA_GITHUB_TOKEN_`, where `` is the hostname uppercased + with every non-alphanumeric character replaced by `_`. For example, a Lucafile entry + pointing at `https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip` + is authenticated by setting `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`. + +```bash +export LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM=ghp_xxxxxxxxxxxx +luca install +``` + +The token is only ever sent to the host it was configured for. Note that `github.com` asset +downloads never carry this header, even if `LUCA_GITHUB_TOKEN` is set — GitHub redirects those +downloads to a separate signed-URL storage host, so the token is unused there today; it is +still read and applied to the `api.github.com` release-metadata lookup used by +`luca install org/repo@version`. + +### GitHub Enterprise Server Behind an SSO Gateway (e.g. Okta) + +If your GHE instance sits behind a browser-SSO gateway, the plain release-download URL +(`.../releases/download/{version}/{asset}`) may be intercepted before it reaches GHE and served +back as an HTML login page — even with `LUCA_GITHUB_TOKEN_` set — because that URL is +treated as web traffic requiring an interactive session, not a token. The `/api/v3/...` paths are +typically exempt from that gate, since they're designed for token-based access. Point `url` at the +release's **API asset URL** instead of the browser download URL: + +```bash +curl -H "Authorization: Bearer $LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM" \ + https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/tags/2.5.0 +``` + +Find the matching asset's `url` field in the response (not `browser_download_url`) — it looks like +`https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345` — and use that in +your Lucafile: + +```yaml +tools: + - name: ModuleCreator + version: 2.5.0 + url: https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345 +``` + +Luca always sends `Accept: application/octet-stream` on the download request, which the API asset +endpoint requires to return the raw binary instead of JSON metadata. + ## Skills The optional `skills:` key installs agentic skills from Git repositories. diff --git a/Sources/ManagerCore/Core/Downloader/Downloader.swift b/Sources/ManagerCore/Core/Downloader/Downloader.swift index d944320..80858f6 100644 --- a/Sources/ManagerCore/Core/Downloader/Downloader.swift +++ b/Sources/ManagerCore/Core/Downloader/Downloader.swift @@ -1,11 +1,19 @@ // Downloader.swift import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Downloads release archives and executables from remote URLs. /// /// The `Downloader` handles fetching tool releases from remote servers, /// supporting archive formats (zip, tar.gz) and standalone executables. +/// When the target host is a GitHub Enterprise Server instance (any host other than +/// `github.com`) and a token is configured for it, the request carries an +/// `Authorization: Bearer` header so private-repository release assets can be downloaded. +/// `github.com` never receives this header: its release-asset downloads redirect to a +/// separate presigned-URL storage host, and forwarding a token there would leak it. /// /// ## Topics /// @@ -14,9 +22,11 @@ import Foundation struct Downloader: Downloading { private var fileDownloader: FileDownloading + private var tokenResolver: GitHubTokenResolving - init(fileDownloader: FileDownloading) { + init(fileDownloader: FileDownloading, tokenResolver: GitHubTokenResolving = GitHubTokenResolver()) { self.fileDownloader = fileDownloader + self.tokenResolver = tokenResolver } /// Downloads a release from the specified URL. @@ -24,7 +34,15 @@ struct Downloader: Downloading { /// - Parameter url: The URL to download from. /// - Returns: A URL to the downloaded file in a temporary location. func downloadRelease(at url: URL) async throws -> URL { - let (tempDownloadURL, _) = try await fileDownloader.download(from: url) + var request = URLRequest(url: url) + // Requesting the raw asset bytes explicitly is required by GitHub's release-asset API + // endpoint (".../releases/assets/{id}"), which otherwise returns JSON metadata. Harmless + // for plain download URLs, which ignore Accept and return the file regardless. + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + if let host = url.host, host != "github.com", let token = tokenResolver.token(forHost: host) { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (tempDownloadURL, _) = try await fileDownloader.download(for: request) return tempDownloadURL } } diff --git a/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolver.swift b/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolver.swift new file mode 100644 index 0000000..11f13e7 --- /dev/null +++ b/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolver.swift @@ -0,0 +1,34 @@ +// GitHubTokenResolver.swift + +import Foundation + +/// Reads a per-host GitHub personal access token from environment variables. +/// +/// `github.com` reads `LUCA_GITHUB_TOKEN`. Any other host — a GitHub Enterprise Server +/// instance — reads `LUCA_GITHUB_TOKEN_`, where `` is the hostname uppercased +/// with every non-alphanumeric character replaced by `_` (e.g. `ghe.my-company.com` becomes +/// `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`). +struct GitHubTokenResolver: GitHubTokenResolving { + + private let environment: [String: String] + + init(environment: [String: String] = ProcessInfo.processInfo.environment) { + self.environment = environment + } + + // MARK: - GitHubTokenResolving + + func token(forHost host: String) -> String? { + let variableName = host == "github.com" + ? "LUCA_GITHUB_TOKEN" + : "LUCA_GITHUB_TOKEN_\(Self.sanitize(host))" + let value = environment[variableName] + return (value?.isEmpty ?? true) ? nil : value + } + + // MARK: - Private + + private static func sanitize(_ host: String) -> String { + String(host.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" }) + } +} diff --git a/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolving.swift b/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolving.swift new file mode 100644 index 0000000..78ce138 --- /dev/null +++ b/Sources/ManagerCore/Core/GitHubTokenResolver/GitHubTokenResolving.swift @@ -0,0 +1,11 @@ +// GitHubTokenResolving.swift + +import Foundation + +/// Resolves a GitHub authentication token for a given host. +protocol GitHubTokenResolving { + /// Returns the token configured for `host`, or `nil` if none is set. + /// - Parameter host: The hostname of the GitHub instance (e.g. `"github.com"` or a + /// GitHub Enterprise Server hostname such as `"ghe.my-company.com"`). + func token(forHost host: String) -> String? +} diff --git a/Sources/ManagerCore/Core/ReleaseInfoProvider/ReleaseInfoProvider.swift b/Sources/ManagerCore/Core/ReleaseInfoProvider/ReleaseInfoProvider.swift index bba46ba..3527b51 100644 --- a/Sources/ManagerCore/Core/ReleaseInfoProvider/ReleaseInfoProvider.swift +++ b/Sources/ManagerCore/Core/ReleaseInfoProvider/ReleaseInfoProvider.swift @@ -36,28 +36,35 @@ struct ReleaseInfoProvider: ReleaseInfoProviding { } private var dataDownloader: DataDownloading - - init(dataDownloader: DataDownloading) { + private var tokenResolver: GitHubTokenResolving + + init(dataDownloader: DataDownloading, tokenResolver: GitHubTokenResolving = GitHubTokenResolver()) { self.dataDownloader = dataDownloader + self.tokenResolver = tokenResolver } - + // MARK: - Internal - + /// Returns the release asset whose name best matches the current platform keywords. func platformAsset(for release: Release) async throws -> ReleaseAsset { let releaseInfo = try await fetchReleaseInfo(release: release) return try findPlatformAsset(in: releaseInfo.assets) } - + // MARK: - Private - + private func fetchReleaseInfo(release: Release) async throws -> ReleaseInfo { let releaseUrl = try GitHubReleaseURLFactory().makeApiReleaseURL(release: release) - + var request = URLRequest(url: releaseUrl) request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") request.setValue("luca.tools.cli", forHTTPHeaderField: "User-Agent") - + // GitHubReleaseURLFactory only ever builds github.com API URLs today, so the token + // to attach is always the one configured for "github.com". + if let token = tokenResolver.token(forHost: "github.com") { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await dataDownloader.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { diff --git a/Sources/ManagerCore/Core/SelfUpdater/SelfUpdater.swift b/Sources/ManagerCore/Core/SelfUpdater/SelfUpdater.swift index d36d1c1..4355ee1 100644 --- a/Sources/ManagerCore/Core/SelfUpdater/SelfUpdater.swift +++ b/Sources/ManagerCore/Core/SelfUpdater/SelfUpdater.swift @@ -198,7 +198,7 @@ public struct SelfUpdater: SelfUpdating { printer.printFormatted("\(.raw("🔄 Updating Luca \(currentVersion) → \(targetVersion)..."))") - let (tempZipURL, _) = try await fileDownloader.download(from: downloadURL(for: targetVersion)) + let (tempZipURL, _) = try await fileDownloader.download(for: URLRequest(url: downloadURL(for: targetVersion))) let tempExtractDir = FileManager.default.temporaryDirectory .appendingPathComponent("luca-update-\(UUID().uuidString)") diff --git a/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloader.swift b/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloader.swift index 646ad58..fc41959 100644 --- a/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloader.swift +++ b/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloader.swift @@ -14,7 +14,7 @@ struct FileDownloader: FileDownloading { self.session = session } - func download(from url: URL) async throws -> (URL, URLResponse) { - try await session.download(from: url) + func download(for request: URLRequest) async throws -> (URL, URLResponse) { + try await session.download(for: request) } } diff --git a/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloading.swift b/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloading.swift index df933f7..75ea93d 100644 --- a/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloading.swift +++ b/Sources/ManagerCore/Core/URLSessionWrappers/FileDownloading.swift @@ -5,10 +5,10 @@ import Foundation import FoundationNetworking #endif -/// Downloads a file from a URL and writes it to a temporary location on disk. +/// Downloads a file and writes it to a temporary location on disk. protocol FileDownloading { - /// Downloads the resource at `url` and saves it to a temporary file. - /// - Parameter url: The remote URL of the file to download. + /// Performs `request` and saves the response body to a temporary file. + /// - Parameter request: The request to perform, with any headers (e.g. `Authorization`) already set. /// - Returns: A tuple of the temporary file URL and the URL response. - func download(from url: URL) async throws -> (URL, URLResponse) + func download(for request: URLRequest) async throws -> (URL, URLResponse) } diff --git a/Tests/Core/DownloaderTests.swift b/Tests/Core/DownloaderTests.swift index 87e2201..04b81cb 100644 --- a/Tests/Core/DownloaderTests.swift +++ b/Tests/Core/DownloaderTests.swift @@ -79,4 +79,59 @@ struct DownloaderTests { _ = try await sut.downloadRelease(at: url) } } + + @Test + func test_downloadRelease_attachesTokenForEnterpriseHost() async throws { + let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip") + let fileDownloader = FileDownloadingMock(result: .success(tempURL)) + let tokenResolver = GitHubTokenResolvingMock(tokensByHost: ["ghe.my-company.com": "ghe-token"]) + let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver) + + let url = try #require(URL(string: "https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip")) + _ = try await sut.downloadRelease(at: url) + + #expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer ghe-token") + } + + @Test + func test_downloadRelease_omitsAuthorizationForGitHubDotCom() async throws { + let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip") + let fileDownloader = FileDownloadingMock(result: .success(tempURL)) + // Even if a token happens to be configured for github.com, it must never be attached + // to the asset-download request: github.com redirects release-asset downloads to + // objects.githubusercontent.com with a presigned URL, and forwarding our token there + // would leak it to a third-party host. + let tokenResolver = GitHubTokenResolvingMock(tokensByHost: ["github.com": "dotcom-token"]) + let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver) + + let url = try #require(URL(string: "https://github.com/realm/SwiftLint/releases/download/0.61.0/SwiftLintBinary.artifactbundle.zip")) + _ = try await sut.downloadRelease(at: url) + + #expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil) + } + + @Test + func test_downloadRelease_omitsAuthorizationWhenNoTokenConfigured() async throws { + let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip") + let fileDownloader = FileDownloadingMock(result: .success(tempURL)) + let tokenResolver = GitHubTokenResolvingMock(tokensByHost: [:]) + let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver) + + let url = try #require(URL(string: "https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip")) + _ = try await sut.downloadRelease(at: url) + + #expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil) + } + + @Test + func test_downloadRelease_setsOctetStreamAcceptHeader() async throws { + let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip") + let fileDownloader = FileDownloadingMock(result: .success(tempURL)) + let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: GitHubTokenResolvingMock(tokensByHost: [:])) + + let url = try #require(URL(string: "https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345")) + _ = try await sut.downloadRelease(at: url) + + #expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Accept") == "application/octet-stream") + } } diff --git a/Tests/Core/FileDownloaderTests.swift b/Tests/Core/FileDownloaderTests.swift index 2dd45d6..5618fd5 100644 --- a/Tests/Core/FileDownloaderTests.swift +++ b/Tests/Core/FileDownloaderTests.swift @@ -26,7 +26,7 @@ struct FileDownloaderTests { let sut = FileDownloader(session: URLSession(configuration: config)) let url = try #require(URL(string: "https://example.com/tool.zip")) - let (downloadedURL, response) = try await sut.download(from: url) + let (downloadedURL, response) = try await sut.download(for: URLRequest(url: url)) #expect(downloadedURL.isFileURL) let httpResponse = try #require(response as? HTTPURLResponse) @@ -42,9 +42,29 @@ struct FileDownloaderTests { let url = try #require(URL(string: "https://example.com/tool.zip")) await #expect(throws: (any Error).self) { - _ = try await sut.download(from: url) + _ = try await sut.download(for: URLRequest(url: url)) } } + + @Test + func test_download_forwardsRequestHeaders() async throws { + var capturedRequest: URLRequest? + MockFileURLProtocol.requestHandler = { request in + capturedRequest = request + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data("file contents".utf8)) + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockFileURLProtocol.self] + let sut = FileDownloader(session: URLSession(configuration: config)) + + let url = try #require(URL(string: "https://example.com/tool.zip")) + var request = URLRequest(url: url) + request.setValue("Bearer test-token-value", forHTTPHeaderField: "Authorization") + _ = try await sut.download(for: request) + + #expect(capturedRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer test-token-value") + } } // MARK: - Private Mocks diff --git a/Tests/Core/GitHubTokenResolverTests.swift b/Tests/Core/GitHubTokenResolverTests.swift new file mode 100644 index 0000000..a757105 --- /dev/null +++ b/Tests/Core/GitHubTokenResolverTests.swift @@ -0,0 +1,38 @@ +// GitHubTokenResolverTests.swift + +import Testing +@testable import ManagerCore + +struct GitHubTokenResolverTests { + + @Test + func test_token_forGitHubDotCom_readsPlainVariable() { + let sut = GitHubTokenResolver(environment: ["LUCA_GITHUB_TOKEN": "dotcom-token"]) + #expect(sut.token(forHost: "github.com") == "dotcom-token") + } + + @Test + func test_token_forEnterpriseHost_readsSanitizedVariable() { + let sut = GitHubTokenResolver(environment: ["LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM": "ghe-token"]) + #expect(sut.token(forHost: "ghe.my-company.com") == "ghe-token") + } + + @Test + func test_token_missingVariable_returnsNil() { + let sut = GitHubTokenResolver(environment: [:]) + #expect(sut.token(forHost: "github.com") == nil) + #expect(sut.token(forHost: "ghe.my-company.com") == nil) + } + + @Test + func test_token_emptyVariable_returnsNil() { + let sut = GitHubTokenResolver(environment: ["LUCA_GITHUB_TOKEN": ""]) + #expect(sut.token(forHost: "github.com") == nil) + } + + @Test + func test_token_hostWithPort_sanitizesColonAndDigits() { + let sut = GitHubTokenResolver(environment: ["LUCA_GITHUB_TOKEN_GHE_INTERNAL_COM_8443": "port-token"]) + #expect(sut.token(forHost: "ghe.internal.com:8443") == "port-token") + } +} diff --git a/Tests/Core/ReleaseInfoProviderTests.swift b/Tests/Core/ReleaseInfoProviderTests.swift index 54c5911..a9c1ed9 100644 --- a/Tests/Core/ReleaseInfoProviderTests.swift +++ b/Tests/Core/ReleaseInfoProviderTests.swift @@ -1,5 +1,9 @@ // ReleaseInfoProviderTests.swift +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Testing @testable import LucaFoundation @testable import ManagerCore @@ -115,4 +119,42 @@ struct ReleaseInfoProviderTests { try await sut.platformAsset(for: release) } } + + @Test + func fetchReleaseInfo_attachesAuthorizationHeaderWhenTokenConfigured() async throws { + let capture = RequestCapture() + let dataDownloader = DataDownloaderMock( + result: .fixture(Fixture(filename: "ReleaseInfo", type: "json")), + onRequest: { capture.request = $0 } + ) + let tokenResolver = GitHubTokenResolvingMock(tokensByHost: ["github.com": "dotcom-token"]) + let sut = ReleaseInfoProvider(dataDownloader: dataDownloader, tokenResolver: tokenResolver) + let release = Release(organization: "organization", repository: "repository", version: "1.0.0") + + _ = try await sut.platformAsset(for: release) + + #expect(capture.request?.value(forHTTPHeaderField: "Authorization") == "Bearer dotcom-token") + } + + @Test + func fetchReleaseInfo_omitsAuthorizationHeaderWhenNoTokenConfigured() async throws { + let capture = RequestCapture() + let dataDownloader = DataDownloaderMock( + result: .fixture(Fixture(filename: "ReleaseInfo", type: "json")), + onRequest: { capture.request = $0 } + ) + let tokenResolver = GitHubTokenResolvingMock(tokensByHost: [:]) + let sut = ReleaseInfoProvider(dataDownloader: dataDownloader, tokenResolver: tokenResolver) + let release = Release(organization: "organization", repository: "repository", version: "1.0.0") + + _ = try await sut.platformAsset(for: release) + + #expect(capture.request?.value(forHTTPHeaderField: "Authorization") == nil) + } +} + +// MARK: - Private Mocks + +private final class RequestCapture: @unchecked Sendable { + var request: URLRequest? } diff --git a/Tests/Mocks/DataDownloaderMock.swift b/Tests/Mocks/DataDownloaderMock.swift index 34822c7..182f1ac 100644 --- a/Tests/Mocks/DataDownloaderMock.swift +++ b/Tests/Mocks/DataDownloaderMock.swift @@ -19,8 +19,10 @@ struct DataDownloaderMock: DataDownloading { } var result: Result + var onRequest: (@Sendable (URLRequest) -> Void)? = nil func data(for request: URLRequest) async throws -> (Data, URLResponse) { + onRequest?(request) switch result { case .fixture(let fixture): let bundle = Bundle.module diff --git a/Tests/Mocks/FileDownloadingMock.swift b/Tests/Mocks/FileDownloadingMock.swift index 0576a64..2de24a6 100644 --- a/Tests/Mocks/FileDownloadingMock.swift +++ b/Tests/Mocks/FileDownloadingMock.swift @@ -16,15 +16,17 @@ class FileDownloadingMock: FileDownloading { } var result: Result + private(set) var lastRequest: URLRequest? init(result: Result) { self.result = result } - func download(from url: URL) async throws -> (URL, URLResponse) { + func download(for request: URLRequest) async throws -> (URL, URLResponse) { + lastRequest = request switch result { case .success(let tempURL): - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return (tempURL, response) case .error(let error): throw error diff --git a/Tests/Mocks/GitHubTokenResolvingMock.swift b/Tests/Mocks/GitHubTokenResolvingMock.swift new file mode 100644 index 0000000..a466641 --- /dev/null +++ b/Tests/Mocks/GitHubTokenResolvingMock.swift @@ -0,0 +1,12 @@ +// GitHubTokenResolvingMock.swift + +import Foundation +@testable import ManagerCore + +struct GitHubTokenResolvingMock: GitHubTokenResolving { + var tokensByHost: [String: String] = [:] + + func token(forHost host: String) -> String? { + tokensByHost[host] + } +}