55 Commits

Author SHA1 Message Date
32f62ec5ab Add validation to notification 2021-04-14 23:53:19 +02:00
39c665b7a9 Add Test for Notification routes
Add @JsonSubTypes on Notification
return all creator on request find_follows_article_by_target
Add testNotifications task
2021-04-14 23:51:44 +02:00
50b4cf1816 Add testFollow task 2021-04-12 01:03:57 +02:00
6a5e00bb4d Add validation on Constitution routes 2021-04-11 00:54:09 +02:00
0c8bcbd634 Add limit on content field (comment request) 2021-04-11 00:54:08 +02:00
8223dd21bb Add validation on route CreateComments & EditComment
rename POST /comments/{comment}/children
method edit and create comment of repository return edited/created comment
2021-04-10 01:16:09 +02:00
27e405c585 Move tests 2021-04-09 18:43:59 +02:00
34513e25b6 Add validation on route CreateConstitutionComment & GetConstitutionCommentRequest 2021-04-09 18:39:03 +02:00
f5c1aa29e8 Add validation on route GetArticleComments 2021-04-09 18:06:32 +02:00
875d0bfffa Add test 404 for GetArticle route 2021-04-09 16:43:46 +02:00
fb7b07340a Improve test of password validation 2021-04-09 01:09:09 +02:00
a07b19a3cb Add validation on route CreateCommentArticle 2021-04-09 00:58:35 +02:00
13cdaaf01a Add validation on route FindCitizens 2021-04-08 22:25:43 +02:00
e473e62068 remove CodeFactor & Codacy 2021-04-08 18:08:48 +02:00
9d3eeeb04b Add validation on route ChangePasswordCitizenRequest 2021-04-08 18:02:27 +02:00
eb399392c9 remove parallel run for tests 2021-04-08 03:03:04 +02:00
1ec1c59c8c remove useless log 2021-04-08 03:02:03 +02:00
9511331cd2 Add validation on route Register 2021-04-08 02:10:45 +02:00
33a8cdb169 Add email validation 2021-04-08 02:02:46 +02:00
6aa3ddb28d Add Password validation 2021-04-08 01:55:10 +02:00
708d241a26 Add tags on tests 2021-04-07 20:53:51 +02:00
e4745e71c2 Add validation on route UpsertArticle 2021-04-07 20:53:21 +02:00
e26710898e add example on openapi 400 error 2021-04-06 23:35:36 +02:00
fe11384ad2 Add validation on route GetOneArticle 2021-04-06 23:04:02 +02:00
61a7091736 Add validation on route Article versions 2021-04-06 00:36:08 +02:00
2ef9f65f2c Clean BadRequest response 2021-04-05 01:40:12 +02:00
b5fc3d25bb Improve Article validation & test on BadRequest 2021-04-05 00:50:02 +02:00
3faf2e5f0d Add function to respond on BadRequest 2021-04-05 00:48:58 +02:00
ab418ae300 Add openapi response of error 400 2021-04-05 00:48:11 +02:00
395d64a44a create testArticle gradle task 2021-04-04 21:02:02 +02:00
a300e275d4 Valid FindArticles request with Konform 2021-04-04 21:02:02 +02:00
3a18ef0554 Improve articles request test 2021-04-04 21:01:57 +02:00
921a545877 Merge pull request #92 from flecomte/sonarq
Sonarcloud
2021-04-04 21:01:27 +02:00
ef942b956e Use sonarcloud 2021-04-04 01:35:02 +02:00
ff74ad7e47 Merge pull request #90 from flecomte/improve-test
Improve tests
2021-04-03 00:39:16 +02:00
2bb90ced03 Refactor 'the response should contain list' 2021-04-03 00:31:24 +02:00
a48cd52652 Add Tags on tests 2021-04-03 00:10:01 +02:00
dd4c2dadab Fix parameters schema validation 2021-04-02 23:47:20 +02:00
c81b63aef2 Merge pull request #89 from flecomte/ArticleViewManager
ArticleViewRepository
2021-04-02 12:39:34 +02:00
cb762a446a Move ArticleViewRepository 2021-04-02 12:29:50 +02:00
db810ab0c6 Rename ArticleViewManager to ArticleViewRepository 2021-04-02 12:29:11 +02:00
01c5b78325 Merge pull request #87 from flecomte/jwt-token-into-env
move JWT secret into ENV
2021-03-31 18:23:13 +02:00
1bc7293660 move JWT secret into ENV 2021-03-31 17:58:47 +02:00
55c890aca5 Merge pull request #86
move "Check auth on all routes" extension into the class
2021-03-31 12:31:51 +02:00
c0e364637a move "Check auth on all routes" extension into the class 2021-03-31 12:30:37 +02:00
0a1ed9ba82 Merge pull request #85 from flecomte/optimise-testsql
Opimize testSql
2021-03-31 03:09:12 +02:00
620085fda8 Optimise gradle task TestSql 2021-03-31 02:58:37 +02:00
3b5c1cf68a Merge pull request #84 from flecomte/69
Error codes
2021-03-31 02:53:58 +02:00
a0d07e88a1 Fix all security routes 2021-03-31 02:43:43 +02:00
f17277c0e9 Test all security of routes #76 2021-03-31 02:35:59 +02:00
9f13213a35 Fix error text when openapi definition was not found 2021-03-27 21:57:53 +01:00
5f0b8de159 #69 Format HTTP error
add 403 for /articles route
2021-03-26 01:53:41 +01:00
6b66130ddc #69 Move HttpStatusPage catch 2021-03-25 23:40:05 +01:00
7f93ec5044 Merge pull request #82 from flecomte/lint
Optimize CI
2021-03-25 02:07:52 +01:00
7e16c7bb74 Merge pull request #81
Lint
2021-03-24 19:49:34 +01:00
121 changed files with 2084 additions and 476 deletions

View File

@@ -101,6 +101,17 @@ jobs:
arguments: coveralls
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
- name: Cache SonarCloud packages
uses: actions/cache@v1
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew build sonarqube --info
lint:
needs: build

View File

@@ -1,6 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Sonarqube" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="SONAR_TOKEN" value="15ad34f46763706727d884ced12c48d5222fe639" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />

View File

@@ -1,10 +1,12 @@
# DC Project
[![CodeFactor](https://www.codefactor.io/repository/github/flecomte/dc-project/badge?s=869dc426625a253a07bea95f9380e23fdb048b94)](https://www.codefactor.io/repository/github/flecomte/dc-project)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dc-project&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=dc-project)
[![Tests](https://github.com/flecomte/dc-project/actions/workflows/tests.yml/badge.svg)](https://github.com/flecomte/dc-project/actions/workflows/tests.yml)
[![Coverage Status](https://coveralls.io/repos/github/flecomte/dc-project/badge.svg?branch=master)](https://coveralls.io/github/flecomte/dc-project?branch=master)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0ec4fe63370148ca956974f90f8d55be)](https://www.codacy.com/gh/flecomte/dc-project/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=flecomte/dc-project&amp;utm_campaign=Badge_Grade)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=dc-project&metric=coverage)](https://sonarcloud.io/dashboard?id=dc-project)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dc-project&metric=ncloc)](https://sonarcloud.io/dashboard?id=dc-project)
[Installation](./doc/installation/Installation.md)

View File

@@ -121,8 +121,8 @@ val testSql by tasks.registering {
group = "verification"
dependsOn(tasks.named("processResources"))
dependsOn(tasks.named("processTestResources"))
dependsOn(tasks.named("testComposeUp"))
finalizedBy(tasks.named("testComposeDown"))
dependsOn(tasks.named("testSqlComposeUp"))
finalizedBy(tasks.named("testSqlComposeDown"))
doLast {
val config = ConfigFactory.parseFile(file("$buildDir/resources/test/application-test.conf")).resolve()
@@ -182,7 +182,11 @@ tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("${archiveBaseName.get()}-latest-all.${archiveExtension.get()}")
}
tasks.sonarqube.configure { dependsOn(tasks.jacocoTestReport) }
tasks.sonarqube.configure {
dependsOn(tasks.test)
dependsOn(tasks.detekt)
dependsOn(tasks.jacocoTestReport)
}
val sourcesJar by tasks.registering(Jar::class) {
group = "build"
@@ -193,7 +197,7 @@ val sourcesJar by tasks.registering(Jar::class) {
tasks.test {
useJUnit()
useJUnitPlatform()
systemProperty("junit.jupiter.execution.parallel.enabled", true)
// systemProperty("junit.jupiter.execution.parallel.enabled", true)
dependsOn(testSql)
finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run
}
@@ -218,23 +222,22 @@ dockerCompose {
removeVolumes = false
removeContainers = false
isRequiredBy(project.tasks.run)
createNested("testSql").apply {
projectName = "dc-project_test"
useComposeFiles = listOf("docker-compose-test.yml")
startedServices = listOf("db", "elasticsearch")
stopContainers = false
isRequiredBy(project.tasks.named("testSql"))
}
createNested("test").apply {
projectName = "dc-project_test"
useComposeFiles = listOf("docker-compose-test.yml")
stopContainers = false
isRequiredBy(project.tasks.test)
isRequiredBy(project.tasks.named("testSql"))
}
createNested("sonarqube").apply {
projectName = "dc-project"
useComposeFiles = listOf("docker-compose-sonar.yml")
stopContainers = false
removeVolumes = false
removeContainers = false
// isRequiredBy(project.tasks.sonarqube)
}
}
tasks.sonarqube.configure { dependsOn(tasks.named("sonarqubeComposeUp")) }
publishing {
if (versioning.info.dirty == false) {
@@ -317,6 +320,43 @@ tasks.named("testComposeUp").configure {
}
}
tasks.register("testArticles", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("article")
}
}
tasks.register("testCitizens", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("citizen")
}
}
tasks.register("testComments", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("comment")
}
}
tasks.register("testConstitutions", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("constitution")
}
}
tasks.register("testFollows", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("follow")
}
}
tasks.register("testNotifications", Test::class) {
group = "tests"
useJUnitPlatform {
includeTags("notification")
}
}
dependencyCheck {
formats = listOf(ReportGenerator.Format.HTML, ReportGenerator.Format.XML)
}
@@ -324,8 +364,9 @@ dependencyCheck {
repositories {
mavenLocal()
jcenter()
maven { url = uri("https://kotlin.bintray.com/ktor") }
maven { url = uri("https://jitpack.io") }
maven("https://kotlin.bintray.com/ktor")
maven("https://jitpack.io")
maven("https://dl.bintray.com/konform-kt/konform")
}
dependencies {
@@ -356,6 +397,7 @@ dependencies {
implementation("org.elasticsearch.client:elasticsearch-rest-client:6.7.1")
implementation("com.jayway.jsonpath:json-path:2.5.0")
implementation("com.avast.gradle:gradle-docker-compose-plugin:0.14.0")
implementation("io.konform:konform-jvm:0.2.0")
testImplementation("io.ktor:ktor-server-tests:$ktorVersion")
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")

View File

@@ -1,48 +0,0 @@
version: '3.8'
services:
sonarqube:
container_name: ${APP_NAME}_sonarqube
image: sonarqube:community
depends_on:
- sonarqube_db
ports:
- ${SONARQUBE_PORT}:9000
networks:
- sonarnet
environment:
SONAR_JDBC_URL: jdbc:postgresql://sonarqube_db:5432/sonar
SONAR_JDBC_USERNAME: sonar
SONAR_JDBC_PASSWORD: sonar
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_extensions:/opt/sonarqube/extensions
- sonarqube_logs:/opt/sonarqube/logs
- sonarqube_temp:/opt/sonarqube/temp
sonarqube_db:
container_name: ${APP_NAME}_sonarqube_db
image: postgres:alpine
networks:
- sonarnet
environment:
POSTGRES_USER: sonar
POSTGRES_PASSWORD: sonar
ports:
- ${SONARQUBE_DB_PORT}:5432
volumes:
- sonarqube_postgresql:/var/lib/postgresql
# This needs explicit mapping due to https://github.com/docker-library/postgres/blob/4e48e3228a30763913ece952c611e5e9b95c8759/Dockerfile.template#L52
- sonarqube_postgresql_data:/var/lib/postgresql/data
networks:
sonarnet:
driver: bridge
volumes:
sonarqube_data:
sonarqube_extensions:
sonarqube_logs:
sonarqube_temp:
sonarqube_postgresql:
sonarqube_postgresql_data:

View File

@@ -38,6 +38,9 @@ services:
REDIS_CONNECTION: ${REDIS_CONNECTION}
RABBITMQ_CONNECTION: ${RABBITMQ_CONNECTION}
ELASTICSEARCH_CONNECTION: ${ELASTICSEARCH_CONNECTION}
JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER}
JWT_VALIDITY: ${JWT_VALIDITY}
depends_on:
- elasticsearch
- db

View File

@@ -1,9 +1,7 @@
kotlin.code.style=official
systemProp.sonar.host.url=http://localhost:9002
systemProp.sonar.login=admin
systemProp.sonar.password=sonar
systemProp.sonar.host.url=https://sonarcloud.io
systemProp.sonar.projectKey=dc-project
systemProp.sonar.projectName=DC Project
systemProp.sonar.organization=flecomte
systemProp.sonar.java.coveragePlugin=jacoco
systemProp.sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml
systemProp.sonar.kotlin.detekt.reportPaths=build/reports/detekt/detekt.xml

View File

@@ -6,17 +6,14 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.joda.JodaModule
import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
import fr.dcproject.application.Env.PROD
import fr.dcproject.application.Env.TEST
import fr.dcproject.common.security.AccessDeniedException
import fr.dcproject.application.http.statusPagesInstallation
import fr.dcproject.component.article.articleKoinModule
import fr.dcproject.component.article.routes.installArticleRoutes
import fr.dcproject.component.auth.ForbiddenException
import fr.dcproject.component.auth.authKoinModule
import fr.dcproject.component.auth.jwt.jwtInstallation
import fr.dcproject.component.auth.routes.installAuthRoutes
import fr.dcproject.component.auth.user
import fr.dcproject.component.citizen.citizenKoinModule
import fr.dcproject.component.citizen.routes.installCitizenRoutes
import fr.dcproject.component.comment.article.routes.installCommentArticleRoutes
@@ -41,7 +38,6 @@ import fr.dcproject.component.workgroup.workgroupKoinModule
import fr.postgresjson.migration.Migrations
import io.ktor.application.Application
import io.ktor.application.ApplicationStopped
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.auth.Authentication
import io.ktor.client.HttpClient
@@ -51,17 +47,14 @@ import io.ktor.features.CORS
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.DataConversion
import io.ktor.features.NotFoundException
import io.ktor.features.StatusPages
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.cio.websocket.pingPeriod
import io.ktor.http.cio.websocket.timeout
import io.ktor.jackson.jackson
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Locations
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.server.jetty.EngineMain
import io.ktor.util.KtorExperimentalAPI
@@ -73,7 +66,6 @@ import org.koin.ktor.ext.Koin
import org.koin.ktor.ext.get
import org.slf4j.event.Level
import java.time.Duration
import java.util.concurrent.CompletionException
fun main(args: Array<String>): Unit = EngineMain.main(args)
@@ -132,7 +124,7 @@ fun Application.module(env: Env = PROD) {
}
}
install(Authentication, jwtInstallation(get()))
install(Authentication, jwtInstallation(get(), get()))
install(AutoHeadResponse)
@@ -171,26 +163,7 @@ fun Application.module(env: Env = PROD) {
installDocRoutes()
}
install(StatusPages) {
exception<CompletionException> { e ->
val parent = e.cause?.cause
if (parent is GenericDatabaseException) {
call.respond(HttpStatusCode.BadRequest, parent.errorMessage.message!!)
} else {
throw e
}
}
exception<NotFoundException> { e ->
call.respond(HttpStatusCode.NotFound, e.message!!)
}
exception<AccessDeniedException> {
if (call.user == null) call.respond(HttpStatusCode.Unauthorized)
else call.respond(HttpStatusCode.Forbidden)
}
exception<ForbiddenException> {
call.respond(HttpStatusCode.Forbidden)
}
}
install(StatusPages, statusPagesInstallation())
install(CORS) {
method(HttpMethod.Options)

View File

@@ -43,4 +43,15 @@ class Configuration(val config: Config) {
val rabbitmq: String = config.getString("rabbitmq.connection")
val exchangeNotificationName = "notification"
val sendGridKey: String = config.getString("mail.sendGrid.key")
interface Jwt {
val secret: String
val issuer: String
val validityInMs: Int
}
val jwt = object : Jwt {
override val secret = config.getString("jwt.secret")
override val issuer = config.getString("jwt.issuer")
override val validityInMs = config.getInt("jwt.validity")
}
}

View File

@@ -1,6 +1,10 @@
package fr.dcproject.application
import fr.dcproject.application.http.BadRequestException
import fr.dcproject.application.http.HttpErrorBadRequest
import fr.dcproject.application.http.HttpErrorBadRequest.InvalidParam
import io.ktor.features.DataConversion
import io.ktor.http.HttpStatusCode
import io.ktor.util.KtorExperimentalAPI
import org.koin.core.context.GlobalContext
import org.koin.core.parameter.ParametersDefinition
@@ -8,6 +12,7 @@ import org.koin.core.qualifier.Qualifier
import java.util.UUID
private typealias ConverterDeclaration = DataConversion.Configuration.() -> Unit
private inline fun <reified T> DataConversion.Configuration.get(
qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null
@@ -17,7 +22,21 @@ private inline fun <reified T> DataConversion.Configuration.get(
val converters: ConverterDeclaration = {
convert<UUID> {
decode { values, _ ->
try {
values.singleOrNull()?.let { UUID.fromString(it) }
} catch (e: Throwable) {
throw BadRequestException(
HttpErrorBadRequest(
HttpStatusCode.BadRequest,
invalidParams = listOf(
InvalidParam(
"ID",
"must be UUID"
)
)
)
)
}
}
encode { value ->

View File

@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.joda.JodaModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.common.email.Mailer
import fr.dcproject.component.auth.jwt.JwtConfig
import fr.dcproject.component.notification.NotificationConsumer
import fr.dcproject.component.notification.NotificationEmailSender
import fr.dcproject.component.notification.NotificationsPush
@@ -25,6 +26,19 @@ import org.koin.dsl.module
@KtorExperimentalAPI
val KoinModule = module {
// JWT
single {
val config: Configuration = get()
JwtConfig(
config.jwt.secret,
config.jwt.issuer,
config.jwt.validityInMs,
)
}
// JWT Verifier
single {
get<JwtConfig>().verifier
}
// SQL connection
single {
val config: Configuration = get()

View File

@@ -0,0 +1,35 @@
package fr.dcproject.application.http
import fr.dcproject.application.http.HttpErrorBadRequest.InvalidParam
import io.konform.validation.ValidationResult
import io.ktor.http.HttpStatusCode
class BadRequestException(val httpError: HttpErrorBadRequest) : Exception()
class HttpErrorBadRequest(
statusCode: HttpStatusCode,
val title: String = statusCode.description,
val invalidParams: List<InvalidParam>,
) {
val statusCode: Int = statusCode.value
data class InvalidParam(
val name: String,
val reason: String
)
}
fun ValidationResult<*>.toOutput() = HttpErrorBadRequest(
HttpStatusCode.BadRequest,
invalidParams = this.errors.map {
InvalidParam(
it.dataPath,
it.message
)
}
)
fun ValidationResult<*>.badRequestIfNotValid() {
if (errors.size > 0) {
throw BadRequestException(toOutput())
}
}

View File

@@ -0,0 +1,86 @@
package fr.dcproject.application.http
import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
import fr.dcproject.common.security.AccessDeniedException
import fr.dcproject.component.auth.ForbiddenException
import fr.dcproject.component.auth.user
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.features.ParameterConversionException
import io.ktor.features.StatusPages
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import java.util.concurrent.CompletionException
class HttpError(
statusCode: HttpStatusCode,
cause: Throwable? = null,
val title: String = cause?.message ?: statusCode.description,
) {
val statusCode: Int = statusCode.value
}
fun statusPagesInstallation(): StatusPages.Configuration.() -> Unit = {
exception<CompletionException> { e ->
val parent = e.cause?.cause
if (parent is GenericDatabaseException) {
HttpError(
HttpStatusCode.BadRequest,
cause = parent
).let {
call.respond(HttpStatusCode.BadRequest, it)
}
} else {
HttpError(
HttpStatusCode.BadRequest,
cause = e
).let {
call.respond(HttpStatusCode.InternalServerError, it)
}
}
}
exception<NotFoundException> { e ->
HttpError(
HttpStatusCode.NotFound,
cause = e
).let {
call.respond(HttpStatusCode.NotFound, it)
}
}
exception<AccessDeniedException> { e ->
if (call.user == null) {
HttpError(
HttpStatusCode.Unauthorized,
cause = e
).let {
call.respond(HttpStatusCode.Unauthorized, it)
}
} else {
HttpError(
HttpStatusCode.Forbidden,
cause = e
).let {
call.respond(HttpStatusCode.Forbidden, it)
}
}
}
exception<ForbiddenException> { e ->
HttpError(
HttpStatusCode.Forbidden,
cause = e
).let {
call.respond(HttpStatusCode.Forbidden, it)
}
}
exception<BadRequestException> { e ->
call.respond(HttpStatusCode.BadRequest, e.httpError)
}
exception<ParameterConversionException> { e ->
val parent = e.cause
if (parent is BadRequestException) {
call.respond(HttpStatusCode.BadRequest, parent.httpError)
} else {
throw e
}
}
}

View File

@@ -6,9 +6,6 @@ interface PaginatedRequestI {
}
open class PaginatedRequest(
page: Int = 1,
limit: Int = 50
) : PaginatedRequestI {
override val page: Int = if (page < 1) 1 else page
override val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
}
override val page: Int = 1,
override val limit: Int = 50
) : PaginatedRequestI

View File

@@ -4,7 +4,6 @@ import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException
import org.apache.http.util.EntityUtils
import org.elasticsearch.client.Response
import org.slf4j.LoggerFactory
fun Response.contentToString(): String {
return EntityUtils.toString(this.entity)
@@ -22,8 +21,6 @@ fun String.getJsonField(jsonPath: String): Int? {
return try {
JsonPath.read(this, jsonPath)
} catch (e: PathNotFoundException) {
LoggerFactory.getLogger("fr.dcproject.utils.getJsonField")
.warn("No value for Json path ${JsonPath.compile(jsonPath).path}")
null
}
}

View File

@@ -0,0 +1,4 @@
package fr.dcproject.common.utils
fun String.isInt(): Boolean = this.toIntOrNull() != null
fun String.isBool(): Boolean = this == "true" || this == "false"

View File

@@ -0,0 +1,6 @@
package fr.dcproject.common.validation
import io.konform.validation.ValidationBuilder
import io.konform.validation.jsonschema.pattern
fun ValidationBuilder<String>.email() = pattern(""".+@.+\..+""")

View File

@@ -0,0 +1,22 @@
package fr.dcproject.common.validation
import io.konform.validation.ValidationBuilder
fun ValidationBuilder<String>.passwordScore(minScore: Int) =
addConstraint("is not enough strong. Use Upper case, Lower case and special characters or juste use more characters.") { value ->
value.passwordScore() >= minScore
}
fun String.passwordScore(): Int {
var score: Int = length
val alphaNum = ('a'..'z').toList() + ('A'..'Z').toList() + ('0'..'9').toList()
val specialCount = length - toList().intersect(alphaNum).size
score += specialCount.let { if (it > 3) 3 else it }
val hasAlphaLower = toList().intersect(('a'..'z').toList()).size.let { if (it > 2) 2 else it }
val hasAlphaUpper = toList().intersect(('A'..'Z').toList()).size.let { if (it > 2) 2 else it }
val hasNum = toList().intersect(('0'..'9').toList()).size.let { if (it > 2) 2 else it }
score += (hasAlphaLower + hasAlphaUpper + hasNum - 2) * 2
return score
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.common.validation
import io.konform.validation.ValidationBuilder
import java.util.UUID
fun ValidationBuilder<String>.isUuid() =
addConstraint("must be UUID") {
try {
UUID.fromString(it)
true
} catch (exception: IllegalArgumentException) {
false
}
}

View File

@@ -1,15 +1,13 @@
package fr.dcproject.component.article
package fr.dcproject.component.article.database
import fr.dcproject.common.entity.VersionableId
import fr.dcproject.common.utils.contentToString
import fr.dcproject.common.utils.getJsonField
import fr.dcproject.common.utils.toIso
import fr.dcproject.component.article.database.ArticleI
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.views.ViewManager
import fr.dcproject.component.views.ViewRepository
import fr.dcproject.component.views.entity.ViewAggregation
import org.elasticsearch.client.Request
import org.elasticsearch.client.Response
import org.elasticsearch.client.RestClient
import org.joda.time.DateTime
import java.util.UUID
@@ -17,11 +15,11 @@ import java.util.UUID
/**
* Wrapper for manage views with elasticsearch
*/
class ArticleViewManager <A> (private val restClient: RestClient) : ViewManager<A> where A : VersionableId, A : ArticleI {
class ArticleViewRepository <A> (private val restClient: RestClient) : ViewRepository<A> where A : VersionableId, A : ArticleI {
/**
* Add view on article to elasticsearch
*/
override fun addView(ip: String, entity: A, citizen: CitizenI?, dateTime: DateTime): Response? {
override fun addView(ip: String, entity: A, citizen: CitizenI?, dateTime: DateTime) {
val isLogged = (citizen != null).toString()
val ref = citizen?.id ?: UUID.nameUUIDFromBytes(ip.toByteArray())!!
val request = Request(
@@ -45,7 +43,7 @@ class ArticleViewManager <A> (private val restClient: RestClient) : ViewManager<
)
}
return restClient.performRequest(request)
restClient.performRequest(request)
}
/**

View File

@@ -1,42 +1,69 @@
package fr.dcproject.component.article.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.toUUID
import fr.dcproject.common.validation.isUuid
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.database.ArticleForListing
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import fr.postgresjson.repository.RepositoryI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.get
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object FindArticleVersions {
@Location("/articles/{article}/versions")
class ArticleVersionsRequest(
article: UUID,
val article: String,
page: Int = 1,
limit: Int = 50,
val sort: String? = null,
val direction: RepositoryI.Direction? = null,
val search: String? = null
) {
val page: Int = if (page < 1) 1 else page
val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
val article = ArticleRef(article)
) : PaginatedRequestI by PaginatedRequest(page, limit) {
fun validate() = Validation<ArticleVersionsRequest> {
ArticleVersionsRequest::page {
minimum(1)
maximum(100)
}
ArticleVersionsRequest::limit {
minimum(1)
maximum(50)
}
ArticleVersionsRequest::sort ifPresent {
enum(
"title",
"createdAt",
"vote",
"popularity",
)
}
ArticleVersionsRequest::article {
isUuid()
}
}.validate(this)
}
private fun ArticleRepository.findVersions(request: ArticleVersionsRequest) =
findVersionsById(request.page, request.limit, request.article.id)
findVersionsById(request.page, request.limit, request.article.toUUID())
fun Route.findArticleVersions(repo: ArticleRepository, ac: ArticleAccessControl) {
get<ArticleVersionsRequest> {
it.validate().badRequestIfNotValid()
repo.findVersions(it)
.apply { ac.assert { canView(result, citizenOrNull) } }
.run {

View File

@@ -1,7 +1,9 @@
package fr.dcproject.component.article.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.common.validation.isUuid
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.database.ArticleForListing
import fr.dcproject.component.article.database.ArticleRepository
@@ -10,6 +12,10 @@ import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.repository.RepositoryI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
@@ -28,7 +34,31 @@ object FindArticles {
val search: String? = null,
val createdBy: String? = null,
val workgroup: String? = null
) : PaginatedRequestI by PaginatedRequest(page, limit)
) : PaginatedRequestI by PaginatedRequest(page, limit) {
fun validate() = Validation<ArticlesRequest> {
ArticlesRequest::page {
minimum(1)
}
ArticlesRequest::limit {
minimum(1)
maximum(50)
}
ArticlesRequest::sort ifPresent {
enum(
"title",
"createdAt",
"vote",
"popularity",
)
}
ArticlesRequest::createdBy ifPresent {
isUuid()
}
ArticlesRequest::workgroup ifPresent {
isUuid()
}
}.validate(this)
}
private fun ArticleRepository.findArticles(request: ArticlesRequest): Paginated<ArticleForListing> {
return find(
@@ -43,6 +73,8 @@ object FindArticles {
fun Route.findArticles(repo: ArticleRepository, ac: ArticleAccessControl) {
get<ArticlesRequest> {
it.validate().badRequestIfNotValid()
repo.findArticles(it)
.apply { ac.assert { canView(result, citizenOrNull) } }
.let {

View File

@@ -2,10 +2,10 @@ package fr.dcproject.component.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.ArticleViewManager
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.article.database.ArticleViewRepository
import fr.dcproject.component.auth.citizenOrNull
import io.ktor.application.call
import io.ktor.features.NotFoundException
@@ -24,7 +24,7 @@ object GetOneArticle {
val article = ArticleRef(article)
}
fun Route.getOneArticle(viewManager: ArticleViewManager<ArticleForView>, ac: ArticleAccessControl, repo: ArticleRepository) {
fun Route.getOneArticle(viewRepository: ArticleViewRepository<ArticleForView>, ac: ArticleAccessControl, repo: ArticleRepository) {
get<ArticleRequest> {
val article: ArticleForView = repo.findById(it.article.id) ?: throw NotFoundException("Article ${it.article.id} not found")
ac.assert { canView(article, citizenOrNull) }
@@ -64,7 +64,7 @@ object GetOneArticle {
val total: Int = a.votes.total
val score: Int = a.votes.score
}
val views: Any = viewManager.getViewsCount(article).let { v ->
val views: Any = viewRepository.getViewsCount(article).let { v ->
object {
val total = v.total
val unique = v.unique
@@ -76,7 +76,7 @@ object GetOneArticle {
)
launch {
viewManager.addView(call.request.local.remoteHost, article, citizenOrNull)
viewRepository.addView(call.request.local.remoteHost, article, citizenOrNull)
}
}
}

View File

@@ -1,5 +1,6 @@
package fr.dcproject.component.article.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.article.ArticleAccessControl
@@ -8,9 +9,15 @@ import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.article.routes.UpsertArticle.UpsertArticleRequest.Input
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.notification.ArticleUpdateNotification
import fr.dcproject.component.notification.Publisher
import fr.dcproject.component.workgroup.database.WorkgroupRef
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minItems
import io.konform.validation.jsonschema.minLength
import io.ktor.application.ApplicationCall
import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -34,11 +41,31 @@ object UpsertArticle {
val draft: Boolean = false,
val versionId: UUID,
val workgroup: WorkgroupRef? = null,
)
) {
fun validate() = Validation<Input> {
Input::title {
minLength(5)
maxLength(80)
}
Input::content {
minLength(50)
maxLength(6000)
}
Input::description {
minLength(50)
maxLength(6000)
}
Input::tags {
minItems(0)
maxItems(15)
}
}.validate(this)
}
}
fun Route.upsertArticle(repo: ArticleRepository, publisher: Publisher, ac: ArticleAccessControl) {
suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receiveOrBadRequest<Input>().run {
validate().badRequestIfNotValid()
ArticleForUpdate(
id = id ?: UUID.randomUUID(),
title = title,
@@ -54,6 +81,7 @@ object UpsertArticle {
}
post<UpsertArticleRequest> {
mustBeAuth()
val article = call.convertRequestToEntity()
ac.assert { canUpsert(article, citizenOrNull) }
repo.upsert(article)?.let { a ->

View File

@@ -26,7 +26,21 @@ val ApplicationCall.citizenOrNull: CitizenEntity?
GlobalContext.get().koin.get<CitizenRepository>().findByUser(it)
}
val ApplicationCall.isAuth: Boolean
get() = citizenOrNull == null
fun ApplicationCall.mustBeAuth() {
citizenOrNull ?: throw ForbiddenException("No User Connected")
}
val PipelineContext<Unit, ApplicationCall>.citizen get() = context.citizen
val PipelineContext<Unit, ApplicationCall>.citizenOrNull get() = context.citizenOrNull
val ApplicationCall.user get() = authentication.principal<User>()
val PipelineContext<Unit, ApplicationCall>.isAuth: Boolean
get() = citizenOrNull == null
fun PipelineContext<Unit, ApplicationCall>.mustBeAuth() {
citizenOrNull ?: throw ForbiddenException("No User Connected")
}

View File

@@ -2,13 +2,16 @@ package fr.dcproject.component.auth.jwt
import com.auth0.jwt.JWT
import fr.dcproject.component.auth.database.UserI
import org.koin.core.context.GlobalContext
/**
* Produce a token for this combination of User and Account
*/
fun UserI.makeToken(): String = JWT.create()
fun UserI.makeToken(): String = GlobalContext.get().koin.get<JwtConfig>().run {
JWT.create()
.withSubject("Authentication")
.withIssuer(JwtConfig.issuer)
.withIssuer(issuer)
.withClaim("id", id.toString())
.withExpiresAt(JwtConfig.getExpiration())
.sign(JwtConfig.algorithm)
.withExpiresAt(getExpiration())
.sign(algorithm)
}

View File

@@ -5,11 +5,11 @@ import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.Date
object JwtConfig {
private const val secret = "zAP5MBA4B4Ijz0MZaS48"
const val issuer = "dc-project.fr"
private const val validityInMs = 3_600_000 * 10 // 10 hours
class JwtConfig(
private val secret: String,
val issuer: String,
private val validityInMs: Int,
) {
// TODO change to RSA512
val algorithm: Algorithm = Algorithm.HMAC512(secret)

View File

@@ -1,5 +1,6 @@
package fr.dcproject.component.auth.jwt
import com.auth0.jwt.JWTVerifier
import fr.dcproject.component.auth.database.User
import fr.dcproject.component.auth.database.UserRepository
import io.ktor.application.ApplicationCall
@@ -9,14 +10,14 @@ import io.ktor.http.auth.HttpAuthHeader
import io.ktor.routing.Routing
import java.util.UUID
fun jwtInstallation(userRepo: UserRepository): Authentication.Configuration.() -> Unit = {
fun jwtInstallation(userRepo: UserRepository, verifier: JWTVerifier): Authentication.Configuration.() -> Unit = {
/**
* Setup the JWT authentication to be used in [Routing].
* If the token is valid, the corresponding [User] is fetched from the database.
* The [User] can then be accessed in each [ApplicationCall].
*/
jwt {
verifier(JwtConfig.verifier)
verifier(verifier)
realm = "dc-project.fr"
validate {
it.payload.getClaim("id").asString()?.let { id ->
@@ -27,7 +28,7 @@ fun jwtInstallation(userRepo: UserRepository): Authentication.Configuration.() -
/* Token in URL */
jwt("url") {
verifier(JwtConfig.verifier)
verifier(verifier)
realm = "dc-project.fr"
authHeader { call ->
call.request.queryParameters["token"]?.let {

View File

@@ -1,7 +1,10 @@
package fr.dcproject.component.auth.routes
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.common.validation.email
import fr.dcproject.common.validation.passwordScore
import fr.dcproject.component.auth.database.UserForCreate
import fr.dcproject.component.auth.database.UserI
import fr.dcproject.component.auth.jwt.makeToken
@@ -9,6 +12,9 @@ import fr.dcproject.component.auth.routes.Register.RegisterRequest.Input
import fr.dcproject.component.citizen.database.CitizenForCreate
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.citizen.database.CitizenRepository
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.features.BadRequestException
import io.ktor.http.ContentType
@@ -43,6 +49,35 @@ object Register {
val username: String,
val password: String
)
fun validate() = Validation<Input> {
Input::name {
Name::firstName {
minLength(2)
maxLength(50)
}
Name::lastName {
minLength(2)
maxLength(50)
}
Name::civility ifPresent {
minLength(1)
maxLength(10)
}
}
Input::user {
User::username {
minLength(7)
maxLength(30)
}
User::password {
passwordScore(15)
}
}
Input::email {
email()
}
}.validate(this)
}
}
@@ -62,7 +97,10 @@ object Register {
post<RegisterRequest> {
try {
val citizen = call.receiveOrBadRequest<Input>().toCitizen()
val citizen = call.receiveOrBadRequest<Input>()
.apply { validate().badRequestIfNotValid() }
.toCitizen()
citizenRepo.insertWithUser(citizen)?.user?.makeToken()?.let { token ->
if (call.request.accept() == ContentType.Application.Json.toString()) {
call.respond(

View File

@@ -1,13 +1,17 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.common.validation.passwordScore
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.database.UserRepository
import fr.dcproject.component.auth.database.UserWithPassword
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.database.CitizenRef
import io.konform.validation.Validation
import io.ktor.application.call
import io.ktor.auth.UserPasswordCredential
import io.ktor.features.BadRequestException
@@ -24,13 +28,21 @@ object ChangeMyPassword {
@Location("/citizens/{citizen}/password/change")
class ChangePasswordCitizenRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
data class Input(val oldPassword: String, val newPassword: String)
data class Input(val oldPassword: String, val newPassword: String) {
fun validate() = Validation<Input> {
Input::newPassword {
passwordScore(15)
}
}.validate(this)
}
}
fun Route.changeMyPassword(ac: CitizenAccessControl, userRepository: UserRepository) {
put<ChangePasswordCitizenRequest> {
ac.assert { canChangePassword(it.citizen, citizenOrNull) }
mustBeAuth()
val content = call.receiveOrBadRequest<ChangePasswordCitizenRequest.Input>()
.apply { validate().badRequestIfNotValid() }
ac.assert { canChangePassword(it.citizen, citizenOrNull) }
userRepository.findByCredentials(UserPasswordCredential(citizen.user.username, content.oldPassword)) ?: throw BadRequestException("Bad Password")
userRepository.changePassword(
UserWithPassword(

View File

@@ -1,14 +1,20 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenRepository
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import fr.postgresjson.repository.RepositoryI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
@@ -26,10 +32,28 @@ object FindCitizens {
val sort: String? = null,
val direction: RepositoryI.Direction? = null,
val search: String? = null
) : PaginatedRequestI by PaginatedRequest(page, limit)
) : PaginatedRequestI by PaginatedRequest(page, limit) {
fun validate() = Validation<CitizensRequest> {
CitizensRequest::page {
minimum(1)
}
CitizensRequest::limit {
minimum(1)
maximum(50)
}
CitizensRequest::sort ifPresent {
enum(
"title",
"createdAt",
)
}
}.validate(this)
}
fun Route.findCitizen(ac: CitizenAccessControl, repo: CitizenRepository) {
get<CitizensRequest> {
mustBeAuth()
it.validate().badRequestIfNotValid()
val citizens = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
ac.assert { canView(citizens.result, citizenOrNull) }
call.respond(

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.citizen.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.CitizenAccessControl
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
@@ -22,6 +23,7 @@ object GetCurrentCitizen {
fun Route.getCurrentCitizen(ac: CitizenAccessControl) {
get<CurrentCitizenRequest> {
mustBeAuth()
val currentUser = citizenOrNull
if (currentUser === null) {
call.respond(HttpStatusCode.Unauthorized)

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.citizen.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.citizen.database.CitizenRepository
@@ -26,6 +27,7 @@ object GetOneCitizen {
fun Route.getOneCitizen(ac: CitizenAccessControl, citizenRepository: CitizenRepository) {
get<CitizenRequest> {
mustBeAuth()
val citizen = citizenRepository.findById(it.citizen.id) ?: throw NotFoundException("Citizen not found ${it.citizen.id}")
ac.assert { canView(citizen, citizenOrNull) }

View File

@@ -41,7 +41,7 @@ class CommentArticleRepository(requester: Requester) : CommentRepositoryAbs<Arti
target: EntityI,
page: Int,
limit: Int,
sort: Sort
sort: String
): Paginated<CommentForView<ArticleForView, CitizenCreatorI>> {
return requester
.getFunction("find_comments_by_target")
@@ -49,18 +49,7 @@ class CommentArticleRepository(requester: Requester) : CommentRepositoryAbs<Arti
page,
limit,
"target_id" to target.id,
"sort" to sort.sql
"sort" to sort
) as Paginated<CommentForView<ArticleForView, CitizenCreatorI>>
}
enum class Sort(val sql: String) {
CREATED_AT("created_at"),
VOTES("votes");
companion object {
fun fromString(string: String): Sort? {
return values().firstOrNull { it.sql == string }
}
}
}
}

View File

@@ -1,16 +1,20 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.common.response.toOutput
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.comment.article.database.CommentArticleRepository
import fr.dcproject.component.comment.article.routes.CreateCommentArticle.PostArticleCommentRequest.Input
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.database.CommentForUpdate
import fr.dcproject.component.comment.toOutput
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -25,12 +29,22 @@ object CreateCommentArticle {
@Location("/articles/{article}/comments")
class PostArticleCommentRequest(article: UUID) {
val article = ArticleRef(article)
class Input(val content: String)
class Input(val content: String) {
fun validate() = Validation<Input> {
Input::content {
minLength(20)
maxLength(6000)
}
}.validate(this)
}
}
fun Route.createCommentArticle(repo: CommentArticleRepository, ac: CommentAccessControl) {
post<PostArticleCommentRequest> {
call.receiveOrBadRequest<Input>().run {
mustBeAuth()
call.receiveOrBadRequest<Input>()
.apply { validate().badRequestIfNotValid() }
.run {
CommentForUpdate(
target = it.article,
createdBy = citizen,

View File

@@ -1,5 +1,6 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.database.ArticleRef
@@ -9,6 +10,10 @@ import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.toOutput
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -26,14 +31,31 @@ object GetArticleComments {
page: Int = 1,
limit: Int = 50,
val search: String? = null,
sort: String = CommentArticleRepository.Sort.CREATED_AT.sql
val sort: String = "createdAt"
) : PaginatedRequestI by PaginatedRequest(page, limit) {
val article = ArticleRef(article)
val sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.fromString(sort) ?: CommentArticleRepository.Sort.CREATED_AT
fun validate() = Validation<ArticleCommentsRequest> {
ArticleCommentsRequest::page {
minimum(1)
}
ArticleCommentsRequest::limit {
minimum(1)
maximum(50)
}
ArticleCommentsRequest::sort ifPresent {
enum(
"votes",
"createdAt",
)
}
}.validate(this)
}
fun Route.getArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
get<ArticleCommentsRequest> {
it.validate().badRequestIfNotValid()
val comments = repo.findByTarget(it.article, it.page, it.limit, it.sort)
if (comments.result.isNotEmpty()) {
ac.assert { canView(comments.result, citizenOrNull) }

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.comment.article.routes
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.comment.article.database.CommentArticleRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
@@ -25,6 +26,7 @@ object GetCitizenArticleComments {
fun Route.getCitizenArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
get<CitizenCommentArticleRequest> {
mustBeAuth()
repo.findByCitizen(it.citizen).let { comments ->
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(

View File

@@ -5,7 +5,6 @@ import fr.dcproject.common.entity.TargetI
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenCreatorI
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.comment.article.database.CommentArticleRepository
import fr.dcproject.component.comment.generic.database.CommentForView
import fr.dcproject.component.comment.generic.database.CommentRepositoryAbs
import fr.dcproject.component.constitution.database.ConstitutionRef
@@ -41,7 +40,7 @@ class CommentConstitutionRepository(requester: Requester) : CommentRepositoryAbs
target: EntityI,
page: Int,
limit: Int,
sort: CommentArticleRepository.Sort
sort: String
): Paginated<CommentForView<ConstitutionRef, CitizenCreatorI>> {
return requester.run {
getFunction("find_comments_by_target")
@@ -49,7 +48,7 @@ class CommentConstitutionRepository(requester: Requester) : CommentRepositoryAbs
page,
limit,
"target_id" to target.id,
"sort" to sort.sql
"sort" to sort
)
as Paginated<CommentForView<ConstitutionRef, CitizenCreatorI>>
}

View File

@@ -1,16 +1,21 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.comment.constitution.database.CommentConstitutionRepository
import fr.dcproject.component.comment.constitution.routes.CreateConstitutionComment.CreateConstitutionCommentRequest.Input
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.database.CommentForUpdate
import fr.dcproject.component.comment.toOutput
import fr.dcproject.component.constitution.database.ConstitutionRef
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -25,12 +30,23 @@ object CreateConstitutionComment {
@Location("/constitutions/{constitution}/comments")
class CreateConstitutionCommentRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
class Input(val content: String)
class Input(val content: String) {
fun validate() = Validation<Input> {
Input::content {
minLength(20)
maxLength(6000)
}
}.validate(this)
}
}
fun Route.createConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
post<CreateConstitutionCommentRequest> {
call.receiveOrBadRequest<Input>().run {
mustBeAuth()
call.receiveOrBadRequest<Input>()
.apply { validate().badRequestIfNotValid() }
.run {
CommentForUpdate(
target = it.constitution,
createdBy = citizen,

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.comment.constitution.database.CommentConstitutionRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
@@ -25,6 +26,7 @@ object GetCitizenCommentConstitution {
fun Route.getCitizenCommentConstitution(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
get<GetCitizenCommentConstitutionRequest> {
mustBeAuth()
val comments = repo.findByCitizen(it.citizen)
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(

View File

@@ -1,5 +1,6 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
@@ -7,6 +8,12 @@ import fr.dcproject.component.comment.constitution.database.CommentConstitutionR
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.toOutput
import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -19,12 +26,36 @@ import java.util.UUID
@KtorExperimentalLocationsAPI
object GetConstitutionComment {
@Location("/constitutions/{constitution}/comments")
class GetConstitutionCommentRequest(constitution: UUID) {
class GetConstitutionCommentRequest(
constitution: UUID,
page: Int = 1,
limit: Int = 50,
val search: String? = null,
val sort: String = "createdAt"
) : PaginatedRequestI by PaginatedRequest(page, limit) {
val constitution = ConstitutionRef(constitution)
fun validate() = Validation<GetConstitutionCommentRequest> {
GetConstitutionCommentRequest::page {
minimum(1)
}
GetConstitutionCommentRequest::limit {
minimum(1)
maximum(50)
}
GetConstitutionCommentRequest::sort ifPresent {
enum(
"votes",
"createdAt",
)
}
}.validate(this)
}
fun Route.getConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
get<GetConstitutionCommentRequest> {
it.validate().badRequestIfNotValid()
val comments = repo.findByTarget(it.constitution)
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(

View File

@@ -63,12 +63,14 @@ open class CommentForUpdate<T : TargetI, C : CitizenI>(
constructor(
createdBy: C,
parent: CommentParent<T>,
content: String
content: String,
id: UUID? = null,
) : this(
createdBy = createdBy,
parent = parent,
target = parent.target,
content = content
content = content,
id = id ?: UUID.randomUUID(),
)
}

View File

@@ -6,7 +6,6 @@ import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenCreatorI
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.comment.article.database.CommentArticleRepository
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.repository.RepositoryI
@@ -49,7 +48,7 @@ abstract class CommentRepositoryAbs<T : TargetI>(override var requester: Request
target: EntityI,
page: Int = 1,
limit: Int = 50,
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
sort: String = "createdAt"
): Paginated<CommentForView<T, CitizenCreatorI>> {
return findByTarget(target.id, page, limit, sort)
}
@@ -58,36 +57,30 @@ abstract class CommentRepositoryAbs<T : TargetI>(override var requester: Request
targetId: UUID,
page: Int = 1,
limit: Int = 50,
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
): Paginated<CommentForView<T, CitizenCreatorI>> {
return requester.run {
getFunction("find_comments_by_target")
sort: String = "createdAt"
): Paginated<CommentForView<T, CitizenCreatorI>> = requester
.getFunction("find_comments_by_target")
.select<CommentForView<T, CitizenCreator>>(
page,
limit,
"target_id" to targetId,
"sort" to sort.sql
)
as Paginated<CommentForView<T, CitizenCreatorI>>
}
}
"sort" to sort
) as Paginated<CommentForView<T, CitizenCreatorI>>
fun <I : TargetI, C : CitizenCreatorI> comment(comment: CommentForUpdate<I, C>) {
requester
fun <I : TargetI, C : CitizenCreatorI> comment(comment: CommentForUpdate<I, C>): CommentForView<TargetRef, CitizenCreator> = requester
.getFunction("comment")
.sendQuery(
.selectOne(
"reference" to comment.target.reference,
"resource" to comment
)
}
)!!
fun <I : T> edit(comment: CommentForUpdate<I, CitizenCreatorI>) {
requester
fun <I : T> edit(comment: CommentForUpdate<I, CitizenCreatorI>): CommentForView<TargetRef, CitizenCreator> {
return requester
.getFunction("edit_comment")
.sendQuery(
.selectOne(
"id" to comment.id,
"content" to comment.content
)
)!!
}
}

View File

@@ -0,0 +1,63 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.database.CommentForUpdate
import fr.dcproject.component.comment.generic.database.CommentRef
import fr.dcproject.component.comment.generic.database.CommentRepository
import fr.dcproject.component.comment.toOutput
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.post
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object CreateComment {
@Location("/comments/{comment}")
class CreateCommentRequest(comment: UUID) {
val comment = CommentRef(comment)
class Input(val content: String) {
fun validate() = Validation<Input> {
Input::content {
minLength(20)
maxLength(6000)
}
}.validate(this)
}
}
fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) {
post<CreateCommentRequest> {
mustBeAuth()
call.receiveOrBadRequest<CreateCommentRequest.Input>()
.apply { validate().badRequestIfNotValid() }
.run {
val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
CommentForUpdate(
content = content,
createdBy = citizen,
target = parent.target,
parent = parent,
)
}.let { newComment ->
ac.assert { canCreate(newComment, citizenOrNull) }
repo.comment(newComment)
call.respond(HttpStatusCode.Created, newComment.toOutput())
}
}
}
}

View File

@@ -1,45 +0,0 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.database.CommentForUpdate
import fr.dcproject.component.comment.generic.database.CommentRef
import fr.dcproject.component.comment.generic.database.CommentRepository
import fr.dcproject.component.comment.toOutput
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.post
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object CreateCommentChildren {
@Location("/comments/{comment}/children")
class CreateCommentChildrenRequest(comment: UUID) {
val comment = CommentRef(comment)
class Input(val content: String)
}
fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) {
post<CreateCommentChildrenRequest> {
val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
val newComment = CommentForUpdate(
content = call.receiveOrBadRequest<CreateCommentChildrenRequest.Input>().content,
createdBy = citizen,
parent = parent
)
ac.assert { canCreate(newComment, citizenOrNull) }
repo.comment(newComment)
call.respond(HttpStatusCode.Created, newComment.toOutput())
}
}
}

View File

@@ -1,13 +1,18 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.common.response.toOutput
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.database.CommentForUpdate
import fr.dcproject.component.comment.generic.database.CommentRef
import fr.dcproject.component.comment.generic.database.CommentRepository
import fr.dcproject.component.comment.toOutput
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.http.HttpStatusCode
@@ -23,21 +28,40 @@ object EditComment {
@Location("/comments/{comment}")
class EditCommentRequest(comment: UUID) {
val comment = CommentRef(comment)
class Input(val content: String)
class Input(val content: String) {
fun validate() = Validation<Input> {
Input::content {
minLength(20)
maxLength(6000)
}
}.validate(this)
}
}
fun Route.editComment(repo: CommentRepository, ac: CommentAccessControl) {
put<EditCommentRequest> {
val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
ac.assert { canUpdate(comment, citizenOrNull) }
comment.content = call.receiveOrBadRequest<EditCommentRequest.Input>().content
repo.edit(comment)
mustBeAuth()
val commentOld = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
ac.assert { canUpdate(commentOld, citizenOrNull) }
call.receiveOrBadRequest<EditCommentRequest.Input>()
.apply { validate().badRequestIfNotValid() }
.run {
CommentForUpdate(
id = commentOld.id,
createdBy = commentOld.createdBy,
target = commentOld.target,
parent = commentOld.parent,
content = content,
)
}
.let { repo.edit(it) }
.let {
call.respond(
HttpStatusCode.OK,
comment.toOutput()
it.toOutput()
)
}
}
}
}

View File

@@ -1,6 +1,6 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.component.comment.generic.routes.CreateCommentChildren.createCommentChildren
import fr.dcproject.component.comment.generic.routes.CreateComment.createCommentChildren
import fr.dcproject.component.comment.generic.routes.EditComment.editComment
import fr.dcproject.component.comment.generic.routes.GetCommentChildren.getChildrenComments
import fr.dcproject.component.comment.generic.routes.GetOneComment.getOneComment

View File

@@ -1,11 +1,13 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.Citizen
import fr.dcproject.component.citizen.database.CitizenWithUserI
import fr.dcproject.component.constitution.ConstitutionAccessControl
@@ -14,6 +16,9 @@ import fr.dcproject.component.constitution.database.ConstitutionForUpdate.TitleF
import fr.dcproject.component.constitution.database.ConstitutionRepository
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input.Title
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -35,7 +40,6 @@ object CreateConstitution {
val draft: Boolean = false,
val versionId: UUID = UUID.randomUUID()
) {
class Title(
val id: UUID = UUID.randomUUID(),
val name: String,
@@ -43,10 +47,25 @@ object CreateConstitution {
) {
class ArticleRef(val id: UUID)
}
fun validate() = Validation<Input> {
Input::title {
minLength(10)
maxLength(80)
}
Input::titles onEach {
Title::name {
minLength(10)
maxLength(80)
}
}
}.validate(this)
}
}
private fun getNewConstitution(input: Input, citizen: Citizen) = input.run {
validate().badRequestIfNotValid()
ConstitutionForUpdate<CitizenWithUserI, TitleForUpdate<ArticleRef>>(
id = UUID.randomUUID(),
title = title,
@@ -68,6 +87,7 @@ object CreateConstitution {
fun Route.createConstitution(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
post<PostConstitutionRequest> {
mustBeAuth()
getNewConstitution(call.receiveOrBadRequest(), citizen).let {
ac.assert { canCreate(it, citizenOrNull) }
val c = repo.upsert(it) ?: error("Unable to create Constitution")

View File

@@ -1,5 +1,6 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
@@ -8,6 +9,10 @@ import fr.dcproject.component.constitution.database.ConstitutionRepository
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import fr.postgresjson.repository.RepositoryI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -27,10 +32,27 @@ object FindConstitutions {
val sort: String? = null,
val direction: RepositoryI.Direction? = null,
val search: String? = null
) : PaginatedRequestI by PaginatedRequest(page, limit)
) : PaginatedRequestI by PaginatedRequest(page, limit) {
fun validate() = Validation<FindConstitutionsRequest> {
FindConstitutionsRequest::page {
minimum(1)
}
FindConstitutionsRequest::limit {
minimum(1)
maximum(50)
}
FindConstitutionsRequest::sort ifPresent {
enum(
"title",
"createdAt",
)
}
}.validate(this)
}
fun Route.findConstitutions(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
get<FindConstitutionsRequest> {
it.validate().badRequestIfNotValid()
val constitutions = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
ac.assert { canView(constitutions.result, citizenOrNull) }
call.respond(

View File

@@ -4,6 +4,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowForUpdate
@@ -25,6 +26,7 @@ object FollowArticle {
fun Route.followArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
post<ArticleFollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
ac.assert { canCreate(follow, citizenOrNull) }
repo.follow(follow)

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.follow.routes.article
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowArticleRepository
@@ -25,6 +26,7 @@ object GetMyFollowsArticle {
fun Route.getMyFollowsArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
get<CitizenFollowArticleRequest> {
mustBeAuth()
val follows = repo.findByCitizen(it.citizen)
ac.assert { canView(follows.result, citizenOrNull) }
call.respond(

View File

@@ -4,6 +4,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowForUpdate
@@ -25,6 +26,7 @@ object UnfollowArticle {
fun Route.unfollowArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
delete<ArticleFollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
ac.assert { canDelete(follow, citizenOrNull) }
repo.unfollow(follow)

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowConstitutionRepository
@@ -25,6 +26,7 @@ object FollowConstitution {
fun Route.followConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
post<ConstitutionFollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
ac.assert { canCreate(follow, citizenOrNull) }
repo.follow(follow)

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowConstitutionRepository
@@ -25,6 +26,7 @@ object GetMyFollowsConstitution {
fun Route.getMyFollowsConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
get<CitizenFollowConstitutionRequest> {
mustBeAuth()
val follows = repo.findByCitizen(it.citizen)
ac.assert { canView(follows.result, citizenOrNull) }
call.respond(

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowConstitutionRepository
@@ -25,6 +26,7 @@ object UnfollowConstitution {
fun Route.unfollowConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
delete<ConstitutionUnfollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
ac.assert { canDelete(follow, citizenOrNull) }
repo.unfollow(follow)

View File

@@ -1,5 +1,7 @@
package fr.dcproject.component.notification
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.SerializationFeature
@@ -9,9 +11,15 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import fr.dcproject.common.entity.Entity
import fr.dcproject.component.article.database.ArticleForView
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import org.joda.time.DateTime
import java.util.concurrent.atomic.AtomicInteger
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
@JsonSubTypes(
JsonSubTypes.Type(value = ArticleUpdateNotification::class, name = "article")
)
open class Notification(
val type: String,
val createdAt: DateTime = DateTime.now()
@@ -44,6 +52,14 @@ open class Notification(
inline fun <reified T : Notification> fromString(raw: String): T = mapper.readValue(raw)
}
fun getValidation() = Validation<Notification> {
Notification::type {
enum(
"article"
)
}
}
}
open class EntityNotification(

View File

@@ -1,6 +1,7 @@
package fr.dcproject.component.notification
import com.fasterxml.jackson.core.JsonProcessingException
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.citizen.database.CitizenI
import io.ktor.http.cio.websocket.Frame
@@ -28,12 +29,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
class NotificationsPush private constructor(
class NotificationsPush (
private val redis: RedisAsyncCommands<String, String>,
private val redisConnectionPubSub: StatefulRedisPubSubConnection<String, String>,
citizen: CitizenI,
incoming: Flow<Notification>,
onRecieve: suspend (Notification) -> Unit,
onReceive: suspend (Notification) -> Unit,
) {
class Builder(val redisClient: RedisClient) {
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis")
@@ -43,8 +44,8 @@ class NotificationsPush private constructor(
fun build(
citizen: CitizenI,
incoming: Flow<Notification>,
onRecieve: suspend (Notification) -> Unit,
): NotificationsPush = NotificationsPush(redis, redisConnectionPubSub, citizen, incoming, onRecieve)
onReceive: suspend (Notification) -> Unit,
): NotificationsPush = NotificationsPush(redis, redisConnectionPubSub, citizen, incoming, onReceive)
@ExperimentalCoroutinesApi
fun build(ws: DefaultWebSocketServerSession): NotificationsPush {
@@ -52,7 +53,10 @@ class NotificationsPush private constructor(
val incomingFlow: Flow<Notification> = ws.incoming.consumeAsFlow()
.mapNotNull<Frame, Text> { it as? Frame.Text }
.map { it.readText() }
.map { Notification.fromString(it) }
.map {
Notification.fromString<Notification>(it)
.apply { getValidation().validate(this).badRequestIfNotValid() }
}
return build(ws.call.citizen, incomingFlow) {
ws.outgoing.send(Text(it.toString()))
@@ -69,7 +73,7 @@ class NotificationsPush private constructor(
override fun message(pattern: String?, channel: String?, message: String?) {
runBlocking {
getNotifications().collect {
onRecieve(it)
onReceive(it)
}
}
}
@@ -85,10 +89,12 @@ class NotificationsPush private constructor(
/* Get old notification and sent it to websocket */
runBlocking {
getNotifications().collect { onRecieve(it) }
getNotifications().collect {
onReceive(it)
}
}
/* Lisen redis event, and sent the new notification into websocket */
/* Listen redis event, and sent the new notification into websocket */
redisConnectionPubSub.run {
addListener(listener)

View File

@@ -5,6 +5,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.opinion.OpinionAccessControl
import fr.dcproject.component.opinion.database.Opinion
@@ -31,6 +32,7 @@ object GetCitizenOpinions {
fun Route.getCitizenOpinions(repo: OpinionArticleRepository, ac: OpinionAccessControl) {
get<CitizenOpinions> {
mustBeAuth()
val opinionsEntities: List<Opinion<ArticleRef>> = repo.findCitizenOpinionsByTargets(it.citizen, it.id)
ac.assert { canView(opinionsEntities, citizenOrNull) }

View File

@@ -5,6 +5,7 @@ import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.opinion.OpinionAccessControl
import fr.dcproject.component.opinion.database.Opinion
@@ -37,6 +38,7 @@ object GetMyOpinionsArticle {
fun Route.getMyOpinionsArticle(repo: OpinionArticleRepository, ac: OpinionAccessControl) {
get<CitizenOpinionsArticleRequest> {
mustBeAuth()
val opinions: Paginated<Opinion<TargetRef>> = repo.findCitizenOpinions(citizen, it.page, it.limit)
ac.assert { canView(opinions.result, citizenOrNull) }
call.respond(

View File

@@ -6,6 +6,7 @@ import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.opinion.OpinionAccessControl
import fr.dcproject.component.opinion.database.OpinionChoiceRef
import fr.dcproject.component.opinion.database.OpinionForUpdate
@@ -34,6 +35,7 @@ object OpinionArticle {
fun Route.setOpinionOnArticle(repo: OpinionArticleRepository, ac: OpinionAccessControl) {
put<ArticleOpinion> {
mustBeAuth()
call.receiveOrBadRequest<ArticleOpinion.Body>().ids.map { id ->
OpinionForUpdate(
choice = OpinionChoiceRef(id),

View File

@@ -1,8 +1,8 @@
package fr.dcproject.component.views
import fr.dcproject.application.Configuration
import fr.dcproject.component.article.ArticleViewManager
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.article.database.ArticleViewRepository
import org.apache.http.HttpHost
import org.elasticsearch.client.RestClient
import org.koin.dsl.module
@@ -17,6 +17,6 @@ val viewKoinModule = module {
).build().apply {
createEsIndexForViews()
}
ArticleViewManager<ArticleForView>(esClient)
ArticleViewRepository<ArticleForView>(esClient)
}
}

View File

@@ -2,14 +2,13 @@ package fr.dcproject.component.views
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.views.entity.ViewAggregation
import org.elasticsearch.client.Response
import org.joda.time.DateTime
interface ViewManager <T> {
interface ViewRepository <T> {
/**
* Add view to one entity
*/
fun addView(ip: String, entity: T, citizen: CitizenI? = null, dateTime: DateTime = DateTime.now()): Response?
fun addView(ip: String, entity: T, citizen: CitizenI? = null, dateTime: DateTime = DateTime.now())
/**
* Get Views aggregations

View File

@@ -4,6 +4,7 @@ import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.vote.VoteAccessControl
import fr.dcproject.component.vote.database.VoteRepository
@@ -26,6 +27,7 @@ object GetCitizenVotes {
fun Route.getCitizenVote(repo: VoteRepository, ac: VoteAccessControl) {
get<CitizenVotesRequest> {
mustBeAuth()
val votes = repo.findCitizenVotesByTargets(it.citizen, it.id)
if (votes.isNotEmpty()) {
ac.assert { canView(votes, citizenOrNull) }

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.vote.routes
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.vote.VoteAccessControl
import fr.dcproject.component.vote.database.VoteArticleRepository
@@ -31,6 +32,7 @@ object GetCitizenVotesOnArticle {
fun Route.getCitizenVotesOnArticle(repo: VoteArticleRepository, ac: VoteAccessControl) {
get<CitizenVoteArticleRequest> {
mustBeAuth()
val votes = repo.findByCitizen(it.citizen, it.page, it.limit)
ac.assert { canView(votes.result, citizenOrNull) }

View File

@@ -6,6 +6,7 @@ import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.vote.VoteAccessControl
import fr.dcproject.component.vote.database.VoteArticleRepository
import fr.dcproject.component.vote.database.VoteForUpdate
@@ -29,6 +30,7 @@ object PutVoteOnArticle {
fun Route.putVoteOnArticle(repo: VoteArticleRepository, ac: VoteAccessControl, articleRepo: ArticleRepository) {
put<ArticleVoteRequest> {
mustBeAuth()
val input = call.receiveOrBadRequest<ArticleVoteRequest.Input>()
val article = articleRepo.findById(it.article.id) ?: throw NotFoundException("Article ${it.article.id} not found")
val vote = VoteForUpdate(

View File

@@ -4,6 +4,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.comment.generic.database.CommentRepository
import fr.dcproject.component.vote.VoteAccessControl
import fr.dcproject.component.vote.database.VoteCommentRepository
@@ -26,6 +27,7 @@ object PutVoteOnComment {
fun Route.putVoteOnComment(voteCommentRepo: VoteCommentRepository, commentRepo: CommentRepository, ac: VoteAccessControl) {
put<CommentVoteRequest> {
mustBeAuth()
val comment = commentRepo.findById(it.comment)!!
val content = call.receiveOrBadRequest<CommentVoteRequest.Content>()
val vote = VoteForUpdate(

View File

@@ -4,6 +4,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.constitution.database.ConstitutionRepository
import fr.dcproject.component.vote.VoteAccessControl
@@ -30,6 +31,7 @@ object PutVoteOnConstitution {
fun Route.voteConstitution(repo: VoteConstitutionRepository, ac: VoteAccessControl, constitutionRepo: ConstitutionRepository) {
put<ConstitutionVoteRequest> {
mustBeAuth()
val constitution = constitutionRepo.findById(it.constitution.id) ?: throw NotFoundException("Unable to find constitution ${it.constitution.id}")
val content = call.receiveOrBadRequest<Input>()
val vote = VoteForUpdate(

View File

@@ -5,6 +5,7 @@ import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupForUpdate
import fr.dcproject.component.workgroup.database.WorkgroupRepository
@@ -33,6 +34,7 @@ object CreateWorkgroup {
fun Route.createWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
post<PostWorkgroupRequest> {
mustBeAuth()
call.receiveOrBadRequest<Input>().run {
WorkgroupForUpdate(
id ?: UUID.randomUUID(),

View File

@@ -2,6 +2,7 @@ package fr.dcproject.component.workgroup.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupRepository
import io.ktor.application.call
@@ -20,6 +21,7 @@ object DeleteWorkgroup {
fun Route.deleteWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
delete<DeleteWorkgroupRequest> {
mustBeAuth()
repo.findById(it.workgroupId)?.let { workgroup ->
ac.assert { canDelete(workgroup, citizenOrNull) }
repo.delete(workgroup)

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.workgroup.routes
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupForUpdate
import fr.dcproject.component.workgroup.database.WorkgroupRepository
@@ -31,6 +32,7 @@ object EditWorkgroup {
fun Route.editWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
put<PutWorkgroupRequest> {
mustBeAuth()
repo.findById(it.workgroupId)?.let { old ->
call.receiveOrBadRequest<Input>().run {
WorkgroupForUpdate(

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.workgroup.routes.members
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupRepository
@@ -44,6 +45,7 @@ object AddMemberToWorkgroup {
fun Route.addMemberToWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
/* Add members to workgroup */
post<WorkgroupsMembersRequest> {
mustBeAuth()
repo.findById(it.workgroupId)?.let { workgroup ->
call.getMembersFromRequest().let { members ->
ac.assert { canAddMembers(workgroup, citizenOrNull) }

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.workgroup.routes.members
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupRepository
@@ -35,6 +36,7 @@ object DeleteMembersOfWorkgroup {
fun Route.deleteMemberOfWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
/* Delete members of workgroup */
delete<WorkgroupsMembersRequest> {
mustBeAuth()
repo.findById(it.workgroupId)?.let { workgroup ->
call.getMembersFromRequest()
.let { members ->

View File

@@ -3,6 +3,7 @@ package fr.dcproject.component.workgroup.routes.members
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.workgroup.WorkgroupAccessControl
import fr.dcproject.component.workgroup.database.WorkgroupRepository
@@ -42,6 +43,7 @@ object UpdateMemberOfWorkgroup {
fun Route.updateMemberOfWorkgroup(repo: WorkgroupRepository, ac: WorkgroupAccessControl) {
/* Update members of workgroup */
put<WorkgroupsMembersRequest> {
mustBeAuth()
repo.findById(it.workgroupId)?.let { workgroup ->
call.getMembersFromRequest().let { members ->
ac.assert { canUpdateMembers(workgroup, citizenOrNull) }

View File

@@ -42,3 +42,11 @@ mail {
key = ${?SEND_GRID_KEY}
}
}
jwt {
secret = ${?JWT_SECRET}
issuer = "dc-project.fr"
issuer = ${?JWT_ISSUER}
validity = 36000000
validity = ${?JWT_VALIDITY}
}

View File

@@ -41,6 +41,12 @@ paths:
maxItems: 50
items:
$ref: '#/components/schemas/ArticleListingResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
post:
security:
- JWTAuth: []
@@ -65,16 +71,21 @@ paths:
Limit power of press
content:
type: string
minLength: 50
maxLength: 6000
example:
Lorem upsum...
description:
type: string
minLength: 50
maxLength: 6000
example:
I think is the bether choice
tags:
type: array
items:
type: string
maxItems: 15
default: [ ]
example: [ power, press ]
anonymous:
@@ -106,18 +117,40 @@ paths:
format: uuid
versionNumber:
type: integer
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
403:
description: Forbiden
content:
application/json:
schema:
description: Forbiden
properties:
statusCode:
type: integer
title:
type: string
/articles/{article}:
parameters:
- $ref: '#/components/parameters/article'
get:
security:
- JWTAuth: []
summary: Get one article
tags:
- article
operationId: getArticle
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/sort'
- $ref: '#/components/parameters/direction'
- $ref: '#/components/parameters/search'
responses:
200:
description: The Article objects
@@ -125,6 +158,19 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ArticleResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
404:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/404'
/articles/{article}/versions:
parameters:
- $ref: '#/components/parameters/article'
@@ -133,6 +179,12 @@ paths:
tags:
- article
operationId: getArticleVersions
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/sort'
- $ref: '#/components/parameters/direction'
- $ref: '#/components/parameters/search'
responses:
200:
description: The versions of Article
@@ -183,6 +235,12 @@ paths:
format: uuid
name:
type: string
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
/login:
post:
@@ -300,7 +358,7 @@ paths:
content:
application/json:
schema:
description: sdf
$ref: '#/components/schemas/400'
/auth/passwordless:
post:
summary: Send a connexion link by email
@@ -344,7 +402,7 @@ paths:
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/sort'
- $ref: '#/components/parameters/citizenSort'
- $ref: '#/components/parameters/direction'
- $ref: '#/components/parameters/search'
responses:
@@ -361,6 +419,12 @@ paths:
type: array
items:
$ref: '#/components/schemas/CitizenListResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
/citizens/current:
@@ -433,6 +497,10 @@ paths:
description: Password changed
400:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
404:
@@ -454,13 +522,13 @@ paths:
in: query
required: false
example:
- created_at
- createdAt
- votes
schema:
type: string
default: created_at
default: createdAt
enum:
- created_at
- createdAt
- votes
responses:
200:
@@ -476,6 +544,12 @@ paths:
type: array
items:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
post:
security:
- JWTAuth: [ ]
@@ -493,8 +567,10 @@ paths:
properties:
content:
type: string
minLength: 20
maxLength: 6000
example:
Lorem ipsum...
Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum...
responses:
201:
description: Return created Comment
@@ -502,6 +578,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
/comments/{comment}:
@@ -518,6 +600,42 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CommentResponse'
post:
security:
- JWTAuth: []
summary: create comment
tags:
- comment
requestBody:
content:
application/json:
schema:
required:
- content
properties:
content:
type: string
minLength: 20
maxLength: 6000
example:
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
responses:
201:
description: Return updated comment
content:
application/json:
schema:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
404:
description: No comment found
put:
security:
- JWTAuth: []
@@ -533,8 +651,10 @@ paths:
properties:
content:
type: string
minLength: 20
maxLength: 6000
example:
Lorem ipsum...
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
responses:
200:
description: Return updated comment
@@ -542,6 +662,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
/comments/{comment}/children:
@@ -627,13 +753,42 @@ paths:
tags:
- comment
- constitution
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/search'
- name: sort
in: query
required: false
example:
- createdAt
- votes
schema:
type: string
default: createdAt
enum:
- createdAt
- votes
responses:
200:
description: Return Comment and children
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Paginated'
- type: object
properties:
result:
type: array
items:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
post:
security:
- JWTAuth: []
@@ -650,8 +805,10 @@ paths:
properties:
content:
type: string
minLength: 20
maxLength: 6000
example:
Lorem ipsum...
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
responses:
201:
description: Return created comment
@@ -659,6 +816,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CommentResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
401:
$ref: '#/components/responses/401'
@@ -688,6 +851,12 @@ paths:
type: array
items:
$ref: '#/components/schemas/ConstitutionListingResponse'
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
post:
security:
- JWTAuth: [ ]
@@ -712,7 +881,11 @@ paths:
401:
$ref: '#/components/responses/401'
400:
$ref: '#/components/responses/400'
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
/constitutions/{constitution}:
parameters:
- $ref: '#/components/parameters/constitution'
@@ -1126,8 +1299,6 @@ paths:
/workgroups:
get:
summary: Get all Workgroup (Paginated)
security:
- JWTAuth: [ ]
tags:
- workgroup
parameters:
@@ -1194,8 +1365,6 @@ paths:
- $ref: '#/components/parameters/workgroup'
get:
summary: Get one workgroup by ID
security:
- JWTAuth: [ ]
tags:
- workgroup
responses:
@@ -1367,6 +1536,17 @@ components:
- createdAt
- vote
- popularity
citizenSort:
name: sort
in: query
description: The sort field name
example: createdAt
required: false
schema:
type: string
enum:
- title
- createdAt
workgroupSort:
name: sort
in: query
@@ -1849,6 +2029,8 @@ components:
$ref: '#/components/schemas/UUID'
title:
type: string
minLength: 10
maxLength: 80
example:
Constitution for the liberty
titles:
@@ -1864,6 +2046,8 @@ components:
$ref: '#/components/schemas/UUID'
name:
type: string
minLength: 10
maxLength: 80
example:
The liberties
articles:
@@ -2203,6 +2387,47 @@ components:
- REPORTER
example: MASTER
400:
description: Bad Request
required:
- title
- invalidParams
additionalProperties: false
properties:
statusCode:
type: integer
example: 400
title:
type: string
example: Bad Request
invalidParams:
type: array
items:
required:
- name
- reason
properties:
name:
type: string
example: '.title'
reason:
type: string
example: 'Cannot be null'
404:
description: Not Found
required:
- title
- statusCode
additionalProperties: false
properties:
statusCode:
type: integer
example: 404
title:
type: string
example: Bad Request
securitySchemes:
JWTAuth:
type: http

View File

@@ -45,7 +45,7 @@ begin
case direction when 'asc' then
case sort
when 'title' then a.title
when 'created_at' then a.created_at::text
when 'createdAt' then a.created_at::text
when 'vote' then ca.score::text
when 'popularity' then ca.total::text
else null
@@ -54,7 +54,7 @@ begin
case direction when 'desc' then
case sort
when 'title' then a.title
when 'created_at' then a.created_at::text
when 'createdAt' then a.created_at::text
when 'vote' then ca.score::text
when 'popularity' then ca.total::text
end

View File

@@ -23,14 +23,14 @@ begin
case direction when 'asc' then
case sort
when 'name' then (z.name->'first_name')::text
when 'created_at' then z.created_at::text
when 'createdAt' then z.created_at::text
else null
end
end,
case direction when 'desc' then
case sort
when 'name' then (z.name->'first_name')::text
when 'created_at' then z.created_at::text
when 'createdAt' then z.created_at::text
end
end
desc,

View File

@@ -1,4 +1,4 @@
create or replace function comment(reference regclass, resource json, out _id uuid)
create or replace function comment(reference regclass, inout resource json)
language plpgsql as
$$
declare
@@ -17,7 +17,8 @@ begin
else
raise exception 'comment with target as "%", is not implemented', reference::text;
end if;
_id = _new_id;
select find_comment_by_id(_new_id) into resource;
end;
$$;

View File

@@ -1,9 +1,11 @@
create or replace function edit_comment(_id uuid, _content text) returns void
create or replace function edit_comment(_id uuid, _content text, out resource json)
language plpgsql as
$$
begin
update comment c set
"content" = _content
where c.id = _id;
select find_comment_by_id(_id) into resource;
end;
$$;

View File

@@ -26,7 +26,7 @@ begin
else null
end desc,
case sort
when 'created_at' then com.created_at::text
when 'createdAt' then com.created_at::text
else null
end desc,
com.created_at desc

View File

@@ -22,14 +22,14 @@ begin
case direction when 'asc' then
case sort
when 'title' then c.title
when 'created_at' then c.created_at::text
when 'createdAt' then c.created_at::text
else null
end
end,
case direction when 'desc' then
case sort
when 'title' then c.title
when 'created_at' then c.created_at::text
when 'createdAt' then c.created_at::text
end
end
desc,

View File

@@ -21,7 +21,7 @@ begin
f.created_at,
f.target_reference,
json_build_object('id', f.target_id) as target,
json_build_object('id', f.created_by_id) as created_by
find_citizen_by_id_with_user(f.created_by_id) as created_by
from follow_article as f
join article a on f.target_id = a.id
where a.version_id = _version_id

View File

@@ -0,0 +1,7 @@
package assert
import kotlin.test.assertTrue
infix fun IntProgression.assertContain(expected: Int) {
assertTrue(this.contains(expected), "Expected $this less than $expected")
}

View File

@@ -22,7 +22,7 @@ import org.koin.test.get
@KtorExperimentalLocationsAPI
@KtorExperimentalAPI
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("functional"))
@Tags(Tag("functional"), Tag("mail"))
class MailerTest : KoinTest, AutoCloseKoinTest() {
@InternalCoroutinesApi
@ExperimentalCoroutinesApi

View File

@@ -33,7 +33,7 @@ import org.junit.jupiter.api.TestInstance
import org.slf4j.LoggerFactory
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
@Tags(Tag("functional"))
@Tags(Tag("functional"), Tag("notification"))
class NotificationConsumerTest {
companion object {
@BeforeAll

View File

@@ -24,13 +24,13 @@ import org.junit.jupiter.api.Tags
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@Tags(Tag("functional"))
@Tags(Tag("functional"), Tag("notification"))
internal class NotificationsPushTest {
companion object {
@BeforeAll
@JvmStatic
fun before() {
val config: Configuration = Configuration("application-test.conf")
val config = Configuration("application-test.conf")
RedisClient.create(config.redis).connect().sync().flushall()
/* Purge rabbit notification queues */
@@ -45,7 +45,7 @@ internal class NotificationsPushTest {
@Test
fun `Notification from redis is well catch and return`() = runBlocking {
val config: Configuration = Configuration("application-test.conf")
val config = Configuration("application-test.conf")
/* Redis client for test */
val redisClientTest = RedisClient.create(config.redis)
@@ -74,7 +74,7 @@ internal class NotificationsPushTest {
}
val notifAfterSubscribe = ArticleUpdateNotification(article)
/* init event for emulate incomint message from websocket */
/* init event for emulate incoming message from websocket */
val event = MutableSharedFlow<Notification>()
val incomingFlow = event.asSharedFlow()

View File

@@ -8,7 +8,7 @@ import org.junit.jupiter.api.TestInstance
import kotlin.test.assertEquals
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("functional"))
@Tags(Tag("functional"), Tag("utils"))
class ResourcesKtTest {
@Test
fun readResource() {

View File

@@ -2,8 +2,8 @@ package functional
import fr.dcproject.application.Env.TEST
import fr.dcproject.application.module
import fr.dcproject.component.article.ArticleViewManager
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.article.database.ArticleViewRepository
import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI
@@ -25,7 +25,7 @@ import java.util.UUID
@KtorExperimentalAPI
@ExperimentalCoroutinesApi
@TestInstance(PER_CLASS)
@Tags(Tag("functional"))
@Tags(Tag("functional"), Tag("view"))
class ViewTest {
@Test
fun `test View Article`() {
@@ -44,33 +44,33 @@ class ViewTest {
val citizenRef = CitizenRef()
withTestApplication({ module(TEST) }) {
val viewManager: ArticleViewManager<ArticleForView> = application.get()
val viewRepository: ArticleViewRepository<ArticleForView> = application.get()
/* Get view before */
val startView = viewManager.getViewsCount(article)
val startView = viewRepository.getViewsCount(article)
/* Add View */
viewManager.addView(
viewRepository.addView(
"1.2.3.4",
article,
citizenRef
)
/* Add View */
viewManager.addView(
viewRepository.addView(
"10.10.10.10",
article,
citizenRef
)
/* Add View */
viewManager.addView(
viewRepository.addView(
"8.8.8.8",
article
)
/* Add View */
viewManager.addView(
viewRepository.addView(
"1.1.1.1",
article
)
@@ -79,7 +79,7 @@ class ViewTest {
Thread.sleep(1000)
/* Get view */
val afterView = viewManager.getViewsCount(article)
val afterView = viewRepository.getViewsCount(article)
/* Check if view has increment */
afterView.total `should be equal to` startView.total + 4

View File

@@ -1,5 +1,7 @@
package integration
import fr.dcproject.common.utils.toUUID
import integration.steps.`when`.Validate
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.`when`.`with body`
@@ -12,11 +14,15 @@ import integration.steps.given.`authenticated as`
import integration.steps.then.`And have property`
import integration.steps.then.`And the response should contain list`
import integration.steps.then.`And the response should contain pattern`
import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`And the response should not contain`
import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains`
import integration.steps.then.`which contains`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Forbidden
import io.ktor.http.HttpStatusCode.Companion.NotFound
import io.ktor.http.HttpStatusCode.Companion.OK
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags
@@ -30,13 +36,24 @@ class `Article routes` : BaseTest() {
fun `I can get article list`() {
withIntegrationApplication {
`Given I have articles`(3)
`When I send a GET request`("/articles") `Then the response should be` OK and {
`Given I have article`(createdBy = "ddb17f17-e8ab-4ada-bdf7-bfd6b0f1b5ed".toUUID())
`When I send a GET request`("/articles?page=1&limit=10&sort=title&createdBy=ddb17f17-e8ab-4ada-bdf7-bfd6b0f1b5ed") `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain pattern`("$.result[0].createdBy.name.firstName", "firstName.+")
`And the response should contain pattern`("$.result[1].createdBy.name.firstName", "firstName.+")
`And the response should contain pattern`("$.result[2].createdBy.name.firstName", "firstName.+")
`And the response should not contain`("$.result[3]")
`And the response should contain list`("$.result", 3, 3)
`And the response should not contain`("$.result[1]")
`And the response should contain list`("$.result", 1)
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get article list`() {
withIntegrationApplication {
`Given I have articles`(3)
`When I send a GET request`("/articles?page=1&limit=10&sort=title&createdBy=hello", Validate.ALL - Validate.REQUEST_PARAM) `Then the response should be` BadRequest and {
`And the response should contain`("$.invalidParams[*].name", ".createdBy")
`And the response should contain`("$.invalidParams[*].reason", "must be UUID")
}
}
}
@@ -49,8 +66,8 @@ class `Article routes` : BaseTest() {
`Given I have article created by workgroup`("2bccd5a7-9082-4b31-88f8-e25d70b22b12")
`When I send a GET request`("/articles?workgroup=2bccd5a7-9082-4b31-88f8-e25d70b22b12") `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.total") `whish contains` 1
`And have property`("$.result[0]workgroup.name") `whish contains` "Les papy"
`And have property`("$.total") `which contains` 1
`And have property`("$.result[0]workgroup.name") `which contains` "Les papy"
}
}
}
@@ -61,7 +78,31 @@ class `Article routes` : BaseTest() {
`Given I have article`(id = "65cda9f3-8991-4420-8d41-1da9da72c9bb")
`When I send a GET request`("/articles/65cda9f3-8991-4420-8d41-1da9da72c9bb") `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.id") `whish contains` "65cda9f3-8991-4420-8d41-1da9da72c9bb"
`And have property`("$.id") `which contains` "65cda9f3-8991-4420-8d41-1da9da72c9bb"
}
}
}
@Test
fun `I cannot get article with id doesn't exist`() {
withIntegrationApplication {
`When I send a GET request`("/articles/635fe2e8-2dbc-4c80-b306-101d38a4ab23") `Then the response should be` NotFound and {
`And the response should not be null`()
`And the response should contain`("$.title", "Article 635fe2e8-2dbc-4c80-b306-101d38a4ab23 not found")
`And the response should contain`("$.statusCode", 404)
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get article by id with wrong id format`() {
withIntegrationApplication {
`Given I have article`(id = "65cda9f3-8991-4420-8d41-1da9da72c9bb")
`When I send a GET request`("/articles/abcd") `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", "ID")
`And the response should contain`("$.invalidParams[0].reason", "must be UUID")
}
}
}
@@ -70,10 +111,36 @@ class `Article routes` : BaseTest() {
fun `I can get versions of article by the id`() {
withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions") `Then the response should be` OK and {
`When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions?page=1&limit=10&sort=title") `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.total") `whish contains` 1
`And have property`("$.result[0].id") `whish contains` "13e6091c-8fed-4600-b079-a97a6b7a9800"
`And have property`("$.total") `which contains` 1
`And have property`("$.result[0].id") `which contains` "13e6091c-8fed-4600-b079-a97a6b7a9800"
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get versions of article by the id with wrong id`() {
withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/abcd/versions") `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".article")
`And the response should contain`("$.invalidParams[0].reason", "must be UUID")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get versions of article by the id with wrong request`() {
withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions?page=1&limit=10&sort=wrong") `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".sort")
`And the response should contain pattern`("$.invalidParams[0].reason", "must be one of: ('[^']+'(, )?)+")
}
}
}
@@ -90,8 +157,8 @@ class `Article routes` : BaseTest() {
"versionId": "09c418b6-63ba-448b-b38b-502b41cd500e",
"title": "title2",
"anonymous": false,
"content": "content2",
"description": "description2",
"content": "Sed malesuada ante et sem congue, scelerisque feugiat lorem viverra.",
"description": "Sed vulputate, ligula id porta posuere, sapien lorem mattis arcu, sit amet luctus erat orci sed tellus.",
"tags": [
"green"
]
@@ -100,7 +167,65 @@ class `Article routes` : BaseTest() {
)
} `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.versionId") `whish contains` "09c418b6-63ba-448b-b38b-502b41cd500e"
`And have property`("$.versionId") `which contains` "09c418b6-63ba-448b-b38b-502b41cd500e"
}
}
}
@Test
@Tag("Forbidden")
fun `I cannot create an article if I'm not connected`() {
withIntegrationApplication {
`When I send a POST request`("/articles") {
`with body`(
"""
{
"versionId": "e3c7ce42-241c-4caf-9a59-aba4e466440e",
"title": "title2",
"anonymous": false,
"content": "Sed malesuada ante et sem congue, scelerisque feugiat lorem viverra.",
"description": "Sed vulputate, ligula id porta posuere, sapien lorem mattis arcu, sit amet luctus erat orci sed tellus.",
"tags": [
"green"
]
}
"""
)
} `Then the response should be` Forbidden and {
`And the response should not be null`()
`And the response should contain`("$.statusCode", 403)
`And the response should contain`("$.title", "No User Connected")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot create an article with wrong request`() {
withIntegrationApplication {
`Given I have citizen`("John", "Doe")
`When I send a POST request`("/articles", Validate.NONE) {
`authenticated as`("John", "Doe")
`with body`(
"""
{
"versionId": "09c418b6-63ba-448b-b38b-502b41cd500e",
"title": "title2",
"anonymous": false,
"content": "content2",
"description": "description2",
"tags": [
"green"
]
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".content")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 50 characters")
`And the response should contain`("$.invalidParams[1].name", ".description")
`And the response should contain`("$.invalidParams[1].reason", "must have at least 50 characters")
}
}
}

View File

@@ -0,0 +1,147 @@
package integration
import fr.dcproject.common.utils.getResource
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.openapi4j.core.model.OAIContext
import org.openapi4j.parser.OpenApi3Parser
import org.openapi4j.parser.model.v3.OpenApi3
import org.openapi4j.parser.model.v3.Operation
import org.openapi4j.parser.model.v3.Parameter
import org.openapi4j.parser.model.v3.Path
import java.io.File
import java.util.UUID
import kotlin.test.assertTrue
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("integration"), Tag("auth"))
class `Check auth on all routes` : BaseTest() {
@Test
fun `Check all routes`() {
val filePath = "/openapi.yaml"
OpenApi3Parser().parse(File(filePath.getResource().toURI()), true).let { api: OpenApi3 ->
/* Loop on paths and http methods */
api.paths.flatMap { (pathName: String, path: Path) ->
path.operations
/* Take only the secure route */
.filter { (_, operation: Operation) -> operation.hasSecurityRequirements() }
.map { (methodName, _) ->
/* Send request to check security */
sendRequest(
path.buildUrl(pathName, methodName, api.context), /* Replace route to real URL */
HttpMethod.parse(methodName.toUpperCase()) /* Convert http method name to enum */
)
}
}.let { requests ->
/* Check security of routes */
assertTrue(
requests.all { it.statusCode == HttpStatusCode.Forbidden },
requests
.filter { it.statusCode != HttpStatusCode.Forbidden }
.joinToString("\n") { it.toString() }
)
}
}
}
private fun sendRequest(uri: String, method: HttpMethod): RequestResponse {
return try {
withIntegrationApplication {
handleRequest(true) {
this.method = method
this.uri = uri
addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
addHeader(HttpHeaders.Accept, ContentType.Application.Json.toString())
}.run {
RequestResponse(
response.status() ?: error("Request error"),
method,
uri
)
}
}
} catch (e: Throwable) {
RequestResponse(
HttpStatusCode.InternalServerError,
method,
uri
)
}
}
private data class RequestResponse(
val statusCode: HttpStatusCode,
val method: HttpMethod,
val uri: String
) {
override fun toString(): String {
return """HttpStatus ${statusCode.value} for: ${method.value.padStart(6, ' ')} $uri"""
}
}
private fun Path.buildUrl(path: String, methodName: String, context: OAIContext): String {
val urlReplaced = this.getParametersIn(context, "path")
.fold(path) { pathToReplace: String, parameter: Parameter ->
"""\{${parameter.name}}""".toRegex().replace(
pathToReplace,
parameter.generateFakeValue()
)
}
val rootQueryParameters = this.getParametersIn(context, "query")
.filter { it.isRequired }
.map { parameter ->
parameter
.generateFakeArray()
.joinToString("&") { "${parameter.name}=$it" }
}
val queryParameters = this.getOperation(methodName).getParametersIn(context, "query")
.filter { it.isRequired }
.map { parameter ->
parameter
.generateFakeArray()
.joinToString("&") { "${parameter.name}=$it" }
}
val allParameters: String = (rootQueryParameters + queryParameters)
.joinToString("&")
.let {
if (it.isNotEmpty()) {
"?$it"
} else {
it
}
}
return "$urlReplaced$allParameters"
}
private fun Parameter.generateFakeValue(): String {
return if (example != null) {
example.toString()
} else if (schema.type == "string" && schema.format == "uuid") {
UUID.randomUUID().toString()
} else {
"example123"
}
}
private fun Parameter.generateFakeArray(): List<String> {
if (schema.type != "array") {
error("Parameter is not an array")
}
return if (example != null && example is Iterable<*>) {
(example as Iterable<*>).map { it.toString() }
} else if (schema.itemsSchema.type == "string" && schema.itemsSchema.format == "uuid") {
listOf(UUID.randomUUID().toString())
} else {
listOf("example123")
}
}
}

View File

@@ -9,7 +9,7 @@ import integration.steps.given.`authenticated as`
import integration.steps.then.`And have property`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains`
import integration.steps.then.`which contains`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created
@@ -26,7 +26,7 @@ class `Citizen routes` : BaseTest() {
fun `I can get Citizens information`() {
withIntegrationApplication {
`Given I have citizen`("Jean", "Perrin", id = "5267a5c6-af42-4a02-aa2b-6b71d2e43973")
`When I send a GET request`("/citizens") {
`When I send a GET request`("/citizens?page=1&limit=5&sort=createdAt") {
`authenticated as`("Jean", "Perrin")
} `Then the response should be` OK and {
`And the response should not be null`()
@@ -34,6 +34,19 @@ class `Citizen routes` : BaseTest() {
}
}
@Test
@Tag("BadRequest")
fun `I cannot get Citizens information with wrong request`() {
withIntegrationApplication {
`Given I have citizen`("Jean", "Perrin", id = "5267a5c6-af42-4a02-aa2b-6b71d2e43973")
`When I send a GET request`("/citizens?page=1&limit=5&sort=created_at", Validate.ALL - Validate.REQUEST_PARAM) {
`authenticated as`("Jean", "Perrin")
} `Then the response should be` BadRequest and {
`And the response should not be null`()
}
}
}
@Test
fun `I can get specific Citizen information`() {
withIntegrationApplication {
@@ -42,7 +55,7 @@ class `Citizen routes` : BaseTest() {
`authenticated as`("Linus", "Pauling")
} `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.id") `whish contains` "47a05c0f-7329-46c3-a7d0-325db37e9114"
`And have property`("$.id") `which contains` "47a05c0f-7329-46c3-a7d0-325db37e9114"
}
}
}
@@ -55,7 +68,7 @@ class `Citizen routes` : BaseTest() {
`authenticated as`("Henri", "Becquerel")
} `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.id") `whish contains` "47356809-c8ef-4649-8b99-1c5cb9886d38"
`And have property`("$.id") `which contains` "47356809-c8ef-4649-8b99-1c5cb9886d38"
}
}
}
@@ -69,8 +82,8 @@ class `Citizen routes` : BaseTest() {
`with body`(
"""
{
"oldPassword": "azerty",
"newPassword": "qwerty"
"oldPassword": "Azerty123!",
"newPassword": "Qwerty123!"
}
"""
)
@@ -79,6 +92,7 @@ class `Citizen routes` : BaseTest() {
}
@Test
@Tag("BadRequest")
fun `I cannot change my password if request is bad formatted`() {
withIntegrationApplication {
`Given I have citizen`("Louis", "Breguet", id = "6cf2a19d-d15d-4ee5-b2a9-907afd26b525")

View File

@@ -1,9 +1,11 @@
package integration
import fr.dcproject.component.citizen.database.CitizenI.Name
import integration.steps.`when`.Validate.ALL
import integration.steps.`when`.Validate.REQUEST_BODY
import integration.steps.`when`.Validate.REQUEST_PARAM
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.`when`.`When I send a PUT request`
import integration.steps.`when`.`with body`
import integration.steps.given.`Given I have article`
import integration.steps.given.`Given I have citizen`
@@ -13,6 +15,7 @@ import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created
import io.ktor.http.HttpStatusCode.Companion.OK
import org.junit.jupiter.api.Tag
@@ -33,14 +36,37 @@ class `Comment articles routes` : BaseTest() {
`with body`(
"""
{
"content": "Hello mister"
"content": "Hello mister MARABOUTCHA"
}
"""
)
} `Then the response should be` Created and {
`And the response should not be null`()
`And the response should contain`("$.target.id", "aa16c635-28da-46f0-9a89-934eef88c7ca")
`And the response should contain`("$.content", "Hello mister")
`And the response should contain`("$.content", "Hello mister MARABOUTCHA")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot comment article with bad request`() {
withIntegrationApplication {
`Given I have citizen`("Michael", "Faraday")
`Given I have article`(id = "aa16c635-28da-46f0-9a89-934eef88c7ca")
`When I send a POST request`("/articles/aa16c635-28da-46f0-9a89-934eef88c7ca/comments", ALL - REQUEST_BODY) {
`authenticated as`("Michael", "Faraday")
`with body`(
"""
{
"content": "To small content"
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".content")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters")
}
}
}
@@ -52,7 +78,7 @@ class `Comment articles routes` : BaseTest() {
`Given I have citizen`("Enrico", "Fermi")
`Given I have article`(id = "6166c078-ca97-4366-b0aa-2a5cd558c78a")
`Given I have comment on article`(article = "6166c078-ca97-4366-b0aa-2a5cd558c78a", createdBy = Name("Enrico", "Fermi"))
`When I send a GET request`("/articles/6166c078-ca97-4366-b0aa-2a5cd558c78a/comments") {
`When I send a GET request`("/articles/6166c078-ca97-4366-b0aa-2a5cd558c78a/comments?page=1&limit=40&sort=votes") {
`authenticated as`("Enrico", "Fermi")
} `Then the response should be` OK and {
`And the response should not be null`()
@@ -61,6 +87,23 @@ class `Comment articles routes` : BaseTest() {
}
}
@Test
@Tag("BadRequest")
fun `I cannot get all comment on article with wrong parameters`() {
withIntegrationApplication {
`Given I have citizen`("Enrico", "Fermi")
`Given I have article`(id = "6166c078-ca97-4366-b0aa-2a5cd558c78a")
`Given I have comment on article`(article = "6166c078-ca97-4366-b0aa-2a5cd558c78a", createdBy = Name("Enrico", "Fermi"))
`When I send a GET request`("/articles/6166c078-ca97-4366-b0aa-2a5cd558c78a/comments?page=1&limit=40&sort=wrong", ALL - REQUEST_PARAM) {
`authenticated as`("Enrico", "Fermi")
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[*].name", ".sort")
`And the response should contain`("$.invalidParams[*].reason", "must be one of: 'votes', 'createdAt'")
}
}
}
/* TODO add votes */
@Test
fun `I can get all comment on article sorted by votes`() {
@@ -84,6 +127,7 @@ class `Comment articles routes` : BaseTest() {
`Given I have article`(id = "17df7fb9-b388-4e20-ab19-29c29972da01", createdBy = Name("Erwin", "Schrodinger"))
`Given I have comment on article`(article = "17df7fb9-b388-4e20-ab19-29c29972da01", createdBy = Name("Erwin", "Schrodinger"))
`When I send a GET request`("/citizens/292a20cc-4a60-489e-9866-a95d38ffaf47/comments/articles") {
`authenticated as`("Erwin", "Schrodinger")
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.currentPage", 1)
@@ -92,45 +136,4 @@ class `Comment articles routes` : BaseTest() {
}
}
}
@Test
fun `I can edit comment`() {
withIntegrationApplication {
`Given I have citizen`("Hubert", "Reeves")
`Given I have article`(id = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1")
`Given I have comment on article`(article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1", createdBy = Name("Hubert", "Reeves"), id = "fd30d20f-656c-42c6-8955-f61c04537464")
`When I send a PUT request`("/comments/fd30d20f-656c-42c6-8955-f61c04537464") {
`authenticated as`("Hubert", "Reeves")
`with body`(
"""
{
"content": "Hello boy"
}
"""
)
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.content", "Hello boy")
}
}
}
@Test
fun `I can get comment by its ID`() {
withIntegrationApplication {
`Given I have citizen`("Alfred", "Kastler")
`Given I have article`(id = "3897465b-19d2-43a0-86ea-1e29dbb11ec9")
`Given I have comment on article`(
article = "3897465b-19d2-43a0-86ea-1e29dbb11ec9",
createdBy = Name("Alfred", "Kastler"),
id = "edd296a8-fc7a-4717-a2bb-9f035ceca3c2",
content = "Hello boy"
)
`When I send a GET request`("/comments/edd296a8-fc7a-4717-a2bb-9f035ceca3c2") {
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.content", "Hello boy")
}
}
}
}

View File

@@ -1,6 +1,9 @@
package integration
import fr.dcproject.component.citizen.database.CitizenI.Name
import integration.steps.`when`.Validate
import integration.steps.`when`.Validate.ALL
import integration.steps.`when`.Validate.REQUEST_BODY
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.`when`.`with body`
@@ -13,6 +16,7 @@ import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created
import io.ktor.http.HttpStatusCode.Companion.OK
import org.junit.jupiter.api.Tag
@@ -33,12 +37,69 @@ class `Comment constitutions routes` : BaseTest() {
`with body`(
"""
{
"content": "Hello mister"
"content": "Hello mister MARABOUTCHA"
}
"""
)
} `Then the response should be` Created and {
`And the response should not be null`()
`And the response should contain`("$.target.id", "1707c287-a472-4a62-89f2-9e85030e915c")
`And the response should contain`("$.content", "Hello mister MARABOUTCHA")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot comment constitution with bad request`() {
withIntegrationApplication {
`Given I have citizen`("Nicolas", "Copernic")
`Given I have constitution`(id = "aa16c635-28da-46f0-9a89-934eef88c7ca")
`When I send a POST request`("/constitutions/aa16c635-28da-46f0-9a89-934eef88c7ca/comments", ALL - REQUEST_BODY) {
`authenticated as`("Nicolas", "Copernic")
`with body`(
"""
{
"content": "To small content"
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".content")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters")
}
}
}
@Test
fun `I can get all comment on constitution`() {
withIntegrationApplication {
`Given I have citizen`("Enrico", "Fermi")
`Given I have constitution`(id = "6166c078-ca97-4366-b0aa-2a5cd558c78a")
`Given I have comment on constitution`(constitution = "6166c078-ca97-4366-b0aa-2a5cd558c78a", createdBy = Name("Enrico", "Fermi"))
`When I send a GET request`("/constitutions/6166c078-ca97-4366-b0aa-2a5cd558c78a/comments?page=1&limit=40&sort=votes") {
`authenticated as`("Enrico", "Fermi")
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.result[0].target.id", "6166c078-ca97-4366-b0aa-2a5cd558c78a")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get all comment on constitution with wrong parameters`() {
withIntegrationApplication {
`Given I have citizen`("Enrico", "Fermi")
`Given I have constitution`(id = "6166c078-ca97-4366-b0aa-2a5cd558c78a")
`Given I have comment on constitution`(constitution = "6166c078-ca97-4366-b0aa-2a5cd558c78a", createdBy = Name("Enrico", "Fermi"))
`When I send a GET request`("/constitutions/6166c078-ca97-4366-b0aa-2a5cd558c78a/comments?page=1&limit=40&sort=wrong", ALL - Validate.REQUEST_PARAM) {
`authenticated as`("Enrico", "Fermi")
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[*].name", ".sort")
`And the response should contain`("$.invalidParams[*].reason", "must be one of: 'votes', 'createdAt'")
}
}
}
@@ -50,13 +111,14 @@ class `Comment constitutions routes` : BaseTest() {
`Given I have constitution`(id = "34ddd50a-da00-4a90-a869-08baa2a121be", createdBy = Name("Charles", "Darwin"))
`Given I have comment on constitution`(constitution = "34ddd50a-da00-4a90-a869-08baa2a121be", createdBy = Name("Charles", "Darwin"))
`When I send a GET request`("/citizens/46e0bda9-ca6a-4c65-a58b-7e7267a0bbc5/comments/constitutions") {
`authenticated as`("Charles", "Darwin")
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.currentPage", 1)
`And the response should contain`("$.limit", 50)
`And the response should contain`("$.result[0].createdBy.id", "46e0bda9-ca6a-4c65-a58b-7e7267a0bbc5")
`And the response should contain`("$.result[0].target.id", "34ddd50a-da00-4a90-a869-08baa2a121be")
`And the response should contain list`("$.result[*]", 1, 1)
`And the response should contain list`("$.result[*]", 1)
}
}
}

View File

@@ -1,13 +1,23 @@
package integration
import fr.dcproject.component.citizen.database.CitizenI
import integration.steps.`when`.Validate.ALL
import integration.steps.`when`.Validate.REQUEST_BODY
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.`when`.`When I send a PUT request`
import integration.steps.`when`.`with body`
import integration.steps.given.`Given I have article`
import integration.steps.given.`Given I have citizen`
import integration.steps.given.`Given I have comment on article`
import integration.steps.given.`Given I have comment on comment`
import integration.steps.given.`authenticated as`
import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created
import io.ktor.http.HttpStatusCode.Companion.OK
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags
@@ -30,4 +40,126 @@ class `Comment routes` : BaseTest() {
}
}
}
@Test
fun `I can create comment`() {
withIntegrationApplication {
`Given I have citizen`("Hubert", "Reeves")
`Given I have comment on comment`(id = "49933147-fc0f-4e5c-aa8d-f77fa0d88fa6")
`When I send a POST request`("/comments/49933147-fc0f-4e5c-aa8d-f77fa0d88fa6") {
`authenticated as`("Hubert", "Reeves")
`with body`(
"""
{
"content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
}
"""
)
} `Then the response should be` Created and {
`And the response should not be null`()
`And the response should contain`("$.content", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot create comment with bad request`() {
withIntegrationApplication {
`Given I have citizen`("Hubert", "Reeves")
`Given I have comment on comment`(id = "49933147-fc0f-4e5c-aa8d-f77fa0d88fa6")
`When I send a POST request`("/comments/49933147-fc0f-4e5c-aa8d-f77fa0d88fa6", ALL - REQUEST_BODY) {
`authenticated as`("Hubert", "Reeves")
`with body`(
"""
{
"content": "small content"
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".content")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters")
}
}
}
@Test
fun `I can edit comment`() {
withIntegrationApplication {
`Given I have citizen`("Hubert", "Reeves")
`Given I have article`(id = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1")
`Given I have comment on article`(
article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1",
createdBy = CitizenI.Name(
"Hubert",
"Reeves"
),
id = "fd30d20f-656c-42c6-8955-f61c04537464"
)
`When I send a PUT request`("/comments/fd30d20f-656c-42c6-8955-f61c04537464") {
`authenticated as`("Hubert", "Reeves")
`with body`(
"""
{
"content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
}
"""
)
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.content", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
}
}
}
@Test
fun `I cannot edit comment with bad request`() {
withIntegrationApplication {
`Given I have citizen`("Hubert", "Reeves")
`Given I have article`(id = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1")
`Given I have comment on article`(
article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1",
createdBy = CitizenI.Name(
"Hubert",
"Reeves"
),
id = "fd30d20f-656c-42c6-8955-f61c04537464"
)
`When I send a PUT request`("/comments/fd30d20f-656c-42c6-8955-f61c04537464", ALL - REQUEST_BODY) {
`authenticated as`("Hubert", "Reeves")
`with body`(
"""
{
"content": "small content"
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".content")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters")
}
}
}
@Test
fun `I can get comment by its ID`() {
withIntegrationApplication {
`Given I have citizen`("Alfred", "Kastler")
`Given I have article`(id = "3897465b-19d2-43a0-86ea-1e29dbb11ec9")
`Given I have comment on article`(
article = "3897465b-19d2-43a0-86ea-1e29dbb11ec9",
createdBy = CitizenI.Name("Alfred", "Kastler"),
id = "edd296a8-fc7a-4717-a2bb-9f035ceca3c2",
content = "Hello boy"
)
`When I send a GET request`("/comments/edd296a8-fc7a-4717-a2bb-9f035ceca3c2") {
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.content", "Hello boy")
}
}
}
}

View File

@@ -1,6 +1,8 @@
package integration
import integration.steps.`when`.Validate
import integration.steps.`when`.Validate.ALL
import integration.steps.`when`.Validate.REQUEST_BODY
import integration.steps.`when`.Validate.REQUEST_PARAM
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.`when`.`with body`
@@ -9,9 +11,10 @@ import integration.steps.given.`Given I have constitution`
import integration.steps.given.`Given I have constitutions`
import integration.steps.given.`authenticated as`
import integration.steps.then.`And have property`
import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains`
import integration.steps.then.`which contains`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created
@@ -28,12 +31,25 @@ class `Constitution routes` : BaseTest() {
fun `I can get constitution list`() {
withIntegrationApplication {
`Given I have constitutions`(3)
`When I send a GET request`("/constitutions") `Then the response should be` OK and {
`When I send a GET request`("/constitutions?page=1&limit=10&sort=title&direction=desc") `Then the response should be` OK and {
`And the response should not be null`()
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot get constitution list with wrong request`() {
withIntegrationApplication {
`Given I have constitutions`(3)
`When I send a GET request`("/constitutions?page=1&limit=5000&sort=title&direction=desc", ALL - REQUEST_PARAM) `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".limit")
`And the response should contain`("$.invalidParams[0].reason", "must be at most '50'")
}
}
}
@Test
fun `I can get constitution by ID`() {
withIntegrationApplication {
@@ -41,7 +57,7 @@ class `Constitution routes` : BaseTest() {
`Given I have constitution`("0321c8d1-4ce3-4763-b5f4-a92611d280b4")
`When I send a GET request`("/constitutions/0321c8d1-4ce3-4763-b5f4-a92611d280b4") `Then the response should be` OK and {
`And the response should not be null`()
`And have property`("$.id") `whish contains` "0321c8d1-4ce3-4763-b5f4-a92611d280b4"
`And have property`("$.id") `which contains` "0321c8d1-4ce3-4763-b5f4-a92611d280b4"
}
}
}
@@ -70,11 +86,11 @@ class `Constitution routes` : BaseTest() {
"""
{
"versionId":"15814bb6-8d90-4c6a-a456-c3939a8ec75e",
"title":"Hello world!",
"title":"Cras sit amet sapien mattis nulla rutrum blandit.",
"anonymous":true,
"titles":[
{
"name":"plop"
"name":"Cras sit amet sapien mattis nulla rutrum blandit."
}
]
}
@@ -82,17 +98,18 @@ class `Constitution routes` : BaseTest() {
)
} `Then the response should be` Created and {
`And the response should not be null`()
`And have property`("$.versionId") `whish contains` "15814bb6-8d90-4c6a-a456-c3939a8ec75e"
`And have property`("$.title") `whish contains` "Hello world!"
`And have property`("$.versionId") `which contains` "15814bb6-8d90-4c6a-a456-c3939a8ec75e"
`And have property`("$.title") `which contains` "Cras sit amet sapien mattis nulla rutrum blandit."
}
}
}
@Test
@Tag("BadRequest")
fun `I cannot create an constitution if bad request`() {
withIntegrationApplication {
`Given I have citizen`("Henri", "Poincaré")
`When I send a POST request`("/constitutions", Validate.ALL - Validate.REQUEST_BODY) {
`When I send a POST request`("/constitutions", ALL - REQUEST_BODY) {
`authenticated as`("Henri", "Poincaré")
`with body`(
"""
@@ -112,4 +129,34 @@ class `Constitution routes` : BaseTest() {
} `Then the response should be` BadRequest
}
}
@Test
@Tag("BadRequest")
fun `I cannot create an constitution if request is not valid`() {
withIntegrationApplication {
`Given I have citizen`("Henri", "Poincaré")
`When I send a POST request`("/constitutions", ALL - REQUEST_BODY) {
`authenticated as`("Henri", "Poincaré")
`with body`(
"""
{
"versionId":"15814bb6-8d90-4c6a-a456-c3939a8ec75e",
"title":"too small",
"anonymous":true,
"titles":[
{
"name":"too small"
}
]
}
"""
)
} `Then the response should be` BadRequest and {
`And the response should contain`("$.invalidParams[0].name", ".title")
`And the response should contain`("$.invalidParams[0].reason", "must have at least 10 characters")
`And the response should contain`("$.invalidParams[1].name", ".titles[0].name")
`And the response should contain`("$.invalidParams[1].reason", "must have at least 10 characters")
}
}
}
}

View File

@@ -27,7 +27,7 @@ class `Login routes` : BaseTest() {
"""
{
"username": "niels-bohr",
"password": "azerty"
"password": "Azerty123!"
}
"""
)

View File

@@ -0,0 +1,78 @@
package integration
import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI.Name
import fr.dcproject.component.notification.ArticleUpdateNotification
import fr.dcproject.component.notification.Notification
import fr.dcproject.component.notification.Publisher
import integration.steps.given.`Given I have article`
import integration.steps.given.`Given I have citizen`
import integration.steps.given.`Given I have follow on article`
import integration.steps.given.`authenticated in url as`
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.readText
import kotlinx.coroutines.launch
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.koin.test.get
import kotlin.test.assertEquals
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("integration"), Tag("notification"))
class `Notification routes` : BaseTest() {
@Test
fun `I can send notification`() {
withIntegrationApplication {
`Given I have citizen`("John", "Doe", id = "1a34191a-9cde-45ba-8ac1-230138a102d3")
`Given I have article`(id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4", createdBy = Name(firstName = "John", lastName = "Doe"))
`Given I have follow on article`("John", "Doe", article = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4")
val notification = ArticleUpdateNotification(
ArticleForView(
id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4".toUUID(),
title = "MyTitle",
content = "myContent",
description = "myDescription",
createdBy = CitizenCreator(
id = "1a34191a-9cde-45ba-8ac1-230138a102d3".toUUID(),
name = Name(firstName = "John", lastName = "Doe"),
email = "john-doe@plop.com",
user = UserCreator(username = "john-doe"),
)
)
)
val publisher = get<Publisher>()
launch {
publisher
.publish(notification)
.await()
}
Thread.sleep(1000)
handleWebSocketConversation(
"/notifications",
{
`authenticated in url as`("John", "Doe")
}
) { incoming, outgoing ->
incoming.receive().let {
when (it) {
is Frame.Text -> Notification.fromString<ArticleUpdateNotification>(it.readText()).let { notif ->
assertEquals(
"a06cbfb7-3094-4d64-aaa1-7486c0c292f4",
notif.target.id.toString()
)
outgoing.send(it)
}
else -> error(it.toString())
}
}
}
}
}
}

View File

@@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("integration"), Tag("article"), Tag("opinion"))
@Tags(Tag("integration"), Tag("opinion"))
class `Opinion routes` : BaseTest() {
@Test
fun `I can get all opinion choices`() {
@@ -48,6 +48,7 @@ class `Opinion routes` : BaseTest() {
}
@Test
@Tag("article")
fun `I can create opinion on article`() {
withIntegrationApplication {
`Given I have citizen`("Isaac", "Newton", id = "2f414045-95d9-42ca-a3a9-8cdde52ad253")
@@ -89,6 +90,7 @@ class `Opinion routes` : BaseTest() {
}
@Test
@Tag("article")
fun `I can receive opinion aggregation with article`() {
withIntegrationApplication {
`Given I have an opinion choice`("Opinion6")
@@ -120,6 +122,7 @@ class `Opinion routes` : BaseTest() {
}
@Test
@Tag("article")
fun `I can get all my opinion of one article`() {
withIntegrationApplication {
`Given I have citizen`("Albert", "Einstein", id = "c1542096-3431-432d-8e35-9dc071d4c818")
@@ -134,7 +137,7 @@ class `Opinion routes` : BaseTest() {
`authenticated as`("Albert", "Einstein")
} `Then the response should be` OK and {
`And the response should contain`("$.result[0].name", "Opinion9")
`And the response should contain list`("$.result[*]", 1, 1)
`And the response should contain list`("$.result[*]", 1)
}
}
}

View File

@@ -29,7 +29,7 @@ class `Register routes` : BaseTest() {
"birthday": "2001-01-01",
"user":{
"username": "george-junior",
"password": "azerty"
"password": "Azerty123!"
},
"email": "george-junior@gmail.com"
}

View File

@@ -119,7 +119,7 @@ class `Workgroup routes` : BaseTest() {
`And the response should contain`("$.description", "Une petite souris")
`And have property`("$.members")
`And the response should contain list`("$.members", 3, 3)
`And the response should contain list`("$.members", 3)
`And the response should contain`("$.members.[1]citizen.id", "94f92424-c257-4582-907c-98564a8c4ac9")
`And the response should contain`("$.members.[2]citizen.id", "87909ba3-2069-431c-9924-219fd8411cf2")
}
@@ -215,7 +215,7 @@ class `Workgroup routes` : BaseTest() {
]
"""
} `Then the response should be` OK and {
`And the response should contain list`("$", 2, 2)
`And the response should contain list`("$", 2)
`And the response should contain`("$.[0]citizen.id", "94f92424-c257-4582-907c-98564a8c4ac9")
`And the response should contain`("$.[1]citizen.id", "1baf48bb-02bc-4d8f-ac86-33335354f5e7")
}
@@ -252,7 +252,7 @@ class `Workgroup routes` : BaseTest() {
"""
)
} `Then the response should be` OK and {
`And the response should contain list`("$", 2, 2)
`And the response should contain list`("$", 2)
`And the response should contain`("$.[0]citizen.id", "be3b0926-8628-4426-804a-75188a6eb315")
`And the response should contain`("$.[1]citizen.id", "b49e20c1-8393-45d6-a6a0-3fa5c71cbdc1")
}

Some files were not shown because too many files have changed in this diff Show More