~jan0sch/smederee
Showing details for patch 1a3e36c52da001590a8cea80c8533469b6f0d704.
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt --- old-smederee/build.sbt 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/build.sbt 2025-01-11 02:55:52.207097712 +0000 @@ -169,10 +169,11 @@ library.pureConfig, library.quickLens, library.springSecurityCrypto, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test + library.munit % Test, + library.munitCatsEffect % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test, + library.scalaCheckEffect % Test ), TwirlKeys.templateImports ++= Seq( "cats.*", @@ -273,30 +274,31 @@ lazy val library = new { object Version { - val apacheSshd = "2.13.1" - val bouncyCastle = "1.78.1" - val cats = "2.12.0" - val catsEffect = "3.5.4" - val circe = "0.14.9" - val doobie = "1.0.0-RC5" - val flyway = "10.15.2" - val fs2 = "3.5.0" - val http4s = "1.0.0-M41" - val ip4s = "3.6.0" - val jansi = "2.4.5" - val jclOverSlf4j = "2.0.13" - val laika = "1.1.0" - val log4cats = "2.7.0" - val logback = "1.5.6" - val munit = "1.0.0" - val munitCatsEffect = "2.0.0" - val osLib = "0.10.2" - val postgresql = "42.7.3" - val pureConfig = "0.17.7" - val quickLens = "1.9.7" - val scalaCheck = "1.18.0" - val simpleJavaMail = "8.11.2" - val springSecurity = "6.3.1" + val apacheSshd = "2.13.1" + val bouncyCastle = "1.78.1" + val cats = "2.12.0" + val catsEffect = "3.5.4" + val circe = "0.14.9" + val doobie = "1.0.0-RC5" + val flyway = "10.15.2" + val fs2 = "3.5.0" + val http4s = "1.0.0-M41" + val ip4s = "3.6.0" + val jansi = "2.4.5" + val jclOverSlf4j = "2.0.13" + val laika = "1.1.0" + val log4cats = "2.7.0" + val logback = "1.5.6" + val munit = "1.0.0" + val munitCatsEffect = "2.0.0" + val osLib = "0.10.2" + val postgresql = "42.7.3" + val pureConfig = "0.17.7" + val quickLens = "1.9.7" + val scalaCheck = "1.18.0" + val scalaCheckEffect = "1.0.4" + val simpleJavaMail = "8.11.2" + val springSecurity = "6.3.1" } val apacheSshdCore = "org.apache.sshd" % "sshd-core" % Version.apacheSshd val apacheSshdSftp = "org.apache.sshd" % "sshd-sftp" % Version.apacheSshd @@ -335,6 +337,7 @@ val pureConfig = "com.github.pureconfig" %% "pureconfig-core" % Version.pureConfig val quickLens = "com.softwaremill.quicklens" %% "quicklens" % Version.quickLens val scalaCheck = "org.scalacheck" %% "scalacheck" % Version.scalaCheck + val scalaCheckEffect = "org.typelevel" %% "scalacheck-effect-munit" % Version.scalaCheckEffect val simpleJavaMail = "org.simplejavamail" % "simple-java-mail" % Version.simpleJavaMail val springSecurityCrypto = "org.springframework.security" % "spring-security-crypto" % Version.springSecurity } diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql --- old-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql 2025-01-11 02:55:52.207097712 +0000 @@ -0,0 +1,20 @@ +CREATE TABLE hub.repo_orga_perms ( + repository BIGINT NOT NULL, + organisation UUID NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + CONSTRAINT repo_orga_perms_pk PRIMARY KEY (repository, organisation), + CONSTRAINT repo_orga_perms_fk_rid FOREIGN KEY (repository) + REFERENCES hub.repositories (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT repo_orga_perms_fk_oid FOREIGN KEY (organisation) + REFERENCES hub.organisations (id) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +-- Create an index to allow fast queries against repo and permissions. +CREATE INDEX repo_orga_perms_idx ON hub.repo_orga_perms (repository, permissions); + +COMMENT ON TABLE hub.repo_orga_perms IS 'Mapping table to save permissions for organisations on repositories.'; +COMMENT ON COLUMN hub.repo_orga_perms.repository IS 'The internal database ID of a repository.'; +COMMENT ON COLUMN hub.repo_orga_perms.organisation IS 'The globally unique ID of an organisation.'; diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-11 02:55:52.207097712 +0000 @@ -12,6 +12,7 @@ import cats.syntax.all.* import de.smederee.email.EmailAddress import de.smederee.hub.VcsMetadataRepositoriesOrdering.* +import de.smederee.security.Permission import de.smederee.security.UserId import de.smederee.security.Username import doobie.* @@ -23,6 +24,8 @@ final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] { given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) + given Meta[OrganisationId] = Meta[UUID].timap(OrganisationId.apply)(_.toUUID) + given Meta[Set[Permission]] = Meta[Int].timap(Permission.decode)(_.encode) given Meta[Uri] = Meta[String].timap(Uri.unsafeFromString)(_.toString) given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) @@ -130,6 +133,15 @@ query.query[VcsRepository].option.transact(tx) } + override def getPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation): F[Set[Permission]] = + sql"""SELECT COALESCE( + ( + SELECT permissions + FROM hub.repo_orga_perms + WHERE repository = $repositoryId + AND organisation = ${organisation.oid} + ), 0)""".query[Set[Permission]].unique.transact(tx) + override def listAllRepositories( requester: Option[Account] )(ordering: VcsMetadataRepositoriesOrdering): Stream[F, VcsRepository] = { @@ -160,6 +172,20 @@ query.query[VcsRepository].stream.transact(tx) } + override def setPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation)( + permissions: Set[Permission] + ): F[Int] = + sql"""INSERT INTO hub.repo_orga_perms ( + repository, + organisation, + permissions + ) VALUES ( + $repositoryId, + ${organisation.oid}, + $permissions + ) ON CONFLICT (repository, organisation) DO UPDATE SET + permissions = EXCLUDED.permissions""".update.run.transact(tx) + override def updateVcsRepository(repository: VcsRepository): F[Int] = sql"""UPDATE hub.repositories SET is_private = ${repository.isPrivate}, diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-11 02:55:52.207097712 +0000 @@ -6,6 +6,7 @@ package de.smederee.hub +import de.smederee.security.Permission import de.smederee.security.Username import fs2.Stream @@ -46,6 +47,17 @@ */ def deleteVcsRepository(repository: VcsRepository): F[Int] + /** Get the permissions for the given repository and organisation. + * + * @param repositoryId + * The id of the repository. + * @param organisation + * The organisation for which permissions shall be set. + * @return + * A permission set that may be empty. + */ + def getPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation): F[Set[Permission]] + /** Search for the vcs repository entry with the given owner and name. * * @param owner @@ -125,6 +137,19 @@ */ def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository] + /** Set the permissions on the given repository for the given organisation. + * + * @param repositoryId + * The id of the repository. + * @param organisation + * The organisation for which permissions shall be set. + * @param permissions + * The permissions that shall be set. + * @return + * The number of affected database rows. + */ + def setPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation)(permissions: Set[Permission]): F[Int] + /** Update the database entry for the given vcs repository. * * @param repository diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala 2025-01-11 02:55:52.207097712 +0000 @@ -33,7 +33,7 @@ * does initialise the test database for each suite. The latter means a possibly existing database with the name * configured **will be deleted**! */ -abstract class BaseSpec extends CatsEffectSuite { +abstract class BaseSpec extends CatsEffectSuite with ScalaCheckEffectSuite { protected final val configuration: SmedereeHubConfig = ConfigSource @@ -202,6 +202,64 @@ r <- IO.delay(statement.executeUpdate()) _ <- IO.delay(statement.close()) } yield r + } + + /** Create the given organisation in the database. Related data (i.e. the owner account) must already exists! + * + * @param organisation + * The organisation to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createOrganisation(organisation: Organisation): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement("""|INSERT INTO hub.organisations ( + |id, + |name, + |owner, + |full_name, + |description, + |is_private, + |website, + |created_at, + |updated_at + |) VALUES ( + | ?, + | ?, + | ?, + | ?, + | ?, + | ?, + | ?, + | NOW(), + | NOW() + |)""".stripMargin) + ) + _ <- IO.delay(statement.setObject(1, organisation.oid)) + _ <- IO.delay(statement.setString(2, organisation.name.toString)) + _ <- IO.delay(statement.setObject(3, organisation.owner)) + _ <- IO.delay( + organisation.fullName.fold(statement.setNull(4, java.sql.Types.VARCHAR))(name => + statement.setString(4, name.toString) + ) + ) + _ <- IO.delay( + organisation.description.fold(statement.setNull(5, java.sql.Types.VARCHAR))(desc => + statement.setString(5, desc.toString) + ) + ) + _ <- IO.delay(statement.setBoolean(6, organisation.isPrivate)) + _ <- IO.delay( + organisation.website.fold(statement.setNull(7, java.sql.Types.VARCHAR))(uri => + statement.setString(7, uri.toString) + ) + ) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r } /** Create the given user session in the database. diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-11 02:55:52.211097718 +0000 @@ -11,14 +11,18 @@ import de.smederee.TestTags.* import de.smederee.email.EmailAddress import de.smederee.hub.Generators.* +import de.smederee.hub.Generators.given import de.smederee.hub.VcsMetadataRepositoriesOrdering.* import de.smederee.security.* import doobie.* import org.http4s.implicits.* +import org.scalacheck.effect.PropF + import scala.collection.immutable.Queue final class DoobieVcsMetadataRepositoryTest extends BaseSpec { + override def scalaCheckTestParameters = super.scalaCheckTestParameters.withMinSuccessfulTests(1) /** Find all forks of the original repository with the given ID. * @@ -372,6 +376,63 @@ } } + test("getPermissions must return existing permissions".tag(NeedsDatabase)) { + PropF.forAllF { + (account: Account, repository: VcsRepository, org: Organisation, permissions: Set[Permission]) => + val organisation = org.copy(owner = account.uid) + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + 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 DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createOrganisation(organisation) + _ <- repo.createVcsRepository(vcsRepository) + repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + created <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions)) + found <- repoId.traverse(id => repo.getPermissions(id)(organisation)) + } yield (created.getOrElse(0), found.getOrElse(Set.empty)) + test.start.flatMap(_.joinWithNever).map { result => + val (created, found) = result + assert(created === 1, "No rows written to database!") + assertEquals(found, permissions) + } + } + } + + test("getPermissions must return empty permissions for non existing entries".tag(NeedsDatabase)) { + PropF.forAllF { (account: Account, repository: VcsRepository, org: Organisation) => + val expectedPermissions = Permission.decode(0) + val organisation = org.copy(owner = account.uid) + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + 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 DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createOrganisation(organisation) + _ <- repo.createVcsRepository(vcsRepository) + repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + found <- repoId.traverse(id => repo.getPermissions(id)(organisation)) + } yield found.getOrElse(Set.empty) + test.start.flatMap(_.joinWithNever).map { found => + assertEquals(found, expectedPermissions) + } + } + } + test("listAllRepositories must return only public repositories for guest users".tag(NeedsDatabase)) { (genValidAccount.sample, genValidVcsRepositories.sample) match { case (Some(account), Some(repositories)) => @@ -609,6 +670,73 @@ } } + test("setPermissions must create permissions correctly".tag(NeedsDatabase)) { + PropF.forAllF { + (account: Account, repository: VcsRepository, org: Organisation, permissions: Set[Permission]) => + val organisation = org.copy(owner = account.uid) + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + 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 DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createOrganisation(organisation) + _ <- repo.createVcsRepository(vcsRepository) + repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + created <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions)) + found <- repoId.traverse(id => repo.getPermissions(id)(organisation)) + } yield (created.getOrElse(0), found.getOrElse(Set.empty)) + test.start.flatMap(_.joinWithNever).map { result => + val (created, found) = result + assert(created === 1, "No rows written to database!") + assertEquals(found, permissions) + } + } + } + + test("setPermissions must update permissions correctly".tag(NeedsDatabase)) { + PropF.forAllF { + ( + account: Account, + repository: VcsRepository, + org: Organisation, + permissions: Set[Permission], + updatedPermissions: Set[Permission] + ) => + val organisation = org.copy(owner = account.uid) + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + 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 DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createOrganisation(organisation) + _ <- repo.createVcsRepository(vcsRepository) + repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + _ <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions)) + updated <- repoId.traverse(id => repo.setPermissions(id)(organisation)(updatedPermissions)) + found <- repoId.traverse(id => repo.getPermissions(id)(organisation)) + } yield (updated.getOrElse(0), found.getOrElse(Set.empty)) + test.start.flatMap(_.joinWithNever).map { result => + val (updated, found) = result + assert(updated === 1, "No rows written to database!") + assertEquals(found, updatedPermissions) + } + } + } + test("updateVcsRepository must update all columns correctly".tag(NeedsDatabase)) { (genValidAccount.sample, genValidVcsRepositories.sample) match { case (Some(account), Some(repository :: repositories)) => diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-01-11 02:55:52.207097712 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-01-11 02:55:52.211097718 +0000 @@ -149,6 +149,8 @@ website = None ) + given Arbitrary[Organisation] = Arbitrary(genOrganisation) + val genOrganisations: Gen[List[Organisation]] = Gen.nonEmptyListOf(genOrganisation) val genValidSession: Gen[Session] = @@ -227,6 +229,11 @@ vcsType <- genValidVcsType } yield VcsRepository(name, owner, isPrivate, description, ticketsEnabled, vcsType, None) + given Arbitrary[VcsRepository] = Arbitrary(genValidVcsRepository) + val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository) + val genPermissions: Gen[Set[Permission]] = Gen.choose(0, 7).map(Permission.decode) + + given Arbitrary[Set[Permission]] = Arbitrary(genPermissions) }