Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,46 @@ class SupabaseUserDatastoreIntegrationTest : SupabaseIntegrationTest() {
assertInstanceOf<ClientRequestExceptions.NotFoundException>(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, null)

// 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, null)

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -304,6 +305,16 @@ class UserController(
return NoResponseBody
}

/**
* 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, request.phoneNumber)
return NoResponseBody
}

/**
* Registers the routes for the user controller. The [route] parameter is the root path for the controller.
*/
Expand Down Expand Up @@ -346,6 +357,9 @@ class UserController(
unauthenticatedHandler(api.checkUserExists, contextRetriever) { request ->
checkUserIsRegistered(request.queryParam.email)
}
unauthenticatedHandler(api.requestPasswordReset, contextRetriever) { request ->
requestPasswordReset(request.requestBody)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,10 @@ interface UserDatastore {
suspend fun purgeUser(
id: UserId,
): Result<Boolean>

/**
* 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?, phoneNumber: String?): Result<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@ class SupabaseUserDatastore(
private val adminApi: AdminApi,
private val postgrest: Postgrest,
private val clock: Clock,
private val auth: Auth,
) : UserDatastore {

/**
Expand Down Expand Up @@ -345,6 +347,21 @@ class SupabaseUserDatastore(
true
}

/**
* 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?, phoneNumber: String?): Result<Unit> =
runSuspendCatching(TAG) {
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 {
const val TAG = "SupabaseUserDatastore"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,21 @@ class UserService(
logD(TAG, "Invite $inviteId cancelled for organization ${invite.organizationId}")
}

/**
* 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?, phoneNumber: String?): Result<Unit> {
logD(TAG, "requestPasswordReset")
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)
}

companion object {
private const val TAG = "UserService"
private const val INVITE_CODE_LENGTH = 12
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserService>()
val contextRetriever = get<ContextRetriever<SupabaseContextPayload>>()
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<UserService>()
val contextRetriever = get<ContextRetriever<SupabaseContextPayload>>()
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<UserService>()
val contextRetriever = get<ContextRetriever<SupabaseContextPayload>>()
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(), any()) } returns Result.success(Unit)

// Act
val result = userService.requestPasswordReset("user@example.com", null)

// Assert
assertTrue(result.isSuccess)
coVerify(exactly = 1) { userDatastore.requestPasswordReset("user@example.com", null) }
}

/**
* 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(), any()) } returns Result.failure(
RuntimeException("Not found")
)

// Act
val result = userService.requestPasswordReset("nonexistent@example.com", null)

// Assert
assertTrue(result.isSuccess)
coVerify(exactly = 1) { userDatastore.requestPasswordReset("nonexistent@example.com", null) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,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
Expand Down Expand Up @@ -171,8 +173,16 @@ class AuthServiceImpl(
getUser().getOrThrow()
}

@OptIn(NetworkModel::class)
override suspend fun passwordReset(email: String?, phoneNumber: String?): Result<Unit> = runSuspendCatching(TAG) {
TODO("Implement functionality to reset password and authenticate user.")
requireAtLeastOne(
"Either email or phone number is required for password reset",
email,
phoneNumber
)
UserApi.requestPasswordReset.buildRequest(
PasswordResetNetworkRequest(email = email, phoneNumber = phoneNumber)
).execute(http)
}

override suspend fun verifyPermissions(): Result<Boolean> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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. Either [email] or [phoneNumber] must be provided.
*/
@NetworkModel
@Serializable
data class PasswordResetNetworkRequest(
val email: String?,
@SerialName("phone_number")
val phoneNumber: String?,
) : RequestBody
Loading