From 27232c5ca9277820254afb9b40cb2130cee8286b Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Fri, 13 Mar 2020 21:05:09 +0100 Subject: [PATCH] #29 Implement Workgroup (route, voter, repo, entity) Create tests for workgroup routes add CitizenWithUserI --- src/main/kotlin/fr/dcproject/Application.kt | 31 +++++- src/main/kotlin/fr/dcproject/Module.kt | 2 + .../kotlin/fr/dcproject/entity/Citizen.kt | 12 ++- .../kotlin/fr/dcproject/entity/Workgroup.kt | 58 +++++++++++ .../fr/dcproject/repository/Workgroup.kt | 46 +++++++++ .../kotlin/fr/dcproject/routes/Workgroup.kt | 99 +++++++++++++++++++ .../security/voter/WorkgroupVoter.kt | 63 ++++++++++++ src/test/kotlin/feature/WorkgroupSteps.kt | 53 ++++++++++ src/test/resources/feature/workgroup.feature | 44 +++++++++ 9 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/fr/dcproject/entity/Workgroup.kt create mode 100644 src/main/kotlin/fr/dcproject/repository/Workgroup.kt create mode 100644 src/main/kotlin/fr/dcproject/routes/Workgroup.kt create mode 100644 src/main/kotlin/fr/dcproject/security/voter/WorkgroupVoter.kt create mode 100644 src/test/kotlin/feature/WorkgroupSteps.kt create mode 100644 src/test/resources/feature/workgroup.feature diff --git a/src/main/kotlin/fr/dcproject/Application.kt b/src/main/kotlin/fr/dcproject/Application.kt index d15fcdb..5257d6c 100644 --- a/src/main/kotlin/fr/dcproject/Application.kt +++ b/src/main/kotlin/fr/dcproject/Application.kt @@ -45,11 +45,13 @@ import org.slf4j.event.Level import java.time.Duration import java.util.* import java.util.concurrent.CompletionException +import fr.dcproject.entity.Workgroup as WorkgroupEntity import fr.dcproject.repository.Article as RepositoryArticle import fr.dcproject.repository.Citizen as RepositoryCitizen import fr.dcproject.repository.Constitution as RepositoryConstitution import fr.dcproject.repository.OpinionChoice as OpinionChoiceRepository import fr.dcproject.repository.User as UserRepository +import fr.dcproject.repository.Workgroup as WorkgroupRepository fun main(args: Array): Unit = io.ktor.server.jetty.EngineMain.main(args) @@ -97,7 +99,7 @@ fun Application.module(env: Env = PROD) { decode { values, _ -> values.singleOrNull()?.let { ArticleRef(UUID.fromString(it)) - } ?: throw NotFoundException("Article $values not found") + } ?: throw NotFoundException("""UUID "$values" is not valid for Article""") } } @@ -105,14 +107,14 @@ fun Application.module(env: Env = PROD) { decode { values, _ -> values.singleOrNull()?.let { CommentRef(UUID.fromString(it)) - } ?: throw NotFoundException("Comment $values not found") + } ?: throw NotFoundException("""UUID "$values" is not valid for Comment""") } } convert { decode { values, _ -> values.singleOrNull()?.let { ConstitutionRef(UUID.fromString(it)) - } ?: throw NotFoundException("Constitution $values not found") + } ?: throw NotFoundException("""UUID "$values" is not valid for Constitution""") } } @@ -136,7 +138,7 @@ fun Application.module(env: Env = PROD) { decode { values, _ -> values.singleOrNull()?.let { CitizenRef(UUID.fromString(it)) - } ?: throw NotFoundException("Citizen $values not found") + } ?: throw NotFoundException("""UUID "$values" is not valid for Citizen""") } } @@ -148,6 +150,23 @@ fun Application.module(env: Env = PROD) { ?: throw NotFoundException("OpinionChoice $values not found") } } + + convert { + decode { values, _ -> + values.singleOrNull()?.let { + CitizenRef(UUID.fromString(it)) + } ?: throw NotFoundException("""UUID "$values" is not valid for Workgroup""") + } + } + + convert { + decode { values, _ -> + val id = values.singleOrNull()?.let { UUID.fromString(it) } + ?: throw InternalError("Cannot convert $values to UUID") + get().findById(id) + ?: throw NotFoundException("Workgroup $values not found") + } + } } install(Locations) { @@ -162,7 +181,8 @@ fun Application.module(env: Env = PROD) { VoteVoter(), FollowVoter(), OpinionVoter(), - OpinionChoiceVoter() + OpinionChoiceVoter(), + WorkgroupVoter() ) } @@ -299,6 +319,7 @@ fun Application.module(env: Env = PROD) { voteConstitution(get()) opinionArticle(get()) opinionChoice(get()) + workgroup(get()) definition() } diff --git a/src/main/kotlin/fr/dcproject/Module.kt b/src/main/kotlin/fr/dcproject/Module.kt index 995b659..ae16703 100644 --- a/src/main/kotlin/fr/dcproject/Module.kt +++ b/src/main/kotlin/fr/dcproject/Module.kt @@ -37,6 +37,7 @@ import fr.dcproject.repository.User as UserRepository import fr.dcproject.repository.VoteArticle as VoteArticleRepository import fr.dcproject.repository.VoteComment as VoteCommentRepository import fr.dcproject.repository.VoteConstitution as VoteConstitutionRepository +import fr.dcproject.repository.Workgroup as WorkgroupRepository val config = Config() @@ -111,6 +112,7 @@ val Module = module { single { VoteCommentRepository(get()) } single { OpinionChoiceRepository(get()) } single { OpinionArticleRepository(get()) } + single { WorkgroupRepository(get()) } // Elasticsearch Client single { diff --git a/src/main/kotlin/fr/dcproject/entity/Citizen.kt b/src/main/kotlin/fr/dcproject/entity/Citizen.kt index 44b12cb..574291d 100644 --- a/src/main/kotlin/fr/dcproject/entity/Citizen.kt +++ b/src/main/kotlin/fr/dcproject/entity/Citizen.kt @@ -41,8 +41,9 @@ open class CitizenSimple( open class CitizenRefWithUser( id: UUID = UUID.randomUUID(), - open val user: UserRef -) : CitizenRef(id), + override val user: UserRef +) : CitizenWithUserI, + CitizenRef(id), EntityDeletedAt by EntityDeletedAtImp() open class CitizenRef( @@ -58,15 +59,18 @@ interface CitizenI : UuidEntityI { ) } -interface CitizenBasicI : CitizenI, EntityDeletedAt { +interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt { var name: Name var email: String var birthday: DateTime var voteAnonymous: Boolean var followAnonymous: Boolean - val user: UserI } interface CitizenFull : CitizenBasicI { override val user: User } + +interface CitizenWithUserI : CitizenI { + val user: UserI +} diff --git a/src/main/kotlin/fr/dcproject/entity/Workgroup.kt b/src/main/kotlin/fr/dcproject/entity/Workgroup.kt new file mode 100644 index 0000000..135bbe0 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/entity/Workgroup.kt @@ -0,0 +1,58 @@ +package fr.dcproject.entity + +import fr.postgresjson.entity.immutable.* +import fr.postgresjson.entity.mutable.EntityDeletedAt +import fr.postgresjson.entity.mutable.EntityDeletedAtImp +import java.util.* + +class Workgroup( + id: UUID?, + name: String, + description: String, + logo: String? = null, + anonymous: Boolean = true, + owner: CitizenBasic, + createdBy: CitizenBasic, + override var members: List = emptyList() +) : WorkgroupWithAuthI, + WorkgroupSimple( + id, + name, + description, + logo, + anonymous, + owner, + createdBy + ), + EntityCreatedAt by EntityCreatedAtImp(), + EntityUpdatedAt by EntityUpdatedAtImp() + +open class WorkgroupSimple( + id: UUID?, + var name: String, + var description: String, + var logo: String? = null, + var anonymous: Boolean = true, + var owner: Z, + createdBy: Z +) : WorkgroupRef(id), + EntityCreatedBy by EntityCreatedByImp(createdBy), + EntityDeletedAt by EntityDeletedAtImp() + +open class WorkgroupRef( + id: UUID? +) : UuidEntity(id ?: UUID.randomUUID()), WorkgroupI + +interface WorkgroupWithAuthI : WorkgroupI, EntityCreatedBy, EntityDeletedAt { + val anonymous: Boolean + val owner: Z + var members: List + + fun isMember(user: UserI): Boolean = + members.map { it.user.id }.contains(user.id) || owner.user.id == user.id + + fun isMember(citizen: CitizenWithUserI): Boolean = + isMember(citizen.user) +} + +interface WorkgroupI : UuidEntityI \ No newline at end of file diff --git a/src/main/kotlin/fr/dcproject/repository/Workgroup.kt b/src/main/kotlin/fr/dcproject/repository/Workgroup.kt new file mode 100644 index 0000000..4cc0977 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/repository/Workgroup.kt @@ -0,0 +1,46 @@ +package fr.dcproject.repository + +import fr.dcproject.entity.CitizenRef +import fr.dcproject.entity.WorkgroupSimple +import fr.postgresjson.connexion.Paginated +import fr.postgresjson.connexion.Requester +import fr.postgresjson.entity.Parameter +import fr.postgresjson.repository.RepositoryI +import fr.postgresjson.repository.RepositoryI.Direction +import net.pearx.kasechange.toSnakeCase +import java.util.* +import fr.dcproject.entity.Workgroup as WorkgroupEntity + +class Workgroup(override var requester: Requester) : RepositoryI { + fun findById(id: UUID): WorkgroupEntity? { + val function = requester.getFunction("find_workgroup_by_id") + return function.selectOne("id" to id) + } + + fun find( + page: Int = 1, + limit: Int = 50, + sort: String? = null, + direction: Direction? = null, + search: String? = null, + filter: Filter = Filter() + ): Paginated { + return requester + .getFunction("find_workgroups") + .select( + page, limit, + "sort" to sort?.toSnakeCase(), + "direction" to direction, + "search" to search, + "filter" to filter + ) + } + + fun upsert(workgroup: WorkgroupSimple): WorkgroupEntity = requester + .getFunction("upsert_workgroup") + .selectOne("resource" to workgroup) ?: error("query 'upsert_workgroup' return null") + + class Filter( + val createdById: String? = null + ) : Parameter +} diff --git a/src/main/kotlin/fr/dcproject/routes/Workgroup.kt b/src/main/kotlin/fr/dcproject/routes/Workgroup.kt new file mode 100644 index 0000000..6b3974a --- /dev/null +++ b/src/main/kotlin/fr/dcproject/routes/Workgroup.kt @@ -0,0 +1,99 @@ +package fr.dcproject.routes + +import fr.dcproject.citizen +import fr.dcproject.entity.CitizenRef +import fr.dcproject.entity.WorkgroupSimple +import fr.dcproject.entity.request.RequestBuilder +import fr.dcproject.entity.request.getContent +import fr.dcproject.repository.Workgroup.Filter +import fr.dcproject.security.voter.WorkgroupVoter.Action.VIEW +import fr.dcproject.security.voter.WorkgroupVoter.Action.CREATE +import fr.dcproject.security.voter.assertCan +import fr.postgresjson.repository.RepositoryI +import io.ktor.application.ApplicationCall +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.locations.KtorExperimentalLocationsAPI +import io.ktor.locations.Location +import io.ktor.locations.get +import io.ktor.locations.post +import io.ktor.request.receive +import io.ktor.response.respond +import io.ktor.routing.Route +import org.koin.core.KoinComponent +import java.util.* +import fr.dcproject.entity.Workgroup as WorkgroupEntity +import fr.dcproject.repository.Workgroup as WorkgroupRepository + +@KtorExperimentalLocationsAPI +object WorkgroupsPaths { + @Location("/workgroups") + class WorkgroupsRequest( + page: Int = 1, + limit: Int = 50, + val sort: String? = null, + val direction: RepositoryI.Direction? = null, + val search: String? = null, + val createdBy: 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 + } + + @Location("/workgroups/{workgroup}") + class WorkgroupRequest(val workgroup: WorkgroupEntity) + + @Location("/workgroups") + class PostWorkgroupRequest : RequestBuilder> { + class Content( + val id: UUID?, + val name: String, + val description: String, + val logo: String?, + val anonymous: Boolean?, + val owner: CitizenRef? + ) : KoinComponent { + fun create(creator: CitizenRef): WorkgroupSimple { + return WorkgroupSimple( + id ?: UUID.randomUUID(), + name, + description, + logo, + anonymous ?: true, + owner ?: creator, + creator + ) + } + } + + override suspend fun getContent(call: ApplicationCall): WorkgroupSimple { + return call.receive().create(call.citizen) + } + } +} + +@KtorExperimentalLocationsAPI +fun Route.workgroup(repo: WorkgroupRepository) { + get { + val workgroups = + repo.find(it.page, it.limit, it.sort, it.direction, it.search, Filter(createdById = it.createdBy)) + assertCan(VIEW, workgroups.result) + call.respond(workgroups) + } + + get { + assertCan(VIEW, it.workgroup) + + call.respond(it.workgroup) + } + + post { + call.getContent(it) + .let { workgroup -> + assertCan(CREATE, workgroup) + repo.upsert(workgroup) + }.let { + call.respond(HttpStatusCode.Created, it) + } + } +} diff --git a/src/main/kotlin/fr/dcproject/security/voter/WorkgroupVoter.kt b/src/main/kotlin/fr/dcproject/security/voter/WorkgroupVoter.kt new file mode 100644 index 0000000..ee0e604 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/security/voter/WorkgroupVoter.kt @@ -0,0 +1,63 @@ +package fr.dcproject.security.voter + +import fr.dcproject.entity.UserI +import fr.dcproject.entity.WorkgroupI +import fr.dcproject.entity.WorkgroupWithAuthI +import io.ktor.application.ApplicationCall + +class WorkgroupVoter : Voter { + enum class Action : ActionI { + CREATE, + UPDATE, + VIEW, + DELETE + } + + override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { + return (action is Action) + .and(subject is List<*> || subject is WorkgroupI?) + } + + override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { + val user = call.user + if (subject is WorkgroupI && action == Action.CREATE && user is UserI) { + return Vote.GRANTED + } + + if (action == Action.VIEW) { + if (subject is WorkgroupWithAuthI<*>) { + return if (subject.isDeleted()) Vote.DENIED + else if (!subject.anonymous) Vote.GRANTED + else if (subject.anonymous && user != null && subject.isMember(user)) Vote.GRANTED + else Vote.DENIED + } + if (subject is List<*>) { + subject.forEach { + if (it !is WorkgroupWithAuthI<*> || it.isDeleted()) { + return Vote.DENIED + } + } + return Vote.GRANTED + } + return Vote.DENIED + } + + if (subject is WorkgroupWithAuthI<*>) { + if (action == Action.DELETE && user is UserI && subject.owner.user.id == user.id) { + return Vote.GRANTED + } + + if (action == Action.UPDATE && user is UserI && subject.owner.user.id == user.id) { + return Vote.GRANTED + } + + return Vote.DENIED + } + + if (action is Action) { + return Vote.DENIED + } + + return Vote.ABSTAIN + } +} diff --git a/src/test/kotlin/feature/WorkgroupSteps.kt b/src/test/kotlin/feature/WorkgroupSteps.kt new file mode 100644 index 0000000..46d4e01 --- /dev/null +++ b/src/test/kotlin/feature/WorkgroupSteps.kt @@ -0,0 +1,53 @@ +package feature + +import fr.dcproject.entity.* +import io.cucumber.datatable.DataTable +import io.cucumber.java8.En +import org.joda.time.DateTime +import org.koin.test.KoinTest +import org.koin.test.get +import java.util.* +import fr.dcproject.repository.Citizen as CitizenRepository +import fr.dcproject.repository.Workgroup as WorkgroupRepository + +class WorkgroupSteps : En, KoinTest { + init { + When("I have workgroup:") { body: DataTable -> + val data = body.asMap(String::class.java, String::class.java) + + val creator = if (data["created_by"] != null) { + CitizenRef(UUID.fromString(data["created_by"])) + } else { + val username = "paul-langevin".toLowerCase() + UUID.randomUUID() + val user = User( + username = username, + plainPassword = "azerty" + ) + Citizen( + name = CitizenI.Name("Paul", "Langevin"), + email = "$username@dc-project.fr", + birthday = DateTime.now(), + user = user + ).also { + get().insertWithUser(it) + } + } + val owner = if (data["owner"] != null) { + CitizenRef(UUID.fromString(data["owner"])) + } else { + creator + } + + val workgroup = WorkgroupSimple( + id = UUID.fromString(data["id"] ?: UUID.randomUUID().toString()), + name = data["name"] ?: "Les Incoruptible", + description = data["description"] ?: "La vie est notre jeux", + createdBy = creator, + owner = owner, + anonymous = (data["anonymous"] ?: false) == true + ) + + get().upsert(workgroup) + } + } +} \ No newline at end of file diff --git a/src/test/resources/feature/workgroup.feature b/src/test/resources/feature/workgroup.feature new file mode 100644 index 0000000..86c2324 --- /dev/null +++ b/src/test/resources/feature/workgroup.feature @@ -0,0 +1,44 @@ +@workgroup +Feature: Workgroup + + Scenario: Can get one workgroup + Given I have citizen Stephen Hawking + And I am authenticated as Stephen Hawking + And I have workgroup: + | id | ab469134-bf14-4856-b093-ae1aa990f977 | + | name | Les Mousquets | + When I send a GET request to "/workgroups/ab469134-bf14-4856-b093-ae1aa990f977" + Then the response status code should be 200 + And the JSON should contain: + | id | ab469134-bf14-4856-b093-ae1aa990f977 | + | name | Les Mousquets | + + Scenario: Can create a workgroup + Given I have citizen Werner Heisenberg + And I am authenticated as Werner Heisenberg + When I send a POST request to "/workgroups" with body: + """ + { + "id":"f496d86d-6654-4068-91ff-90e1dbcc5f38", + "name":"Les Bouffons", + "description":"La vie est belle", + "anonymous":false + } + """ + Then the response status code should be 201 + And the JSON should contain: + | id | f496d86d-6654-4068-91ff-90e1dbcc5f38 | + | name | Les Bouffons | + | description | La vie est belle | + | anonymous | false | + + Scenario: Can get workgroups list + Given I have citizen Max Planck + And I am authenticated as Max Planck + And I have workgroup: + | id | 3fd8edb6-c4b4-4c94-bc75-ddd9b290d32c | + | name | Les Pissenlits | + When I send a GET request to "/workgroups" + Then the response status code should be 200 + And the response should contain object: + | $.result[0]id | 3fd8edb6-c4b4-4c94-bc75-ddd9b290d32c | \ No newline at end of file