diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala b/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala index e91205e..a5bd8f6 100644 --- a/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala +++ b/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala @@ -2,6 +2,7 @@ package gs.smolban.auth import cats.effect.Sync import cats.syntax.all.* +import java.nio.charset.Charset import java.nio.charset.StandardCharsets import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.params.Argon2Parameters @@ -28,13 +29,19 @@ final class Argon2[F[_]: Sync]( * @return * The calculated hash. */ - def calculateHash(input: String): F[Argon2Hash] = + def calculateHash( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): F[Argon2Hash] = + calculateHash(input.getBytes(charset)) + + def calculateHash(input: Array[Byte]): F[Argon2Hash] = randomSalt().map { salt => val ap = buildAlgorithmParams(salt) val a2 = new Argon2BytesGenerator() val _ = a2.init(ap) val bytes = new Array[Byte](config.hashLengthInBytes) - val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes) + val _ = a2.generateBytes(input, bytes) new Argon2Hash( algorithmVersion = config.algorithmVersion, algorithmType = config.algorithmType, diff --git a/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala b/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala index 223e850..7b1e0af 100644 --- a/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala @@ -14,10 +14,11 @@ import gs.smolban.model.account.AiAgent import gs.smolban.model.account.PermissionSet import gs.smolban.model.account.ServiceAccount import gs.smolban.model.account.User +import gs.smolban.model.metadata.CreatedAt /** Database interface for [[Account]] and related management activities. */ -trait AuthDb[F[_]]: +trait AuthDb[DBIO[_], F[_]]: /** Get the user account with the given name. * @@ -28,7 +29,7 @@ trait AuthDb[F[_]]: */ def getUser( name: AccountName - ): F[Option[User]] + ): DBIO[Option[User]] /** Create a new [[User]]. * @@ -44,8 +45,9 @@ trait AuthDb[F[_]]: def createUser( name: AccountName, initialPassword: Password, - initialPermissions: PermissionSet - ): EitherT[F, DbError, User] + initialPermissions: PermissionSet, + createdAt: CreatedAt + ): F[DBIO[Either[DbError, User]]] /** Update the password for an existing [[User]]. * @@ -59,12 +61,12 @@ trait AuthDb[F[_]]: def setUserPassword( id: AccountId, newPassword: Password - ): EitherT[F, DbError, User] + ): EitherT[DBIO, DbError, User] /** @return * List of all active [[User]]. */ - def listActiveUsers(): F[List[User]] + def listActiveUsers(): fs2.Stream[DBIO, User] /** Get the [[ServiceAccount]] with the given name. * @@ -76,7 +78,7 @@ trait AuthDb[F[_]]: */ def getServiceAccount( name: AccountName - ): F[Option[ServiceAccount]] + ): DBIO[Option[ServiceAccount]] /** Create a new [[ServiceAccount]]. * @@ -93,12 +95,12 @@ trait AuthDb[F[_]]: name: AccountName, owner: AccountId, initialPermissions: PermissionSet - ): EitherT[F, DbError, NewServiceAccount] + ): EitherT[DBIO, DbError, NewServiceAccount] /** @return * List of all active [[ServiceAccount]]. */ - def listActiveServiceAccounts(): F[List[ServiceAccount]] + def listActiveServiceAccounts(): DBIO[List[ServiceAccount]] /** Get the [[AiAgent]] with the given name. * @@ -109,7 +111,7 @@ trait AuthDb[F[_]]: */ def getAgentAccount( name: AccountName - ): F[Option[AiAgent]] + ): DBIO[Option[AiAgent]] /** Create a new [[AiAgent]]. * @@ -126,12 +128,12 @@ trait AuthDb[F[_]]: name: AccountName, owner: AccountId, initialPermissions: PermissionSet - ): EitherT[F, DbError, NewAiAgentAccount] + ): EitherT[DBIO, DbError, NewAiAgentAccount] /** @return * List of all active [[AiAgent]]. */ - def listActiveAgentAccounts(): F[List[AiAgent]] + def listActiveAgentAccounts(): DBIO[List[AiAgent]] /** Rotate the specified secret and return the new secret. This is the only * time the new value will ever be exposed. @@ -153,7 +155,7 @@ trait AuthDb[F[_]]: accountId: AccountId, credentialId: CredentialId, overlapHours: Int - ): EitherT[F, DbError, Base64] + ): EitherT[DBIO, DbError, Base64] /** Replace the permissions assigned to some [[Account]]. * @@ -167,7 +169,7 @@ trait AuthDb[F[_]]: def setAccountPermissions( id: AccountId, newPermissions: PermissionSet - ): EitherT[F, DbError, Account] + ): EitherT[DBIO, DbError, Account] /** List all credentials that exist for the given [[AccountId]]. * @@ -178,7 +180,7 @@ trait AuthDb[F[_]]: */ def listAccountCredentials( id: AccountId - ): F[List[Credential]] + ): DBIO[List[Credential]] /** Revoke a credential, making it unusable. * @@ -189,7 +191,7 @@ trait AuthDb[F[_]]: */ def revokeCredential( id: CredentialId - ): EitherT[F, DbError, Unit] + ): EitherT[DBIO, DbError, Unit] /** Expire a credential, making it unusable. * @@ -200,4 +202,4 @@ trait AuthDb[F[_]]: */ def expireCredential( id: CredentialId - ): EitherT[F, DbError, Unit] + ): EitherT[DBIO, DbError, Unit] diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala index 3defac1..dd82df6 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala @@ -1,47 +1,161 @@ package gs.smolban.db.doobie import cats.data.EitherT +import cats.effect.Sync +import cats.syntax.all.* import doobie.* +import doobie.implicits.* +import gs.smolban.auth.Argon2 +import gs.smolban.auth.Argon2Hash import gs.smolban.auth.Base64 import gs.smolban.auth.Credential +import gs.smolban.auth.CredentialEffectivity import gs.smolban.auth.CredentialId +import gs.smolban.auth.CredentialStatus +import gs.smolban.auth.CredentialType import gs.smolban.auth.NewAiAgentAccount import gs.smolban.auth.NewServiceAccount import gs.smolban.auth.Password +import gs.smolban.auth.RsaDecryption +import gs.smolban.auth.RsaEncryptedBytes import gs.smolban.db.AuthDb import gs.smolban.db.DbError +import gs.smolban.db.doobie.DoobieTypes.* import gs.smolban.model.account.Account import gs.smolban.model.account.AccountId import gs.smolban.model.account.AccountName +import gs.smolban.model.account.AccountStatus +import gs.smolban.model.account.AccountType import gs.smolban.model.account.AiAgent +import gs.smolban.model.account.Permission import gs.smolban.model.account.PermissionSet import gs.smolban.model.account.ServiceAccount import gs.smolban.model.account.User +import gs.smolban.model.group.GroupName +import gs.smolban.model.metadata.CreatedAt -final class DoobieAuthDb extends AuthDb[ConnectionIO] { +final class DoobieAuthDb[F[_]: Sync]( + val argon2: Argon2[F], + val rsaDecryption: RsaDecryption[F], + val sqlStates: CuratedSqlStates, + val implTypes: ImplTypes +) extends AuthDb[ConnectionIO, F]: + private val Sql = new DoobieAuthDb.Sql(implTypes) /** @inheritDocs */ - override def getUser(name: AccountName): ConnectionIO[Option[User]] = ??? + override def getUser(name: AccountName): ConnectionIO[Option[User]] = + Sql.getUser(name).option.flatMap { + case Some((id, status, createdAt)) => + getUserPermissions(id).map { permissions => + Some( + new User( + id = id, + name = name, + status = status, + permissions = permissions, + createdAt = createdAt + ) + ) + } + case None => None.pure[ConnectionIO] + } + + private def getUserPermissions(id: AccountId): ConnectionIO[PermissionSet] = + for + global <- Sql.getUserGlobalPermissions(id).to[Set] + group <- Sql.getUserGroupPermissions(id).to[Set] + yield PermissionSet( + global = global, + group = group.groupMap(_._1)(_._2) + ) /** @inheritDocs */ override def createUser( name: AccountName, initialPassword: Password, - initialPermissions: PermissionSet - ): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? + initialPermissions: PermissionSet, + createdAt: CreatedAt + ): F[ConnectionIO[Either[DbError, User]]] = + // Prepare Password + exchangeEncryptedForHash(initialPassword.unwrap()).map { passwordHash => + // Insert the base user record. + val accountId = AccountId.generate() + Sql + .insertUser(accountId, name, AccountStatus.Active, createdAt) + .run + .attemptSqlState + .flatMap { + case Left(sqlState) => + if sqlState.value == sqlStates.uniqueViolation.value then + Left(DbError.AccountAlreadyExists(name, AccountType.User)) + .pure[ConnectionIO] + else Left(DbError.GenericDatabaseError(sqlState)).pure[ConnectionIO] + case Right(_) => + for + // Insert the user's credential. + _ <- Sql + .insertCredential( + id = CredentialId.generate(), + hash = passwordHash, + accountId = accountId, + accountType = AccountType.User, + credentialType = CredentialType.Password, + status = CredentialStatus.Active, + effective = None, + createdAt = createdAt + ) + .run + // Insert the user's global permissions. + _ <- Sql.insertUserGlobalPermissions( + accountId, + initialPermissions.global + ) + // Insert the user's group permissions. + _ <- Sql.insertUserGroupPermissions( + accountId, + initialPermissions.group + ) + yield Right( + new User( + id = accountId, + name = name, + status = AccountStatus.Active, + permissions = initialPermissions, + createdAt = createdAt + ) + ) + } + } + + private def exchangeEncryptedForHash(rsa: RsaEncryptedBytes): F[Argon2Hash] = + rsaDecryption.decrypt(rsa).flatMap(argon2.calculateHash) /** @inheritDocs */ override def setUserPassword( id: AccountId, newPassword: Password - ): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? + ): EitherT[ConnectionIO, DbError, User] = ??? /** @inheritDocs */ - override def listActiveUsers(): ConnectionIO[List[User]] = ??? + override def listActiveUsers(): fs2.Stream[ConnectionIO, User] = + Sql + .listUsersForStatus(AccountStatus.Active) + .stream + .evalMap { case (id, name, createdAt) => + getUserPermissions(id).map { permissions => + new User( + id = id, + name = name, + status = AccountStatus.Active, + permissions = permissions, + createdAt = createdAt + ) + } + } /** @inheritDocs */ @@ -54,7 +168,7 @@ final class DoobieAuthDb extends AuthDb[ConnectionIO] { name: AccountName, owner: AccountId, initialPermissions: PermissionSet - ): EitherT[[A] =>> ConnectionIO[A], DbError, NewServiceAccount] = ??? + ): EitherT[ConnectionIO, DbError, NewServiceAccount] = ??? /** @inheritDocs */ @@ -108,4 +222,94 @@ final class DoobieAuthDb extends AuthDb[ConnectionIO] { override def expireCredential(id: CredentialId) : EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ??? -} +object DoobieAuthDb: + + class Sql(implTypes: ImplTypes): + import implTypes.given + + def getUser(name: AccountName) + : Query0[(AccountId, AccountStatus, CreatedAt)] = + sql""" + SELECT id, status, created_at + FROM user + WHERE name = $name + """.query[(AccountId, AccountStatus, CreatedAt)] + + def getUserGlobalPermissions(id: AccountId): Query0[Permission] = + sql""" + SELECT permission + FROM user_permission_global + WHERE user_id = $id + """.query[Permission] + + def getUserGroupPermissions(id: AccountId) + : Query0[(GroupName, Permission)] = + sql""" + SELECT group_name, permission + FROM user_permission_group + WHERE user_id = $id + """.query[(GroupName, Permission)] + + def listUsersForStatus(status: AccountStatus) + : Query0[(AccountId, AccountName, CreatedAt)] = + sql""" + SELECT id, name, created_at + FROM user + WHERE status = $status + """.query[(AccountId, AccountName, CreatedAt)] + + def insertUser( + id: AccountId, + name: AccountName, + status: AccountStatus, + createdAt: CreatedAt + ): Update0 = + sql""" + INSERT INTO user(id, name, status, created_at) + VALUES ($id, $name, $status, $createdAt) + """.update + + def insertCredential( + id: CredentialId, + hash: Argon2Hash, + accountId: AccountId, + accountType: AccountType, + credentialType: CredentialType, + status: CredentialStatus, + effective: Option[CredentialEffectivity], + createdAt: CreatedAt + ): Update0 = + sql""" + INSERT INTO credential(credential_id, credential_hash, account_id, + account_type, credential_type, status, effective_at, effective_through, + created_at) + VALUES ($id, $hash, $accountId, $accountType, $credentialType, $status, + ${effective.map(_.startDate)}, ${effective.flatMap(_.endDate)}, + $createdAt) + """.update + + def insertUserGlobalPermissions( + id: AccountId, + permissions: Set[Permission] + ): ConnectionIO[Int] = + val command = """ + INSERT INTO user_permission_global (user_id, permission) VALUES (?, ?) + """.stripMargin + Update[(AccountId, Permission)](command) + .updateMany(permissions.map(p => id -> p).toList) + + def insertUserGroupPermissions( + id: AccountId, + permissions: Map[GroupName, Set[Permission]] + ): ConnectionIO[Int] = + val command = """ + INSERT INTO user_permission_group (user_id, group_name, permission) VALUES (?, ?, ?) + """.stripMargin + Update[(AccountId, GroupName, Permission)](command) + .updateMany(permissions.flatMap { case (gn, ps) => + ps.map(p => (id, gn, p)) + }.toList) + + end Sql + +end DoobieAuthDb diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala index c00ba51..d4a7be4 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala @@ -11,10 +11,10 @@ import gs.smolban.model.metadata.Tag import gs.smolban.model.metadata.TagValue final class DoobieTagDb( - sqlStates: CuratedSqlStates + sqlStates: CuratedSqlStates, + implTypes: ImplTypes ) extends TagDb[ConnectionIO]: - - import DoobieTagDb.Sql + private val Sql = new DoobieTagDb.Sql(implTypes) /** @inheritdoc */ @@ -46,7 +46,8 @@ final class DoobieTagDb( object DoobieTagDb: - private object Sql: + private class Sql(implTypes: ImplTypes): + import implTypes.given def createTag( tag: TagValue, diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala index b7cb04d..0910719 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala @@ -2,16 +2,80 @@ package gs.smolban.db.doobie import DoobieTypes.ErrorMessages import doobie.* -import gs.smolban.model.metadata.CreatedAt +import gs.smolban.auth.Argon2Hash +import gs.smolban.auth.CredentialId +import gs.smolban.auth.CredentialStatus +import gs.smolban.auth.CredentialType +import gs.smolban.model.account.AccountId +import gs.smolban.model.account.AccountName +import gs.smolban.model.account.AccountStatus +import gs.smolban.model.account.AccountType +import gs.smolban.model.account.Permission +import gs.smolban.model.group.GroupName import gs.smolban.model.metadata.TagValue import gs.smolban.model.ticket.CommentId import gs.smolban.model.ticket.TicketStatus import gs.uuid.v0.UUID import gs.uuid.v0.UUIDFormat -import java.time.Instant trait DoobieTypes: + implicit val accountIdGet: Get[AccountId] = + Get[Array[Byte]].tmap(bytes => AccountId(UUID(UUIDFormat.fromBytes(bytes)))) + + implicit val accountIdPut: Put[AccountId] = + Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID())) + + implicit val accountNameGet: Get[AccountName] = + Get[String].temap { value => + AccountName.validate(value) match + case None => Left(ErrorMessages.invalidAccountName(value)) + case Some(s) => Right(s) + } + + implicit val accountNamePut: Put[AccountName] = + Put[String].tcontramap(_.toString()) + + implicit val accountStatusGet: Get[AccountStatus] = + Get[String].temap(dbValue => + AccountStatus.parse(dbValue) match + case None => Left(ErrorMessages.invalidAccountStatus(dbValue)) + case Some(status) => Right(status) + ) + + implicit val accountStatusPut: Put[AccountStatus] = + Put[String].tcontramap(_.name) + + implicit val accountTypeGet: Get[AccountType] = + Get[String].temap(dbValue => + AccountType.parse(dbValue) match + case None => Left(ErrorMessages.invalidAccountType(dbValue)) + case Some(status) => Right(status) + ) + + implicit val accountTypePut: Put[AccountType] = + Put[String].tcontramap(_.name) + + implicit val permissionGet: Get[Permission] = + Get[String].temap(dbValue => + Permission.parse(dbValue) match + case None => Left(ErrorMessages.invalidPermission(dbValue)) + case Some(status) => Right(status) + ) + + implicit val permissionPut: Put[Permission] = + Put[String].tcontramap(_.name) + + implicit val groupNameGet: Get[GroupName] = + Get[String].temap { value => + GroupName.validate(value) match + case None => Left(ErrorMessages.invalidGroupName(value)) + case Some(s) => Right(s) + } + + implicit val groupNamePut: Put[GroupName] = + Put[String].tcontramap(_.toString()) + implicit val ticketStatusGet: Get[TicketStatus] = Get[String].temap(dbValue => TicketStatus.parse(dbValue) match @@ -28,18 +92,6 @@ trait DoobieTypes: implicit val commentIdPut: Put[CommentId] = Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID())) - implicit val createdAtGet: Get[CreatedAt] = - Get[Long].tmap(CreatedAt.fromMilliseconds) - - implicit val createdAtPut: Put[CreatedAt] = - Put[Long].tcontramap(_.toMilliseconds()) - - implicit val instantGet: Get[Instant] = - Get[Long].tmap(Instant.ofEpochMilli) - - implicit val instantPut: Put[Instant] = - Put[Long].tcontramap(_.toEpochMilli()) - implicit val tagValueGet: Get[TagValue] = Get[String].temap { value => TagValue.validate(value) match @@ -50,16 +102,78 @@ trait DoobieTypes: implicit val tagValuePut: Put[TagValue] = Put[String].tcontramap(_.toString()) + implicit val credentialIdGet: Get[CredentialId] = + Get[Array[Byte]].tmap(bytes => + CredentialId(UUID(UUIDFormat.fromBytes(bytes))) + ) + + implicit val credentialIdPut: Put[CredentialId] = + Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID())) + + implicit val argon2HashPut: Put[Argon2Hash] = + Put[String].tcontramap(_.encode()) + + implicit val argon2HashGet: Get[Argon2Hash] = + Get[String].temap(dbValue => + Argon2Hash.decode(dbValue) match + case None => Left(ErrorMessages.invalidAccountStatus(dbValue)) + case Some(hash) => Right(hash) + ) + + implicit val credentialTypeGet: Get[CredentialType] = + Get[String].temap(dbValue => + CredentialType.parse(dbValue) match + case None => Left(ErrorMessages.invalidCredentialType(dbValue)) + case Some(status) => Right(status) + ) + + implicit val credentialTypePut: Put[CredentialType] = + Put[String].tcontramap(_.name) + + implicit val credentialStatusGet: Get[CredentialStatus] = + Get[String].temap(dbValue => + CredentialStatus.parse(dbValue) match + case None => Left(ErrorMessages.invalidCredentialStatus(dbValue)) + case Some(status) => Right(status) + ) + + implicit val credentialStatusPut: Put[CredentialStatus] = + Put[String].tcontramap(_.name) + object DoobieTypes extends DoobieTypes: object ErrorMessages: + def invalidAccountName(candidate: String): String = + s"'$candidate' is not a valid account name." + + def invalidAccountStatus(candidate: String): String = + s"'$candidate' is not a valid account status." + + def invalidAccountType(candidate: String): String = + s"'$candidate' is not a valid account type." + + def invalidPermission(candidate: String): String = + s"'$candidate' is not a valid permission." + + def invalidGroupName(candidate: String): String = + s"'$candidate' is not a valid group name." + def invalidTagValue(candidate: String): String = s"'$candidate' is not a valid tag value." def invalidTicketStatus(candidate: String): String = s"'$candidate' is not a valid ticket status." + def invalidArgon2Hash(candidate: String): String = + s"'$candidate' is not a valid Argon2 hash." + + def invalidCredentialType(candidate: String): String = + s"'$candidate' is not a valid credential type." + + def invalidCredentialStatus(candidate: String): String = + s"'$candidate' is not a valid credential status." + end ErrorMessages end DoobieTypes diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/ImplTypes.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/ImplTypes.scala new file mode 100644 index 0000000..d2ad672 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/ImplTypes.scala @@ -0,0 +1,16 @@ +package gs.smolban.db.doobie + +import doobie.* +import gs.smolban.model.metadata.CreatedAt +import java.time.Instant +import java.time.LocalDate + +/** Abstraction for supporting implementation-specific types. + */ +trait ImplTypes: + given createdAtGet: Get[CreatedAt] + given createdAtPut: Put[CreatedAt] + given instantGet: Get[Instant] + given instantPut: Put[Instant] + given localDateGet: Get[LocalDate] + given localDatePut: Put[LocalDate] diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/SqliteImplTypes.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/SqliteImplTypes.scala new file mode 100644 index 0000000..e53af98 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/SqliteImplTypes.scala @@ -0,0 +1,28 @@ +package gs.smolban.db.doobie + +import doobie.* +import gs.smolban.model.metadata.CreatedAt +import java.time.Instant +import java.time.LocalDate + +/** SQLite implementation of specific types. + */ +final class SqliteImplTypes extends ImplTypes: + + given createdAtGet: Get[CreatedAt] = + Get[Long].tmap(CreatedAt.fromMilliseconds) + + given createdAtPut: Put[CreatedAt] = + Put[Long].tcontramap(_.toMilliseconds()) + + given instantGet: Get[Instant] = + Get[Long].tmap(Instant.ofEpochMilli) + + given instantPut: Put[Instant] = + Put[Long].tcontramap(_.toEpochMilli()) + + given localDateGet: Get[LocalDate] = + Get[String].tmap(LocalDate.parse) + + given localDatePut: Put[LocalDate] = + Put[String].tcontramap(_.toString()) diff --git a/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala index d63acaf..a2c9e9c 100644 --- a/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala +++ b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala @@ -29,7 +29,8 @@ class DoobieTagDbTests extends munit.FunSuite: test(name)(f.unsafeRunSync()) private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb( - CuratedSqlStates.sqlite + CuratedSqlStates.sqlite, + new SqliteImplTypes ) private val clock = Clock.systemDefaultZone()