test: add tests for token authentication

This commit is contained in:
Stefan Hausotte 2026-03-08 18:25:39 +01:00
parent d5e7ccdc4d
commit 6c171b2ae3
8 changed files with 140 additions and 18 deletions

View file

@ -9,10 +9,12 @@ final class ForgejoInstance {
var isDefault: Bool
var lastUsed: Date
var allowSelfSignedCertificates: Bool
var useTokenAuth: Bool = false
init(
serverURL: String, username: String, name: String = "",
isDefault: Bool = false, allowSelfSignedCertificates: Bool = false,
useTokenAuth: Bool = false,
) {
self.serverURL = serverURL
self.username = username
@ -20,5 +22,6 @@ final class ForgejoInstance {
self.isDefault = isDefault
lastUsed = Date()
self.allowSelfSignedCertificates = allowSelfSignedCertificates
self.useTokenAuth = useTokenAuth
}
}

View file

@ -117,16 +117,26 @@ class AuthenticationService {
token: token,
allowSelfSignedCertificates: instance.allowSelfSignedCertificates,
)
// Validate the token still works
let user = try await tokenClient.fetchCurrentUser()
client = tokenClient
currentUser = user
isAuthenticated = true
currentInstance = instance
return
do {
let user = try await tokenClient.fetchCurrentUser()
client = tokenClient
currentUser = user
isAuthenticated = true
currentInstance = instance
return
} catch {
// Token is invalid/expired only fall through to password if this is not a token-only instance
if instance.useTokenAuth {
throw SessionRestoreError.tokenExpired
}
// Otherwise fall through to password-based login below
}
}
// Fall back to password-based login (creates a new token)
guard !instance.useTokenAuth else {
throw SessionRestoreError.tokenExpired
}
let password = try await KeychainManager.shared.getPassword(for: normalizedURL, username: instance.username)
try await login(
serverURL: normalizedURL, username: instance.username,
@ -152,3 +162,14 @@ class AuthenticationService {
}
#endif
}
enum SessionRestoreError: LocalizedError {
case tokenExpired
var errorDescription: String? {
switch self {
case .tokenExpired:
"Your API token is expired or has been revoked. Please edit this instance and enter a new token."
}
}
}

View file

@ -31,6 +31,7 @@ struct InstanceFormView: View {
@AppStorage("dev_serverURL") private var devServerURL = ""
@AppStorage("dev_username") private var devUsername = ""
@AppStorage("dev_password") private var devPassword = ""
@AppStorage("dev_apiToken") private var devApiToken = ""
@AppStorage("dev_allowSelfSigned") private var devAllowSelfSigned = false
#endif
@ -96,19 +97,19 @@ struct InstanceFormView: View {
} header: {
VStack(alignment: .leading, spacing: 8) {
Text("Credentials")
if isAddMode {
Picker("Login Method", selection: $authMode) {
ForEach(AuthMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
Picker("Login Method", selection: $authMode) {
ForEach(AuthMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
.pickerStyle(.segmented)
}
.pickerStyle(.segmented)
}
.textCase(nil)
.padding(.bottom, 4)
} footer: {
if authMode == .token {
if authMode == .token, case .edit = mode {
Text("Leave token blank to keep the existing one.")
} else if authMode == .token {
Text(
"""
Generate a token under Settings Applications with the following scopes:
@ -160,7 +161,7 @@ struct InstanceFormView: View {
|| (authMode == .credentials
&& (username.isEmpty || (isAddMode && password.isEmpty)
|| (needsOTP && otpCode.isEmpty)))
|| (authMode == .token && apiToken.isEmpty),
|| (authMode == .token && isAddMode && apiToken.isEmpty),
)
.listRowBackground(Color.clear)
.accessibilityIdentifier("login-button")
@ -192,6 +193,7 @@ struct InstanceFormView: View {
if !devServerURL.isEmpty { serverURL = devServerURL }
if !devUsername.isEmpty { username = devUsername }
if !devPassword.isEmpty { password = devPassword }
if !devApiToken.isEmpty { apiToken = devApiToken }
allowSelfSignedCertificates = devAllowSelfSigned
#endif
case .edit(let instance):
@ -200,6 +202,7 @@ struct InstanceFormView: View {
username = instance.username
allowSelfSignedCertificates = instance.allowSelfSignedCertificates
isDefault = instance.isDefault
authMode = instance.useTokenAuth ? .token : .credentials
}
}
@ -257,6 +260,7 @@ struct InstanceFormView: View {
name: name,
isDefault: isDefault,
allowSelfSignedCertificates: allowSelfSignedCertificates,
useTokenAuth: authMode == .token,
)
modelContext.insert(instance)
do {
@ -267,14 +271,18 @@ struct InstanceFormView: View {
authService.currentInstance = instance
case .edit(let instance):
if !password.isEmpty {
if authMode == .token, !apiToken.isEmpty {
try await performLogin()
instance.username = authService.currentUser?.login ?? username
} else if authMode == .credentials, !password.isEmpty {
try await performLogin()
instance.username = username
}
instance.name = name
instance.serverURL = ForgejoClient.normalizeServerURL(serverURL)
instance.username = username
instance.allowSelfSignedCertificates = allowSelfSignedCertificates
instance.useTokenAuth = authMode == .token
if isDefault, !instance.isDefault {
for inst in instances where inst.isDefault {

View file

@ -42,6 +42,20 @@ class ForgejoUITestBase: XCTestCase, UITestNavigating {
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
}
// MARK: - Test Token Resolution
/// Resolves the API token created by the seed script for token-based auth tests.
static func resolveTestToken() -> String? {
let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "")
.replacingOccurrences(of: " ", with: "_")
let specificPath = "/tmp/forgejo_test_token_\(deviceName).txt"
let genericPath = "/tmp/forgejo_test_token.txt"
return ((try? String(contentsOfFile: specificPath, encoding: .utf8))
?? (try? String(contentsOfFile: genericPath, encoding: .utf8)))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
}
// MARK: - Helpers
/// Launches the app and waits for HomeView to appear via auto-login.

View file

@ -30,4 +30,71 @@ final class LoginUITests: ForgejoUITestBase {
let alert = app.alerts["Login Failed"]
XCTAssertTrue(alert.waitForExistence(timeout: 10))
}
// MARK: - Token Login Success
@MainActor
func testLoginWithToken() throws {
guard let token = Self.resolveTestToken(), !token.isEmpty else {
throw XCTSkip("No test API token available")
}
app.launchArguments = [
"-dev_serverURL", serverURL!,
"-dev_apiToken", token,
"-dev_skipAutoLogin", "true",
]
app.launch()
let addButton = app.buttons["instance-add-button"]
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
addButton.tap()
// Switch to API Token auth mode
let tokenSegment = app.buttons["API Token"]
XCTAssertTrue(tokenSegment.waitForExistence(timeout: 5), "API Token segment not found")
tokenSegment.tap()
// Scroll form to reveal login button
app.swipeUp()
let loginButton = app.buttons["login-button"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
// Verify we land on HomeView
let reposTab = app.tabBars.buttons["Repositories"]
XCTAssertTrue(reposTab.waitForExistence(timeout: 15), "HomeView did not appear after token login")
}
// MARK: - Token Login Bad Token
@MainActor
func testLoginWithBadToken() throws {
app.launchArguments = [
"-dev_serverURL", serverURL!,
"-dev_apiToken", "invalid_token_value",
"-dev_skipAutoLogin", "true",
]
app.launch()
let addButton = app.buttons["instance-add-button"]
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
addButton.tap()
// Switch to API Token auth mode
let tokenSegment = app.buttons["API Token"]
XCTAssertTrue(tokenSegment.waitForExistence(timeout: 5), "API Token segment not found")
tokenSegment.tap()
// Scroll form to reveal login button
app.swipeUp()
let loginButton = app.buttons["login-button"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
let alert = app.alerts["Login Failed"]
XCTAssertTrue(alert.waitForExistence(timeout: 10))
}
}

View file

@ -1 +0,0 @@
11b89bd63fe2a77529f2f8124efafaadcf4deccb435ae43957501aab0795fffd

View file

@ -203,5 +203,15 @@ curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/4" \
wait
# --- Phase 6: Create API token for token-based auth tests ---
echo "Creating API token for testadmin..."
TOKEN_RESPONSE=$(curl -sf -X POST "$BASE_URL/api/v1/users/$ADMIN_USER/tokens" \
-u "$ADMIN_AUTH" \
-H "Content-Type: application/json" \
-d '{"name": "integration-test-token", "scopes": ["read:user", "write:user", "read:repository", "write:repository", "read:issue", "write:issue", "read:notification", "write:notification", "read:organization"]}')
API_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['sha1'])")
echo "$API_TOKEN" > /tmp/forgejo_test_token.txt
echo "API token written to /tmp/forgejo_test_token.txt"
echo ""
echo "Seed data created successfully."