diff --git a/Forji/Forji/Models/ForgejoInstance.swift b/Forji/Forji/Models/ForgejoInstance.swift index d1e9d6a..052a020 100644 --- a/Forji/Forji/Models/ForgejoInstance.swift +++ b/Forji/Forji/Models/ForgejoInstance.swift @@ -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 } } diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift index 9f60bb1..b95f2e3 100644 --- a/Forji/Forji/Services/AuthenticationService.swift +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -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." + } + } +} diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift index 41305ff..c6d33be 100644 --- a/Forji/Forji/Views/InstanceFormView.swift +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -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 { diff --git a/Forji/ForjiUITests/ForgejoUITestBase.swift b/Forji/ForjiUITests/ForgejoUITestBase.swift index 2adbe69..a6090d2 100644 --- a/Forji/ForjiUITests/ForgejoUITestBase.swift +++ b/Forji/ForjiUITests/ForgejoUITestBase.swift @@ -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. diff --git a/Forji/ForjiUITests/LoginUITests.swift b/Forji/ForjiUITests/LoginUITests.swift index 3d8b4f4..a6d3bd9 100644 --- a/Forji/ForjiUITests/LoginUITests.swift +++ b/Forji/ForjiUITests/LoginUITests.swift @@ -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)) + } } diff --git a/integration/.forgejo-seed-hash b/integration/.forgejo-seed-hash deleted file mode 100644 index d49875f..0000000 --- a/integration/.forgejo-seed-hash +++ /dev/null @@ -1 +0,0 @@ -11b89bd63fe2a77529f2f8124efafaadcf4deccb435ae43957501aab0795fffd diff --git a/integration/.forgejo-seed-snapshot.tar.gz b/integration/.forgejo-seed-snapshot.tar.gz deleted file mode 100644 index 2d67bbf..0000000 Binary files a/integration/.forgejo-seed-snapshot.tar.gz and /dev/null differ diff --git a/integration/setup.sh b/integration/setup.sh index 0659fac..53837bf 100755 --- a/integration/setup.sh +++ b/integration/setup.sh @@ -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."