diff --git a/build.gradle.kts b/build.gradle.kts index ec536cd..59ad27c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-mongodb") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-validation") developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-docker-compose") @@ -66,6 +67,8 @@ dependencies { implementation("org.jsoup:jsoup:1.17.2") implementation("com.rometools:rome:2.1.0") + implementation("com.jayway.jsonpath:json-path:2.9.0") + testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(group = "org.mockito") } @@ -75,6 +78,7 @@ dependencies { testImplementation("io.mockk:mockk:1.13.10") testImplementation("com.icegreen:greenmail:2.0.1") testImplementation("com.icegreen:greenmail-junit5:2.0.1") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } tasks.withType { diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 3f23d47..a4f6e8b 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -576,11 +576,11 @@ style: active: true comments: - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' - value: 'FIXME:' + value: 'FIXME' - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' - value: 'STOPSHIP:' + value: 'STOPSHIP' - reason: 'Forbidden TODO todo marker in comment, please do the changes.' - value: 'TODO:' + value: 'TODO' allowedPatterns: '' ForbiddenImport: active: false diff --git a/n2rss.http b/n2rss.http index 478ae23..8bfde8e 100644 --- a/n2rss.http +++ b/n2rss.http @@ -11,3 +11,21 @@ X-Secret-Key: abcd ### Content-Type GET http://localhost:8080/rss/pointer + +### Request (Success) +POST http://localhost:8080/send-request +Content-Type: application/x-www-form-urlencoded + +newsletterUrl = https://www.some.fo + +### Request (FAIL) +POST http://localhost:8080/send-request +Content-Type: application/x-www-form-urlencoded + +newsletterUrl = pouet + +### Request (almost same) +POST http://localhost:8080/send-request +Content-Type: application/x-www-form-urlencoded + +newsletterUrl = https://www.some.fo/ diff --git a/src/main/kotlin/fr/nicopico/n2rss/config/N2RssProperties.kt b/src/main/kotlin/fr/nicopico/n2rss/config/N2RssProperties.kt index e40756b..0697894 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/config/N2RssProperties.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/config/N2RssProperties.kt @@ -26,12 +26,18 @@ data class N2RssProperties @ConstructorBinding constructor( val maintenance: MaintenanceProperties, - val feeds: FeedsProperties, + val feeds: FeedsProperties = FeedsProperties(), + val recaptcha: ReCaptchaProperties, ) { data class MaintenanceProperties( val secretKey: String, ) data class FeedsProperties( - val forceHttps: Boolean, + val forceHttps: Boolean = true, + ) + data class ReCaptchaProperties( + val enabled: Boolean = true, + val siteKey: String, + val secretKey: String, ) } diff --git a/src/main/kotlin/fr/nicopico/n2rss/controller/home/HomeController.kt b/src/main/kotlin/fr/nicopico/n2rss/controller/home/HomeController.kt index ddc3f15..e2444ce 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/controller/home/HomeController.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/controller/home/HomeController.kt @@ -19,17 +19,30 @@ package fr.nicopico.n2rss.controller.home import fr.nicopico.n2rss.config.N2RssProperties import fr.nicopico.n2rss.service.NewsletterService +import fr.nicopico.n2rss.service.ReCaptchaService +import fr.nicopico.n2rss.utils.Url import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.ConstraintViolationException +import jakarta.validation.constraints.NotEmpty +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.ui.Model +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import java.net.URL +@Validated @Controller class HomeController( private val newsletterService: NewsletterService, - props: N2RssProperties, + private val reCaptchaService: ReCaptchaService, + private val properties: N2RssProperties, ) { - private val properties = props.feeds @GetMapping("/") fun home(request: HttpServletRequest, model: Model): String { @@ -37,7 +50,7 @@ class HomeController( .filter { it.publicationCount > 0 } val requestUrl: String = request.requestURL .let { - if (properties.forceHttps) { + if (properties.feeds.forceHttps) { it.replace(Regex("http://"), "https://") } else { it.toString() @@ -47,7 +60,42 @@ class HomeController( with(model) { addAttribute("newsletters", newslettersInfo) addAttribute("requestUrl", requestUrl) + addAttribute("reCaptchaEnabled", properties.recaptcha.enabled) + addAttribute("reCaptchaSiteKey", properties.recaptcha.siteKey) } return "index" } + + @PostMapping("/send-request") + fun requestNewsletter( + @NotEmpty @Url @RequestParam("newsletterUrl") newsletterUrl: String, + @RequestParam("g-recaptcha-response") captchaResponse: String? = null, + ): ResponseEntity { + val isCaptchaValid = if (properties.recaptcha.enabled) { + reCaptchaService.isCaptchaValid( + captchaSecretKey = properties.recaptcha.secretKey, + captchaResponse = captchaResponse ?: "", + ) + } else true + + return if (isCaptchaValid) { + newsletterService.saveRequest(URL(newsletterUrl)) + ResponseEntity.ok().build() + } else { + ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("reCaptcha challenge failed") + } + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(ConstraintViolationException::class) + fun handleExceptions( + exception: ConstraintViolationException, + ): ResponseEntity> { + val errors = exception.constraintViolations.associate { + it.propertyPath.toString() to it.message + } + return ResponseEntity.badRequest().body(errors) + } } diff --git a/src/main/kotlin/fr/nicopico/n2rss/data/NewsletterRequestRepository.kt b/src/main/kotlin/fr/nicopico/n2rss/data/NewsletterRequestRepository.kt new file mode 100644 index 0000000..a7e313b --- /dev/null +++ b/src/main/kotlin/fr/nicopico/n2rss/data/NewsletterRequestRepository.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package fr.nicopico.n2rss.data + +import fr.nicopico.n2rss.models.NewsletterRequest +import org.springframework.data.mongodb.repository.MongoRepository +import java.net.URL + +interface NewsletterRequestRepository : MongoRepository { + fun getByNewsletterUrl(url: URL): NewsletterRequest? +} diff --git a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/AndroidWeeklyNewsletterHandler.kt b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/AndroidWeeklyNewsletterHandler.kt index a59770c..448915a 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/AndroidWeeklyNewsletterHandler.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/AndroidWeeklyNewsletterHandler.kt @@ -20,7 +20,7 @@ package fr.nicopico.n2rss.mail.newsletter import fr.nicopico.n2rss.models.Article import fr.nicopico.n2rss.models.Email import fr.nicopico.n2rss.models.Newsletter -import fr.nicopico.n2rss.utils.toURL +import fr.nicopico.n2rss.utils.toUrlOrNull import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -61,7 +61,7 @@ class AndroidWeeklyNewsletterHandler : NewsletterHandler { .filter { it -> it.text().isNotBlank() } .mapNotNull { tag -> // Ignore entries with invalid link - tag.attr("href").toURL() + tag.attr("href").toUrlOrNull() ?.let { link -> val title = tag.text().trim() Article( diff --git a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/KotlinWeeklyNewsletterHandler.kt b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/KotlinWeeklyNewsletterHandler.kt index deeac58..5f38d79 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/KotlinWeeklyNewsletterHandler.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/KotlinWeeklyNewsletterHandler.kt @@ -23,7 +23,7 @@ import fr.nicopico.n2rss.mail.newsletter.jsoup.process import fr.nicopico.n2rss.models.Article import fr.nicopico.n2rss.models.Email import fr.nicopico.n2rss.models.Newsletter -import fr.nicopico.n2rss.utils.toURL +import fr.nicopico.n2rss.utils.toUrlOrNull import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.safety.Safelist @@ -54,7 +54,7 @@ class KotlinWeeklyNewsletterHandler : NewsletterHandler { sectionDocument.select("a[href]") .mapNotNull { tag -> // Ignore entries with invalid link - val link = tag.attr("href").toURL() + val link = tag.attr("href").toUrlOrNull() ?: return@mapNotNull null val title = markSponsoredTitle(section, tag.text()).trim() diff --git a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/PointerNewsletterHandler.kt b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/PointerNewsletterHandler.kt index a439848..adfe62a 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/PointerNewsletterHandler.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/mail/newsletter/PointerNewsletterHandler.kt @@ -20,7 +20,7 @@ package fr.nicopico.n2rss.mail.newsletter import fr.nicopico.n2rss.models.Article import fr.nicopico.n2rss.models.Email import fr.nicopico.n2rss.models.Newsletter -import fr.nicopico.n2rss.utils.toURL +import fr.nicopico.n2rss.utils.toUrlOrNull import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -66,7 +66,7 @@ class PointerNewsletterHandler : NewsletterHandler { } val sponsorSubtitleElement = sponsorSection.selectFirst("a[href]:has(strong:has(span))") - val sponsorLink = sponsorSubtitleElement?.attr("href")?.toURL() + val sponsorLink = sponsorSubtitleElement?.attr("href")?.toUrlOrNull() return if (sponsorSubtitleElement != null && sponsorLink != null) { val sponsorName = sponsorSection.select("p") @@ -104,7 +104,7 @@ class PointerNewsletterHandler : NewsletterHandler { val links = articleSectionDocument.select("a[href]:has(strong:has(span))") val articles = links.mapNotNull { articleTitle -> - val link = articleTitle.attr("href").toURL() + val link = articleTitle.attr("href").toUrlOrNull() ?: return@mapNotNull null val title = articleTitle.text() val description = articleTitle.findDescription() diff --git a/src/main/kotlin/fr/nicopico/n2rss/models/NewsletterRequest.kt b/src/main/kotlin/fr/nicopico/n2rss/models/NewsletterRequest.kt new file mode 100644 index 0000000..04d440e --- /dev/null +++ b/src/main/kotlin/fr/nicopico/n2rss/models/NewsletterRequest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +package fr.nicopico.n2rss.models + +import kotlinx.datetime.LocalDateTime +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.net.URL + +@Document("newsletter_request") +data class NewsletterRequest( + @Id + val newsletterUrl: URL, + val firstRequestDate: LocalDateTime, + val lastRequestDate: LocalDateTime, + val requestCount: Int, +) diff --git a/src/main/kotlin/fr/nicopico/n2rss/service/NewsletterService.kt b/src/main/kotlin/fr/nicopico/n2rss/service/NewsletterService.kt index d72392f..5f94690 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/service/NewsletterService.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/service/NewsletterService.kt @@ -18,15 +18,23 @@ package fr.nicopico.n2rss.service +import fr.nicopico.n2rss.data.NewsletterRequestRepository import fr.nicopico.n2rss.data.PublicationRepository import fr.nicopico.n2rss.mail.newsletter.NewsletterHandler import fr.nicopico.n2rss.models.NewsletterInfo +import fr.nicopico.n2rss.models.NewsletterRequest +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.net.URL @Service class NewsletterService( private val newsletterHandlers: List, private val publicationRepository: PublicationRepository, + private val newsletterRequestRepository: NewsletterRequestRepository, ) { fun getNewslettersInfo(): List { return newsletterHandlers @@ -41,4 +49,30 @@ class NewsletterService( ) } } + + @Transactional + fun saveRequest(newsletterUrl: URL) { + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + // Sanitize URL + val uniqueUrl = URL( + /* protocol = */ "https", + /* host = */ newsletterUrl.host, + /* port = */ newsletterUrl.port, + /* file = */ "", + ) + val request = newsletterRequestRepository.getByNewsletterUrl(uniqueUrl) + + val updatedRequest = request?.copy( + lastRequestDate = now, + requestCount = request.requestCount + 1 + ) ?: NewsletterRequest( + newsletterUrl = uniqueUrl, + firstRequestDate = now, + lastRequestDate = now, + requestCount = 1, + ) + + newsletterRequestRepository.save(updatedRequest) + } } diff --git a/src/main/kotlin/fr/nicopico/n2rss/service/ReCaptchaService.kt b/src/main/kotlin/fr/nicopico/n2rss/service/ReCaptchaService.kt new file mode 100644 index 0000000..23f7991 --- /dev/null +++ b/src/main/kotlin/fr/nicopico/n2rss/service/ReCaptchaService.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +package fr.nicopico.n2rss.service + +import com.jayway.jsonpath.JsonPath +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.toEntity + +private val LOG = LoggerFactory.getLogger(ReCaptchaService::class.java) + +@Service +class ReCaptchaService( + private val restClient: RestClient = RestClient.create("https://www.google.com/recaptcha/api") +) { + + fun isCaptchaValid( + captchaSecretKey: String, + captchaResponse: String + ): Boolean { + val params: MultiValueMap = LinkedMultiValueMap() + params.add("secret", captchaSecretKey) + params.add("response", captchaResponse) + + val response = restClient + .post() + .uri("/siteverify") + .body(params) + .retrieve() + .toEntity() + + LOG.debug(response.body) + + return JsonPath + .parse(response.body) + .read("""["success"]""") + } +} diff --git a/src/main/kotlin/fr/nicopico/n2rss/utils/UrlExt.kt b/src/main/kotlin/fr/nicopico/n2rss/utils/UrlExt.kt index 3773c7e..43f927f 100644 --- a/src/main/kotlin/fr/nicopico/n2rss/utils/UrlExt.kt +++ b/src/main/kotlin/fr/nicopico/n2rss/utils/UrlExt.kt @@ -20,7 +20,8 @@ package fr.nicopico.n2rss.utils import java.net.MalformedURLException import java.net.URL -fun String.toURL(): URL? = try { +fun String.toUrlOrNull(): URL? = try { + URL(this) URL(this) } catch (_: MalformedURLException) { null diff --git a/src/main/kotlin/fr/nicopico/n2rss/utils/UrlValidation.kt b/src/main/kotlin/fr/nicopico/n2rss/utils/UrlValidation.kt new file mode 100644 index 0000000..6585455 --- /dev/null +++ b/src/main/kotlin/fr/nicopico/n2rss/utils/UrlValidation.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +package fr.nicopico.n2rss.utils + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import kotlin.reflect.KClass + +@MustBeDocumented +@Constraint(validatedBy = [UrlValidator::class]) +@Target( + AnnotationTarget.FIELD, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, +) +@Retention(AnnotationRetention.RUNTIME) +@Repeatable +annotation class Url( + val message: String = "Invalid URL", + val groups: Array> = [], + val payload: Array> = [] +) + +private val urlRegex = Regex( + "^((http|https):\\/\\/)?(www\\.)?([A-z]+)\\.([A-z]{2,})" +) + +internal class UrlValidator : ConstraintValidator { + override fun isValid( + url: String?, + context: ConstraintValidatorContext? + ): Boolean { + if (url.isNullOrBlank()) { + return true + } + return urlRegex.matches(url) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 86b54ba..f7e4102 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,21 @@ +# +# Copyright (c) 2024 Nicolas PICON +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies or substantial portions +# of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +# TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# + # Logging logging.level.fr.nicopico=DEBUG logging.level.org.springframework=INFO @@ -9,3 +27,6 @@ spring.profiles.active=local # n2rss n2rss.maintenance.secret-key=abcd n2rss.feeds.force-https=false +n2rss.recaptcha.enabled=false +n2rss.recaptcha.site-key=${N2RSS_RECAPTCHA_SITE_KEY} +n2rss.recaptcha.secret-key=${N2RSS_RECAPTCHA_SECRET_KEY} diff --git a/src/main/resources/static/css/n2rss.css b/src/main/resources/static/css/n2rss.css index b0182f7..6640216 100644 --- a/src/main/resources/static/css/n2rss.css +++ b/src/main/resources/static/css/n2rss.css @@ -26,6 +26,7 @@ :root { --background-color: #e5effa; + --max-width: 800px; } body { @@ -61,12 +62,12 @@ body { } .header p.project-description { - max-width: 800px; + max-width: var(--max-width); margin: 24px auto; } .container { - max-width: 800px; + max-width: var(--max-width); margin: 0 auto; padding: 20px; background: #fff; @@ -106,3 +107,37 @@ table tbody tr:nth-child(odd) { font-style: italic; font-size: 0.8em; } + +form { + margin: 34px auto; + max-width: var(--max-width); + text-align: center; +} + +form p { + margin-bottom: 0.8em; +} + +div.g-recaptcha { + margin: 8px auto; + display: flex; + justify-content: center; +} + +.message { + margin: 8px auto; +} + +.message.message-success { + color: #017701; +} + +.message.message-info { + color: #0829ec; + font-weight: bold; + font-style: italic; +} + +.message.message-error { + color: #c60101; +} diff --git a/src/main/resources/static/js/n2rss.js b/src/main/resources/static/js/n2rss.js new file mode 100644 index 0000000..15f4b6b --- /dev/null +++ b/src/main/resources/static/js/n2rss.js @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +let newsletterForm = document.getElementById('newsletterForm'); +let newsletterUrl = document.getElementById('newsletterUrl'); +let sendRequest = document.getElementById("sendRequest"); +let message = document.getElementById('message'); +let recaptcha = document.getElementById('recaptcha'); + +function submitForm() { + sendRequest.disabled = false; + newsletterUrl.disabled = false; + + message.style.display = "none"; + + fetch("/send-request", {method: 'POST', body: new FormData(newsletterForm)}) + .then(response => { + if (!response.ok) { + throw new Error("HTTP error " + response.status); + } + message.textContent = "Successfully subscribed!"; + message.classList.value = "message message-success" + }) + .catch(() => { + message.textContent = "There was an error subscribing."; + message.classList.value = "message message-error"; + }) + .finally(() => { + message.style.display = "block"; + recaptcha.style.display = "none"; + }); +} + +newsletterForm + .addEventListener('submit', function (event) { + event.preventDefault(); + if (recaptcha) { + sendRequest.disabled = true; + newsletterUrl.disabled = true; + recaptcha.style.display = "flex"; + + message.textContent = "Please handle the captcha challenge to send your request"; + message.style.display = "block"; + message.classList.value = "message message-info"; + } else { + submitForm(); + } + }); diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 204aa97..d8e4346 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -73,6 +73,30 @@

Newsletters to RSS

+ +
+

+ You can request we add new RSS feed by submitting a request in the form below +

+ + + + + + + + + +
+ Newsletters to RSS decoding="async" data-recalc-dims="1"/> + + + diff --git a/src/test/kotlin/fr/nicopico/n2rss/controller/UrlValidatorTest.kt b/src/test/kotlin/fr/nicopico/n2rss/controller/UrlValidatorTest.kt new file mode 100644 index 0000000..7f7893c --- /dev/null +++ b/src/test/kotlin/fr/nicopico/n2rss/controller/UrlValidatorTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +package fr.nicopico.n2rss.controller + +import fr.nicopico.n2rss.utils.UrlValidator +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class UrlValidatorTest { + + private lateinit var urlValidator: UrlValidator + + @BeforeEach + fun setUp() { + urlValidator = UrlValidator() + } + + companion object { + @JvmStatic + @Suppress("HttpUrlsUsage") + fun provideUrls(): List { + return listOf( + // Unprovided values are considered valid + Arguments.of(null, true), + Arguments.of("", true), + // HTTP and HTTPS are valid + Arguments.of("https://www.google.com", true), + Arguments.of("http://www.android.com", true), + // Urls without scheme can be valid + Arguments.of("www.google.com", true), + Arguments.of("nirvana.com", true), + // Invalid values + Arguments.of("mail://test@n2rss.fr", false), + Arguments.of("test@n2rss.fr", false), + Arguments.of("Invalid_URL", false), + ) + } + } + + @ParameterizedTest + @MethodSource("provideUrls") + fun `test url validator`(url: String?, expected: Boolean) { + // WHEN + val result = urlValidator.isValid(url, null) + + // THEN + result shouldBe expected + } +} diff --git a/src/test/kotlin/fr/nicopico/n2rss/controller/home/HomeControllerTest.kt b/src/test/kotlin/fr/nicopico/n2rss/controller/home/HomeControllerTest.kt index eab64db..d81b459 100644 --- a/src/test/kotlin/fr/nicopico/n2rss/controller/home/HomeControllerTest.kt +++ b/src/test/kotlin/fr/nicopico/n2rss/controller/home/HomeControllerTest.kt @@ -1,28 +1,60 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + package fr.nicopico.n2rss.controller.home import fr.nicopico.n2rss.config.N2RssProperties import fr.nicopico.n2rss.models.Newsletter import fr.nicopico.n2rss.models.NewsletterInfo import fr.nicopico.n2rss.service.NewsletterService +import fr.nicopico.n2rss.service.ReCaptchaService +import io.kotest.assertions.throwables.shouldThrowAny import io.kotest.matchers.collections.shouldContainOnly +import io.kotest.matchers.should import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beOfType import io.mockk.MockKAnnotations +import io.mockk.Runs import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify import jakarta.servlet.http.HttpServletRequest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus import org.springframework.ui.Model +import java.net.MalformedURLException +import java.net.URL class HomeControllerTest { @MockK private lateinit var newsletterService: NewsletterService @MockK + private lateinit var reCaptchaService: ReCaptchaService + @MockK private lateinit var feedProperties: N2RssProperties.FeedsProperties + @MockK(relaxed = true) + private lateinit var reCaptchaProperties: N2RssProperties.ReCaptchaProperties private lateinit var homeController: HomeController @@ -32,71 +64,161 @@ class HomeControllerTest { val properties = mockk() { every { feeds } returns feedProperties + every { recaptcha } returns reCaptchaProperties } - homeController = HomeController(newsletterService, properties) + homeController = HomeController(newsletterService, reCaptchaService, properties) } - @Test - fun `home should provide necessary information to the template`() { - // GIVEN - val newslettersInfo = listOf( - NewsletterInfo("A", "Newsletter A", "Website A", 12, null), - NewsletterInfo("B", "Newsletter B", "Website B", 3, null), - NewsletterInfo("C", "Newsletter C", "Website C", 0, null), - NewsletterInfo("D", "Newsletter D", "Website D", 1, null), - ) - every { newsletterService.getNewslettersInfo() } returns newslettersInfo - every { feedProperties.forceHttps } returns false - - val requestUrl = StringBuffer("http://localhost:8134") - val request = mockk { - every { requestURL } returns requestUrl + @Nested + inner class GetTest { + @Test + fun `home should provide necessary information to the template`() { + // GIVEN + val newslettersInfo = listOf( + NewsletterInfo("A", "Newsletter A", "Website A", 12, null), + NewsletterInfo("B", "Newsletter B", "Website B", 3, null), + NewsletterInfo("C", "Newsletter C", "Website C", 0, null), + NewsletterInfo("D", "Newsletter D", "Website D", 1, null), + ) + every { newsletterService.getNewslettersInfo() } returns newslettersInfo + every { feedProperties.forceHttps } returns false + + val requestUrl = StringBuffer("http://localhost:8134") + val request = mockk { + every { requestURL } returns requestUrl + } + + val model = mockk(relaxed = true) + + // WHEN + val result = homeController.home(request, model) + + // THEN + result shouldBe "index" + val newslettersSlot = slot>() + verify { + model.addAttribute("newsletters", capture(newslettersSlot)) + model.addAttribute("requestUrl", "http://localhost:8134") + } + + // Newsletters without publication should not be displayed + newslettersSlot.captured shouldContainOnly listOf( + NewsletterInfo("A", "Newsletter A", "Website A", 12, null), + NewsletterInfo("B", "Newsletter B", "Website B", 3, null), + NewsletterInfo("D", "Newsletter D", "Website D", 1, null), + ) } - val model = mockk(relaxed = true) - - // WHEN - val result = homeController.home(request, model) + @Test + fun `home should use HTTPS feed when the feature is activated`() { + // GIVEN + val newslettersInfo = listOf() + every { newsletterService.getNewslettersInfo() } returns newslettersInfo + every { feedProperties.forceHttps } returns true + + val requestUrl = StringBuffer("http://localhost:8134") + val request = mockk { + every { requestURL } returns requestUrl + } + + val model = mockk(relaxed = true) + + // WHEN + val result = homeController.home(request, model) + + // THEN + result shouldBe "index" + verify { + model.addAttribute("newsletters", any()) + model.addAttribute("requestUrl", "https://localhost:8134") + } + } + } - // THEN - result shouldBe "index" - val newslettersSlot = slot>() - verify { - model.addAttribute("newsletters", capture(newslettersSlot)) - model.addAttribute("requestUrl", "http://localhost:8134") + @Nested + inner class SendRequestTest { + @Test + fun `sendRequest should save the request if captcha is valid`() { + // GIVEN + val newsletterUrl = "http://localhost:8134" + val captchaResponse = "captchaResponse" + val captchaSecretKey = "captchaSecretKey" + + // SETUP + every { reCaptchaProperties.enabled } returns true + every { reCaptchaProperties.secretKey } returns captchaSecretKey + every { newsletterService.saveRequest(any()) } just Runs + every { reCaptchaService.isCaptchaValid(any(), any()) } returns true + + // WHEN + val response = homeController.requestNewsletter(newsletterUrl, captchaResponse) + + // THEN + verify { newsletterService.saveRequest(URL(newsletterUrl)) } + verify { reCaptchaService.isCaptchaValid(captchaSecretKey, captchaResponse) } + response.statusCode shouldBe HttpStatus.OK } - // Newsletters without publication should not be displayed - newslettersSlot.captured shouldContainOnly listOf( - NewsletterInfo("A", "Newsletter A", "Website A", 12, null), - NewsletterInfo("B", "Newsletter B", "Website B", 3, null), - NewsletterInfo("D", "Newsletter D", "Website D", 1, null), - ) - } + @Test + fun `sendRequest should not save the request if captcha is not valid`() { + // GIVEN + val newsletterUrl = "http://localhost:8134" + val captchaResponse = "captchaResponse" + val captchaSecretKey = "captchaSecretKey" + + // SETUP + every { reCaptchaProperties.enabled } returns true + every { reCaptchaProperties.secretKey } returns captchaSecretKey + every { reCaptchaService.isCaptchaValid(any(), any()) } returns false - @Test - fun `home should use HTTPS feed when the feature is activated`() { - // GIVEN - val newslettersInfo = listOf() - every { newsletterService.getNewslettersInfo() } returns newslettersInfo - every { feedProperties.forceHttps } returns true + // WHEN + val response = homeController.requestNewsletter(newsletterUrl, captchaResponse) - val requestUrl = StringBuffer("http://localhost:8134") - val request = mockk { - every { requestURL } returns requestUrl + // THEN + verify(exactly = 0) { newsletterService.saveRequest(any()) } + response.statusCode shouldBe HttpStatus.BAD_REQUEST } - val model = mockk(relaxed = true) + @Test + fun `sendRequest should validate without captcha if disabled`() { + // GIVEN + val newsletterUrl = "http://localhost:8134" + + // SETUP + every { reCaptchaProperties.enabled } returns false + every { newsletterService.saveRequest(any()) } just Runs - // WHEN - val result = homeController.home(request, model) + // WHEN + val response = homeController.requestNewsletter(newsletterUrl) + + // THEN + verify(exactly = 1) { newsletterService.saveRequest(any()) } + verify(exactly = 0) { reCaptchaService.isCaptchaValid(any(), any()) } + response.statusCode shouldBe HttpStatus.OK + } - // THEN - result shouldBe "index" - verify { - model.addAttribute("newsletters", any()) - model.addAttribute("requestUrl", "https://localhost:8134") + @Test + fun `sendRequest should fail if newsletterUrl is not a valid url`() { + // GIVEN + val newsletterUrl = "something" + val captchaResponse = "captchaResponse" + val captchaSecretKey = "captchaSecretKey" + + // SETUP + every { reCaptchaProperties.enabled } returns true + every { reCaptchaProperties.secretKey } returns captchaSecretKey + every { newsletterService.saveRequest(any()) } just Runs + every { reCaptchaService.isCaptchaValid(any(), any()) } returns true + + // WHEN + val error = shouldThrowAny { + homeController.requestNewsletter(newsletterUrl, captchaResponse) + } + + // THEN + verify(exactly = 0) { newsletterService.saveRequest(any()) } + error should beOfType() } } } diff --git a/src/test/kotlin/fr/nicopico/n2rss/service/NewsletterServiceTest.kt b/src/test/kotlin/fr/nicopico/n2rss/service/NewsletterServiceTest.kt index eb93f30..2c71745 100644 --- a/src/test/kotlin/fr/nicopico/n2rss/service/NewsletterServiceTest.kt +++ b/src/test/kotlin/fr/nicopico/n2rss/service/NewsletterServiceTest.kt @@ -1,17 +1,26 @@ package fr.nicopico.n2rss.service +import fr.nicopico.n2rss.data.NewsletterRequestRepository import fr.nicopico.n2rss.data.PublicationRepository import fr.nicopico.n2rss.fakes.NewsletterHandlerFake import fr.nicopico.n2rss.models.Newsletter import fr.nicopico.n2rss.models.NewsletterInfo +import fr.nicopico.n2rss.models.NewsletterRequest import fr.nicopico.n2rss.models.Publication import io.kotest.matchers.collections.shouldContainOnly +import io.kotest.matchers.kotlinx.datetime.shouldBeAfter +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.slot +import io.mockk.verify import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.net.URL class NewsletterServiceTest { @@ -23,6 +32,8 @@ class NewsletterServiceTest { @MockK private lateinit var publicationRepository: PublicationRepository + @MockK + private lateinit var newsletterRequestRepository: NewsletterRequestRepository private lateinit var newsletterService: NewsletterService @@ -32,6 +43,7 @@ class NewsletterServiceTest { newsletterService = NewsletterService( newsletterHandlers = newsletterHandlers, publicationRepository = publicationRepository, + newsletterRequestRepository = newsletterRequestRepository, ) } @@ -96,4 +108,83 @@ class NewsletterServiceTest { ), ) } + + @Test + fun `new newsletterRequest are added to the database`() { + // GIVEN + val newsletterUrl = URL("https://www.nicopico.com") + every { newsletterRequestRepository.getByNewsletterUrl(any()) } returns null + every { newsletterRequestRepository.save(any()) } answers { firstArg() } + + // WHEN + newsletterService.saveRequest(newsletterUrl) + + // THEN + val slotNewsletterRequest = slot() + verify { + newsletterRequestRepository.getByNewsletterUrl(newsletterUrl) + newsletterRequestRepository.save(capture(slotNewsletterRequest)) + } + slotNewsletterRequest.captured should { + it.newsletterUrl shouldBe newsletterUrl + it.requestCount shouldBe 1 + it.firstRequestDate shouldBe it.lastRequestDate + } + } + + @Test + fun `variant of a newsletterRequest will be unified`() { + // GIVEN + @Suppress("HttpUrlsUsage") + val newsletterUrl = URL("http://www.nicopico.com/test/") + every { newsletterRequestRepository.getByNewsletterUrl(any()) } returns null + every { newsletterRequestRepository.save(any()) } answers { firstArg() } + val uniqueUrl = URL("https://www.nicopico.com") + + // WHEN + newsletterService.saveRequest(newsletterUrl) + + // THEN + val slotNewsletterRequest = slot() + verify { + newsletterRequestRepository.getByNewsletterUrl(uniqueUrl) + newsletterRequestRepository.save(capture(slotNewsletterRequest)) + } + slotNewsletterRequest.captured should { + it.newsletterUrl shouldBe uniqueUrl + it.requestCount shouldBe 1 + it.firstRequestDate shouldBe it.lastRequestDate + } + } + + @Test + fun `existing newsletterRequest are incremented in the database`() { + // GIVEN + val newsletterUrl = URL("https://www.nicopico.com") + val existingRequest = NewsletterRequest( + newsletterUrl = newsletterUrl, + firstRequestDate = LocalDateTime(2020, 1, 1, 0, 0, 0), + lastRequestDate = LocalDateTime(2020, 1, 10, 0, 0, 0), + requestCount = 2, + ) + + every { newsletterRequestRepository.getByNewsletterUrl(any()) } returns existingRequest + every { newsletterRequestRepository.save(any()) } answers { firstArg() } + + // WHEN + newsletterService.saveRequest(newsletterUrl) + + // THEN + val slotNewsletterRequest = slot() + verify { + newsletterRequestRepository.getByNewsletterUrl(newsletterUrl) + newsletterRequestRepository.save(capture(slotNewsletterRequest)) + } + slotNewsletterRequest.captured should { + it.newsletterUrl shouldBe newsletterUrl + it.requestCount shouldBe 3 + it.firstRequestDate shouldBe existingRequest.firstRequestDate + it.lastRequestDate shouldBeAfter existingRequest.lastRequestDate + } + } } diff --git a/src/test/kotlin/fr/nicopico/n2rss/service/ReCaptchaServiceTest.kt b/src/test/kotlin/fr/nicopico/n2rss/service/ReCaptchaServiceTest.kt new file mode 100644 index 0000000..dc232fe --- /dev/null +++ b/src/test/kotlin/fr/nicopico/n2rss/service/ReCaptchaServiceTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 Nicolas PICON + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +package fr.nicopico.n2rss.service + +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.web.client.RestClient + +class ReCaptchaServiceTest { + + private val server = MockWebServer() + private lateinit var reCaptchaService: ReCaptchaService + + @BeforeEach + fun setup() { + server.start() + // Create a new RestClient with server url + val restClient = RestClient.create(server.url("/").toString()) + reCaptchaService = ReCaptchaService(restClient) + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `isCaptchaValid should return true if response success is true`() { + // GIVEN + val responseBody = """{ + | "success": true, + | "challenge_ts": "2024-03-21T21:00:00ZZ", + | "hostname": "localhost" + |} + |""".trimMargin() + + // enqueue responses + server.enqueue( + MockResponse() + .setBody(responseBody) + .addHeader( + name = "Content-Type", + value = "application/json", + ) + ) + + // WHEN + val isCaptchaValid = reCaptchaService.isCaptchaValid( + captchaSecretKey = "captchaSecretKey", + captchaResponse = "captchaResponse", + ) + + // THEN + isCaptchaValid shouldBe true + server.takeRequest() should { + it.requestLine shouldBe "POST /siteverify HTTP/1.1" + it.body.readUtf8() shouldBe "secret=captchaSecretKey&response=captchaResponse" + } + } + + @Test + fun `isCaptchaValid should return false if response success is false`() { + // GIVEN + val responseBody = """{ + | "success": false, + | "challenge_ts": "2024-03-21T21:00:00ZZ", + | "hostname": "localhost", + | "error-codes": ["missing-input-secret"] + |} + |""".trimMargin() + + // enqueue responses + server.enqueue( + MockResponse() + .setBody(responseBody) + .addHeader( + name = "Content-Type", + value = "application/json", + ) + ) + + // WHEN + val isCaptchaValid = reCaptchaService.isCaptchaValid( + captchaSecretKey = "captchaSecretKey", + captchaResponse = "captchaResponse", + ) + + // THEN + isCaptchaValid shouldBe false + } +} diff --git a/src/test/kotlin/fr/nicopico/n2rss/utils/UrlExtKtTest.kt b/src/test/kotlin/fr/nicopico/n2rss/utils/UrlExtKtTest.kt index 3acca94..93a0a36 100644 --- a/src/test/kotlin/fr/nicopico/n2rss/utils/UrlExtKtTest.kt +++ b/src/test/kotlin/fr/nicopico/n2rss/utils/UrlExtKtTest.kt @@ -32,7 +32,7 @@ class UrlExtKtTest { val urlString = "https://www.example.com" // WHEN - val result = urlString.toURL() + val result = urlString.toUrlOrNull() // THEN assertSoftly { @@ -48,7 +48,7 @@ class UrlExtKtTest { val urlString = "invalid_url" // WHEN - val result: URL? = urlString.toURL() + val result: URL? = urlString.toUrlOrNull() // THEN result shouldBe null