Flatten packages

Remove modules prefix
This commit is contained in:
2020-11-11 22:32:23 +01:00
parent e6a7af840a
commit 8439782430
155 changed files with 51 additions and 33 deletions
+32
View File
@@ -0,0 +1,32 @@
import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
id("be.simplenotes.kotlinx-serialization")
kotlin("kapt")
}
dependencies {
implementation(project(":config"))
implementation(project(":types"))
implementation(project(":persistance"))
implementation(project(":search"))
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
implementation(Libs.kotlinxSerializationJson)
implementation(Libs.arrowCoreData)
implementation(Libs.konform)
implementation(Libs.jbcrypt)
implementation(Libs.javaJwt)
implementation(Libs.flexmark)
implementation(Libs.flexmarkGfmTasklist)
implementation(Libs.snakeyaml)
implementation(Libs.owaspHtmlSanitizer)
implementation(Libs.commonsCompress)
testImplementation(Libs.hamkrest)
testImplementation(Libs.junit)
testImplementation(Libs.mockk)
}
+1
View File
@@ -0,0 +1 @@
package be.simplenotes.domain
+38
View File
@@ -0,0 +1,38 @@
package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import org.owasp.html.HtmlChangeListener
import org.owasp.html.HtmlPolicyBuilder
import org.slf4j.LoggerFactory
import javax.inject.Singleton
@Singleton
class HtmlSanitizer {
private val htmlPolicy = HtmlPolicyBuilder()
.allowElements("a")
.allowCommonBlockElements()
.allowCommonInlineFormattingElements()
.allowElements("pre")
.allowAttributes("class").onElements("code")
.allowUrlProtocols("http", "https")
.allowAttributes("href").onElements("a")
.allowElements("input")
.allowAttributes("type", "checked", "disabled", "readonly").onElements("input")
.requireRelNofollowOnLinks()
.toFactory()!!
private val logger = LoggerFactory.getLogger(javaClass)
private val htmlChangeListener = object : HtmlChangeListener<LoggedInUser> {
override fun discardedTag(context: LoggedInUser?, elementName: String) {
logger.warn("Discarded tag $elementName for user $context")
}
override fun discardedAttributes(context: LoggedInUser?, tagName: String, vararg attributeNames: String) {
logger.warn("Discarded attributes ${attributeNames.contentToString()} on tag $tagName for user $context")
}
}
fun sanitize(userId: LoggedInUser, unsafeHtml: String) =
htmlPolicy.sanitize(unsafeHtml, htmlChangeListener, userId)!!
}
@@ -0,0 +1,19 @@
package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.exceptions.JWTVerificationException
import javax.inject.Singleton
@Singleton
class JwtPayloadExtractor(private val jwt: SimpleJwt) {
operator fun invoke(token: String): LoggedInUser? = try {
val decodedJWT = jwt.verifier.verify(token)
val id = decodedJWT.getClaim(userIdField).asInt() ?: null
val username = decodedJWT.getClaim(usernameField).asString() ?: null
id?.let { username?.let { LoggedInUser(id, username) } }
} catch (e: JWTVerificationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
+20
View File
@@ -0,0 +1,20 @@
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
import javax.inject.Inject
import javax.inject.Singleton
internal interface PasswordHash {
fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean
}
@Singleton
internal class BcryptPasswordHash constructor(test: Boolean) : PasswordHash {
@Inject
constructor() : this(false)
private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
}
+28
View File
@@ -0,0 +1,28 @@
package be.simplenotes.domain.security
import be.simplenotes.config.JwtConfig
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
internal const val userIdField = "i"
internal const val usernameField = "u"
@Singleton
class SimpleJwt(jwtConfig: JwtConfig) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
private val algorithm = Algorithm.HMAC256(jwtConfig.secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(loggedInUser: LoggedInUser): String = JWT.create()
.withClaim(userIdField, loggedInUser.userId)
.withClaim(usernameField, loggedInUser.username)
.withExpiresAt(getExpiration())
.sign(algorithm)
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}
+13
View File
@@ -0,0 +1,13 @@
package be.simplenotes.domain.usecases
import be.simplenotes.persistance.DbHealthCheck
import javax.inject.Singleton
interface HealthCheckService {
fun isOk(): Boolean
}
@Singleton
class HealthCheckServiceImpl(private val dbHealthCheck: DbHealthCheck) : HealthCheckService {
override fun isOk() = dbHealthCheck.isOk()
}
+115
View File
@@ -0,0 +1,115 @@
package be.simplenotes.domain.usecases
import arrow.core.computations.either
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.search.parseSearchTerms
import be.simplenotes.persistance.repositories.NoteRepository
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.persistance.transactions.TransactionService
import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import java.util.*
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
@Singleton
class NoteService(
private val markdownConverter: MarkdownConverter,
private val noteRepository: NoteRepository,
private val userRepository: UserRepository,
private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer,
private val transaction: TransactionService,
) {
fun create(user: LoggedInUser, markdownText: String) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote> {
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.create(user.userId, it) }
searcher.indexNote(user.userId, persistedNote)
persistedNote
}
}
fun update(
user: LoggedInUser,
uuid: UUID,
markdownText: String,
) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote?> {
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.update(user.userId, uuid, it) }
persistedNote?.let { searcher.updateIndex(user.userId, it) }
persistedNote
}
}
fun paginatedNotes(
userId: Int,
page: Int,
itemsPerPage: Int = 20,
tag: String? = null,
deleted: Boolean = false,
): PaginatedNotes {
val count = noteRepository.count(userId, tag, deleted)
val offset = (page - 1) * itemsPerPage
val numberOfPages = (count / itemsPerPage) + 1
val notes = if (count == 0) emptyList() else noteRepository.findAll(userId, itemsPerPage, offset, tag, deleted)
return PaginatedNotes(numberOfPages, notes)
}
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun trash(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.delete(userId, uuid, permanent = false)
if (res) searcher.deleteIndex(userId, uuid)
res
}
fun restore(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.restore(userId, uuid)
if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) }
res
}
fun delete(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.delete(userId, uuid, permanent = true)
if (res) searcher.deleteIndex(userId, uuid)
res
}
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
@PostConstruct
fun indexAll() {
dropAllIndexes()
val userIds = userRepository.findAll()
userIds.forEach { id ->
val notes = noteRepository.findAllDetails(id)
searcher.indexNotes(id, notes)
}
}
fun search(userId: Int, searchInput: String) = searcher.search(userId, parseSearchTerms(searchInput))
@PreDestroy
fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePublic(userId, uuid) }
fun makePrivate(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePrivate(userId, uuid) }
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
}
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)
+18
View File
@@ -0,0 +1,18 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
import javax.inject.Singleton
@Singleton
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase,
deleteUseCase: DeleteUseCase,
exportUseCase: ExportUseCase,
) : LoginUseCase by loginUseCase,
RegisterUseCase by registerUseCase,
DeleteUseCase by deleteUseCase,
ExportUseCase by exportUseCase
@@ -0,0 +1,8 @@
package be.simplenotes.domain.usecases.export
import java.io.InputStream
interface ExportUseCase {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@@ -0,0 +1,56 @@
package be.simplenotes.domain.usecases.export
import be.simplenotes.persistance.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import io.micronaut.context.annotation.Primary
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Singleton
@Primary
@Singleton
internal class ExportUseCaseImpl(
private val noteRepository: NoteRepository,
private val json: Json,
) : ExportUseCase {
override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
}
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
override fun exportAsZip(userId: Int): InputStream {
val notes = noteRepository.export(userId)
val zipOutput = ZipOutput()
zipOutput.use { zip ->
notes.forEach {
val name = sanitizeFilename(it.title)
zip.write("notes/$name.md", it.markdown)
}
}
return ByteArrayInputStream(zipOutput.outputStream.toByteArray())
}
}
class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream)
fun write(path: String, content: String) {
val entry = ZipArchiveEntry(path)
zipOutputStream.putArchiveEntry(entry)
zipOutputStream.write(content.toByteArray())
zipOutputStream.closeArchiveEntry()
}
override fun close() {
zipOutputStream.finish()
zipOutputStream.close()
}
}
@@ -0,0 +1,34 @@
package be.simplenotes.domain.usecases.markdown
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import io.micronaut.context.annotation.Factory
import javax.inject.Singleton
@Factory
class FlexmarkFactory {
@Singleton
fun parser(options: MutableDataSet) = Parser.builder(options).build()
@Singleton
fun htmlRenderer(options: MutableDataSet) = HtmlRenderer.builder(options).build()
@Singleton
fun options() = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf(TaskListExtension.create()))
set(TaskListExtension.TIGHT_ITEM_CLASS, "")
set(TaskListExtension.LOOSE_ITEM_CLASS, "")
set(
TaskListExtension.ITEM_DONE_MARKER,
"""<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;"""
)
set(
TaskListExtension.ITEM_NOT_DONE_MARKER,
"""<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;"""
)
set(HtmlRenderer.SOFT_BREAK, "<br>")
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import be.simplenotes.types.NoteMetadata
import io.konform.validation.ValidationErrors
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
@@ -0,0 +1,63 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
import javax.inject.Singleton
private typealias MetaMdPair = Pair<String, String>
@Singleton
internal class MarkdownConverterImpl(
private val parser: Parser,
private val renderer: HtmlRenderer,
) : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return InvalidMeta.left()
} catch (e: ScannerException) {
return InvalidMeta.left()
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left()
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
}
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = either.eager<MarkdownParsingError, Document> {
val (meta, md) = !splitMetaFromDocument(input)
val parsedMeta = !parseMeta(meta)
!Either.fromNullable(NoteValidations.validateMetadata(parsedMeta)).swap()
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
@@ -0,0 +1,43 @@
package be.simplenotes.domain.usecases.search
import be.simplenotes.search.SearchTerms
private fun innerRegex(name: String) =
"""$name:['"](.*?)['"]""".toRegex()
private fun outerRegex(name: String) =
"""($name:['"].*?['"])""".toRegex()
private val titleRe = innerRegex("title")
private val outerTitleRe = outerRegex("title")
private val tagRe = innerRegex("tag")
private val outerTagRe = outerRegex("tag")
private val contentRe = innerRegex("content")
private val outerContentRe = outerRegex("content")
internal fun parseSearchTerms(input: String): SearchTerms {
var c: String = input
fun extract(innerRegex: Regex, outerRegex: Regex): String? {
val match = innerRegex.find(input)?.groups?.get(1)?.value
if (match != null) {
val group = outerRegex.find(input)?.groups?.get(1)?.value
group?.let { c = c.replace(it, "") }
}
return match
}
val title: String? = extract(titleRe, outerTitleRe)
val tag: String? = extract(tagRe, outerTagRe)
val content: String? = extract(contentRe, outerContentRe)
val all = c.trim().ifEmpty { null }
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}
@@ -0,0 +1,35 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.persistance.transactions.TransactionService
import be.simplenotes.search.NoteSearcher
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class DeleteUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : DeleteUseCase {
override fun delete(form: DeleteForm) = transactionService.use {
either.eager<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
!Either.conditionally(
passwordHash.verify(user.password, persistedUser.password),
{ DeleteError.WrongPassword },
{ Unit }
)
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { Unit })
searcher.dropIndex(persistedUser.id)
}
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
interface DeleteUseCase {
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@@ -0,0 +1,28 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.computations.either
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.LoggedInUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Singleton
@Primary
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt
) : LoginUseCase {
override fun login(form: LoginForm) = either.eager<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(LoggedInUser(it)) }
}
}
@@ -0,0 +1,19 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}
@@ -0,0 +1,28 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.persistance.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val transactionService: TransactionService,
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> = transactionService.use {
UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}
+35
View File
@@ -0,0 +1,35 @@
package be.simplenotes.domain.validation
import be.simplenotes.domain.usecases.markdown.ValidationError
import be.simplenotes.types.NoteMetadata
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.uniqueItems
internal object NoteValidations {
private val metaValidator = Validation<NoteMetadata> {
NoteMetadata::title required {
addConstraint("must not be blank") { it.isNotBlank() }
maxLength(50)
}
NoteMetadata::tags required {
maxItems(5)
uniqueItems(true)
}
NoteMetadata::tags onEach {
maxLength(15)
addConstraint("must not be blank") { it.isNotBlank() }
addConstraint("must only contain alphanumeric characters, `-` and `_`") {
it.matches("^[a-zA-Z0-9-_]+\$".toRegex())
}
}
}
fun validateMetadata(meta: NoteMetadata): ValidationError? {
val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) null
else return ValidationError(errors)
}
}
+60
View File
@@ -0,0 +1,60 @@
package be.simplenotes.domain.validation
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.types.User
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
internal object UserValidations {
private val loginValidator = Validation<LoginForm> {
LoginForm::username required {
minLength(3)
maxLength(50)
}
LoginForm::password required {
minLength(8)
maxLength(72) // jbcrypt limit, see https://security.stackexchange.com/a/39851
}
}
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidLoginForm(errors).left()
}
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidRegisterForm(errors).left()
}
private val deleteValidator = Validation<DeleteForm> {
DeleteForm::username required {
minLength(3)
maxLength(50)
}
DeleteForm::password required {
minLength(8)
maxLength(72)
}
DeleteForm::checked required {
addConstraint("Should be checked") { it }
}
}
fun validateDelete(form: DeleteForm): Either<DeleteError.InvalidForm, User> {
val errors = deleteValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return DeleteError.InvalidForm(errors).left()
}
}
+1
View File
@@ -0,0 +1 @@
package be.simplenotes.domain
@@ -0,0 +1,51 @@
package be.simplenotes.domain.security
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.users.login.Token
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.natpryce.hamkrest.absent
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.concurrent.TimeUnit
import java.util.stream.Stream
internal class LoggedInUserExtractorTest {
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val jwtPayloadExtractor = JwtPayloadExtractor(simpleJwt)
private fun createToken(username: String? = null, id: Int? = null, secret: String = jwtConfig.secret): Token {
val algo = Algorithm.HMAC256(secret)
return JWT.create().apply {
username?.let { withClaim(usernameField, it) }
id?.let { withClaim(userIdField, it) }
}.sign(algo)
}
@Suppress("Unused")
private fun invalidTokens() = Stream.of(
createToken(id = 1),
createToken(username = "user"),
createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"),
createToken(username = "user", id = 1) + "\"efesfsef",
"something that is not even a token"
)
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid")
@MethodSource("invalidTokens")
fun `parse invalid tokens`(token: String) {
assertThat(jwtPayloadExtractor(token), absent())
}
@Test
fun `parse valid token`() {
val token = createToken(username = "someone", id = 1)
assertThat(jwtPayloadExtractor(token), equalTo(LoggedInUser(1, "someone")))
}
}
+42
View File
@@ -0,0 +1,42 @@
package be.simplenotes.domain.testutils
import arrow.core.Either
import com.natpryce.hamkrest.MatchResult
import com.natpryce.hamkrest.Matcher
fun isLeft() = object : Matcher<Either<*, *>> {
override val description: String
get() = "is Either.Left<>"
override fun invoke(actual: Either<*, *>) = when {
actual.isLeft() -> MatchResult.Match
else -> MatchResult.Mismatch("is Either.Right<>")
}
}
fun isRight() = object : Matcher<Either<*, *>> {
override val description: String
get() = "is Either.Right<>"
override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Match
is Either.Left -> {
val valueA = actual.a
MatchResult.Mismatch("is Either.Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
}
}
}
inline fun <reified A> isLeftOfType() = object : Matcher<Either<*, *>> {
override val description: String
get() = "is Either.Left<${A::class.qualifiedName}>"
override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Mismatch("was Either.Right<>")
is Either.Left -> {
val valueA = actual.a
if (valueA is A) MatchResult.Match
else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
}
}
}
@@ -0,0 +1,45 @@
package be.simplenotes.domain.usecases.search
import be.simplenotes.search.SearchTerms
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
internal class SearchTermsParserKtTest {
private fun createResult(
input: String,
title: String? = null,
tag: String? = null,
content: String? = null,
all: String? = null,
): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all)
@Suppress("Unused")
private fun results() = Stream.of(
createResult("title:'example'", title = "example"),
createResult("title:'example with words'", title = "example with words"),
createResult("title:'example with words'", title = "example with words"),
createResult("""title:"double quotes"""", title = "double quotes"),
createResult("title:'example' something else", title = "example", all = "something else"),
createResult("tag:'example'", tag = "example"),
createResult("tag:'example' title:'other'", title = "other", tag = "example"),
createResult("blah blah tag:'example' title:'other'", title = "other", tag = "example", all = "blah blah"),
createResult("tag:'example' middle title:'other'", title = "other", tag = "example", all = "middle"),
createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"),
createResult(
"tag:'example abc' title:'other with words' this is the end ",
title = "other with words",
tag = "example abc",
all = "this is the end"
),
)
@ParameterizedTest
@MethodSource("results")
fun `valid search parser`(case: Pair<String, SearchTerms>) {
assertThat(parseSearchTerms(case.first), equalTo(case.second))
}
}
@@ -0,0 +1,64 @@
package be.simplenotes.domain.usecases.users.login
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class LoginUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(form.username!!) } returns null
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { mockUserRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = loginUseCase.login(loginForm)
assertThat(res, isRight())
}
}
@@ -0,0 +1,54 @@
package be.simplenotes.domain.usecases.users.register
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.persistance.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class RegisterUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash, noopTransactionService)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns true
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns false
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = registerUseCase.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
}
@@ -0,0 +1,77 @@
package be.simplenotes.domain.validation
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import com.natpryce.hamkrest.assertion.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
internal class UserValidationsTest {
@Nested
inner class Login {
@Suppress("Unused")
fun invalidLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = null, password = null),
LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"),
LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
LoginForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidLoginForms")
fun `validate invalid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
LoginForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validLoginForms")
fun `validate valid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
@Nested
inner class Register {
@Suppress("Unused")
fun invalidRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = null, password = null),
RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"),
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
RegisterForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidRegisterForms")
fun `validate invalid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
RegisterForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validRegisterForms")
fun `validate valid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
}