~jan0sch/smederee
Showing details for patch abea6b3972b0d8259ce7a0d3b27467bc9f0b1672.
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql --- old-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql 2025-01-11 09:14:48.235985084 +0000 @@ -0,0 +1,17 @@ +CREATE TABLE hub.organisation_members +( + organisation UUID NOT NULL, + member UUID NOT NULL, + CONSTRAINT organisation_members_pk PRIMARY KEY (organisation, member), + CONSTRAINT organisation_members_fk_oid FOREIGN KEY (organisation) + REFERENCES hub.organisations (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT organisation_members_fk_uid FOREIGN KEY (member) + REFERENCES hub.accounts (uid) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE hub.organisation_members IS 'A mapping table for organisation memberships.'; +COMMENT ON COLUMN hub.organisation_members.organisation IS 'The globally unique ID of the organisation.'; +COMMENT ON COLUMN hub.organisation_members.member IS 'The unique ID of a user account that is a member of the organisation.'; diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties --- old-smederee/modules/hub/src/main/resources/messages.properties 2025-01-11 09:14:48.231985083 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-11 09:14:48.235985084 +0000 @@ -63,6 +63,11 @@ form.organisation.name.placeholder=Please enter a organisation name. form.organisation.name=Name form.organisation.owner.help=You may change the primary owner of the organisation here. +form.organisation.members.add.name=Add member +form.organisation.members.add.name.help=You may add a new member to this organisation by specifying the name of a validated user account. +form.organisation.members.add.name.placeholder= +form.organisation.members.button.edit.submit=Save changes +form.organisation.members.delete.notice=Please select all members that you would like to remove. form.organisation.owner=Owner form.organisation.website.help=An optional URI pointing to a website. form.organisation.website.placeholder=https://example.com @@ -293,6 +298,7 @@ user.settings.language.title=You preferred language. user.settings.organisation.admins=Administrators user.settings.organisation.edit=Edit +user.settings.organisation.members=Members user.settings.organisations.description=Here you find all organisations that you''re the owner of. user.settings.organisations.title=Organisations user.settings.ssh.add.title=Add a new public ssh key. @@ -365,6 +371,7 @@ milestone.due-date=Due date organisation.menu.edit.admins=Manage organisation administrators +organisation.menu.edit.members=Manage organisation members organisation.menu.edit.settings=Edit organisation settings project.label.edit.link=Edit diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala 2025-01-11 09:14:48.231985083 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala 2025-01-11 09:14:48.235985084 +0000 @@ -56,6 +56,23 @@ ) ON CONFLICT (organisation, admin) DO NOTHING""".update.run .transact(tx) + override def addMember(organisationId: OrganisationId)(user: UserId): F[Int] = + sql"""INSERT INTO hub.organisation_members ( + organisation, + member + ) VALUES ( + $organisationId, + $user + ) ON CONFLICT (organisation, member) DO NOTHING""".update.run + .transact(tx) + + override def allByMember(member: UserId): Stream[F, Organisation] = { + val membersJoin = fr"""JOIN hub.organisation_members AS members ON organisations.id = members.organisation""" + val memberFilter = fr"""members.member = $member""" + val sqlQuery = selectOrganisationColumns ++ membersJoin ++ whereAnd(memberFilter) + sqlQuery.query[Organisation].stream.transact(tx) + } + override def allByOwner(owner: UserId): Stream[F, Organisation] = { val ownerFilter = fr"""owner = $owner""" val sqlQuery = selectOrganisationColumns ++ whereAnd(ownerFilter) @@ -100,7 +117,7 @@ sqlQuery.query[Organisation].option.transact(tx) } - override def findNewAdminByName(name: Username): F[Option[Account]] = { + override def findAccountByName(name: Username): F[Option[Account]] = { val nameFilter = fr"""name = $name""" val notLockedFilter = fr"""locked_at IS NULL""" val validatedEmailFilter = fr"""validated_email IS TRUE""" @@ -148,10 +165,30 @@ sqlQuery.query[Account].stream.transact(tx) } + override def getMembers(organisationId: OrganisationId): Stream[F, Account] = { + val organisationFilter = fr"""members.organisation = $organisationId""" + val sqlQuery = + fr"""SELECT + accounts.uid, + accounts.name, + accounts.email, + accounts.full_name, + accounts.validated_email, + accounts.language + FROM hub.accounts AS accounts + JOIN hub.organisation_members AS members + ON accounts.uid = members.member""" ++ whereAnd(organisationFilter) + sqlQuery.query[Account].stream.transact(tx) + } + override def removeAdministrator(organisationId: OrganisationId)(user: UserId): F[Int] = sql"""DELETE FROM hub.organisation_admins WHERE organisation = $organisationId AND admin = $user""".update.run .transact(tx) + override def removeMember(organisationId: OrganisationId)(user: UserId): F[Int] = + sql"""DELETE FROM hub.organisation_members WHERE organisation = $organisationId AND member = $user""".update.run + .transact(tx) + override def update(org: Organisation): F[Int] = sql"""UPDATE hub.organisations SET name = ${org.name}, diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala 2025-01-11 09:14:48.235985084 +0000 @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.data.* +import cats.syntax.all.* +import de.smederee.hub.forms.* +import de.smederee.hub.forms.types.* +import de.smederee.security.* + +final case class OrganisationMembersForm( + membersToDelete: List[Username], + newMemberName: Option[Username] +) + +object OrganisationMembersForm extends FormValidator[OrganisationMembersForm] { + val fieldMembersToDelete: FormField = FormField("membersToDelete") + val fieldNewMemberName: FormField = FormField("newMemberName") + + override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, OrganisationMembersForm] = { + val newMemberName = data + .get(fieldNewMemberName) + .filter(_.nonEmpty) + .fold(None.validNec)( + _.headOption + .filter(_.nonEmpty) + .fold(None.validNec)(string => + Username.from(string).fold(FormFieldError("Invalid name!").invalidNec)(_.some.validNec) + ) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldNewMemberName -> es.toList))) + // TODO: Currently we silently drop invalid names from the list, should we send feedback to the user? + val membersToDelete = data + .get(fieldMembersToDelete) + .filter(_.nonEmpty) + .fold(Nil.validNec)(_.toList.map(Username.from).flatten.validNec) + (membersToDelete, newMemberName).mapN { case (members, name) => + OrganisationMembersForm(membersToDelete = members, newMemberName = name) + } + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala 2025-01-11 09:14:48.231985083 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala 2025-01-11 09:14:48.235985084 +0000 @@ -39,6 +39,26 @@ */ def addAdministrator(organisationId: OrganisationId)(user: UserId): F[Int] + /** Add a member to the organisation. + * + * @param organisationId + * The unique ID of the organisation. + * @param user + * The globally unique ID of the user to be added to the organisation. + * @return + * The number of affected database rows. + */ + def addMember(organisationId: OrganisationId)(user: UserId): F[Int] + + /** Find all organisations of which the user with the given id is a member of. + * + * @param owner + * The unique user id of an account. + * @return + * A stream with all organisation of which the user is a member of. + */ + def allByMember(member: UserId): Stream[F, Organisation] + /** Find all organisations of which the user with the given id is an owner. * * @param owner @@ -84,15 +104,15 @@ */ def findByName(name: Username): F[Option[Organisation]] - /** Find the user account that is supposed to be an new administrator for an organisation by the user name. The user - * account must not be locked and must also have validated their email address to be returned. + /** Find the user account that is supposed to be a new member or administrator for an organisation by the user name. + * The user account must not be locked and must also have validated their email address to be returned. * * @param name * A user name. * @return * An option to the found user account. */ - def findNewAdminByName(name: Username): F[Option[Account]] + def findAccountByName(name: Username): F[Option[Account]] /** Find the account of an organisation owner. * @@ -112,6 +132,15 @@ */ def getAdministrators(organisationId: OrganisationId): Stream[F, Account] + /** Return the accounts of all users that are members of the given organisation. + * + * @param organisationId + * The unique ID of the organisation. + * @return + * A stream of accounts that may be empty. + */ + def getMembers(organisationId: OrganisationId): Stream[F, Account] + /** Remove an administrator from the organisation. * * @param organisationId @@ -123,6 +152,17 @@ */ def removeAdministrator(organisationId: OrganisationId)(user: UserId): F[Int] + /** Remove a member from the organisation. + * + * @param organisationId + * The unique ID of the organisation. + * @param user + * The globally unique ID of the user to be removed. + * @return + * The number of affected database rows. + */ + def removeMember(organisationId: OrganisationId)(user: UserId): F[Int] + /** Update the database entry for the given organisation. * * @param org diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala 2025-01-11 09:14:48.231985083 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala 2025-01-11 09:14:48.235985084 +0000 @@ -299,10 +299,10 @@ val logic = for { newAdmin <- organisationAdminsForm.newAdminName match { case None => None.pure - case Some(name) => orgRepo.findNewAdminByName(name) + case Some(name) => orgRepo.findAccountByName(name) } foundAdminsToRemove <- organisationAdminsForm.adminsToDelete.traverse( - orgRepo.findNewAdminByName + orgRepo.findAccountByName ) adminsToRemove = foundAdminsToRemove.flatten addedAdmin <- newAdmin @@ -327,6 +327,83 @@ } } + /** Handle the submission of the form to edit organisation members. + */ + private val editOrganisationMembers: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter( + organisationName + ) / "members" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + _ <- Sync[F].raiseUnless(user.validatedEmail)( + new Error( + "An unvalidated account is not allowed to edit an organisation!" + ) // FIXME: Proper error handling! + ) + orgAndAdmins <- loadOrganisation(user.some)(organisationName) + orgMembers <- orgAndAdmins.traverse(tuple => orgRepo.getMembers(tuple._1.oid).compile.toList) + orgaData = orgAndAdmins.filter(tuple => tuple._1.owner === user.uid || tuple._2.exists(_ === user)) + resp <- orgaData match { + case None => NotFound() + case Some((organisation, admins, _)) => + val members = orgMembers.getOrElse(Nil) + for { + formData <- Sync[F].delay(urlForm.valuesWithoutEmptyStrings) + form <- Sync[F].delay(OrganisationMembersForm.validate(formData)) + resp <- form match { + case Validated.Invalid(es) => + val actionBaseUri = + uri"user/settings/organisations".addSegment( + s"~${organisation.name.toString}" + ) + val editAction = linkConfig.createFullUri(actionBaseUri.addSegment("members")) + BadRequest( + views.html + .editOrganisationMembers(lang = language)( + editAction, + csrf, + members, + Option(s"~$organisationName - members"), + user + )( + formData.withDefaultValue(Chain.empty), + FormErrors.fromNec(es) + ) + ) + case Validated.Valid(organisationMembersForm) => + val logic = for { + newMember <- organisationMembersForm.newMemberName match { + case None => None.pure + case Some(name) => orgRepo.findAccountByName(name) + } + foundMembersToRemove <- organisationMembersForm.membersToDelete.traverse( + orgRepo.findAccountByName + ) + membersToRemove = foundMembersToRemove.flatten + addedMember <- newMember + .map(_.uid) + .traverse(orgRepo.addMember(organisation.oid)) + removedMembers <- membersToRemove + .map(_.uid) + .traverse(orgRepo.removeMember(organisation.oid)) + } yield (addedMember.getOrElse(0), removedMembers.sum) + val targetUri = linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~${organisation.name.toString}")) + ) + ) + ) + logic *> SeeOther(Location(targetUri)) + } + } yield resp + } + } yield resp + } + } + private val editOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter( organisationName @@ -472,6 +549,50 @@ } yield resp } + private val showEditOrganisationMembersForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / "user" / "settings" / "organisations" / UsernamePathParameter( + organisationName + ) / "members" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + orgAndAdmins <- loadOrganisation(user.some)(organisationName) + orgMembers <- orgAndAdmins.traverse(tuple => orgRepo.getMembers(tuple._1.oid).compile.toList) + orgaData = orgAndAdmins.filter(tuple => tuple._1.owner === user.uid || tuple._2.exists(_ === user)) + resp <- orgaData match { + case None => NotFound("Organisation not found!") + case Some((organisation, admins, _)) => + val members = orgMembers.getOrElse(Nil) + user.validatedEmail match { + case false => + Forbidden( + views.html.errors + .unvalidatedAccount(lang = language)( + csrf, + "Smederee - Account not validated!".some, + user + ) + ) + case true => + val actionBaseUri = + uri"user/settings/organisations".addSegment(s"~${organisation.name.toString}") + val editAction = + linkConfig.createFullUri(actionBaseUri.addSegment("members")) + Ok( + views.html + .editOrganisationMembers(lang = language)( + editAction, + csrf, + members, + Option(s"~$organisationName - members"), + user + )() + ) + } + } + } yield resp + } + private val showEditOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "user" / "settings" / "organisations" / UsernamePathParameter( organisationName @@ -522,7 +643,8 @@ } val protectedRoutes = - showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationAdminsForm <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisationAdmins <+> editOrganisation + showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationAdminsForm <+> showEditOrganisationMembersForm + <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisationAdmins <+> editOrganisationMembers <+> editOrganisation val routes = HttpRoutes.empty } diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html 2025-01-11 09:14:48.235985084 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html 2025-01-11 09:14:48.235985084 +0000 @@ -42,6 +42,7 @@ <div class="pure-u-7-12" style="padding: 10px 5px 10px 5px;"><a href="@baseUri.addSegment(s"~$organisationName")">@organisationName</a></div> <div class="pure-u-2-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("edit")">@Messages("user.settings.organisation.edit")</a></div> <div class="pure-u-3-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("admins")">@Messages("user.settings.organisation.admins")</a></div> + <div class="pure-u-3-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("members")">@Messages("user.settings.organisation.members")</a></div> </div> } } diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html 2025-01-11 09:14:48.235985084 +0000 @@ -0,0 +1,65 @@ +@import de.smederee.hub.* +@import de.smederee.hub.OrganisationMembersForm.* +@import de.smederee.hub.forms.types.* +@import de.smederee.hub.views.html.forms.* + +@( + baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)( + editAction: Uri, + csrf: Option[CsrfToken] = None, + members: List[Account], + title: Option[String] = None, + user: Account +)( + formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), + formErrors: FormErrors = FormErrors.empty +) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => + <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="organisation-form"> + <form action="@editAction" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned"> + <fieldset id="organisation-add-member"> + <div class="pure-control-group"> + <label for="@fieldNewMemberName">@Messages("form.organisation.members.add.name")</label> + <input class="pure-input-1-2" id="@fieldNewMemberName" name="@fieldNewMemberName" placeholder="@Messages("form.organisation.members.add.name.placeholder")" maxlength="31" type="text" value="@{formData(fieldNewMemberName).headOption}" autofocus> + <small class="pure-form-message" id="@{fieldNewMemberName}.help">@Messages("form.organisation.members.add.name.help")</small> + @renderFormErrors(fieldNewMemberName, formErrors) + </div> + </fieldset> + <fieldset id="organisation-members"> + <p class="alert alert-warning">@Messages("form.organisation.members.delete.notice")</p> + @for(member <- members) { + <label for="remove-member-@{member.name}" class="pure-checkbox"> + <input type="checkbox" id="remove-member-@{member.name}" name="@fieldMembersToDelete" value="@{member.name}" /> @{member.name} @{member.fullName.map(fn => "(" + fn + ")")} + </label> + } + </fieldset> + @csrfToken(csrf) + <div class="pure-controls"> + <button type="submit" class="pure-button">@Messages("form.organisation.members.button.edit.submit")</button> + </div> + </form> + </div> + </div> + </div> + </div> + </div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html 2025-01-11 09:14:48.235985084 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html 2025-01-11 09:14:48.235985084 +0000 @@ -64,7 +64,8 @@ @for(orgActionBaseUri <- organisationActionBaseUri) { <div class="l-box"> <a href="@{orgActionBaseUri.addSegment("edit")}">@Messages("organisation.menu.edit.settings")</a><br/> - <a href="@{orgActionBaseUri.addSegment("admins")}">@Messages("organisation.menu.edit.admins")</a> + <a href="@{orgActionBaseUri.addSegment("admins")}">@Messages("organisation.menu.edit.admins")</a><br/> + <a href="@{orgActionBaseUri.addSegment("members")}">@Messages("organisation.menu.edit.members")</a> </div> } } else { diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala 2025-01-11 09:14:48.235985084 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala 2025-01-11 09:14:48.235985084 +0000 @@ -53,6 +53,34 @@ } } + test("addMember must add a member to the organisation".tag(NeedsDatabase)) { + (genValidAccounts.sample, genOrganisation.sample) match { + case (Some(owner :: accounts), Some(org)) => + val organisation = org.copy(owner = owner.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + val repo = new DoobieOrganisationRepository[IO](tx) + val test = for { + _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None)) + written <- repo.create(organisation) + added <- accounts.headOption.traverse(member => repo.addMember(organisation.oid)(member.uid)) + } yield (written, added.getOrElse(0)) + test.map { result => + val (written, added) = result + assert(written === 1, "Organisation not written to database!") + accounts.headOption.foreach(_ => assert(added === 1, "Member not written to database!")) + } + case _ => fail("Could not generate data samples!") + } + } + test("create must create an organisation".tag(NeedsDatabase)) { (genValidAccount.sample, genOrganisation.sample) match { case (Some(account), Some(org)) => @@ -77,6 +105,37 @@ } } + test("allByMember must return all organisations which the user is a member of".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidAccount.sample, genOrganisations.sample) match { + case (Some(owner), Some(member), Some(orgs)) => + val organisations = orgs.map(_.copy(owner = owner.uid)) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + val repo = new DoobieOrganisationRepository[IO](tx) + val test = for { + _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None) + _ <- createAccount(member, PasswordHash("I am not a password hash!"), None, None) + writtenOrgs <- organisations.traverse(repo.create) + writtenMemberships <- organisations.traverse(org => repo.addMember(org.oid)(member.uid)) + found <- repo.allByMember(member.uid).compile.toList + } yield (writtenOrgs, writtenMemberships, found) + test.map { result => + val (writtenOrgs, writtenMemberships, found) = result + assert(writtenOrgs.sum === organisations.size, "Not all organisations written to database!") + assert(writtenMemberships.sum === organisations.size, "Not all memberships written to database!") + assertEquals(found.size, organisations.size, "Not all organisations found!") + assertEquals(found.sortBy(_.oid), organisations.sortBy(_.oid)) + } + case _ => fail("Could not generate data samples!") + } + } + test("allByOwner must return all organisations owned by the user".tag(NeedsDatabase)) { (genValidAccount.sample, genOrganisations.sample) match { case (Some(account), Some(orgs)) => @@ -188,7 +247,7 @@ } } - test("findNewAdminByName must return an unlocked and validated account".tag(NeedsDatabase)) { + test("findAccountByName must return an unlocked and validated account".tag(NeedsDatabase)) { genValidAccount.sample match { case Some(genAccount) => val account = genAccount.copy(language = None, validatedEmail = true) @@ -203,7 +262,7 @@ val repo = new DoobieOrganisationRepository[IO](tx) val test = for { _ <- createAccount(account, PasswordHash("I am not a password hash!")) - found <- repo.findNewAdminByName(account.name) + found <- repo.findAccountByName(account.name) } yield found test.map { found => assertEquals(found, account.some) @@ -212,7 +271,7 @@ } } - test("findNewAdminByName must not return a locked account".tag(NeedsDatabase)) { + test("findAccountByName must not return a locked account".tag(NeedsDatabase)) { genValidAccount.sample match { case Some(genAccount) => val account = genAccount.copy(validatedEmail = true) @@ -228,7 +287,7 @@ val repo = new DoobieOrganisationRepository[IO](tx) val test = for { _ <- createAccount(account, PasswordHash("I am not a password hash!"), unlockToken = token.some) - found <- repo.findNewAdminByName(account.name) + found <- repo.findAccountByName(account.name) } yield found test.map { found => assertEquals(found, None) @@ -237,7 +296,7 @@ } } - test("findNewAdminByName must not return an unvalidated account".tag(NeedsDatabase)) { + test("findAccountByName must not return an unvalidated account".tag(NeedsDatabase)) { genValidAccount.sample match { case Some(genAccount) => val account = genAccount.copy(validatedEmail = false) @@ -253,7 +312,7 @@ val repo = new DoobieOrganisationRepository[IO](tx) val test = for { _ <- createAccount(account, PasswordHash("I am not a password hash!"), validationToken = token.some) - found <- repo.findNewAdminByName(account.name) + found <- repo.findAccountByName(account.name) } yield found test.map { found => assertEquals(found, None) @@ -327,6 +386,44 @@ } } + test("getMembers must return all members".tag(NeedsDatabase)) { + (genValidAccounts.sample, genOrganisation.sample) match { + case (Some(owner :: accounts), Some(org)) => + val members = accounts.zipWithLongIndex.foldLeft(List.empty[Account])((acc, tuple) => + if (tuple._2 % 2 === 0) { + tuple._1 :: acc + } else { + acc + } + ) + val organisation = org.copy(owner = owner.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + val repo = new DoobieOrganisationRepository[IO](tx) + val addMember = repo.addMember(organisation.oid) + val test = for { + _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None)) + written <- repo.create(organisation) + added <- members.map(_.uid).traverse(addMember) + foundMembers <- repo.getMembers(organisation.oid).compile.toList + } yield (written, added.sum, foundMembers) + test.map { result => + val (written, added, foundMembers) = result + assert(written === 1, "Organisation not written to database!") + assert(added === members.size, "Wrong number of members created!") + assertEquals(foundMembers.map(_.uid).sorted, members.map(_.uid).sorted) + } + case _ => fail("Could not generate data samples!") + } + } + test("removeAdministrator must remove an administrator from an organisation".tag(NeedsDatabase)) { (genValidAccounts.sample, genOrganisation.sample) match { case (Some(owner :: accounts), Some(org)) => @@ -344,18 +441,44 @@ _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None) _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None)) written <- repo.create(organisation) - added <- accounts.headOption.traverse(admin => repo.addAdministrator(organisation.oid)(admin.uid)) - removed <- accounts.headOption.traverse(admin => - repo.removeAdministrator(organisation.oid)(admin.uid) - ) - } yield (written, added.getOrElse(0), removed.getOrElse(0)) + added <- accounts.traverse(admin => repo.addAdministrator(organisation.oid)(admin.uid)) + removed <- accounts.traverse(admin => repo.removeAdministrator(organisation.oid)(admin.uid)) + } yield (written, added.sum, removed.sum) test.map { result => val (written, added, removed) = result assert(written === 1, "Organisation not written to database!") - accounts.headOption.foreach { _ => - assert(added === 1, "Administrator not written to database!") - assert(removed === 1, "Administrator not removed from database!") - } + assertEquals(added, removed) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeMember must remove a member from an organisation".tag(NeedsDatabase)) { + (genValidAccounts.sample, genOrganisation.sample) match { + case (Some(owner :: accounts), Some(org)) => + val organisation = org.copy(owner = owner.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + val repo = new DoobieOrganisationRepository[IO](tx) + val test = for { + _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None)) + written <- repo.create(organisation) + added <- accounts.traverse(member => repo.addMember(organisation.oid)(member.uid)) + removed <- accounts.traverse(member => repo.removeMember(organisation.oid)(member.uid)) + foundOrgs <- accounts.traverse(member => repo.allByMember(member.uid).compile.toList) + } yield (written, added.sum, removed.sum, foundOrgs) + test.map { result => + val (written, added, removed, foundOrgs) = result + assert(written === 1, "Organisation not written to database!") + assertEquals(added, removed) + foundOrgs.foreach(orgs => assert(orgs.isEmpty)) } case _ => fail("Could not generate data samples!") }