mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
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.
142 lines
6.5 KiB
Swift
142 lines
6.5 KiB
Swift
@testable import ForgejoKit
|
|
import Testing
|
|
|
|
struct ErrorMappingTests {
|
|
@Test func classifiesHTTPStatusCodes() {
|
|
#expect(HTTPErrorCategory(statusCode: 401) == .authentication)
|
|
#expect(HTTPErrorCategory(statusCode: 403) == .permissionDenied)
|
|
#expect(HTTPErrorCategory(statusCode: 404) == .notFound)
|
|
#expect(HTTPErrorCategory(statusCode: 500) == .server)
|
|
#expect(HTTPErrorCategory(statusCode: 503) == .server)
|
|
#expect(HTTPErrorCategory(statusCode: 400) == .other)
|
|
}
|
|
|
|
@Test func exposesServiceErrorStatusAndCategory() {
|
|
let forbidden = ServiceError.httpError(statusCode: 403, message: "missing scope")
|
|
#expect(forbidden.httpStatusCode == 403)
|
|
#expect(forbidden.httpErrorCategory == .permissionDenied)
|
|
|
|
let serverError = ServiceError.httpError(statusCode: 502)
|
|
#expect(serverError.httpStatusCode == 502)
|
|
#expect(serverError.httpErrorCategory == .server)
|
|
|
|
#expect(ServiceError.invalidURL.httpStatusCode == nil)
|
|
#expect(ServiceError.invalidURL.httpErrorCategory == nil)
|
|
}
|
|
|
|
@Test func mapsAuthenticationStatusCodes() {
|
|
#expect(AuthenticationError.from(statusCode: 401) == .invalidCredentials)
|
|
#expect(AuthenticationError.from(statusCode: 401, body: "OTP required") == .otpRequired)
|
|
#expect(AuthenticationError.from(statusCode: 403) == .unknownError(statusCode: 403))
|
|
#expect(AuthenticationError.from(statusCode: 404) == .serverNotFound)
|
|
#expect(AuthenticationError.from(statusCode: 500) == .unknownError(statusCode: 500))
|
|
#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)
|
|
#expect(AuthenticationError.serverNotFound.httpStatusCode == 404)
|
|
#expect(AuthenticationError.serverNotFound.httpErrorCategory == .notFound)
|
|
#expect(AuthenticationError.unknownError(statusCode: 403).httpStatusCode == 403)
|
|
#expect(AuthenticationError.unknownError(statusCode: 403).httpErrorCategory == .permissionDenied)
|
|
#expect(AuthenticationError.unknownError(statusCode: 502).httpErrorCategory == .server)
|
|
#expect(AuthenticationError.certificateError.httpStatusCode == nil)
|
|
#expect(AuthenticationError.certificateError.httpErrorCategory == nil)
|
|
}
|
|
|
|
// MARK: - ServiceError descriptions
|
|
|
|
@Test func serviceErrorDescriptions() {
|
|
#expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance")
|
|
#expect(ServiceError.invalidURL.errorDescription == "Invalid URL")
|
|
#expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server")
|
|
#expect(ServiceError.httpError(statusCode: 404).errorDescription == "HTTP error: 404")
|
|
#expect(ServiceError.httpError(statusCode: 500).errorDescription == "HTTP error: 500")
|
|
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
|
|
}
|
|
|
|
@Test func httpErrorDescriptionUsesForgejoMessageBody() {
|
|
let error = ServiceError.httpError(
|
|
statusCode: 405,
|
|
message: #"{"message":"This pull request has failing status checks"}"#,
|
|
)
|
|
|
|
#expect(error.errorDescription == "HTTP 405: This pull request has failing status checks")
|
|
}
|
|
|
|
@Test func httpErrorDescriptionFallsBackToRawBody() {
|
|
let error = ServiceError.httpError(statusCode: 500, message: "database unavailable")
|
|
|
|
#expect(error.errorDescription == "HTTP 500: database unavailable")
|
|
}
|
|
|
|
@Test func httpErrorDescriptionIgnoresWhitespaceOnlyMessage() {
|
|
let error = ServiceError.httpError(statusCode: 503, message: " \n ")
|
|
|
|
#expect(error.errorDescription == "HTTP error: 503")
|
|
}
|
|
|
|
@Test func httpErrorDescriptionFallsBackWhenJSONMessageFieldIsEmpty() {
|
|
let error = ServiceError.httpError(
|
|
statusCode: 422,
|
|
message: #"{"message":" "}"#,
|
|
)
|
|
|
|
#expect(error.errorDescription == "HTTP error: 422")
|
|
}
|
|
|
|
@Test func httpErrorDescriptionReturnsRawJSONWhenMessageFieldMissing() {
|
|
let raw = #"{"errors":["bad request"]}"#
|
|
let error = ServiceError.httpError(statusCode: 400, message: raw)
|
|
|
|
#expect(error.errorDescription == "HTTP 400: \(raw)")
|
|
}
|
|
|
|
// MARK: - Merge error collapsing
|
|
|
|
@Test func collapseMergeErrorMaps405WithEmptyBodyToNotMergeable() {
|
|
let collapsed = PullRequestService.collapseMergeError(
|
|
.httpError(statusCode: 405, message: ""),
|
|
)
|
|
#expect(collapsed == .notMergeable)
|
|
}
|
|
|
|
@Test func collapseMergeErrorMaps409WithEmptyBodyToMergeConflict() {
|
|
let collapsed = PullRequestService.collapseMergeError(
|
|
.httpError(statusCode: 409, message: " \n "),
|
|
)
|
|
#expect(collapsed == .mergeConflict)
|
|
}
|
|
|
|
@Test func collapseMergeErrorPreserves405WithBody() {
|
|
let original = ServiceError.httpError(
|
|
statusCode: 405,
|
|
message: #"{"message":"failing status checks"}"#,
|
|
)
|
|
#expect(PullRequestService.collapseMergeError(original) == original)
|
|
}
|
|
|
|
@Test func collapseMergeErrorPreserves409WithBody() {
|
|
let original = ServiceError.httpError(statusCode: 409, message: "conflict in README.md")
|
|
#expect(PullRequestService.collapseMergeError(original) == original)
|
|
}
|
|
|
|
@Test func collapseMergeErrorPassesOtherStatusCodesThrough() {
|
|
let original = ServiceError.httpError(statusCode: 500, message: "")
|
|
#expect(PullRequestService.collapseMergeError(original) == original)
|
|
}
|
|
|
|
@Test func collapseMergeErrorPassesNonHTTPErrorsThrough() {
|
|
#expect(PullRequestService.collapseMergeError(.invalidURL) == .invalidURL)
|
|
#expect(PullRequestService.collapseMergeError(.noActiveInstance) == .noActiveInstance)
|
|
}
|
|
}
|