| 1 | import Foundation |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import AuthCore |
| 5 | |
| 6 | final class AuthCoreTests: XCTestCase { |
| 7 | |
| 8 | // MARK: - Session |
| 9 | |
| 10 | func testSessionScopeJoinsScopesWithSpaces() { |
| 11 | let session = SmbCloudSession(accessToken: "token", scopes: ["openid", "profile", "email"]) |
| 12 | XCTAssertEqual(session.scope, "openid profile email") |
| 13 | XCTAssertEqual(session.authorizationHeaderValue, "Bearer token") |
| 14 | } |
| 15 | |
| 16 | func testSessionValidityReflectsExpiryDate() { |
| 17 | let future = SmbCloudSession(accessToken: "f", expiresAt: Date().addingTimeInterval(300)) |
| 18 | let past = SmbCloudSession(accessToken: "p", expiresAt: Date().addingTimeInterval(-300)) |
| 19 | XCTAssertTrue(future.isValid()) |
| 20 | XCTAssertFalse(future.isExpired) |
| 21 | XCTAssertFalse(past.isValid(leeway: 0)) |
| 22 | XCTAssertTrue(past.isExpired) |
| 23 | } |
| 24 | |
| 25 | func testSessionCodableRoundTripPreservesValues() throws { |
| 26 | let original = SmbCloudSession( |
| 27 | accessToken: "access-token", |
| 28 | refreshToken: "refresh-token", |
| 29 | idToken: "id-token", |
| 30 | tokenType: "Bearer", |
| 31 | expiresAt: Date(timeIntervalSince1970: 1_750_000_000), |
| 32 | scopes: ["openid", "profile", "email"] |
| 33 | ) |
| 34 | let data = try JSONEncoder().encode(original) |
| 35 | let decoded = try JSONDecoder().decode(SmbCloudSession.self, from: data) |
| 36 | XCTAssertEqual(decoded, original) |
| 37 | } |
| 38 | |
| 39 | func testSessionFromTokenResponseMapsFields() { |
| 40 | let now = Date(timeIntervalSince1970: 1_000_000) |
| 41 | let token = OIDC.TokenResponse( |
| 42 | accessToken: "at", tokenType: "Bearer", expiresIn: 3600, |
| 43 | refreshToken: "rt", scope: "openid profile openid", idToken: "it") |
| 44 | let session = SmbCloudSession(tokenResponse: token, now: now) |
| 45 | XCTAssertEqual(session.accessToken, "at") |
| 46 | XCTAssertEqual(session.refreshToken, "rt") |
| 47 | XCTAssertEqual(session.idToken, "it") |
| 48 | XCTAssertEqual(session.expiresAt, now.addingTimeInterval(3600)) |
| 49 | XCTAssertEqual(session.scopes, ["openid", "profile"]) // deduped |
| 50 | } |
| 51 | |
| 52 | // MARK: - Credentials store |
| 53 | |
| 54 | func testInMemoryCredentialsStoreRoundTrips() throws { |
| 55 | let store = SmbCloudInMemoryCredentialsStore() |
| 56 | XCTAssertNil(try store.current()) |
| 57 | |
| 58 | try store.store( |
| 59 | SmbCloudSession( |
| 60 | accessToken: "access-token", |
| 61 | expiresAt: Date().addingTimeInterval(300), |
| 62 | scopes: ["openid", "openid", " profile "])) |
| 63 | |
| 64 | XCTAssertEqual(try store.current()?.scopes, ["openid", "profile"]) |
| 65 | XCTAssertTrue(try store.hasValidSession()) |
| 66 | XCTAssertNotNil(try store.currentValidSession()) |
| 67 | |
| 68 | try store.clear() |
| 69 | XCTAssertNil(try store.current()) |
| 70 | } |
| 71 | |
| 72 | func testExpiredSessionIsNotReturnedAsValid() throws { |
| 73 | let store = SmbCloudInMemoryCredentialsStore( |
| 74 | session: SmbCloudSession(accessToken: "stale", expiresAt: Date().addingTimeInterval(-300))) |
| 75 | XCTAssertNotNil(try store.current()) |
| 76 | XCTAssertNil(try store.currentValidSession()) |
| 77 | XCTAssertFalse(try store.hasValidSession()) |
| 78 | } |
| 79 | |
| 80 | // MARK: - Environment |
| 81 | |
| 82 | func testEnvironmentHostsAndProtocols() { |
| 83 | XCTAssertEqual(SmbCloudEnvironment.dev.apiProtocol, "http") |
| 84 | XCTAssertEqual(SmbCloudEnvironment.dev.apiHost, "localhost:8088") |
| 85 | XCTAssertEqual(SmbCloudEnvironment.production.apiProtocol, "https") |
| 86 | XCTAssertEqual(SmbCloudEnvironment.production.apiHost, "api.smbcloud.xyz") |
| 87 | XCTAssertEqual(SmbCloudEnvironment.production.baseURL.absoluteString, "https://api.smbcloud.xyz/") |
| 88 | } |
| 89 | |
| 90 | // MARK: - ClientCredentials URL building |
| 91 | |
| 92 | func testTenantURLCarriesCredentialsAsQuery() { |
| 93 | let creds = ClientCredentials(appId: "app-1", appSecret: "secret-1") |
| 94 | let url = creds.tenantURL(environment: .production, path: "v1/client/me") |
| 95 | let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) |
| 96 | XCTAssertEqual(comps?.scheme, "https") |
| 97 | XCTAssertEqual(comps?.host, "api.smbcloud.xyz") |
| 98 | XCTAssertEqual(comps?.path, "/v1/client/me") |
| 99 | XCTAssertEqual(query(url, "client_id"), "app-1") |
| 100 | XCTAssertEqual(query(url, "client_secret"), "secret-1") |
| 101 | } |
| 102 | |
| 103 | func testTenantURLDevPreservesPort() { |
| 104 | let creds = ClientCredentials(appId: "a", appSecret: "s") |
| 105 | let url = creds.tenantURL(environment: .dev, path: "v1/client/users/sign_in") |
| 106 | let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) |
| 107 | XCTAssertEqual(comps?.host, "localhost") |
| 108 | XCTAssertEqual(comps?.port, 8088) |
| 109 | XCTAssertEqual(comps?.scheme, "http") |
| 110 | } |
| 111 | |
| 112 | // MARK: - OIDC |
| 113 | |
| 114 | func testOIDCAuthorizationRequestProducesPkceChallenge() throws { |
| 115 | let request = try OIDC.buildAuthorizationRequest( |
| 116 | environment: .production, oidcClientId: "oidc-1", redirectURI: "myapp://auth/callback") |
| 117 | let url = request.authorizeURL |
| 118 | XCTAssertEqual( |
| 119 | URLComponents(url: url, resolvingAgainstBaseURL: false)?.path, "/v1/client/oauth/authorize") |
| 120 | XCTAssertEqual(query(url, "response_type"), "code") |
| 121 | XCTAssertEqual(query(url, "code_challenge_method"), "S256") |
| 122 | XCTAssertEqual(query(url, "client_id"), "oidc-1") |
| 123 | XCTAssertEqual(query(url, "scope"), "openid profile email") |
| 124 | XCTAssertEqual(query(url, "state"), request.state) |
| 125 | XCTAssertNotNil(query(url, "code_challenge")) |
| 126 | XCTAssertEqual(request.codeVerifier.count, 64) // two uuid-simple strings |
| 127 | } |
| 128 | |
| 129 | func testOIDCParseCallbackURL() throws { |
| 130 | let payload = try OIDC.parseCallbackURL("myapp://cb?code=abc&state=xyz") |
| 131 | XCTAssertEqual(payload.code, "abc") |
| 132 | XCTAssertEqual(payload.state, "xyz") |
| 133 | } |
| 134 | |
| 135 | func testOIDCParseCallbackURLMissingParamsThrows() { |
| 136 | XCTAssertThrowsError(try OIDC.parseCallbackURL("myapp://cb?code=abc")) { error in |
| 137 | XCTAssertEqual((error as? SmbCloudError)?.code, .invalidParams) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // MARK: - Apple provider |
| 142 | |
| 143 | func testBuildAppleAuthorizationRequest() { |
| 144 | let client = AuthCoreClient( |
| 145 | environment: .production, credentials: .init(appId: "app-1", appSecret: "secret-1")) |
| 146 | let request = client.buildAppleAuthorizationRequest(redirectURI: "myapp://cb", state: "the-state") |
| 147 | XCTAssertEqual(request.state, "the-state") |
| 148 | XCTAssertEqual( |
| 149 | URLComponents(url: request.authorizeURL, resolvingAgainstBaseURL: false)?.path, |
| 150 | "/v1/client/oauth/apple/authorize") |
| 151 | XCTAssertEqual(query(request.authorizeURL, "client_id"), "app-1") |
| 152 | XCTAssertEqual(query(request.authorizeURL, "redirect_uri"), "myapp://cb") |
| 153 | XCTAssertEqual(query(request.authorizeURL, "state"), "the-state") |
| 154 | } |
| 155 | |
| 156 | func testParseAppleCallbackURL() throws { |
| 157 | let client = AuthCoreClient( |
| 158 | environment: .production, credentials: .init(appId: "a", appSecret: "s")) |
| 159 | let session = try client.parseAppleCallbackURL( |
| 160 | "myapp://cb?access_token=AT&provider_account_id=PA&email=e@x.com&state=S", |
| 161 | expectedState: "S") |
| 162 | XCTAssertEqual(session.accessToken, "AT") |
| 163 | XCTAssertEqual(session.providerAccountId, "PA") |
| 164 | XCTAssertEqual(session.email, "e@x.com") |
| 165 | XCTAssertEqual(session.provider, "apple") |
| 166 | XCTAssertEqual(session.state, "S") |
| 167 | } |
| 168 | |
| 169 | func testParseAppleCallbackURLStateMismatchThrows() { |
| 170 | let client = AuthCoreClient( |
| 171 | environment: .production, credentials: .init(appId: "a", appSecret: "s")) |
| 172 | XCTAssertThrowsError( |
| 173 | try client.parseAppleCallbackURL( |
| 174 | "myapp://cb?access_token=AT&provider_account_id=PA&state=S", expectedState: "other") |
| 175 | ) { error in |
| 176 | XCTAssertEqual((error as? SmbCloudError)?.code, .invalidParams) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func testParseAppleCallbackURLPropagatesError() { |
| 181 | let client = AuthCoreClient( |
| 182 | environment: .production, credentials: .init(appId: "a", appSecret: "s")) |
| 183 | XCTAssertThrowsError( |
| 184 | try client.parseAppleCallbackURL("myapp://cb?error=access_denied") |
| 185 | ) { error in |
| 186 | XCTAssertEqual((error as? SmbCloudError)?.code, .invalidParams) |
| 187 | XCTAssertEqual((error as? SmbCloudError)?.message, "access_denied") |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // MARK: - Errors |
| 192 | |
| 193 | func testErrorCodeRawValuesMatchContract() { |
| 194 | XCTAssertEqual(SmbCloudErrorCode.unauthorized.rawValue, 100) |
| 195 | XCTAssertEqual(SmbCloudErrorCode.emailNotVerified.rawValue, 1001) |
| 196 | XCTAssertEqual(AccountErrorCode.emailUnverified.rawValue, 1001) |
| 197 | XCTAssertEqual(SmbCloudError(code: .unauthorized).message, "Unauthorized access.") |
| 198 | } |
| 199 | |
| 200 | // MARK: - Helpers |
| 201 | |
| 202 | private func query(_ url: URL, _ name: String) -> String? { |
| 203 | URLComponents(url: url, resolvingAgainstBaseURL: false)? |
| 204 | .queryItems?.first { $0.name == name }?.value |
| 205 | } |
| 206 | } |