ReadKeep/readeckTests/Domain/ServerInfoTests.swift
Ilyas Hallak ec432a037c feat: Implement OAuth 2.0 authentication with PKCE and automatic fallback
Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative
to API token authentication, with automatic server detection and graceful
fallback to classic login.

**OAuth Core (RFC 7636 PKCE):**
- PKCEGenerator: S256 challenge generation for secure code exchange
- OAuth DTOs: Client registration, token request/response models
- OAuthClient, OAuthToken, AuthenticationMethod domain models
- API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints
- OAuthRepository + POAuthRepository protocol

**Browser Integration (ASWebAuthenticationSession):**
- OAuthSession: Wraps native authentication session
- OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow
- readeck:// URL scheme for OAuth callback handling
- State verification for CSRF protection
- User cancellation handling

**Token Management:**
- KeychainHelper: OAuth token storage alongside API tokens
- TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod()
- AuthenticationMethod enum to distinguish token types
- AuthRepository: loginWithOAuth(), getAuthenticationMethod()
- Endpoint persistence in both Keychain and Settings

**Server Feature Detection:**
- ServerInfo extended with features array and supportsOAuth flag
- GET /api/info endpoint integration (backward compatible)
- GetServerInfoUseCase with optional endpoint parameter

**User Profile Integration:**
- ProfileApiClient: Fetch user data via GET /api/profile
- UserProfileDto with username, email, provider information
- GetUserProfileUseCase: Extract username from profile
- Username saved and displayed for OAuth users (like classic auth)

**Automatic OAuth Flow (No User Selection):**
- OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic)
- OAuth attempted automatically if server supports it
- Fallback to username/password on OAuth failure or unsupported
- SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth()

**Cleanup & Refactoring:**
- Remove all #if os(iOS) && !APP_EXTENSION conditionals
- Remove LoginMethodSelectionView (no longer needed)
- Remove switchToClassicLogin() method
- Factories updated with OAuth dependencies

**Testing:**
- PKCEGeneratorTests: Verify RFC 7636 compliance
- ServerInfoTests: Feature detection and backward compatibility
- Mock implementations for all OAuth components

**Documentation:**
- docs/OAuth2-Implementation-Plan.md: Complete implementation guide
- openapi.json: Readeck API specification

**Scopes Requested:**
- bookmarks:read, bookmarks:write, profile:read

OAuth users now have full feature parity with classic authentication.
Server auto-detects OAuth support via /info endpoint. Seamless UX with
browser-based login and automatic fallback.
2025-12-19 21:56:40 +01:00

177 lines
5.4 KiB
Swift

//
// ServerInfoTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 15.12.25.
//
import XCTest
@testable import readeck
final class ServerInfoTests: XCTestCase {
// MARK: - OAuth Feature Detection
func testSupportsOAuth_WithOAuthFeature_ReturnsTrue() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: ["oauth", "email"]
)
XCTAssertTrue(serverInfo.supportsOAuth)
}
func testSupportsOAuth_WithoutOAuthFeature_ReturnsFalse() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: ["email"]
)
XCTAssertFalse(serverInfo.supportsOAuth)
}
func testSupportsOAuth_WithNilFeatures_ReturnsFalse() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: nil
)
XCTAssertFalse(serverInfo.supportsOAuth, "Should return false for old servers without features array")
}
func testSupportsOAuth_WithEmptyFeatures_ReturnsFalse() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: []
)
XCTAssertFalse(serverInfo.supportsOAuth)
}
// MARK: - Email Feature Detection
func testSupportsEmail_WithEmailFeature_ReturnsTrue() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: ["oauth", "email"]
)
XCTAssertTrue(serverInfo.supportsEmail)
}
func testSupportsEmail_WithoutEmailFeature_ReturnsFalse() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: ["oauth"]
)
XCTAssertFalse(serverInfo.supportsEmail)
}
func testSupportsEmail_WithNilFeatures_ReturnsFalse() {
let serverInfo = ServerInfo(
version: "1.0.0",
isReachable: true,
features: nil
)
XCTAssertFalse(serverInfo.supportsEmail)
}
// MARK: - DTO Conversion
func testInit_FromDto_WithFeatures() {
let dto = ServerInfoDto(
version: ServerInfoDto.VersionInfo(canonical: "1.2.3", release: "1.2.3", build: "abc123"),
features: ["oauth", "email"]
)
let serverInfo = ServerInfo(from: dto)
XCTAssertEqual(serverInfo.version, "1.2.3")
XCTAssertTrue(serverInfo.isReachable)
XCTAssertEqual(serverInfo.features, ["oauth", "email"])
XCTAssertTrue(serverInfo.supportsOAuth)
XCTAssertTrue(serverInfo.supportsEmail)
}
func testInit_FromDto_WithoutFeatures() {
let dto = ServerInfoDto(
version: ServerInfoDto.VersionInfo(canonical: "0.9.0", release: "0.9.0", build: ""),
features: nil
)
let serverInfo = ServerInfo(from: dto)
XCTAssertEqual(serverInfo.version, "0.9.0")
XCTAssertTrue(serverInfo.isReachable)
XCTAssertNil(serverInfo.features)
XCTAssertFalse(serverInfo.supportsOAuth, "Old server without features should not support OAuth")
XCTAssertFalse(serverInfo.supportsEmail)
}
func testUnreachable_HasNoFeatures() {
let serverInfo = ServerInfo.unreachable
XCTAssertEqual(serverInfo.version, "")
XCTAssertFalse(serverInfo.isReachable)
XCTAssertNil(serverInfo.features)
XCTAssertFalse(serverInfo.supportsOAuth)
XCTAssertFalse(serverInfo.supportsEmail)
}
// MARK: - Backward Compatibility
func testBackwardCompatibility_OldServerWithoutFeaturesArray() {
// Simulates an old Readeck server that doesn't include features in /api/info response
let dto = ServerInfoDto(
version: ServerInfoDto.VersionInfo(canonical: "0.5.0", release: "0.5.0", build: ""),
features: nil // Old server doesn't send features
)
let serverInfo = ServerInfo(from: dto)
// Should work without crashing
XCTAssertNotNil(serverInfo)
XCTAssertEqual(serverInfo.version, "0.5.0")
// OAuth detection should gracefully return false
XCTAssertFalse(serverInfo.supportsOAuth, "Old servers should gracefully report no OAuth support")
XCTAssertFalse(serverInfo.supportsEmail)
}
func testBackwardCompatibility_NewServerWithFeaturesArray() {
// Simulates a new Readeck server with OAuth support
let dto = ServerInfoDto(
version: ServerInfoDto.VersionInfo(canonical: "1.0.0", release: "1.0.0", build: "xyz"),
features: ["oauth", "email"]
)
let serverInfo = ServerInfo(from: dto)
XCTAssertNotNil(serverInfo)
XCTAssertTrue(serverInfo.supportsOAuth, "New servers with oauth feature should report OAuth support")
XCTAssertTrue(serverInfo.supportsEmail)
}
func testBackwardCompatibility_NewServerWithoutOAuthFeature() {
// Simulates a new server that has features array but OAuth is disabled
let dto = ServerInfoDto(
version: ServerInfoDto.VersionInfo(canonical: "1.0.0", release: "1.0.0", build: "xyz"),
features: ["email"] // Has features array but no oauth
)
let serverInfo = ServerInfo(from: dto)
XCTAssertNotNil(serverInfo)
XCTAssertFalse(serverInfo.supportsOAuth, "Server with features but no oauth should report no OAuth support")
XCTAssertTrue(serverInfo.supportsEmail)
}
}