From 4deccdbd0b2b525f6d34a6bf4c8bf2f8a4605878 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Tue, 26 Sep 2023 20:04:55 -0600 Subject: [PATCH] Add tests for /api/budget routes --- api/build.gradle.kts | 3 +- .../kotlin/com/wbrawner/twigs/BudgetApi.kt | 6 +- app/build.gradle.kts | 5 +- .../com/wbrawner/twigs/server/Application.kt | 13 +- app/src/main/resources/application.conf | 5 +- .../RecurringTransactionProcessingJobTest.kt | 34 +- .../com/wbrawner/twigs/server/api/ApiTest.kt | 60 +++ .../twigs/server/api/BudgetRouteTest.kt | 423 ++++++++++++++++++ .../twigs/db/JdbcPermissionRepository.kt | 4 + .../wbrawner/twigs/db/MetadataRepository.kt | 11 +- gradle/libs.versions.toml | 7 +- testhelpers/build.gradle.kts | 7 + .../twigs/test/helpers/FakeEmailService.kt | 14 + .../repository/FakeBudgetRepository.kt | 6 + .../repository/FakeCategoryRepository.kt | 18 + .../repository/FakeMetadataRepository.kt | 21 + .../repository/FakePasswordResetRepository.kt | 6 + .../repository/FakePermissionRepository.kt | 26 ++ ... => FakeRecurringTransactionRepository.kt} | 2 +- .../repository/FakeSessionRepository.kt | 15 + .../helpers/repository/FakeUserRepository.kt | 19 + 21 files changed, 666 insertions(+), 39 deletions(-) create mode 100644 app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt create mode 100644 app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/FakeEmailService.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeBudgetRepository.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeCategoryRepository.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeMetadataRepository.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePasswordResetRepository.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt rename testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/{FakeRecurringTransactionsRepository.kt => FakeRecurringTransactionRepository.kt} (81%) create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeSessionRepository.kt create mode 100644 testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index b60f2b6..186cfe8 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -11,8 +11,7 @@ dependencies { api(libs.ktor.server.core) api(libs.ktor.serialization) api(libs.kotlinx.coroutines.core) - testImplementation(libs.junit.jupiter.api) - testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(project(":testhelpers")) } tasks.getByName("test") { diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt index c25911a..1d83da9 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt @@ -15,9 +15,9 @@ data class BudgetRequest( @Serializable data class BudgetResponse( val id: String, - val name: String?, - val description: String?, - private val users: List + val name: String? = null, + val description: String? = null, + val users: List ) { constructor(budget: Budget, users: Iterable) : this( Objects.requireNonNull(budget.id), diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9f2852..e813f97 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,4 @@ import java.net.URI -import java.util.* plugins { java @@ -27,8 +26,8 @@ dependencies { implementation(libs.logback) implementation(libs.mail) testImplementation(project(":testhelpers")) - testImplementation(libs.junit.jupiter.api) - testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.ktor.client.content.negotiation) + testImplementation(libs.ktor.server.test) } description = "twigs-server" diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index edcdd38..f42b12e 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -1,4 +1,4 @@ -package com.wbrawner.twigs.server +package com.wbrawner.twigs.server import ch.qos.logback.classic.Level import com.wbrawner.twigs.* @@ -12,6 +12,8 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.auth.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* import io.ktor.server.plugins.callloging.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* @@ -23,10 +25,13 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory -import java.lang.RuntimeException import java.util.concurrent.TimeUnit -fun main(args: Array): Unit = io.ktor.server.cio.EngineMain.main(args) +fun main() { + embeddedServer(CIO, port = System.getenv("PORT")?.toIntOrNull() ?: 8080) { + module() + }.start(wait = true) +} private const val DATABASE_VERSION = 3 @@ -63,7 +68,7 @@ fun Application.module() { username = environment.config.propertyOrNull("twigs.smtp.user")?.getString(), password = environment.config.propertyOrNull("twigs.smtp.pass")?.getString(), ), - metadataRepository = MetadataRepository(it), + metadataRepository = JdbcMetadataRepository(it), budgetRepository = JdbcBudgetRepository(it), categoryRepository = JdbcCategoryRepository(it), passwordResetRepository = JdbcPasswordResetRepository(it), diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index 0044ede..d8a698b 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -1,10 +1,7 @@ ktor { deployment { port = 8080 - port = ${?TWIGS_PORT} - } - application { - modules = [ com.wbrawner.twigs.server.ApplicationKt.module ] + port = ${?PORT} } } diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt index 14936b3..08fb1e0 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt @@ -3,9 +3,9 @@ package com.wbrawner.twigs.server import com.wbrawner.twigs.model.* import com.wbrawner.twigs.storage.RecurringTransactionRepository import com.wbrawner.twigs.storage.TransactionRepository -import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionsRepository +import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionRepository import com.wbrawner.twigs.test.helpers.repository.FakeTransactionRepository -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -21,13 +21,13 @@ class RecurringTransactionProcessingJobTest { @BeforeEach fun setup() { - recurringTransactionRepository = FakeRecurringTransactionsRepository() + recurringTransactionRepository = FakeRecurringTransactionRepository() transactionRepository = FakeTransactionRepository() job = RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository) } @Test - fun `daily transactions are created every day`() = runBlockingTest { + fun `daily transactions are created every day`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -49,7 +49,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `daily transactions are only created once per day`() = runBlockingTest { + fun `daily transactions are only created once per day`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -71,7 +71,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `daily transactions are created every other day`() = runBlockingTest { + fun `daily transactions are created every other day`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -90,7 +90,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `weekly transactions are created every thursday`() = runBlockingTest { + fun `weekly transactions are created every thursday`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -113,7 +113,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `weekly transactions are created every third thursday`() = runBlockingTest { + fun `weekly transactions are created every third thursday`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -134,7 +134,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `monthly transactions are created every 1st of month`() = runBlockingTest { + fun `monthly transactions are created every 1st of month`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -157,7 +157,7 @@ class RecurringTransactionProcessingJobTest { @Test fun `monthly transactions are created every last day of month when greater than max days in month`() = - runBlockingTest { + runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -180,7 +180,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `monthly transactions are created every 6 months`() = runBlockingTest { + fun `monthly transactions are created every 6 months`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -201,7 +201,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `monthly transactions are created every 2nd tuesday`() = runBlockingTest { + fun `monthly transactions are created every 2nd tuesday`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -228,7 +228,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `monthly transactions are created every last friday`() = runBlockingTest { + fun `monthly transactions are created every last friday`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -255,7 +255,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `monthly transactions are created in the new year`() = runBlockingTest { + fun `monthly transactions are created in the new year`() = runTest { val start = Instant.parse("1971-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -280,7 +280,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `yearly transactions are created every march 31st`() = runBlockingTest { + fun `yearly transactions are created every march 31st`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -303,7 +303,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `yearly transactions are created every other march 31st`() = runBlockingTest { + fun `yearly transactions are created every other march 31st`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( @@ -324,7 +324,7 @@ class RecurringTransactionProcessingJobTest { } @Test - fun `yearly transactions are created every february 29th`() = runBlockingTest { + fun `yearly transactions are created every february 29th`() = runTest { val start = Instant.parse("1970-01-01T00:00:00Z") recurringTransactionRepository.save( RecurringTransaction( diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt new file mode 100644 index 0000000..ff929b0 --- /dev/null +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt @@ -0,0 +1,60 @@ +package com.wbrawner.twigs.server.api + +import com.wbrawner.twigs.server.moduleWithDependencies +import com.wbrawner.twigs.test.helpers.FakeEmailService +import com.wbrawner.twigs.test.helpers.repository.* +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.BeforeEach + +open class ApiTest { + lateinit var budgetRepository: FakeBudgetRepository + lateinit var categoryRepository: FakeCategoryRepository + lateinit var emailService: FakeEmailService + lateinit var metadataRepository: FakeMetadataRepository + lateinit var passwordResetRepository: FakePasswordResetRepository + lateinit var permissionRepository: FakePermissionRepository + lateinit var recurringTransactionRepository: FakeRecurringTransactionRepository + lateinit var sessionRepository: FakeSessionRepository + lateinit var transactionRepository: FakeTransactionRepository + lateinit var userRepository: FakeUserRepository + + @BeforeEach + fun setup() { + budgetRepository = FakeBudgetRepository() + categoryRepository = FakeCategoryRepository() + emailService = FakeEmailService() + metadataRepository = FakeMetadataRepository() + passwordResetRepository = FakePasswordResetRepository() + permissionRepository = FakePermissionRepository() + recurringTransactionRepository = FakeRecurringTransactionRepository() + sessionRepository = FakeSessionRepository() + transactionRepository = FakeTransactionRepository() + userRepository = FakeUserRepository() + } + + fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication { + application { + moduleWithDependencies( + emailService = emailService, + metadataRepository = metadataRepository, + budgetRepository = budgetRepository, + categoryRepository = categoryRepository, + passwordResetRepository = passwordResetRepository, + permissionRepository = permissionRepository, + recurringTransactionRepository = recurringTransactionRepository, + sessionRepository = sessionRepository, + transactionRepository = transactionRepository, + userRepository = userRepository + ) + } + val client = createClient { + install(ContentNegotiation) { + json() + } + } + test(client) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt new file mode 100644 index 0000000..c7f1837 --- /dev/null +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt @@ -0,0 +1,423 @@ +package com.wbrawner.twigs.server.api + +import com.wbrawner.twigs.BudgetRequest +import com.wbrawner.twigs.BudgetResponse +import com.wbrawner.twigs.UserPermissionRequest +import com.wbrawner.twigs.UserPermissionResponse +import com.wbrawner.twigs.model.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +class BudgetRouteTest : ApiTest() { + + @Test + fun `fetching budgets requires authentication`() = apiTest { client -> + val response = client.get("/api/budgets") + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `fetching budgets returns empty list when there are no budgets`() = apiTest { client -> + val session = Session() + sessionRepository.save(session) + val response = client.get("/api/budgets") { + header("Authorization", "Bearer ${session.token}") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(0, response.body>().size) + } + + @Test + fun `fetching budgets only returns budgets for current user`() = 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 currentUserBudget = budgetRepository.save(Budget(name = "Test User's Budget")) + val otherUserBudget = budgetRepository.save(Budget(name = "Other User's Budget")) + permissionRepository.save( + UserPermission( + budgetId = currentUserBudget.id, + userId = users[0].id, + Permission.OWNER + ) + ) + permissionRepository.save(UserPermission(budgetId = otherUserBudget.id, userId = users[1].id, Permission.OWNER)) + val response = client.get("/api/budgets") { + header("Authorization", "Bearer ${session.token}") + } + val returnedBudgets = response.body>() + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(1, returnedBudgets.size) + assertEquals(currentUserBudget.id, returnedBudgets.first().id) + } + + @Test + fun `creating budgets requires authentication`() = apiTest { client -> + val request = BudgetRequest("Test Budget", "A budget for testing") + val response = client.post("/api/budgets") { + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `newly created budgets are saved`() = 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 permissions = setOf( + UserPermissionRequest(users[0].id, Permission.OWNER), + UserPermissionRequest(users[1].id, Permission.READ), + ) + val request = BudgetRequest("Test Budget", "A budget for testing", permissions) + val response = client.post("/api/budgets") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.OK, response.status) + val responseBody: BudgetResponse = response.body() + assert(responseBody.id.isNotEmpty()) + assertEquals("Test Budget", responseBody.name) + assertEquals("A budget for testing", responseBody.description) + assertEquals(2, responseBody.users.size) + assert(responseBody.users.containsAll(permissions.map { UserPermissionResponse(it.user, it.permission) })) + } + + @Test + fun `newly created budgets include current user as owner if omitted`() = 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 permissions = setOf( + UserPermissionRequest(users[1].id, Permission.OWNER), + ) + val request = BudgetRequest("Test Budget", "A budget for testing", permissions) + val response = client.post("/api/budgets") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.OK, response.status) + val responseBody: BudgetResponse = response.body() + assert(responseBody.id.isNotEmpty()) + assertEquals("Test Budget", responseBody.name) + assertEquals("A budget for testing", responseBody.description) + assertEquals(2, responseBody.users.size) + val expectedPermissions = listOf( + UserPermissionResponse(users[0].id, Permission.OWNER), + UserPermissionResponse(users[1].id, Permission.OWNER), + ) + assert(responseBody.users.containsAll(expectedPermissions)) + } + + @Test + fun `updating budgets requires authentication`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val request = BudgetRequest("Update Budget", "A budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `updating budgets returns forbidden for users with read only access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val request = BudgetRequest("Update Budget", "A budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `updating budgets returns forbidden for users with write only access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val request = BudgetRequest("Update Budget", "A budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `updating budgets returns success for users with manage access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val request = BudgetRequest("Update Budget", "An update budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.OK, response.status) + val updatedBudget: BudgetResponse = response.body() + assertEquals(request.name, updatedBudget.name) + assertEquals(request.description, updatedBudget.description) + val expectedUsers = permissions.map { UserPermissionResponse(it.userId, it.permission) } + val updatedUsers = updatedBudget.users + assertEquals(expectedUsers, updatedUsers) + } + + @Disabled("Will be fixed with service layer refactor") + @Test + fun `updating budgets returns not found for users with no access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val request = BudgetRequest("Update Budget", "An update budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Disabled("Will be fixed with service layer refactor") + @Test + fun `updating non-existent budgets returns not found`() = 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 request = BudgetRequest("Update Budget", "An update budget for testing") + val response = client.put("/api/budgets/random-budget-id") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Disabled("Will be fixed with service layer refactor") + @Test + fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() = + apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = + budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val request = BudgetRequest( + "Update Budget", + "An update budget for testing", + setOf( + UserPermissionRequest(users[0].id, Permission.OWNER), + UserPermissionRequest(users[0].id, Permission.MANAGE), + ) + ) + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `deleting budgets requires authentication`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val request = BudgetRequest("Update Budget", "A budget for testing") + val response = client.put("/api/budgets/${existingBudget.id}") { + header("Content-Type", "application/json") + setBody(request) + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `deleting budgets returns forbidden for users with read only access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val response = client.delete("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `deleting budgets returns forbidden for users with write only access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val response = client.delete("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `deleting budgets returns forbidden for users with manage access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val response = client.delete("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `deleting budgets returns success for users with owner access`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing")) + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val permissions = setOf( + UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.OWNER), + UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER), + ) + permissions.forEach { + permissionRepository.save(it) + } + val response = client.delete("/api/budgets/${existingBudget.id}") { + header("Authorization", "Bearer ${session.token}") + header("Content-Type", "application/json") + } + assertEquals(HttpStatusCode.NoContent, response.status) + } +} \ No newline at end of file diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt index 5e38581..9a8832f 100644 --- a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt @@ -32,6 +32,10 @@ class JdbcPermissionRepository(dataSource: DataSource) : conn.executeQuery(sql.toString(), params) } + override suspend fun findAll(ids: List?): List { + throw UnsupportedOperationException("UserPermission requires a userId and budgetId") + } + override fun ResultSet.toEntity(): UserPermission = UserPermission( budgetId = getString(Fields.BUDGET_ID.name.lowercase()), userId = getString(Fields.USER_ID.name.lowercase()), diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt index d256ca0..d9960b9 100644 --- a/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt @@ -1,16 +1,21 @@ package com.wbrawner.twigs.db +import com.wbrawner.twigs.storage.Repository import java.sql.ResultSet import java.sql.SQLException import javax.sql.DataSource -class MetadataRepository(dataSource: DataSource) : - JdbcRepository(dataSource) { +interface MetadataRepository : Repository { + fun runMigration(toVersion: Int) +} + +class JdbcMetadataRepository(dataSource: DataSource) : + JdbcRepository(dataSource), MetadataRepository { override val tableName: String = TABLE_METADATA override val fields: Map Any?> = Fields.values().associateWith { it.entityField } override val conflictFields: Collection = listOf() - suspend fun runMigration(toVersion: Int) { + override fun runMigration(toVersion: Int) { val queries = MetadataRepository::class.java .getResource("/sql/$toVersion.sql") ?.readText() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adaa675..ec794b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,9 +2,9 @@ bcrypt = "0.9.0" hikari = "5.0.1" junit = "5.8.2" -kotlin = "1.6.21" +kotlin = "1.9.10" kotlinx-coroutines = "1.6.2" -ktor = "2.0.2" +ktor = "2.3.4" logback = "1.2.11" mail = "1.6.2" postgres = "42.3.8" @@ -18,8 +18,10 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } @@ -28,6 +30,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" } +ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } diff --git a/testhelpers/build.gradle.kts b/testhelpers/build.gradle.kts index c198366..34a330b 100644 --- a/testhelpers/build.gradle.kts +++ b/testhelpers/build.gradle.kts @@ -8,5 +8,12 @@ dependencies { implementation(project(":storage")) api(libs.kotlinx.coroutines.test) api(libs.junit.jupiter.api) + implementation(project(mapOf("path" to ":db"))) runtimeOnly(libs.junit.jupiter.engine) } + +tasks { + test { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/FakeEmailService.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/FakeEmailService.kt new file mode 100644 index 0000000..a66d396 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/FakeEmailService.kt @@ -0,0 +1,14 @@ +package com.wbrawner.twigs.test.helpers + +import com.wbrawner.twigs.EmailService +import com.wbrawner.twigs.model.PasswordResetToken + +class FakeEmailService : EmailService { + val emails = mutableListOf>() + + override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) { + emails.add(FakeEmail(to, token)) + } +} + +data class FakeEmail(val to: String, val data: Data) \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeBudgetRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeBudgetRepository.kt new file mode 100644 index 0000000..6b45f37 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeBudgetRepository.kt @@ -0,0 +1,6 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.Budget +import com.wbrawner.twigs.storage.BudgetRepository + +class FakeBudgetRepository : FakeRepository(), BudgetRepository \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeCategoryRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeCategoryRepository.kt new file mode 100644 index 0000000..1fc1e84 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeCategoryRepository.kt @@ -0,0 +1,18 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.Category +import com.wbrawner.twigs.storage.CategoryRepository + +class FakeCategoryRepository : FakeRepository(), CategoryRepository { + override fun findAll( + budgetIds: List, + ids: List?, + expense: Boolean?, + archived: Boolean? + ): List = entities.filter { + budgetIds.contains(it.budgetId) + && ids?.contains(it.id) ?: true + && it.expense == (expense ?: it.expense) + && it.archived == (archived ?: it.archived) + } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeMetadataRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeMetadataRepository.kt new file mode 100644 index 0000000..f1d4ca1 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeMetadataRepository.kt @@ -0,0 +1,21 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.db.DatabaseMetadata +import com.wbrawner.twigs.db.MetadataRepository +import com.wbrawner.twigs.storage.Repository + +class FakeMetadataRepository : Repository, MetadataRepository { + var metadata = DatabaseMetadata() + override fun runMigration(toVersion: Int) { + metadata = metadata.copy(version = toVersion) + } + + override suspend fun findAll(ids: List?): List = listOf(metadata) + + override suspend fun delete(item: DatabaseMetadata): Boolean = false + + override suspend fun save(item: DatabaseMetadata): DatabaseMetadata { + metadata = item + return metadata + } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePasswordResetRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePasswordResetRepository.kt new file mode 100644 index 0000000..aa0653f --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePasswordResetRepository.kt @@ -0,0 +1,6 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.PasswordResetToken +import com.wbrawner.twigs.storage.PasswordResetRepository + +class FakePasswordResetRepository : FakeRepository(), PasswordResetRepository \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt new file mode 100644 index 0000000..4b44d31 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt @@ -0,0 +1,26 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.UserPermission +import com.wbrawner.twigs.storage.PermissionRepository + +class FakePermissionRepository : PermissionRepository { + val permissions: MutableList = mutableListOf() + override fun findAll(budgetIds: List?, userId: String?): List = + permissions.filter { userPermission -> + budgetIds?.contains(userPermission.budgetId) ?: true + && userId?.let { it == userPermission.userId } ?: true + } + + override suspend fun findAll(ids: List?): List { + throw UnsupportedOperationException("UserPermission requires a userId and budgetId") + } + + override suspend fun save(item: UserPermission): UserPermission { + permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId } + permissions.add(item) + return item + } + + override suspend fun delete(item: UserPermission): Boolean = + permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionRepository.kt similarity index 81% rename from testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt rename to testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionRepository.kt index 0d6468d..70fc7d4 100644 --- a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionRepository.kt @@ -4,7 +4,7 @@ import com.wbrawner.twigs.model.RecurringTransaction import com.wbrawner.twigs.storage.RecurringTransactionRepository import java.time.Instant -class FakeRecurringTransactionsRepository : FakeRepository(), RecurringTransactionRepository { +class FakeRecurringTransactionRepository : FakeRepository(), RecurringTransactionRepository { override suspend fun findAll(now: Instant): List = entities.filter { (it.start == now || it.start.isBefore(now)) && it.finish?.isAfter(now) ?: true } diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeSessionRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeSessionRepository.kt new file mode 100644 index 0000000..73288e7 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeSessionRepository.kt @@ -0,0 +1,15 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.Session +import com.wbrawner.twigs.storage.SessionRepository +import java.util.function.Predicate + +class FakeSessionRepository : FakeRepository(), SessionRepository { + var expirationPredicate: Predicate = Predicate { false } + + override fun findAll(token: String): List = entities.filter { it.token == token } + + override fun deleteExpired() { + entities.removeIf(expirationPredicate) + } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt new file mode 100644 index 0000000..23e7ec8 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt @@ -0,0 +1,19 @@ +package com.wbrawner.twigs.test.helpers.repository + +import com.wbrawner.twigs.model.User +import com.wbrawner.twigs.storage.UserRepository + +class FakeUserRepository : FakeRepository(), UserRepository { + override fun findAll(nameOrEmail: String, password: String?): List { + return entities.filter { + (it.name.equals(nameOrEmail, ignoreCase = true) || it.email.equals( + nameOrEmail, + ignoreCase = true + )) && it.password == password + } + } + + override fun findAll(nameLike: String): List { + return entities.filter { it.name.contains(nameLike, ignoreCase = true) } + } +} \ No newline at end of file