Compare commits

...

5 commits
0.7.0 ... main

Author SHA1 Message Date
Stefan Hausotte
7675312f5c release 0.8.1 2026-06-15 18:44:16 +02:00
Stefan Hausotte
1ad2ee2607 fix: report security-key-blocked Basic auth instead of invalid credentials
Forgejo rejects username/password (Basic auth) for accounts with a
security key / passkey (WebAuthn) enrolled, responding 401 with
"Basic authorization is not allowed while having security keys enrolled".
This was misclassified as .invalidCredentials, showing "Invalid username
or password" and sending users in circles. Add a dedicated
.basicAuthBlockedBySecurityKey case that tells the user to use a token.

Also extract AuthenticationError into its own file.
2026-06-15 18:43:55 +02:00
Stefan Hausotte
485c147e7f release 0.8.0 2026-06-15 12:06:48 +02:00
Stefan Hausotte
81c74f6dc3 chore: formatting and function names 2026-06-15 12:06:27 +02:00
Stefan Hausotte
b78c57ffd9 feat: add Attachment model and image upload support
- Add Attachment model with isImage computed property
- Add assets field to Issue, IssueComment, and PullRequest
- Add multipart/form-data upload support to ForgejoClient
- Add uploadIssueAttachment and uploadCommentAttachment to IssueService
2026-06-15 09:48:47 +02:00
15 changed files with 476 additions and 76 deletions

View file

@ -1,2 +1,3 @@
--swiftversion 6.2
--disable redundantMemberwiseInit
--disable swiftTestingTestCaseNames

View file

@ -17,7 +17,7 @@ Add ForgejoKit as a dependency in your `Package.swift`:
```swift
dependencies: [
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.7.0"),
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.8.1"),
],
targets: [
.target(

View file

@ -0,0 +1,41 @@
import Foundation
public struct Attachment: Codable, Identifiable, Sendable {
public let id: Int
public let uuid: String
public let name: String
public let size: Int
public let browserDownloadUrl: String
public let type: String?
public let created: Date
public init(
id: Int, uuid: String, name: String, size: Int,
browserDownloadUrl: String,
type: String?, created: Date,
) {
self.id = id
self.uuid = uuid
self.name = name
self.size = size
self.browserDownloadUrl = browserDownloadUrl
self.type = type
self.created = created
}
public var isImage: Bool {
if let type, type.hasPrefix("image/") { return true }
let ext = (name as NSString).pathExtension.lowercased()
return ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "svg"].contains(ext)
}
enum CodingKeys: String, CodingKey {
case id
case uuid
case name
case size
case browserDownloadUrl = "browser_download_url"
case type
case created = "created_at"
}
}

View file

@ -17,6 +17,7 @@ public struct Issue: Codable, Identifiable, Sendable {
public let pullRequest: IssuePullRequest?
public let comments: Int
public let repository: Repository?
public let assets: [Attachment]?
public init(
id: Int, number: Int, title: String,
@ -28,6 +29,7 @@ public struct Issue: Codable, Identifiable, Sendable {
closedAt: Date? = nil, dueDate: Date? = nil,
pullRequest: IssuePullRequest? = nil,
comments: Int = 0, repository: Repository? = nil,
assets: [Attachment]? = nil,
) {
self.id = id
self.number = number
@ -45,6 +47,7 @@ public struct Issue: Codable, Identifiable, Sendable {
self.pullRequest = pullRequest
self.comments = comments
self.repository = repository
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -64,6 +67,7 @@ public struct Issue: Codable, Identifiable, Sendable {
case pullRequest = "pull_request"
case comments
case repository
case assets
}
}

View file

@ -6,13 +6,19 @@ public struct IssueComment: Codable, Identifiable, Sendable {
public let user: User
public let createdAt: Date
public let updatedAt: Date
public let assets: [Attachment]?
public init(id: Int, body: String, user: User, createdAt: Date, updatedAt: Date) {
public init(
id: Int, body: String, user: User,
createdAt: Date, updatedAt: Date,
assets: [Attachment]? = nil,
) {
self.id = id
self.body = body
self.user = user
self.createdAt = createdAt
self.updatedAt = updatedAt
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -21,5 +27,6 @@ public struct IssueComment: Codable, Identifiable, Sendable {
case user
case createdAt = "created_at"
case updatedAt = "updated_at"
case assets
}
}

View file

@ -23,6 +23,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
public let closedAt: Date?
public let mergedAt: Date?
public let htmlUrl: String?
public let assets: [Attachment]?
public init(
id: Int, number: Int, title: String,
@ -38,6 +39,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
createdAt: Date, updatedAt: Date,
closedAt: Date? = nil, mergedAt: Date? = nil,
htmlUrl: String? = nil,
assets: [Attachment]? = nil,
) {
self.id = id
self.number = number
@ -61,6 +63,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
self.closedAt = closedAt
self.mergedAt = mergedAt
self.htmlUrl = htmlUrl
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -86,6 +89,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
case closedAt = "closed_at"
case mergedAt = "merged_at"
case htmlUrl = "html_url"
case assets
}
}

View file

@ -3,7 +3,7 @@ import Foundation
/// Represents a single Forgejo Actions run.
///
/// Mirrors the Forgejo `ActionRun` schema. Note: Forgejo merges status and
/// conclusion into a single `status` field values are
/// conclusion into a single `status` field, values are
/// `success`, `failure`, `cancelled`, `skipped`, `running`, `waiting`,
/// `blocked`, `unknown`.
public struct WorkflowRun: Codable, Identifiable, Sendable {

View file

@ -3,7 +3,7 @@ import Foundation
/// Response from Forgejo's internal web route
/// `POST /{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}`.
///
/// **Experimental.** This endpoint is the web UI's own JSON contract it is
/// **Experimental.** This endpoint is the web UI's own JSON contract, it is
/// not part of `/api/v1` and may change between Forgejo releases without
/// notice. Use only for surfaces where graceful degradation is acceptable.
public struct WorkflowRunView: Codable, Sendable {

View file

@ -3,7 +3,7 @@ import Foundation
public class URLSessionManager: NSObject, @unchecked Sendable {
public let allowSelfSignedCertificates: Bool
private let trustedHost: String?
// Initialized eagerly in init safe for concurrent access as it's only written once.
// Initialized eagerly in init, safe for concurrent access as it's only written once.
private var _session: URLSession!
public var session: URLSession {
_session

View file

@ -0,0 +1,73 @@
import Foundation
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
case invalidURL
case invalidCredentials
case otpRequired
case basicAuthBlockedBySecurityKey
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
switch statusCode {
case 401 where body.localizedCaseInsensitiveContains("security key"):
.basicAuthBlockedBySecurityKey
case 401:
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
case 404:
.serverNotFound
default:
.unknownError(statusCode: statusCode)
}
}
public var httpStatusCode: Int? {
switch self {
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey:
401
case .serverNotFound:
404
case let .unknownError(statusCode):
statusCode
case .invalidURL, .invalidResponse, .certificateError:
nil
}
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid server URL"
case .invalidCredentials:
"Invalid username or password"
case .otpRequired:
"Two-factor authentication code required"
case .basicAuthBlockedBySecurityKey:
"An enrolled security key blocks Basic auth. Create a personal access token and use 'Login with Token'."
case .serverNotFound:
"Server not found. Please check the URL."
case .invalidResponse:
"Invalid response from server"
case .certificateError:
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
case let .unknownError(statusCode):
switch HTTPErrorCategory(statusCode: statusCode) {
case .permissionDenied:
"Permission denied (Status: \(statusCode))"
case .server:
"Server error occurred (Status: \(statusCode))"
case .authentication, .notFound, .other:
"Unknown error occurred (Status: \(statusCode))"
}
}
}
}

View file

@ -4,7 +4,7 @@ public final class ForgejoClient: Sendable {
public let serverURL: String
public let username: String
private enum AuthCredential: Sendable {
private enum AuthCredential {
case basic(base64: String)
case token(String)
case bearer(String)
@ -204,7 +204,7 @@ public final class ForgejoClient: Sendable {
else { throw AuthenticationError.invalidResponse }
return sha1
case 400, 422:
// Token name already taken Forgejo returns 400, some versions use 422
// Token name already taken, Forgejo returns 400, some versions use 422
let body = String(data: data, encoding: .utf8) ?? ""
guard body.contains("name") else {
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
@ -372,6 +372,53 @@ extension ForgejoClient {
try JSONEncoder().encode(body)
}
/// Builds a multipart/form-data body with a single file part named "attachment".
static func buildMultipartBody(
fileData: Data,
fileName: String,
mimeType: String,
boundary: String,
) -> Data {
var body = Data()
body.append(contentsOf: "--\(boundary)\r\n".utf8)
let disposition = "Content-Disposition: form-data; name=\"attachment\"; filename=\"\(fileName)\"\r\n"
body.append(contentsOf: disposition.utf8)
body.append(contentsOf: "Content-Type: \(mimeType)\r\n\r\n".utf8)
body.append(fileData)
body.append(contentsOf: "\r\n--\(boundary)--\r\n".utf8)
return body
}
func performMultipartRequest<T: Decodable>(
url: URL,
fileData: Data,
fileName: String,
mimeType: String,
boundary: String = UUID().uuidString,
responseType: T.Type,
) async throws -> T {
let body = Self.buildMultipartBody(
fileData: fileData, fileName: fileName, mimeType: mimeType, boundary: boundary,
)
var request = authenticatedRequest(url: url, method: "POST")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
try validateStatus(httpResponse, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
do {
return try decoder.decode(responseType, from: data)
} catch let error as DecodingError {
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
}
}
public static func normalizeServerURL(_ url: String) -> String {
var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
while normalized.hasSuffix("/") {
@ -413,73 +460,6 @@ public enum HTTPErrorCategory: Equatable, Sendable {
}
}
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
case invalidURL
case invalidCredentials
case otpRequired
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
switch statusCode {
case 401:
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
case 404:
.serverNotFound
default:
.unknownError(statusCode: statusCode)
}
}
public var httpStatusCode: Int? {
switch self {
case .invalidCredentials, .otpRequired:
401
case .serverNotFound:
404
case let .unknownError(statusCode):
statusCode
case .invalidURL, .invalidResponse, .certificateError:
nil
}
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid server URL"
case .invalidCredentials:
"Invalid username or password"
case .otpRequired:
"Two-factor authentication code required"
case .serverNotFound:
"Server not found. Please check the URL."
case .invalidResponse:
"Invalid response from server"
case .certificateError:
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
case let .unknownError(statusCode):
switch HTTPErrorCategory(statusCode: statusCode) {
case .permissionDenied:
"Permission denied (Status: \(statusCode))"
case .server:
"Server error occurred (Status: \(statusCode))"
case .authentication, .notFound, .other:
"Unknown error occurred (Status: \(statusCode))"
}
}
}
}
public enum ServiceError: LocalizedError, Sendable, Equatable {
case noActiveInstance
case invalidURL

View file

@ -184,4 +184,37 @@ public final class IssueService: Sendable {
responseType: IssueComment.self,
)
}
// swiftlint:disable:next function_parameter_count
public func uploadIssueAttachment(
owner: String, repo: String, index: Int,
fileData: Data, fileName: String, mimeType: String,
) async throws -> Attachment {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/assets")
return try await client.performMultipartRequest(
url: url,
fileData: fileData,
fileName: fileName,
mimeType: mimeType,
responseType: Attachment.self,
)
}
// swiftlint:disable:next function_parameter_count
public func uploadCommentAttachment(
owner: String, repo: String, commentId: Int,
fileData: Data, fileName: String, mimeType: String,
) async throws -> Attachment {
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/issues/comments/\(commentId)/assets",
)
return try await client.performMultipartRequest(
url: url,
fileData: fileData,
fileName: fileName,
mimeType: mimeType,
responseType: Attachment.self,
)
}
}

View file

@ -1,7 +1,7 @@
import Foundation
/// Forgejo Actions API. Forgejo's Actions API is much smaller than Gitea's
/// or GitHub's there is no public endpoint to list workflow definitions,
/// or GitHub's, there is no public endpoint to list workflow definitions,
/// fetch jobs of a run, or stream job logs. Only run listing and run detail
/// are exposed.
public final class WorkflowService: Sendable {
@ -114,7 +114,7 @@ public final class WorkflowService: Sendable {
)
// Forgejo's POST handler at this base path uses `attempt=0` which fails
// for any run whose tasks are stored with the (1-based) attempt number.
// The matching GET endpoint 307-redirects to the latest-attempt URL
// The matching GET endpoint 307-redirects to the latest-attempt URL,
// resolve that first, then POST to the resolved attempt-suffixed URL.
let postURL = await (try? client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let body = try client.encodeRequestBody(ViewRequestBody(logCursors: logCursors))

View file

@ -0,0 +1,249 @@
@testable import ForgejoKit
import Foundation
import Testing
struct AttachmentTests {
private func decoder() -> JSONDecoder {
let dec = JSONDecoder()
dec.dateDecodingStrategy = forgejoDateDecodingStrategy
return dec
}
// MARK: - Model decoding
@Test func decodesAttachment() throws {
let json = """
{
"id": 1,
"uuid": "ee01801f-ffdb-4154-856b-77eab52aaad2",
"name": "screenshot.png",
"size": 222208,
"browser_download_url": "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2",
"type": "image/png",
"created_at": "2026-06-14T17:49:26Z"
}
"""
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
#expect(attachment.id == 1)
#expect(attachment.uuid == "ee01801f-ffdb-4154-856b-77eab52aaad2")
#expect(attachment.name == "screenshot.png")
#expect(attachment.size == 222_208)
#expect(attachment.browserDownloadUrl == "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2")
#expect(attachment.type == "image/png")
#expect(attachment.isImage == true)
}
@Test func decodesAttachmentWithoutTypeField() throws {
// Forgejo API does not always include a type field, isImage falls back to file extension.
let json = """
{
"id": 3,
"uuid": "no-type-uuid",
"name": "photo.jpg",
"size": 10000,
"browser_download_url": "https://codeberg.org/attachments/no-type-uuid",
"created_at": "2026-01-01T00:00:00Z"
}
"""
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
#expect(attachment.type == nil)
#expect(attachment.isImage == true)
}
@Test func decodesNonImageAttachment() throws {
let json = """
{
"id": 2,
"uuid": "abc123",
"name": "report.pdf",
"size": 50000,
"browser_download_url": "https://forgejo.example.com/attachments/abc123",
"type": "application/pdf",
"created_at": "2026-01-01T00:00:00Z"
}
"""
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
#expect(attachment.name == "report.pdf")
#expect(attachment.type == "application/pdf")
#expect(attachment.isImage == false)
}
@Test func decodesIssueWithAssets() throws {
let json = """
{
"id": 1,
"number": 42,
"title": "Bug report",
"state": "open",
"user": { "id": 1, "login": "alice" },
"labels": [],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"comments": 0,
"assets": [
{
"id": 10,
"uuid": "uuid-1",
"name": "bug.png",
"size": 1024,
"browser_download_url": "https://forgejo.example.com/attachments/uuid-1",
"type": "image/png",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
"""
let issue = try decoder().decode(Issue.self, from: Data(json.utf8))
#expect(issue.assets?.count == 1)
#expect(issue.assets?.first?.name == "bug.png")
#expect(issue.assets?.first?.isImage == true)
}
@Test func decodesIssueWithNoAssets() throws {
let json = """
{
"id": 1,
"number": 1,
"title": "No attachments",
"state": "open",
"user": { "id": 1, "login": "alice" },
"labels": [],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"comments": 0
}
"""
let issue = try decoder().decode(Issue.self, from: Data(json.utf8))
#expect(issue.assets == nil)
}
@Test func decodesIssueCommentWithAssets() throws {
let json = """
{
"id": 5,
"body": "See attached screenshot",
"user": { "id": 2, "login": "bob" },
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"assets": [
{
"id": 20,
"uuid": "uuid-2",
"name": "comment-image.jpg",
"size": 2048,
"browser_download_url": "https://forgejo.example.com/attachments/uuid-2",
"type": "image/jpeg",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
"""
let comment = try decoder().decode(IssueComment.self, from: Data(json.utf8))
#expect(comment.assets?.count == 1)
#expect(comment.assets?.first?.name == "comment-image.jpg")
}
// MARK: - isImage
@Test func isImageForVariousMimeTypes() {
let makeAttachment = { (type: String) in
Attachment(
id: 1, uuid: "u", name: "f.bin", size: 0,
browserDownloadUrl: "https://example.com/f",
type: type, created: Date(),
)
}
#expect(makeAttachment("image/png").isImage == true)
#expect(makeAttachment("image/jpeg").isImage == true)
#expect(makeAttachment("image/gif").isImage == true)
#expect(makeAttachment("image/webp").isImage == true)
#expect(makeAttachment("application/pdf").isImage == false)
#expect(makeAttachment("text/plain").isImage == false)
#expect(makeAttachment("application/zip").isImage == false)
}
@Test func isImageFallsBackToFileExtensionWhenTypeIsNil() {
let makeAttachment = { (name: String) in
Attachment(
id: 1, uuid: "u", name: name, size: 0,
browserDownloadUrl: "https://example.com/f",
type: nil, created: Date(),
)
}
#expect(makeAttachment("photo.png").isImage == true)
#expect(makeAttachment("photo.jpg").isImage == true)
#expect(makeAttachment("photo.jpeg").isImage == true)
#expect(makeAttachment("photo.gif").isImage == true)
#expect(makeAttachment("photo.webp").isImage == true)
#expect(makeAttachment("document.pdf").isImage == false)
#expect(makeAttachment("archive.zip").isImage == false)
#expect(makeAttachment("readme.txt").isImage == false)
}
@Test func isImageFallsBackToExtensionWhenTypeIsNotAMimeType() {
// Forgejo returns "type": "attachment" for all uploads, not an actual MIME type.
// isImage must fall through to the extension check in this case.
let makeAttachment = { (name: String) in
Attachment(
id: 1, uuid: "u", name: name, size: 0,
browserDownloadUrl: "https://example.com/f",
type: "attachment", created: Date(),
)
}
#expect(makeAttachment("photo.png").isImage == true)
#expect(makeAttachment("photo.jpg").isImage == true)
#expect(makeAttachment("photo.gif").isImage == true)
#expect(makeAttachment("document.pdf").isImage == false)
#expect(makeAttachment("archive.zip").isImage == false)
}
// MARK: - Upload URL construction
@Test func uploadIssueAttachmentURL() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/42/assets")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/42/assets")
}
@Test func uploadCommentAttachmentURL() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/comments/99/assets")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/comments/99/assets")
}
@Test func uploadURLEncodesOwnerAndRepo() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
let url = try client.makeRepoURL(owner: "my org", repo: "my repo", path: "/issues/1/assets")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/my%20org/my%20repo/issues/1/assets")
}
// MARK: - Multipart body
@Test func buildMultipartBodyStructure() {
let fileData = Data("hello".utf8)
let boundary = "test-boundary-123"
let body = ForgejoClient.buildMultipartBody(
fileData: fileData,
fileName: "test.png",
mimeType: "image/png",
boundary: boundary,
)
let bodyString = String(data: body, encoding: .utf8) ?? ""
#expect(bodyString.contains("--\(boundary)\r\n"))
#expect(bodyString.contains("Content-Disposition: form-data; name=\"attachment\"; filename=\"test.png\""))
#expect(bodyString.contains("Content-Type: image/png"))
#expect(bodyString.contains("hello"))
#expect(bodyString.hasSuffix("--\(boundary)--\r\n"))
}
@Test func buildMultipartBodyContainsFileData() {
let fileData = Data([0xFF, 0xD8, 0xFF, 0xE0]) // JPEG header bytes
let boundary = "b"
let body = ForgejoClient.buildMultipartBody(
fileData: fileData, fileName: "img.jpg", mimeType: "image/jpeg", boundary: boundary,
)
let jpegHeader = Data([0xFF, 0xD8, 0xFF, 0xE0])
let range = body.range(of: jpegHeader)
#expect(range != nil)
}
}

View file

@ -33,6 +33,14 @@ struct ErrorMappingTests {
#expect(AuthenticationError.from(statusCode: 418) == .unknownError(statusCode: 418))
}
@Test func mapsSecurityKeyBlockedBasicAuth() {
// Forgejo's exact 401 body when a passkey/security key is enrolled on the account.
let body = "Basic authorization is not allowed while having security keys enrolled"
#expect(AuthenticationError.from(statusCode: 401, body: body) == .basicAuthBlockedBySecurityKey)
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpStatusCode == 401)
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpErrorCategory == .authentication)
}
@Test func exposesAuthenticationStatusAndCategory() {
#expect(AuthenticationError.invalidCredentials.httpStatusCode == 401)
#expect(AuthenticationError.invalidCredentials.httpErrorCategory == .authentication)