From 505c4b0dbf95b9ce91941237ade519a36cb224a6 Mon Sep 17 00:00:00 2001 From: Paul Scott Date: Wed, 19 Nov 2025 12:23:12 +0200 Subject: [PATCH 1/3] Added some possible changes to capture more errors and remove Option --- .../learning/domain/security/JWTProvider.kt | 2 +- .../domain/security/PasswordProvider.kt | 4 +- .../za/co/ee/learning/domain/users/User.kt | 3 +- .../learning/domain/users/UserRepository.kt | 5 ++- .../domain/users/usecases/Authenticate.kt | 33 ++++++--------- .../database/InMemoryUserRepository.kt | 6 ++- .../security/BCryptPasswordProvider.kt | 12 +++++- .../security/DefaultJWTProvider.kt | 42 +++++++++++-------- .../domain/users/usecases/AuthenticateTest.kt | 35 +++++++++------- .../domain/users/usecases/GetUsersTest.kt | 5 ++- .../api/AuthenticateEndpointTest.kt | 34 ++++++++------- .../api/GetUsersEndpointTest.kt | 7 ++-- .../database/InMemoryUserRepositoryTest.kt | 15 +++---- .../security/BCryptPasswordProviderTest.kt | 16 +++---- .../security/DefaultJWTProviderTest.kt | 23 +++++----- 15 files changed, 132 insertions(+), 110 deletions(-) diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt index 58f6186..42f69d4 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/JWTProvider.kt @@ -10,7 +10,7 @@ data class TokenInfo( ) interface JWTProvider { - fun generate(user: User): TokenInfo + fun generate(user: User): DomainResult fun verify(jwt: String): DomainResult } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt index 6dc4b48..c816c6c 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/security/PasswordProvider.kt @@ -1,7 +1,9 @@ package za.co.ee.learning.domain.security +import za.co.ee.learning.domain.DomainResult + interface PasswordProvider { - fun encode(password: String): String + fun encode(password: String): DomainResult fun matches( password: String, diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt index 11b8d68..dc59f6b 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/User.kt @@ -1,9 +1,10 @@ package za.co.ee.learning.domain.users +import za.co.ee.learning.domain.DomainResult import java.util.UUID data class User( val id: UUID, val email: String, - val passwordHash: String, + val passwordHash: DomainResult, ) diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt index 3216557..1e0ea20 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt @@ -1,10 +1,11 @@ package za.co.ee.learning.domain.users -import arrow.core.Option +import arrow.core.Either +import za.co.ee.learning.domain.DomainError import za.co.ee.learning.domain.DomainResult interface UserRepository { - fun findByEmail(email: String): DomainResult> + fun findByEmail(email: String): DomainResult fun findAll(): DomainResult> } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt index 1b5e559..29d82d4 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt @@ -1,6 +1,5 @@ package za.co.ee.learning.domain.users.usecases -import arrow.core.Option import arrow.core.left import arrow.core.raise.either import arrow.core.right @@ -31,9 +30,10 @@ class Authenticate( operator fun invoke(request: AuthenticateRequest): DomainResult = either { val validatedRequest = validate(request).bind() - val user = findUser(validatedRequest.email).bind() - val authenticatedUser = checkPassword(user, validatedRequest).bind() - createToken(authenticatedUser).bind() + val user = userRepository.findByEmail(validatedRequest.email).bind() + val pwdHashString = user.passwordHash.bind() + checkPassword(pwdHashString, validatedRequest).bind() + createToken(user).bind() } private fun validate(request: AuthenticateRequest): DomainResult { @@ -56,31 +56,22 @@ class Authenticate( return request.right() } - private fun findUser(email: String): DomainResult = - either { - val optUser: Option = userRepository.findByEmail(email).bind() - return optUser.fold( - ifEmpty = { DomainError.InvalidCredentials.left() }, - ifSome = { user -> user.right() }, - ) - } - private fun checkPassword( - user: User, + passwordHash: String, validatedRequest: AuthenticateRequest, - ): DomainResult { - if (passwordProvider.matches(validatedRequest.password, user.passwordHash)) { - return user.right() + ): DomainResult { + if (passwordProvider.matches(validatedRequest.password, passwordHash)) { + return true.right() } return DomainError.InvalidCredentials.left() } - private fun createToken(authenticatedUser: User): DomainResult { - val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser) - return AuthenticateResponse( + private fun createToken(authenticatedUser: User): DomainResult = either { + val tokenInfo: TokenInfo = jwtProvider.generate(authenticatedUser).bind() + AuthenticateResponse( token = tokenInfo.token, expires = tokenInfo.expires, - ).right() + ) } } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt index 1c063af..bd7969e 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt @@ -1,7 +1,9 @@ package za.co.ee.learning.infrastructure.database +import arrow.core.Either import arrow.core.Option import arrow.core.right +import za.co.ee.learning.domain.DomainError import za.co.ee.learning.domain.DomainResult import za.co.ee.learning.domain.users.User import za.co.ee.learning.domain.users.UserRepository @@ -16,10 +18,10 @@ class InMemoryUserRepository : UserRepository { } // Find the first user in the list that has the matching email, wrap it in an option and return a Either.right() - override fun findByEmail(email: String): DomainResult> = + override fun findByEmail(email: String): DomainResult = Option .fromNullable(users.firstOrNull { it.email == email }) - .right() + .toEither { DomainError.InvalidCredentials } override fun findAll(): DomainResult> = users.toList().right() } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt index 6c88a01..9fb0f16 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProvider.kt @@ -1,10 +1,20 @@ package za.co.ee.learning.infrastructure.security +import arrow.core.raise.either import org.mindrot.jbcrypt.BCrypt +import za.co.ee.learning.domain.DomainError +import za.co.ee.learning.domain.DomainResult import za.co.ee.learning.domain.security.PasswordProvider class BCryptPasswordProvider : PasswordProvider { - override fun encode(password: String): String = BCrypt.hashpw(password, BCrypt.gensalt()) + override fun encode(password: String): DomainResult = + either { + try { + BCrypt.hashpw(password, BCrypt.gensalt()) + } catch (e: Exception) { + raise(DomainError.ValidationError("Error encoding password: ${e.message}")) + } + } override fun matches( password: String, diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt index 9a6f63e..6d2180d 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProvider.kt @@ -3,6 +3,7 @@ package za.co.ee.learning.infrastructure.security import arrow.core.raise.either import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTCreationException import com.auth0.jwt.exceptions.JWTVerificationException import za.co.ee.learning.domain.DomainError import za.co.ee.learning.domain.DomainResult @@ -10,8 +11,7 @@ import za.co.ee.learning.domain.security.JWTProvider import za.co.ee.learning.domain.security.TokenInfo import za.co.ee.learning.domain.users.User import java.time.Instant -import java.util.Date -import java.util.UUID +import java.util.* class DefaultJWTProvider( private val secret: String, @@ -26,24 +26,30 @@ class DefaultJWTProvider( .withIssuer(issuer) .build() - override fun generate(user: User): TokenInfo { - val now = Instant.now() - val expiresAt = now.plusSeconds(expirationSeconds) + override fun generate(user: User): DomainResult = + either { + try { + val now = Instant.now() + val expiresAt = now.plusSeconds(expirationSeconds) - val token = - JWT - .create() - .withIssuer(issuer) - .withSubject(user.id.toString()) - .withIssuedAt(Date.from(now)) - .withExpiresAt(Date.from(expiresAt)) - .sign(algorithm) + val token = JWT + .create() + .withIssuer(issuer) + .withSubject(user.id.toString()) + .withIssuedAt(Date.from(now)) + .withExpiresAt(Date.from(expiresAt)) + .sign(algorithm) - return TokenInfo( - token = token, - expires = expiresAt.epochSecond, - ) - } + TokenInfo( + token = token, + expires = expiresAt.epochSecond, + ) + } catch (e: JWTCreationException) { + raise(DomainError.JWTError("Token creation error: ${e.message}")) + } catch (e: Exception) { + raise(DomainError.JWTError("Token creation failed: ${e.message}")) + } + } override fun verify(jwt: String): DomainResult = either { diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt index dcf75e3..463c285 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.domain.users.usecases +import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.core.some @@ -28,7 +29,7 @@ class AuthenticateTest : val validEmail = "user@example.com" val validPassword = "SecurePass123" - val passwordHash = "hashed_password" + val passwordHash = Either.Right("hashed_password") val testUser = User( id = UUID.randomUUID(), @@ -36,9 +37,11 @@ class AuthenticateTest : passwordHash = passwordHash, ) val tokenInfo = - TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + Either.Right( + TokenInfo( + token = "jwt.token.here", + expires = 1234567890L, + ) ) beforeTest { @@ -49,21 +52,21 @@ class AuthenticateTest : test("should return token when credentials are valid") { val request = AuthenticateRequest(email = validEmail, password = validPassword) - every { userRepository.findByEmail(validEmail) } returns testUser.some().right() - every { passwordProvider.matches(validPassword, passwordHash) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.right() + every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val result = authenticate(request) val value = result.shouldBeRight() value shouldBe - AuthenticateResponse( - token = tokenInfo.token, - expires = tokenInfo.expires, - ) + AuthenticateResponse( + token = tokenInfo.getOrNull()!!.token, + expires = tokenInfo.getOrNull()!!.expires, + ) verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches(validPassword, passwordHash) } + verify { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } verify { jwtProvider.generate(testUser) } } } @@ -113,7 +116,7 @@ class AuthenticateTest : test("should return InvalidCredentials when user does not exist") { val request = AuthenticateRequest(email = "nonexistent@example.com", password = validPassword) - every { userRepository.findByEmail("nonexistent@example.com") } returns arrow.core.none().right() + every { userRepository.findByEmail("nonexistent@example.com") } returns Either.Left(DomainError.InvalidCredentials) val result = authenticate(request) @@ -126,7 +129,7 @@ class AuthenticateTest : test("should return InvalidCredentials when repository returns None") { val request = AuthenticateRequest(email = validEmail, password = validPassword) - every { userRepository.findByEmail(validEmail) } returns arrow.core.none().right() + every { userRepository.findByEmail(validEmail) } returns Either.Left(DomainError.InvalidCredentials) val result = authenticate(request) @@ -139,8 +142,8 @@ class AuthenticateTest : test("should return InvalidCredentials when password does not match") { val request = AuthenticateRequest(email = validEmail, password = "WrongPassword123") - every { userRepository.findByEmail(validEmail) } returns testUser.some().right() - every { passwordProvider.matches("WrongPassword123", passwordHash) } returns false + every { userRepository.findByEmail(validEmail) } returns testUser.right() + every { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } returns false val result = authenticate(request) @@ -148,7 +151,7 @@ class AuthenticateTest : error shouldBe DomainError.InvalidCredentials verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches("WrongPassword123", passwordHash) } + verify { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } } } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt index 29876c9..35b2531 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/GetUsersTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.domain.users.usecases +import arrow.core.Either import arrow.core.left import arrow.core.right import io.kotest.assertions.arrow.core.shouldBeLeft @@ -26,13 +27,13 @@ class GetUsersTest : User( id = UUID.randomUUID(), email = "user1@example.com", - passwordHash = "hash1", + passwordHash = Either.Right("hash1"), ) val user2 = User( id = UUID.randomUUID(), email = "user2@example.com", - passwordHash = "hash2", + passwordHash = Either.Right("hash2"), ) beforeTest { diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt index 6973780..fbbb5ef 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.api +import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.core.some @@ -31,7 +32,7 @@ class AuthenticateEndpointTest : val validEmail = "user@example.com" val validPassword = "SecurePass123" - val passwordHash = "hashed_password" + val passwordHash = Either.Right("hashed_password") val testUser = User( id = UUID.randomUUID(), @@ -39,11 +40,12 @@ class AuthenticateEndpointTest : passwordHash = passwordHash, ) val tokenInfo = - TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + Either.Right( + TokenInfo( + token = "jwt.token.here", + expires = 1234567890L, + ) ) - beforeTest { clearAllMocks() } @@ -53,18 +55,18 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.some().right() - every { passwordProvider.matches(validPassword, passwordHash) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.right() + every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val response = endpoint.handler(request) response.status shouldBe Status.OK - response.bodyString() shouldContain "\"token\":\"${tokenInfo.token}\"" - response.bodyString() shouldContain "\"expires\":${tokenInfo.expires}" + response.bodyString() shouldContain "\"token\":\"${tokenInfo.getOrNull()!!.token}\"" + response.bodyString() shouldContain "\"expires\":${tokenInfo.getOrNull()!!.expires}" verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches(validPassword, passwordHash) } + verify { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } verify { jwtProvider.generate(testUser) } } } @@ -116,7 +118,7 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"nonexistent@example.com","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail("nonexistent@example.com") } returns arrow.core.none().right() + every { userRepository.findByEmail("nonexistent@example.com") } returns Either.Left(DomainError.InvalidCredentials) val response = endpoint.handler(request) @@ -130,8 +132,8 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"WrongPassword123"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.some().right() - every { passwordProvider.matches("WrongPassword123", passwordHash) } returns false + every { userRepository.findByEmail(validEmail) } returns testUser.right() + every { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } returns false val response = endpoint.handler(request) @@ -139,7 +141,7 @@ class AuthenticateEndpointTest : response.bodyString() shouldContain "Invalid email or password" verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches("WrongPassword123", passwordHash) } + verify { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } } } @@ -165,8 +167,8 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.some().right() - every { passwordProvider.matches(validPassword, passwordHash) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.right() + every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val response = endpoint.handler(request) diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt index 218f5e9..3793934 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/GetUsersEndpointTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.api +import arrow.core.Either import arrow.core.left import arrow.core.right import io.kotest.core.spec.style.FunSpec @@ -27,13 +28,13 @@ class GetUsersEndpointTest : User( id = UUID.randomUUID(), email = "user1@example.com", - passwordHash = "hash1", + passwordHash = Either.Right("hash1"), ) val user2 = User( id = UUID.randomUUID(), email = "user2@example.com", - passwordHash = "hash2", + passwordHash = Either.Right("hash2"), ) beforeTest { @@ -151,7 +152,7 @@ class GetUsersEndpointTest : User( id = UUID.randomUUID(), email = "user$index@example.com", - passwordHash = "hash$index", + passwordHash = Either.Right("hash$index"), ) } every { userRepository.findAll() } returns manyUsers.right() diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt index e14278e..3604615 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt @@ -1,10 +1,13 @@ package za.co.ee.learning.infrastructure.database +import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.assertions.arrow.core.shouldBeSome import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveAtLeastSize import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import za.co.ee.learning.domain.DomainError class InMemoryUserRepositoryTest : FunSpec({ @@ -24,24 +27,22 @@ class InMemoryUserRepositoryTest : test("should return Some when user exists") { val result = repository.findByEmail("admin@local.com") - val option = result.shouldBeRight() - val user = option.shouldBeSome() + val user = result.shouldBeRight() user.email shouldBe "admin@local.com" } test("should have password hash for existing user") { val result = repository.findByEmail("admin@local.com") - val option = result.shouldBeRight() - val user = option.shouldBeSome() - user.passwordHash.isNotEmpty() shouldBe true + val user = result.shouldBeRight() + user.passwordHash.getOrNull()!!.isNotEmpty() shouldBe true } test("should return None for non-existent user") { val result = repository.findByEmail("nonexistent@example.com") - val option = result.shouldBeRight() - option.isNone() shouldBe true + val err = result.shouldBeLeft() + err shouldBeSameInstanceAs DomainError.InvalidCredentials } } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt index 673ceb5..4ffd438 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt @@ -15,8 +15,8 @@ class BCryptPasswordProviderTest : val hash = passwordProvider.encode(password) - hash shouldStartWith "\$2a\$" - hash.length shouldBe 60 + hash.getOrNull()!! shouldStartWith "\$2a\$" + hash.getOrNull()!!.length shouldBe 60 } test("should generate different hashes for the same password") { @@ -43,7 +43,7 @@ class BCryptPasswordProviderTest : val hash = passwordProvider.encode(password) - hash shouldStartWith "\$2a\$" + hash.getOrNull()!! shouldStartWith "\$2a\$" } } @@ -52,7 +52,7 @@ class BCryptPasswordProviderTest : val password = "correctPassword123" val hash = passwordProvider.encode(password) - val result = passwordProvider.matches(password, hash) + val result = passwordProvider.matches(password, hash.getOrNull()!!) result shouldBe true } @@ -62,7 +62,7 @@ class BCryptPasswordProviderTest : val wrongPassword = "wrongPassword456" val hash = passwordProvider.encode(correctPassword) - val result = passwordProvider.matches(wrongPassword, hash) + val result = passwordProvider.matches(wrongPassword, hash.getOrNull()!!) result shouldBe false } @@ -71,7 +71,7 @@ class BCryptPasswordProviderTest : val password = "actualPassword" val hash = passwordProvider.encode(password) - val result = passwordProvider.matches("", hash) + val result = passwordProvider.matches("", hash.getOrNull()!!) result shouldBe false } @@ -80,7 +80,7 @@ class BCryptPasswordProviderTest : val password = "Password123" val hash = passwordProvider.encode(password) - val result = passwordProvider.matches("password123", hash) + val result = passwordProvider.matches("password123", hash.getOrNull()!!) result shouldBe false } @@ -89,7 +89,7 @@ class BCryptPasswordProviderTest : val password = "P@ssw0rd!#\$%^&*()" val hash = passwordProvider.encode(password) - val result = passwordProvider.matches(password, hash) + val result = passwordProvider.matches(password, hash.getOrNull()!!) result shouldBe true } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt index 697fe54..af24e2c 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.security +import arrow.core.Either import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import io.kotest.assertions.arrow.core.shouldBeLeft @@ -27,28 +28,28 @@ class DefaultJWTProviderTest : User( id = UUID.randomUUID(), email = "test@example.com", - passwordHash = "hash", + passwordHash = Either.Right("hash"), ) context("generate") { test("should generate a valid JWT token") { val tokenInfo = jwtProvider.generate(testUser) - tokenInfo.token shouldContain "." - tokenInfo.expires shouldBeGreaterThan Instant.now().epochSecond + tokenInfo.getOrNull()!!.token shouldContain "." + tokenInfo.getOrNull()!!.expires shouldBeGreaterThan Instant.now().epochSecond } test("should include correct issuer in token") { val tokenInfo = jwtProvider.generate(testUser) - val decodedJWT = JWT.decode(tokenInfo.token) + val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) decodedJWT.issuer shouldBe issuer } test("should include user ID as subject in token") { val tokenInfo = jwtProvider.generate(testUser) - val decodedJWT = JWT.decode(tokenInfo.token) + val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) decodedJWT.subject shouldBe testUser.id.toString() } @@ -58,14 +59,14 @@ class DefaultJWTProviderTest : // Expiration should be approximately expirationSeconds from now // Allow for 2 seconds of tolerance - tokenInfo.expires shouldBeGreaterThan beforeGeneration + expirationSeconds - 2 + tokenInfo.getOrNull()!!.expires shouldBeGreaterThan beforeGeneration + expirationSeconds - 2 } test("should include issued at timestamp") { val beforeGeneration = Instant.now().epochSecond val tokenInfo = jwtProvider.generate(testUser) - val decodedJWT = JWT.decode(tokenInfo.token) + val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) val issuedAt = decodedJWT.issuedAt.toInstant().epochSecond issuedAt shouldBeGreaterThan beforeGeneration - 1 @@ -76,7 +77,7 @@ class DefaultJWTProviderTest : test("should successfully verify a valid token") { val tokenInfo = jwtProvider.generate(testUser) - val result = jwtProvider.verify(tokenInfo.token) + val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) val userId = result.shouldBeRight() userId shouldBe testUser.id @@ -90,7 +91,7 @@ class DefaultJWTProviderTest : // Wait a bit to ensure token is expired Thread.sleep(1000) - val result = jwtProvider.verify(tokenInfo.token) + val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() @@ -101,7 +102,7 @@ class DefaultJWTProviderTest : val differentSecretProvider = DefaultJWTProvider("different-secret", issuer, expirationSeconds) val tokenInfo = differentSecretProvider.generate(testUser) - val result = jwtProvider.verify(tokenInfo.token) + val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() @@ -128,7 +129,7 @@ class DefaultJWTProviderTest : val wrongIssuerProvider = DefaultJWTProvider(secret, "wrong-issuer", expirationSeconds) val tokenInfo = wrongIssuerProvider.generate(testUser) - val result = jwtProvider.verify(tokenInfo.token) + val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() From 40acc63df39abcbf6adbd721a440ce776681fd27 Mon Sep 17 00:00:00 2001 From: Paul Scott Date: Wed, 19 Nov 2025 15:41:10 +0200 Subject: [PATCH 2/3] Changes based on PR comments --- .../learning/domain/users/UserRepository.kt | 5 +-- .../domain/users/usecases/Authenticate.kt | 15 ++++---- .../database/InMemoryUserRepository.kt | 4 +- .../domain/users/usecases/AuthenticateTest.kt | 30 ++++++++------- .../api/AuthenticateEndpointTest.kt | 32 ++++++++-------- .../database/InMemoryUserRepositoryTest.kt | 16 ++++---- .../security/BCryptPasswordProviderTest.kt | 30 ++++++++------- .../security/DefaultJWTProviderTest.kt | 38 +++++++++---------- 8 files changed, 88 insertions(+), 82 deletions(-) diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt index 1e0ea20..3216557 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/UserRepository.kt @@ -1,11 +1,10 @@ package za.co.ee.learning.domain.users -import arrow.core.Either -import za.co.ee.learning.domain.DomainError +import arrow.core.Option import za.co.ee.learning.domain.DomainResult interface UserRepository { - fun findByEmail(email: String): DomainResult + fun findByEmail(email: String): DomainResult> fun findAll(): DomainResult> } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt index 29d82d4..805fbad 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt @@ -30,10 +30,11 @@ class Authenticate( operator fun invoke(request: AuthenticateRequest): DomainResult = either { val validatedRequest = validate(request).bind() - val user = userRepository.findByEmail(validatedRequest.email).bind() + val userOption = userRepository.findByEmail(validatedRequest.email).bind() + val user = userOption.toEither { DomainError.InvalidCredentials }.bind() val pwdHashString = user.passwordHash.bind() - checkPassword(pwdHashString, validatedRequest).bind() - createToken(user).bind() + val authenticatedUser = authenticateUser(user, pwdHashString, validatedRequest).bind() + createToken(authenticatedUser).bind() } private fun validate(request: AuthenticateRequest): DomainResult { @@ -56,14 +57,14 @@ class Authenticate( return request.right() } - private fun checkPassword( + private fun authenticateUser( + user: User, passwordHash: String, validatedRequest: AuthenticateRequest, - ): DomainResult { + ): DomainResult { if (passwordProvider.matches(validatedRequest.password, passwordHash)) { - return true.right() + return user.right() } - return DomainError.InvalidCredentials.left() } diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt index bd7969e..a92b25b 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepository.kt @@ -18,10 +18,10 @@ class InMemoryUserRepository : UserRepository { } // Find the first user in the list that has the matching email, wrap it in an option and return a Either.right() - override fun findByEmail(email: String): DomainResult = + override fun findByEmail(email: String): DomainResult> = Option .fromNullable(users.firstOrNull { it.email == email }) - .toEither { DomainError.InvalidCredentials } + .right() override fun findAll(): DomainResult> = users.toList().right() } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt index 463c285..1df94f5 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/domain/users/usecases/AuthenticateTest.kt @@ -29,18 +29,20 @@ class AuthenticateTest : val validEmail = "user@example.com" val validPassword = "SecurePass123" - val passwordHash = Either.Right("hashed_password") + val passwordHash = "hashed_password" val testUser = User( id = UUID.randomUUID(), email = validEmail, - passwordHash = passwordHash, + passwordHash = Either.Right(passwordHash), ) + val testToken = "jwt.token.here" + val testExpires = 1234567890L val tokenInfo = Either.Right( TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + token = testToken, + expires = testExpires, ) ) @@ -52,8 +54,8 @@ class AuthenticateTest : test("should return token when credentials are valid") { val request = AuthenticateRequest(email = validEmail, password = validPassword) - every { userRepository.findByEmail(validEmail) } returns testUser.right() - every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.some().right() + every { passwordProvider.matches(validPassword, passwordHash) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val result = authenticate(request) @@ -61,12 +63,12 @@ class AuthenticateTest : val value = result.shouldBeRight() value shouldBe AuthenticateResponse( - token = tokenInfo.getOrNull()!!.token, - expires = tokenInfo.getOrNull()!!.expires, + token = testToken, + expires = testExpires, ) verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } + verify { passwordProvider.matches(validPassword, passwordHash) } verify { jwtProvider.generate(testUser) } } } @@ -116,7 +118,7 @@ class AuthenticateTest : test("should return InvalidCredentials when user does not exist") { val request = AuthenticateRequest(email = "nonexistent@example.com", password = validPassword) - every { userRepository.findByEmail("nonexistent@example.com") } returns Either.Left(DomainError.InvalidCredentials) + every { userRepository.findByEmail("nonexistent@example.com") } returns arrow.core.none().right() val result = authenticate(request) @@ -129,7 +131,7 @@ class AuthenticateTest : test("should return InvalidCredentials when repository returns None") { val request = AuthenticateRequest(email = validEmail, password = validPassword) - every { userRepository.findByEmail(validEmail) } returns Either.Left(DomainError.InvalidCredentials) + every { userRepository.findByEmail(validEmail) } returns arrow.core.none().right() val result = authenticate(request) @@ -142,8 +144,8 @@ class AuthenticateTest : test("should return InvalidCredentials when password does not match") { val request = AuthenticateRequest(email = validEmail, password = "WrongPassword123") - every { userRepository.findByEmail(validEmail) } returns testUser.right() - every { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } returns false + every { userRepository.findByEmail(validEmail) } returns testUser.some().right() + every { passwordProvider.matches("WrongPassword123", passwordHash) } returns false val result = authenticate(request) @@ -151,7 +153,7 @@ class AuthenticateTest : error shouldBe DomainError.InvalidCredentials verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } + verify { passwordProvider.matches("WrongPassword123", passwordHash) } } } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt index fbbb5ef..59c26a6 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/api/AuthenticateEndpointTest.kt @@ -32,18 +32,20 @@ class AuthenticateEndpointTest : val validEmail = "user@example.com" val validPassword = "SecurePass123" - val passwordHash = Either.Right("hashed_password") + val passwordHash = "hashed_password" val testUser = User( id = UUID.randomUUID(), email = validEmail, - passwordHash = passwordHash, + passwordHash = Either.Right(passwordHash), ) + val testToken = "jwt.token.here" + val testExpires = 1234567890L val tokenInfo = Either.Right( TokenInfo( - token = "jwt.token.here", - expires = 1234567890L, + token = testToken, + expires = testExpires, ) ) beforeTest { @@ -55,18 +57,18 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.right() - every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.some().right() + every { passwordProvider.matches(validPassword, passwordHash) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val response = endpoint.handler(request) response.status shouldBe Status.OK - response.bodyString() shouldContain "\"token\":\"${tokenInfo.getOrNull()!!.token}\"" - response.bodyString() shouldContain "\"expires\":${tokenInfo.getOrNull()!!.expires}" + response.bodyString() shouldContain "\"token\":\"$testToken\"" + response.bodyString() shouldContain "\"expires\":$testExpires" verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } + verify { passwordProvider.matches(validPassword, passwordHash) } verify { jwtProvider.generate(testUser) } } } @@ -118,7 +120,7 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"nonexistent@example.com","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail("nonexistent@example.com") } returns Either.Left(DomainError.InvalidCredentials) + every { userRepository.findByEmail("nonexistent@example.com") } returns arrow.core.none().right() val response = endpoint.handler(request) @@ -132,8 +134,8 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"WrongPassword123"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.right() - every { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } returns false + every { userRepository.findByEmail(validEmail) } returns testUser.some().right() + every { passwordProvider.matches("WrongPassword123", passwordHash) } returns false val response = endpoint.handler(request) @@ -141,7 +143,7 @@ class AuthenticateEndpointTest : response.bodyString() shouldContain "Invalid email or password" verify { userRepository.findByEmail(validEmail) } - verify { passwordProvider.matches("WrongPassword123", passwordHash.getOrNull()!!) } + verify { passwordProvider.matches("WrongPassword123", passwordHash) } } } @@ -167,8 +169,8 @@ class AuthenticateEndpointTest : val requestBody = """{"email":"$validEmail","password":"$validPassword"}""" val request = Request(Method.POST, "/auth/login").body(requestBody) - every { userRepository.findByEmail(validEmail) } returns testUser.right() - every { passwordProvider.matches(validPassword, passwordHash.getOrNull()!!) } returns true + every { userRepository.findByEmail(validEmail) } returns testUser.some().right() + every { passwordProvider.matches(validPassword, passwordHash) } returns true every { jwtProvider.generate(testUser) } returns tokenInfo val response = endpoint.handler(request) diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt index 3604615..cd4d481 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/database/InMemoryUserRepositoryTest.kt @@ -1,13 +1,10 @@ package za.co.ee.learning.infrastructure.database -import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.assertions.arrow.core.shouldBeSome import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveAtLeastSize import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeSameInstanceAs -import za.co.ee.learning.domain.DomainError class InMemoryUserRepositoryTest : FunSpec({ @@ -27,22 +24,25 @@ class InMemoryUserRepositoryTest : test("should return Some when user exists") { val result = repository.findByEmail("admin@local.com") - val user = result.shouldBeRight() + val option = result.shouldBeRight() + val user = option.shouldBeSome() user.email shouldBe "admin@local.com" } test("should have password hash for existing user") { val result = repository.findByEmail("admin@local.com") - val user = result.shouldBeRight() - user.passwordHash.getOrNull()!!.isNotEmpty() shouldBe true + val option = result.shouldBeRight() + val user = option.shouldBeSome() + val pwdHash = user.passwordHash.shouldBeRight() + pwdHash.isNotEmpty() shouldBe true } test("should return None for non-existent user") { val result = repository.findByEmail("nonexistent@example.com") - val err = result.shouldBeLeft() - err shouldBeSameInstanceAs DomainError.InvalidCredentials + val option = result.shouldBeRight() + option.isNone() shouldBe true } } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt index 4ffd438..68990bf 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/BCryptPasswordProviderTest.kt @@ -1,5 +1,6 @@ package za.co.ee.learning.infrastructure.security +import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -15,8 +16,9 @@ class BCryptPasswordProviderTest : val hash = passwordProvider.encode(password) - hash.getOrNull()!! shouldStartWith "\$2a\$" - hash.getOrNull()!!.length shouldBe 60 + val hashResult = hash.shouldBeRight() + hashResult shouldStartWith "\$2a\$" + hashResult.length shouldBe 60 } test("should generate different hashes for the same password") { @@ -42,17 +44,17 @@ class BCryptPasswordProviderTest : val password = "" val hash = passwordProvider.encode(password) - - hash.getOrNull()!! shouldStartWith "\$2a\$" + val hashResult = hash.shouldBeRight() + hashResult shouldStartWith "\$2a\$" } } context("matches") { test("should return true when password matches hash") { val password = "correctPassword123" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() - val result = passwordProvider.matches(password, hash.getOrNull()!!) + val result = passwordProvider.matches(password, hash) result shouldBe true } @@ -60,36 +62,36 @@ class BCryptPasswordProviderTest : test("should return false when password does not match hash") { val correctPassword = "correctPassword123" val wrongPassword = "wrongPassword456" - val hash = passwordProvider.encode(correctPassword) + val hash = passwordProvider.encode(correctPassword).shouldBeRight() - val result = passwordProvider.matches(wrongPassword, hash.getOrNull()!!) + val result = passwordProvider.matches(wrongPassword, hash) result shouldBe false } test("should return false for empty password when hash is for non-empty password") { val password = "actualPassword" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() - val result = passwordProvider.matches("", hash.getOrNull()!!) + val result = passwordProvider.matches("", hash) result shouldBe false } test("should be case sensitive") { val password = "Password123" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() - val result = passwordProvider.matches("password123", hash.getOrNull()!!) + val result = passwordProvider.matches("password123", hash) result shouldBe false } test("should handle special characters in password") { val password = "P@ssw0rd!#\$%^&*()" - val hash = passwordProvider.encode(password) + val hash = passwordProvider.encode(password).shouldBeRight() - val result = passwordProvider.matches(password, hash.getOrNull()!!) + val result = passwordProvider.matches(password, hash) result shouldBe true } diff --git a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt index af24e2c..d442a33 100644 --- a/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt +++ b/http4k-example/src/test/kotlin/za/co/ee/learning/infrastructure/security/DefaultJWTProviderTest.kt @@ -33,40 +33,40 @@ class DefaultJWTProviderTest : context("generate") { test("should generate a valid JWT token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() - tokenInfo.getOrNull()!!.token shouldContain "." - tokenInfo.getOrNull()!!.expires shouldBeGreaterThan Instant.now().epochSecond + tokenInfo.token shouldContain "." + tokenInfo.expires shouldBeGreaterThan Instant.now().epochSecond } test("should include correct issuer in token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() - val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) + val decodedJWT = JWT.decode(tokenInfo.token) decodedJWT.issuer shouldBe issuer } test("should include user ID as subject in token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() - val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) + val decodedJWT = JWT.decode(tokenInfo.token) decodedJWT.subject shouldBe testUser.id.toString() } test("should set expiration time correctly") { val beforeGeneration = Instant.now().epochSecond - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() // Expiration should be approximately expirationSeconds from now // Allow for 2 seconds of tolerance - tokenInfo.getOrNull()!!.expires shouldBeGreaterThan beforeGeneration + expirationSeconds - 2 + tokenInfo.expires shouldBeGreaterThan beforeGeneration + expirationSeconds - 2 } test("should include issued at timestamp") { val beforeGeneration = Instant.now().epochSecond - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() - val decodedJWT = JWT.decode(tokenInfo.getOrNull()!!.token) + val decodedJWT = JWT.decode(tokenInfo.token) val issuedAt = decodedJWT.issuedAt.toInstant().epochSecond issuedAt shouldBeGreaterThan beforeGeneration - 1 @@ -75,9 +75,9 @@ class DefaultJWTProviderTest : context("verify") { test("should successfully verify a valid token") { - val tokenInfo = jwtProvider.generate(testUser) + val tokenInfo = jwtProvider.generate(testUser).shouldBeRight() - val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) + val result = jwtProvider.verify(tokenInfo.token) val userId = result.shouldBeRight() userId shouldBe testUser.id @@ -86,12 +86,12 @@ class DefaultJWTProviderTest : test("should return error for expired token") { // Create a JWT provider with very short expiration val shortExpirationProvider = DefaultJWTProvider(secret, issuer, -1) - val tokenInfo = shortExpirationProvider.generate(testUser) + val tokenInfo = shortExpirationProvider.generate(testUser).shouldBeRight() // Wait a bit to ensure token is expired Thread.sleep(1000) - val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) + val result = jwtProvider.verify(tokenInfo.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() @@ -100,9 +100,9 @@ class DefaultJWTProviderTest : test("should return error for token with invalid signature") { val differentSecretProvider = DefaultJWTProvider("different-secret", issuer, expirationSeconds) - val tokenInfo = differentSecretProvider.generate(testUser) + val tokenInfo = differentSecretProvider.generate(testUser).shouldBeRight() - val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) + val result = jwtProvider.verify(tokenInfo.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() @@ -127,9 +127,9 @@ class DefaultJWTProviderTest : test("should return error for token with wrong issuer") { val wrongIssuerProvider = DefaultJWTProvider(secret, "wrong-issuer", expirationSeconds) - val tokenInfo = wrongIssuerProvider.generate(testUser) + val tokenInfo = wrongIssuerProvider.generate(testUser).shouldBeRight() - val result = jwtProvider.verify(tokenInfo.getOrNull()!!.token) + val result = jwtProvider.verify(tokenInfo.token) val error = result.shouldBeLeft() error.shouldBeInstanceOf() From 25eb60bc4fb78c4281242b43f5c000453c279ccf Mon Sep 17 00:00:00 2001 From: Paul Scott Date: Wed, 19 Nov 2025 15:56:49 +0200 Subject: [PATCH 3/3] Final change based on PR comments --- .../learning/domain/users/usecases/Authenticate.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt index 805fbad..c6befe8 100644 --- a/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt +++ b/http4k-example/src/main/kotlin/za/co/ee/learning/domain/users/usecases/Authenticate.kt @@ -32,8 +32,7 @@ class Authenticate( val validatedRequest = validate(request).bind() val userOption = userRepository.findByEmail(validatedRequest.email).bind() val user = userOption.toEither { DomainError.InvalidCredentials }.bind() - val pwdHashString = user.passwordHash.bind() - val authenticatedUser = authenticateUser(user, pwdHashString, validatedRequest).bind() + val authenticatedUser = authenticateUser(user, validatedRequest).bind() createToken(authenticatedUser).bind() } @@ -59,13 +58,14 @@ class Authenticate( private fun authenticateUser( user: User, - passwordHash: String, validatedRequest: AuthenticateRequest, - ): DomainResult { - if (passwordProvider.matches(validatedRequest.password, passwordHash)) { - return user.right() + ): DomainResult = either { + val passwordHashString = user.passwordHash.bind() + if (passwordProvider.matches(validatedRequest.password, passwordHashString)) { + user + } else { + raise(DomainError.InvalidCredentials) } - return DomainError.InvalidCredentials.left() } private fun createToken(authenticatedUser: User): DomainResult = either {