From f2be758a9f7d175e7b5557029a4a71a38996710f Mon Sep 17 00:00:00 2001 From: gaalici1990 Date: Mon, 23 Mar 2026 18:24:53 -0500 Subject: [PATCH 1/3] [EDIFIKANA] #425 Implementing password reset functionality --- .../com/cramsan/edifikana/api/UserApi.kt | 13 ++++++ .../SupabaseUserDatastoreIntegrationTest.kt | 40 +++++++++++++++++++ .../server/controller/UserController.kt | 14 +++++++ .../server/datastore/UserDatastore.kt | 6 +++ .../supabase/SupabaseUserDatastore.kt | 11 +++++ .../edifikana/server/service/UserService.kt | 12 ++++++ .../server/service/UserServiceTest.kt | 35 ++++++++++++++++ .../lib/service/impl/AuthServiceImpl.kt | 7 +++- .../network/PasswordResetNetworkRequest.kt | 14 +++++++ 9 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UserApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UserApi.kt index 080ecebf6..8574bf708 100644 --- a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UserApi.kt +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UserApi.kt @@ -5,6 +5,7 @@ import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.lib.model.network.CheckUserNetworkResponse import com.cramsan.edifikana.lib.model.network.CreateUserNetworkRequest +import com.cramsan.edifikana.lib.model.network.PasswordResetNetworkRequest import com.cramsan.edifikana.lib.model.network.GetAllUsersQueryParams import com.cramsan.edifikana.lib.model.network.InviteListNetworkResponse import com.cramsan.edifikana.lib.model.network.InviteUserNetworkRequest @@ -139,4 +140,16 @@ object UserApi : Api("user") { HttpMethod.Get, "checkUser" ) + + /** + * Request a password reset email for the given email address. + * Route: POST /user/request-password-reset + * Always returns 200 regardless of whether the email exists. + */ + val requestPasswordReset = operation< + PasswordResetNetworkRequest, + NoQueryParam, + NoPathParam, + NoResponseBody + >(HttpMethod.Post, "request-password-reset") } diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt index ac3e78f9f..ca57840a5 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt @@ -262,6 +262,46 @@ class SupabaseUserDatastoreIntegrationTest : SupabaseIntegrationTest() { assertInstanceOf(associateResult.exceptionOrNull()) } + /** + * Tests that requestPasswordReset succeeds for a registered user's email. + * Supabase will silently trigger the reset flow without throwing. + */ + @Test + fun `requestPasswordReset should succeed for registered email`() = runCoroutineTest { + // Arrange + val email = "${test_prefix}_pwreset@test.com" + userDatastore.createUser( + email = email, + phoneNumber = "123-456-7890", + password = "Password1!", + firstName = "Reset", + lastName = "User", + isTransient = false, + ).registerUserForDeletion() + + // Act + val result = userDatastore.requestPasswordReset(email) + + // Assert + assertTrue(result.isSuccess) + } + + /** + * Tests that requestPasswordReset succeeds even for an unregistered email. + * Supabase never reveals whether the email exists to prevent enumeration. + */ + @Test + fun `requestPasswordReset should succeed for unregistered email`() = runCoroutineTest { + // Arrange — no user created + val email = "${test_prefix}_nonexist@test.com" + + // Act + val result = userDatastore.requestPasswordReset(email) + + // Assert + assertTrue(result.isSuccess) + } + // We cannot test this in the integration test because it requires a Supabase user to be created first. // Right now we can create the user but we are not able to retrieve the user ID from Supabase Auth. @Ignore diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt index 0fb236696..ee814f683 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt @@ -7,6 +7,7 @@ import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.lib.model.network.CheckUserNetworkResponse import com.cramsan.edifikana.lib.model.network.CreateUserNetworkRequest +import com.cramsan.edifikana.lib.model.network.PasswordResetNetworkRequest import com.cramsan.edifikana.lib.model.network.GetAllUsersQueryParams import com.cramsan.edifikana.lib.model.network.InviteListNetworkResponse import com.cramsan.edifikana.lib.model.network.InviteUserNetworkRequest @@ -304,6 +305,16 @@ class UserController( return NoResponseBody } + /** + * Handles a password reset request. Always returns 200 regardless of email existence + * to prevent email enumeration. No authentication required. + */ + @OptIn(NetworkModel::class) + suspend fun requestPasswordReset(request: PasswordResetNetworkRequest): NoResponseBody { + userService.requestPasswordReset(request.email) + return NoResponseBody + } + /** * Registers the routes for the user controller. The [route] parameter is the root path for the controller. */ @@ -346,6 +357,9 @@ class UserController( unauthenticatedHandler(api.checkUserExists, contextRetriever) { request -> checkUserIsRegistered(request.queryParam.email) } + unauthenticatedHandler(api.requestPasswordReset, contextRetriever) { request -> + requestPasswordReset(request.requestBody) + } } } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt index 361004528..cbb9e4a54 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt @@ -75,4 +75,10 @@ interface UserDatastore { suspend fun purgeUser( id: UserId, ): Result + + /** + * Sends a password reset email to the given [email] via Supabase Auth. + * Returns success regardless of whether the email exists in the system. + */ + suspend fun requestPasswordReset(email: String): Result } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt index 0d129e738..a63204e4c 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt @@ -15,6 +15,7 @@ import com.cramsan.framework.logging.logD import com.cramsan.framework.logging.logW import com.cramsan.framework.utils.exceptions.ClientRequestExceptions import com.cramsan.framework.utils.uuid.UUID +import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.auth.admin.AdminApi import io.github.jan.supabase.auth.exception.AuthRestException import io.github.jan.supabase.postgrest.Postgrest @@ -30,6 +31,7 @@ class SupabaseUserDatastore( private val adminApi: AdminApi, private val postgrest: Postgrest, private val clock: Clock, + private val auth: Auth, ) : UserDatastore { /** @@ -345,6 +347,15 @@ class SupabaseUserDatastore( true } + /** + * Sends a password reset email to [email] via Supabase Auth. + */ + override suspend fun requestPasswordReset(email: String): Result = + runSuspendCatching(TAG) { + logD(TAG, "Requesting password reset for email: %s", email) + auth.resetPasswordForEmail(email) + } + companion object { const val TAG = "SupabaseUserDatastore" } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt index 5a0e31d89..d0d03f4eb 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt @@ -319,6 +319,18 @@ class UserService( logD(TAG, "Invite $inviteId cancelled for organization ${invite.organizationId}") } + /** + * Requests a password reset email for the given [email]. + * Always returns success to prevent email enumeration. + */ + suspend fun requestPasswordReset(email: String): Result { + logD(TAG, "requestPasswordReset") + userDatastore.requestPasswordReset(email).onFailure { e -> + logW(TAG, "Password reset request failed (suppressed)", e) + } + return Result.success(Unit) + } + companion object { private const val TAG = "UserService" private const val INVITE_CODE_LENGTH = 12 diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt index 779e38288..5a22f3a74 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt @@ -674,4 +674,39 @@ class UserServiceTest { assertTrue(result.isFailure) coVerify(exactly = 0) { membershipDatastore.cancelInvite(any()) } } + + /** + * Tests that requestPasswordReset returns success when the datastore succeeds. + */ + @Test + fun `requestPasswordReset should return success when datastore succeeds`() = runTest { + // Arrange + coEvery { userDatastore.requestPasswordReset(any()) } returns Result.success(Unit) + + // Act + val result = userService.requestPasswordReset("user@example.com") + + // Assert + assertTrue(result.isSuccess) + coVerify(exactly = 1) { userDatastore.requestPasswordReset("user@example.com") } + } + + /** + * Tests that requestPasswordReset always returns success even when the datastore fails + * (prevents email enumeration). + */ + @Test + fun `requestPasswordReset should return success even when datastore fails`() = runTest { + // Arrange + coEvery { userDatastore.requestPasswordReset(any()) } returns Result.failure( + RuntimeException("Not found") + ) + + // Act + val result = userService.requestPasswordReset("nonexistent@example.com") + + // Assert + assertTrue(result.isSuccess) + coVerify(exactly = 1) { userDatastore.requestPasswordReset("nonexistent@example.com") } + } } diff --git a/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt b/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt index 3e932bbcd..9fb284075 100644 --- a/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt +++ b/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt @@ -9,6 +9,7 @@ import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.lib.model.UserRole import com.cramsan.edifikana.lib.model.network.CreateUserNetworkRequest +import com.cramsan.edifikana.lib.model.network.PasswordResetNetworkRequest import com.cramsan.edifikana.lib.model.network.GetAllUsersQueryParams import com.cramsan.edifikana.lib.model.network.InviteNetworkResponse import com.cramsan.edifikana.lib.model.network.InviteUserNetworkRequest @@ -171,8 +172,12 @@ class AuthServiceImpl( getUser().getOrThrow() } + @OptIn(NetworkModel::class) override suspend fun passwordReset(email: String?, phoneNumber: String?): Result = runSuspendCatching(TAG) { - TODO("Implement functionality to reset password and authenticate user.") + requireNotNull(email) { "Email is required for password reset" } + UserApi.requestPasswordReset.buildRequest( + PasswordResetNetworkRequest(email = email) + ).execute(http) } override suspend fun verifyPermissions(): Result { diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt new file mode 100644 index 000000000..deca7a351 --- /dev/null +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt @@ -0,0 +1,14 @@ +package com.cramsan.edifikana.lib.model.network + +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.RequestBody +import kotlinx.serialization.Serializable + +/** + * Network request model for requesting a password reset email. + */ +@NetworkModel +@Serializable +data class PasswordResetNetworkRequest( + val email: String, +) : RequestBody From 87a27d7921cdfb1aba259bcb30159f094e263657 Mon Sep 17 00:00:00 2001 From: gaalici1990 Date: Wed, 25 Mar 2026 10:47:41 -0500 Subject: [PATCH 2/3] [EDIFIKANA] Addressing PR feedback for unsupported phone number --- .../SupabaseUserDatastoreIntegrationTest.kt | 4 ++-- .../edifikana/server/controller/UserController.kt | 6 +++--- .../edifikana/server/datastore/UserDatastore.kt | 6 +++--- .../datastore/supabase/SupabaseUserDatastore.kt | 14 ++++++++++---- .../edifikana/server/service/UserService.kt | 11 +++++++---- .../edifikana/server/service/UserServiceTest.kt | 12 ++++++------ .../client/lib/service/impl/AuthServiceImpl.kt | 9 +++++++-- .../model/network/PasswordResetNetworkRequest.kt | 7 +++++-- 8 files changed, 43 insertions(+), 26 deletions(-) diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt index ca57840a5..1e46b97e2 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastoreIntegrationTest.kt @@ -280,7 +280,7 @@ class SupabaseUserDatastoreIntegrationTest : SupabaseIntegrationTest() { ).registerUserForDeletion() // Act - val result = userDatastore.requestPasswordReset(email) + val result = userDatastore.requestPasswordReset(email, null) // Assert assertTrue(result.isSuccess) @@ -296,7 +296,7 @@ class SupabaseUserDatastoreIntegrationTest : SupabaseIntegrationTest() { val email = "${test_prefix}_nonexist@test.com" // Act - val result = userDatastore.requestPasswordReset(email) + val result = userDatastore.requestPasswordReset(email, null) // Assert assertTrue(result.isSuccess) diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt index ee814f683..43ff6ff69 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UserController.kt @@ -306,12 +306,12 @@ class UserController( } /** - * Handles a password reset request. Always returns 200 regardless of email existence - * to prevent email enumeration. No authentication required. + * Handles a password reset request. Always returns 200 regardless of email or phone number existence + * to prevent enumeration. No authentication required. */ @OptIn(NetworkModel::class) suspend fun requestPasswordReset(request: PasswordResetNetworkRequest): NoResponseBody { - userService.requestPasswordReset(request.email) + userService.requestPasswordReset(request.email, request.phoneNumber) return NoResponseBody } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt index cbb9e4a54..cd5e6923f 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UserDatastore.kt @@ -77,8 +77,8 @@ interface UserDatastore { ): Result /** - * Sends a password reset email to the given [email] via Supabase Auth. - * Returns success regardless of whether the email exists in the system. + * Sends a password reset notification to the given [email] or [phoneNumber] via Supabase Auth. + * Returns success regardless of whether the identifier exists in the system. */ - suspend fun requestPasswordReset(email: String): Result + suspend fun requestPasswordReset(email: String?, phoneNumber: String?): Result } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt index a63204e4c..d9582028e 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUserDatastore.kt @@ -348,12 +348,18 @@ class SupabaseUserDatastore( } /** - * Sends a password reset email to [email] via Supabase Auth. + * Sends a password reset notification via Supabase Auth. + * Email-based reset is supported. Phone-based reset is not yet implemented. */ - override suspend fun requestPasswordReset(email: String): Result = + override suspend fun requestPasswordReset(email: String?, phoneNumber: String?): Result = runSuspendCatching(TAG) { - logD(TAG, "Requesting password reset for email: %s", email) - auth.resetPasswordForEmail(email) + if (email != null) { + logD(TAG, "Requesting password reset for email: %s", email) + auth.resetPasswordForEmail(email) + } else { + // TODO: Implement phone-based password reset when Supabase phone auth is fully supported + throw NotImplementedError("Phone-based password reset is not yet supported") + } } companion object { diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt index d0d03f4eb..00f1beb50 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UserService.kt @@ -320,12 +320,15 @@ class UserService( } /** - * Requests a password reset email for the given [email]. - * Always returns success to prevent email enumeration. + * Requests a password reset for the given [email] or [phoneNumber]. At least one must be non-null. + * Always returns success to prevent enumeration attacks. */ - suspend fun requestPasswordReset(email: String): Result { + suspend fun requestPasswordReset(email: String?, phoneNumber: String?): Result { logD(TAG, "requestPasswordReset") - userDatastore.requestPasswordReset(email).onFailure { e -> + require(email != null || phoneNumber != null) { + "Either email or phone number is required for password reset" + } + userDatastore.requestPasswordReset(email, phoneNumber).onFailure { e -> logW(TAG, "Password reset request failed (suppressed)", e) } return Result.success(Unit) diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt index 5a22f3a74..3352bbd40 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UserServiceTest.kt @@ -681,14 +681,14 @@ class UserServiceTest { @Test fun `requestPasswordReset should return success when datastore succeeds`() = runTest { // Arrange - coEvery { userDatastore.requestPasswordReset(any()) } returns Result.success(Unit) + coEvery { userDatastore.requestPasswordReset(any(), any()) } returns Result.success(Unit) // Act - val result = userService.requestPasswordReset("user@example.com") + val result = userService.requestPasswordReset("user@example.com", null) // Assert assertTrue(result.isSuccess) - coVerify(exactly = 1) { userDatastore.requestPasswordReset("user@example.com") } + coVerify(exactly = 1) { userDatastore.requestPasswordReset("user@example.com", null) } } /** @@ -698,15 +698,15 @@ class UserServiceTest { @Test fun `requestPasswordReset should return success even when datastore fails`() = runTest { // Arrange - coEvery { userDatastore.requestPasswordReset(any()) } returns Result.failure( + coEvery { userDatastore.requestPasswordReset(any(), any()) } returns Result.failure( RuntimeException("Not found") ) // Act - val result = userService.requestPasswordReset("nonexistent@example.com") + val result = userService.requestPasswordReset("nonexistent@example.com", null) // Assert assertTrue(result.isSuccess) - coVerify(exactly = 1) { userDatastore.requestPasswordReset("nonexistent@example.com") } + coVerify(exactly = 1) { userDatastore.requestPasswordReset("nonexistent@example.com", null) } } } diff --git a/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt b/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt index 9fb284075..7ce8c36c8 100644 --- a/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt +++ b/edifikana/front-end/shared-app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/AuthServiceImpl.kt @@ -24,6 +24,7 @@ import com.cramsan.framework.logging.logE import com.cramsan.framework.logging.logW import com.cramsan.framework.networkapi.buildRequest import com.cramsan.framework.utils.exceptions.ClientRequestExceptions +import com.cramsan.framework.utils.exceptions.requireAtLeastOne import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.auth.OtpType import io.github.jan.supabase.auth.exception.AuthRestException @@ -174,9 +175,13 @@ class AuthServiceImpl( @OptIn(NetworkModel::class) override suspend fun passwordReset(email: String?, phoneNumber: String?): Result = runSuspendCatching(TAG) { - requireNotNull(email) { "Email is required for password reset" } + requireAtLeastOne( + "Either email or phone number is required for password reset", + email, + phoneNumber + ) UserApi.requestPasswordReset.buildRequest( - PasswordResetNetworkRequest(email = email) + PasswordResetNetworkRequest(email = email, phoneNumber = phoneNumber) ).execute(http) } diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt index deca7a351..6ed24012e 100644 --- a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/PasswordResetNetworkRequest.kt @@ -2,13 +2,16 @@ package com.cramsan.edifikana.lib.model.network import com.cramsan.framework.annotations.NetworkModel import com.cramsan.framework.annotations.api.RequestBody +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Network request model for requesting a password reset email. + * Network request model for requesting a password reset. Either [email] or [phoneNumber] must be provided. */ @NetworkModel @Serializable data class PasswordResetNetworkRequest( - val email: String, + val email: String?, + @SerialName("phone_number") + val phoneNumber: String?, ) : RequestBody From 070cbee8adba863be28411596daddde800fd3935 Mon Sep 17 00:00:00 2001 From: gaalici1990 Date: Wed, 25 Mar 2026 10:52:13 -0500 Subject: [PATCH 3/3] [EDIFIKANA] Addressing PR feedback for missing unit tests in user controller --- .../server/controller/UserControllerTest.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UserControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UserControllerTest.kt index ae5a8b94e..72348b239 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UserControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UserControllerTest.kt @@ -788,6 +788,98 @@ class UserControllerTest : CoroutineTest(), KoinTest { coVerify { userService.cancelInvite(inviteId) } } + /** + * Test that requestPasswordReset returns HTTP 200 when called with an email. + * The endpoint is unauthenticated and always returns 200 to prevent enumeration. + */ + @Test + fun `test requestPasswordReset returns 200 when called with email`() = testBackEndApplication { + // Arrange + val userService = get() + val contextRetriever = get>() + coEvery { + userService.requestPasswordReset("user@example.com", null) + }.answers { + Result.success(Unit) + } + coEvery { + contextRetriever.getContext(any()) + }.answers { + ClientContext.UnauthenticatedClientContext() + } + + // Act + val response = client.post("user/request-password-reset") { + setBody("""{"email":"user@example.com","phone_number":null}""") + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + coVerify(exactly = 1) { userService.requestPasswordReset("user@example.com", null) } + } + + /** + * Test that requestPasswordReset returns HTTP 200 when called with a phone number. + * The endpoint is unauthenticated and always returns 200 to prevent enumeration. + */ + @Test + fun `test requestPasswordReset returns 200 when called with phone number`() = testBackEndApplication { + // Arrange + val userService = get() + val contextRetriever = get>() + coEvery { + userService.requestPasswordReset(null, "5051352468") + }.answers { + Result.success(Unit) + } + coEvery { + contextRetriever.getContext(any()) + }.answers { + ClientContext.UnauthenticatedClientContext() + } + + // Act + val response = client.post("user/request-password-reset") { + setBody("""{"email":null,"phone_number":"5051352468"}""") + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + coVerify(exactly = 1) { userService.requestPasswordReset(null, "5051352468") } + } + + /** + * Test that requestPasswordReset returns HTTP 200 even when the service fails. + * This prevents enumeration attacks — the client must never learn whether the identifier exists. + */ + @Test + fun `test requestPasswordReset returns 200 even when service fails`() = testBackEndApplication { + // Arrange + val userService = get() + val contextRetriever = get>() + coEvery { + userService.requestPasswordReset("unknown@example.com", null) + }.answers { + Result.success(Unit) + } + coEvery { + contextRetriever.getContext(any()) + }.answers { + ClientContext.UnauthenticatedClientContext() + } + + // Act + val response = client.post("user/request-password-reset") { + setBody("""{"email":"unknown@example.com","phone_number":null}""") + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + } + /** * Test that cancelInvite fails when user does not have manager role. */