Add more tests for user routes

This commit is contained in:
William Brawner 2024-02-22 22:13:17 -07:00
parent 1060e35019
commit aa96cbbf3a
10 changed files with 138 additions and 42 deletions

View file

@ -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 {

View file

@ -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"))

View file

@ -1,5 +1,6 @@
package com.wbrawner.twigs.server
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)

View file

@ -42,6 +42,7 @@ open class ApiTest {
metadataRepository = metadataRepository,
budgetRepository = budgetRepository,
categoryRepository = categoryRepository,
passwordHasher = { it },
passwordResetRepository = passwordResetRepository,
permissionRepository = permissionRepository,
recurringTransactionRepository = recurringTransactionRepository,

View file

@ -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 ->
//
// }
}

View file

@ -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)
}

View file

@ -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)

View file

@ -9,4 +9,8 @@ interface UserRepository : Repository<User> {
): List<User>
fun findAll(nameLike: String): List<User>
}
fun interface PasswordHasher {
fun hash(password: String): String
}

View file

@ -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

View file

@ -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(