commit
ff7b80a273
22 changed files with 691 additions and 39 deletions
25
.github/workflows/pull-request.yml
vendored
Normal file
25
.github/workflows/pull-request.yml
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
name: Pull request workflow
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
- name: Run checks with Gradle Wrapper
|
||||
run: ./gradlew check
|
||||
automerge:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'wbrawner' }}
|
||||
steps:
|
||||
- name: Enable auto-merge
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
|
@ -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>("test") {
|
||||
|
|
|
@ -15,9 +15,9 @@ data class BudgetRequest(
|
|||
@Serializable
|
||||
data class BudgetResponse(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
private val users: List<UserPermissionResponse>
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermissionResponse>
|
||||
) {
|
||||
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
|
||||
Objects.requireNonNull<String>(budget.id),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<String>): 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),
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
ktor {
|
||||
deployment {
|
||||
port = 8080
|
||||
port = ${?TWIGS_PORT}
|
||||
}
|
||||
application {
|
||||
modules = [ com.wbrawner.twigs.server.ApplicationKt.module ]
|
||||
port = ${?PORT}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
60
app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt
Normal file
60
app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<List<BudgetResponse>>().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<List<BudgetResponse>>()
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -32,6 +32,10 @@ class JdbcPermissionRepository(dataSource: DataSource) :
|
|||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
|
||||
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()),
|
||||
|
|
|
@ -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<DatabaseMetadata, MetadataRepository.Fields>(dataSource) {
|
||||
interface MetadataRepository : Repository<DatabaseMetadata> {
|
||||
fun runMigration(toVersion: Int)
|
||||
}
|
||||
|
||||
class JdbcMetadataRepository(dataSource: DataSource) :
|
||||
JdbcRepository<DatabaseMetadata, JdbcMetadataRepository.Fields>(dataSource), MetadataRepository {
|
||||
override val tableName: String = TABLE_METADATA
|
||||
override val fields: Map<Fields, (DatabaseMetadata) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf()
|
||||
|
||||
suspend fun runMigration(toVersion: Int) {
|
||||
override fun runMigration(toVersion: Int) {
|
||||
val queries = MetadataRepository::class.java
|
||||
.getResource("/sql/$toVersion.sql")
|
||||
?.readText()
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<FakeEmail<*>>()
|
||||
|
||||
override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) {
|
||||
emails.add(FakeEmail(to, token))
|
||||
}
|
||||
}
|
||||
|
||||
data class FakeEmail<Data>(val to: String, val data: Data)
|
|
@ -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<Budget>(), BudgetRepository
|
|
@ -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<Category>(), CategoryRepository {
|
||||
override fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>?,
|
||||
expense: Boolean?,
|
||||
archived: Boolean?
|
||||
): List<Category> = entities.filter {
|
||||
budgetIds.contains(it.budgetId)
|
||||
&& ids?.contains(it.id) ?: true
|
||||
&& it.expense == (expense ?: it.expense)
|
||||
&& it.archived == (archived ?: it.archived)
|
||||
}
|
||||
}
|
|
@ -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<DatabaseMetadata>, MetadataRepository {
|
||||
var metadata = DatabaseMetadata()
|
||||
override fun runMigration(toVersion: Int) {
|
||||
metadata = metadata.copy(version = toVersion)
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<DatabaseMetadata> = listOf(metadata)
|
||||
|
||||
override suspend fun delete(item: DatabaseMetadata): Boolean = false
|
||||
|
||||
override suspend fun save(item: DatabaseMetadata): DatabaseMetadata {
|
||||
metadata = item
|
||||
return metadata
|
||||
}
|
||||
}
|
|
@ -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<PasswordResetToken>(), PasswordResetRepository
|
|
@ -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<UserPermission> = mutableListOf()
|
||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||
permissions.filter { userPermission ->
|
||||
budgetIds?.contains(userPermission.budgetId) ?: true
|
||||
&& userId?.let { it == userPermission.userId } ?: true
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
|
||||
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 }
|
||||
}
|
|
@ -4,7 +4,7 @@ import com.wbrawner.twigs.model.RecurringTransaction
|
|||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import java.time.Instant
|
||||
|
||||
class FakeRecurringTransactionsRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository {
|
||||
class FakeRecurringTransactionRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository {
|
||||
override suspend fun findAll(now: Instant): List<RecurringTransaction> = entities.filter {
|
||||
(it.start == now || it.start.isBefore(now)) && it.finish?.isAfter(now) ?: true
|
||||
}
|
|
@ -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<Session>(), SessionRepository {
|
||||
var expirationPredicate: Predicate<in Session> = Predicate { false }
|
||||
|
||||
override fun findAll(token: String): List<Session> = entities.filter { it.token == token }
|
||||
|
||||
override fun deleteExpired() {
|
||||
entities.removeIf(expirationPredicate)
|
||||
}
|
||||
}
|
|
@ -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<User>(), UserRepository {
|
||||
override fun findAll(nameOrEmail: String, password: String?): List<User> {
|
||||
return entities.filter {
|
||||
(it.name.equals(nameOrEmail, ignoreCase = true) || it.email.equals(
|
||||
nameOrEmail,
|
||||
ignoreCase = true
|
||||
)) && it.password == password
|
||||
}
|
||||
}
|
||||
|
||||
override fun findAll(nameLike: String): List<User> {
|
||||
return entities.filter { it.name.contains(nameLike, ignoreCase = true) }
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue