Add more tests for user routes
This commit is contained in:
parent
1060e35019
commit
aa96cbbf3a
10 changed files with 138 additions and 42 deletions
|
@ -3,10 +3,7 @@ package com.wbrawner.twigs
|
|||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.storage.PasswordResetRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
import com.wbrawner.twigs.storage.UserRepository
|
||||
import com.wbrawner.twigs.storage.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
|
@ -20,16 +17,17 @@ fun Application.userRoutes(
|
|||
passwordResetRepository: PasswordResetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
userRepository: UserRepository
|
||||
userRepository: UserRepository,
|
||||
passwordHasher: PasswordHasher
|
||||
) {
|
||||
routing {
|
||||
route("/api/users") {
|
||||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
val user =
|
||||
userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
userRepository.findAll(nameOrEmail = request.username, password = passwordHasher.hash(request.password))
|
||||
.firstOrNull()
|
||||
?: userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
?: userRepository.findAll(nameOrEmail = request.username, password = passwordHasher.hash(request.password))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
||||
|
@ -65,7 +63,7 @@ fun Application.userRoutes(
|
|||
userRepository.save(
|
||||
User(
|
||||
name = request.username,
|
||||
password = request.password.hash(),
|
||||
password = passwordHasher.hash(request.password),
|
||||
email = if (request.email.isNullOrBlank()) "" else request.email
|
||||
)
|
||||
).asResponse()
|
||||
|
@ -74,9 +72,12 @@ fun Application.userRoutes(
|
|||
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val query = call.request.queryParameters.getAll("query")
|
||||
if (query?.firstOrNull()?.isNotBlank() == true) {
|
||||
call.respond(userRepository.findAll(nameLike = query.first()).map { it.asResponse() })
|
||||
val query = call.request.queryParameters["query"]
|
||||
if (query != null) {
|
||||
if (query.isBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty")
|
||||
}
|
||||
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
|
||||
return@get
|
||||
}
|
||||
permissionRepository.findAll(
|
||||
|
@ -111,7 +112,7 @@ fun Application.userRoutes(
|
|||
.run {
|
||||
copy(
|
||||
name = request.username ?: name,
|
||||
password = request.password?.hash() ?: password,
|
||||
password = request.password?.let { passwordHasher.hash(it) } ?: password,
|
||||
email = request.email ?: email
|
||||
)
|
||||
}
|
||||
|
@ -143,7 +144,7 @@ fun Application.userRoutes(
|
|||
userRepository.findAll(nameOrEmail = request.username)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
val email = it.email ?: return@let
|
||||
val email = it.email
|
||||
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
||||
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
||||
}
|
||||
|
@ -167,7 +168,7 @@ fun Application.userRoutes(
|
|||
userRepository.findAll(listOf(passwordResetToken.userId))
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
userRepository.save(it.copy(password = request.password.hash()))
|
||||
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
|
||||
passwordResetRepository.delete(passwordResetToken)
|
||||
}
|
||||
?: run {
|
||||
|
|
|
@ -23,6 +23,7 @@ dependencies {
|
|||
implementation(libs.kotlin.reflect)
|
||||
implementation(libs.bundles.ktor.server)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.bcrypt)
|
||||
implementation(libs.logback)
|
||||
implementation(libs.mail)
|
||||
testImplementation(project(":testhelpers"))
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import ch.qos.logback.classic.Level
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.db.*
|
||||
|
@ -19,10 +20,7 @@ import io.ktor.server.plugins.contentnegotiation.*
|
|||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.sessions.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -46,10 +44,12 @@ fun Application.module() {
|
|||
"postgresql" -> {
|
||||
"jdbc:$dbType://$dbHost:$dbPort/$dbName?stringtype=unspecified"
|
||||
}
|
||||
|
||||
"sqlite" -> {
|
||||
Class.forName("org.sqlite.JDBC")
|
||||
"jdbc:$dbType:$dbName"
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw RuntimeException("Unsupported DB type: $dbType")
|
||||
}
|
||||
|
@ -60,6 +60,27 @@ fun Application.module() {
|
|||
username = dbUser
|
||||
password = dbPass
|
||||
}).also {
|
||||
val metadataRepository = JdbcMetadataRepository(it)
|
||||
val metadata = runBlocking {
|
||||
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
||||
var version = metadata.version
|
||||
while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) {
|
||||
metadataRepository.runMigration(version)
|
||||
metadataRepository.save(metadata.copy(version = version))
|
||||
}
|
||||
if (metadata.salt.isBlank()) {
|
||||
metadataRepository.save(
|
||||
metadata.copy(
|
||||
salt = environment.config
|
||||
.propertyOrNull("twigs.password.salt")
|
||||
?.getString()
|
||||
?: randomString(16)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
metadata
|
||||
}
|
||||
}
|
||||
moduleWithDependencies(
|
||||
emailService = SmtpEmailService(
|
||||
from = environment.config.propertyOrNull("twigs.smtp.from")?.getString(),
|
||||
|
@ -72,6 +93,9 @@ fun Application.module() {
|
|||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
passwordResetRepository = JdbcPasswordResetRepository(it),
|
||||
passwordHasher = { password ->
|
||||
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
|
||||
},
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
|
@ -87,6 +111,7 @@ fun Application.moduleWithDependencies(
|
|||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
passwordHasher: PasswordHasher,
|
||||
permissionRepository: PermissionRepository,
|
||||
recurringTransactionRepository: RecurringTransactionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
|
@ -171,25 +196,9 @@ fun Application.moduleWithDependencies(
|
|||
categoryRoutes(categoryRepository, permissionRepository)
|
||||
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
||||
transactionRoutes(transactionRepository, permissionRepository)
|
||||
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository)
|
||||
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher)
|
||||
webRoutes()
|
||||
launch {
|
||||
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
||||
var version = metadata.version
|
||||
while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) {
|
||||
metadataRepository.runMigration(version)
|
||||
metadataRepository.save(metadata.copy(version = version))
|
||||
}
|
||||
salt = metadata.salt.ifEmpty {
|
||||
metadataRepository.save(
|
||||
metadata.copy(
|
||||
salt = environment.config
|
||||
.propertyOrNull("twigs.password.salt")
|
||||
?.getString()
|
||||
?: randomString(16)
|
||||
)
|
||||
).salt
|
||||
}
|
||||
val jobs = listOf(
|
||||
SessionCleanupJob(sessionRepository),
|
||||
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
|
|
|
@ -42,6 +42,7 @@ open class ApiTest {
|
|||
metadataRepository = metadataRepository,
|
||||
budgetRepository = budgetRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
passwordHasher = { it },
|
||||
passwordResetRepository = passwordResetRepository,
|
||||
permissionRepository = permissionRepository,
|
||||
recurringTransactionRepository = recurringTransactionRepository,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.User
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
|
@ -218,4 +219,75 @@ class UserRouteTest : ApiTest() {
|
|||
val response = client.get("/api/users")
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get users with empty query returns 400`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val response = client.get("/api/users?query=") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
val error: ErrorResponse = response.body()
|
||||
assertEquals("query cannot be empty", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get users with valid query but no matches returns empty list`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val response = client.get("/api/users?query=something") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val userQueryResponse: List<UserResponse> = response.body()
|
||||
assertEquals(0, userQueryResponse.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get users with valid query and matches returns list`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val response = client.get("/api/users?query=user") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val userQueryResponse: List<UserResponse> = response.body()
|
||||
assertEquals(2, userQueryResponse.size)
|
||||
repeat(2) { i ->
|
||||
assertEquals(users[i].id, userQueryResponse[i].id, "User IDs at index $i don't match")
|
||||
assertEquals(users[i].name, userQueryResponse[i].username, "Usernames at index $i don't match")
|
||||
assertEquals(users[i].email, userQueryResponse[i].email, "User emails at index $i don't match")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get users with empty budgetId returns 400`() = apiTest { client ->
|
||||
|
||||
}
|
||||
|
||||
// @Test
|
||||
// fun ``() = apiTest { client ->
|
||||
//
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// fun ``() = apiTest { client ->
|
||||
//
|
||||
// }
|
||||
}
|
|
@ -6,7 +6,6 @@ plugins {
|
|||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(libs.ktor.server.auth)
|
||||
api(libs.bcrypt)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
@ -51,9 +50,6 @@ fun randomString(length: Int = 32): String {
|
|||
return id.toString()
|
||||
}
|
||||
|
||||
lateinit var salt: String
|
||||
fun String.hash(): String = String(BCrypt.withDefaults().hash(10, salt.toByteArray(), this.toByteArray()))
|
||||
|
||||
fun String.toInstant(): Instant = Instant.parse(this)
|
||||
|
||||
fun String.asFrequency(): Frequency = Frequency.parse(this)
|
||||
|
|
|
@ -10,3 +10,7 @@ interface UserRepository : Repository<User> {
|
|||
|
||||
fun findAll(nameLike: String): List<User>
|
||||
}
|
||||
|
||||
fun interface PasswordHasher {
|
||||
fun hash(password: String): String
|
||||
}
|
|
@ -4,7 +4,7 @@ import com.wbrawner.twigs.Identifiable
|
|||
import com.wbrawner.twigs.storage.Repository
|
||||
|
||||
abstract class FakeRepository<T : Identifiable> : Repository<T> {
|
||||
val entities = mutableListOf<T>()
|
||||
open val entities = mutableListOf<T>()
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<T> = if (ids == null) {
|
||||
entities
|
||||
|
|
|
@ -4,6 +4,19 @@ import com.wbrawner.twigs.model.User
|
|||
import com.wbrawner.twigs.storage.UserRepository
|
||||
|
||||
class FakeUserRepository : FakeRepository<User>(), UserRepository {
|
||||
override val entities: MutableList<User> = mutableListOf(
|
||||
User(
|
||||
name = "testuser",
|
||||
email = "test@example.com",
|
||||
password = "\$2a\$10\$bETxbFPja1PyXVLybETxb.CWBYzyYdZpmCcA7NSIN8dkdzidt1Xv2" // testpass
|
||||
),
|
||||
User(
|
||||
name = "otheruser",
|
||||
email = "other@example.com",
|
||||
password = "\$2a\$10\$bETxbFPja1PyXVLybETxb..rhfIeOkP4qil1Drj29LDUhBxVkm6fS"
|
||||
),
|
||||
)
|
||||
|
||||
override fun findAll(nameOrEmail: String, password: String?): List<User> {
|
||||
return entities.filter { user ->
|
||||
(user.name.equals(nameOrEmail, ignoreCase = true) || user.email.equals(
|
||||
|
|
Loading…
Reference in a new issue