diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48fcb0..8a5d80d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: - name: Run linter run: sbt "scalafixAll --check" - name: Run locust tests - run: ./tests/run.sh + run: sudo apt-get install -y tesseract-ocr && ./tests/run.sh diff --git a/src/main/java/lc/misc/HelperFunctions.java b/src/main/java/lc/misc/HelperFunctions.java index f46d49e..fa12fc8 100644 --- a/src/main/java/lc/misc/HelperFunctions.java +++ b/src/main/java/lc/misc/HelperFunctions.java @@ -11,9 +11,19 @@ public class HelperFunctions { RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); } - public static String randomString(int n) { - String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz23456789$#%@&?"; - StringBuilder stringBuilder = new StringBuilder(); + public static final String safeAlphabets = "ABCDEFGHJKMNOPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + public static final String allAlphabets = safeAlphabets + "ILl"; + public static final String safeNumbers = "23456789"; + public static final String allNumbers = safeNumbers + "1"; + public static final String specialCharacters = "$#%@&?"; + public static final String safeCharacters = safeAlphabets + safeNumbers + specialCharacters; + + public static String randomString(final int n) { + return randomString(n, safeCharacters); + } + + public static String randomString(final int n, final String characters) { + final StringBuilder stringBuilder = new StringBuilder(); for (int i = 0; i < n; i++) { int index = (int) (characters.length() * Math.random()); stringBuilder.append(characters.charAt(index)); diff --git a/src/main/scala/lc/captchas/DebugCaptcha.scala b/src/main/scala/lc/captchas/DebugCaptcha.scala new file mode 100644 index 0000000..084f1e1 --- /dev/null +++ b/src/main/scala/lc/captchas/DebugCaptcha.scala @@ -0,0 +1,64 @@ +package lc.captchas + +import javax.imageio.ImageIO +import java.awt.Color +import java.awt.Font +import java.awt.font.TextLayout +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.util.Map +import java.util.List + +import lc.misc.HelperFunctions +import lc.captchas.interfaces.Challenge +import lc.captchas.interfaces.ChallengeProvider + +/** This captcha is only for debugging purposes. It creates very simple captchas that are deliberately easy to solve with OCR engines */ +class DebugCaptcha extends ChallengeProvider { + + def getId(): String = { + "DebugCaptcha" + } + + def configure(config: String): Unit = { + // TODO: Add custom config + } + + def supportedParameters(): Map[String, List[String]] = { + Map.of( + "supportedLevels", List.of("debug"), + "supportedMedia", List.of("image/png"), + "supportedInputType", List.of("text") + ) + } + + def checkAnswer(secret: String, answer: String): Boolean = { + answer.toLowerCase().equals(secret) + } + + private def simpleText(text: String): Array[Byte] = { + val img = new BufferedImage(350, 100, BufferedImage.TYPE_INT_RGB) + val font = new Font("Arial", Font.ROMAN_BASELINE, 56) + val graphics2D = img.createGraphics() + val textLayout = new TextLayout(text, font, graphics2D.getFontRenderContext()) + HelperFunctions.setRenderingHints(graphics2D) + graphics2D.setPaint(Color.WHITE) + graphics2D.fillRect(0, 0, 350, 100) + graphics2D.setPaint(Color.BLACK) + textLayout.draw(graphics2D, 15, 50) + graphics2D.dispose() + val baos = new ByteArrayOutputStream() + try { + ImageIO.write(img, "png", baos) + } catch { + case e: Exception => + e.printStackTrace() + } + baos.toByteArray() + } + + def returnChallenge(): Challenge = { + val secret = HelperFunctions.randomString(6, HelperFunctions.safeAlphabets) + new Challenge(simpleText(secret), "image/png", secret.toLowerCase()) + } +} diff --git a/src/main/scala/lc/core/captchaProviders.scala b/src/main/scala/lc/core/captchaProviders.scala index d23f888..1174ade 100644 --- a/src/main/scala/lc/core/captchaProviders.scala +++ b/src/main/scala/lc/core/captchaProviders.scala @@ -11,7 +11,8 @@ object CaptchaProviders { //"FontFunCaptcha" -> new FontFunCaptcha, "GifCaptcha" -> new GifCaptcha, "ShadowTextCaptcha" -> new ShadowTextCaptcha, - "RainDropsCaptcha" -> new RainDropsCP + "RainDropsCaptcha" -> new RainDropsCP, + "DebugCaptcha" -> new DebugCaptcha, //"LabelCaptcha" -> new LabelCaptcha ) @@ -56,7 +57,7 @@ object CaptchaProviders { def getProvider(param: Parameters): ChallengeProvider = { val providerConfig = filterProviderByParam(param).toList - if (providerConfig.length == 0) throw new NoSuchElementException(ErrorMessageEnum.NO_CAPTCHA.toString) + if (providerConfig.length == 0) throw new NoSuchElementException(ErrorMessageEnum.NO_CAPTCHA.toString) val randomIndex = getNextRandomInt(providerConfig.length) val providerIndex = providerConfig(randomIndex)._1 val selectedProvider = providers(providerIndex) diff --git a/src/main/scala/lc/core/config.scala b/src/main/scala/lc/core/config.scala index 09dbc20..233108f 100644 --- a/src/main/scala/lc/core/config.scala +++ b/src/main/scala/lc/core/config.scala @@ -44,25 +44,9 @@ object Config { case JField("config", JObject(config)) => ("config", JString(config.toString)) } val captchaConfig: List[CaptchaConfig] = captchaConfigTransform.extract[List[CaptchaConfig]] - val allowedLevels: Set[String] = getAllValues(configJson, ParametersEnum.ALLOWEDLEVELS.toString) - val allowedMedia: Set[String] = getAllValues(configJson, ParametersEnum.ALLOWEDMEDIA.toString) - val allowedInputType: Set[String] = getAllValues(configJson, ParametersEnum.ALLOWEDINPUTTYPE.toString) - - private def getAllValues(config: JValue, param: String): Set[String] = { - val configValues = (config \\ param) - val result = for { - JObject(child) <- configValues - JField(param) <- child - } yield (param) - - var valueSet = Set[String]() - for (valueList <- result) { - for (value <- valueList._2.children) { - valueSet += value.values.toString - } - } - valueSet - } + val allowedLevels: Set[String] = captchaConfig.flatMap(_.allowedLevels).toSet + val allowedMedia: Set[String] = captchaConfig.flatMap(_.allowedMedia).toSet + val allowedInputType: Set[String] = captchaConfig.flatMap(_.allowedInputType).toSet private def getDefaultConfig(): String = { val defaultConfigMap = diff --git a/tests/debug-config.json b/tests/debug-config.json new file mode 100644 index 0000000..ae04974 --- /dev/null +++ b/tests/debug-config.json @@ -0,0 +1,14 @@ +{ + "randomSeed" : 20, + "port" : 8888, + "captchaExpiryTimeLimit" : 5, + "throttle" : 10, + "threadDelay" : 2, + "captchas" : [ { + "name" : "DebugCaptcha", + "allowedLevels" : [ "debug" ], + "allowedMedia" : [ "image/png" ], + "allowedInputType" : [ "text" ], + "config" : { } + }] +} diff --git a/tests/locustfile-functional.py b/tests/locustfile-functional.py new file mode 100644 index 0000000..5034e43 --- /dev/null +++ b/tests/locustfile-functional.py @@ -0,0 +1,65 @@ +from locust import task, between, SequentialTaskSet +from locust.contrib.fasthttp import FastHttpUser +from locust import events +import json +import logging +import subprocess + +@events.quitting.add_listener +def _(environment, **kw): + totalStats = environment.stats.total + if totalStats.fail_ratio > 0.20: + logging.error("Test failed due to failure ratio " + totalStats.fail_ratio + " > 20%") + environment.process_exit_code = 1 + elif totalStats.get_response_time_percentile(0.80) > 800: + logging.error("Test failed due to 80th percentile response time > 800 ms") + environment.process_exit_code = 1 + else: + environment.process_exit_code = 0 + +class QuickStartUser(SequentialTaskSet): + wait_time = between(0.1,0.2) + + @task + def captcha(self): + captcha_params = {"level":"debug","media":"image/png","input_type":"text"} + + with self.client.post(path="/v1/captcha", json=captcha_params, name="/captcha", catch_response = True) as resp: + if resp.status_code != 200: + resp.failure("Status was not 200: " + resp.text) + captchaJson = resp.json() + uuid = captchaJson.get("id") + if not uuid: + resp.failure("uuid not returned on /captcha endpoint: " + resp.text) + + with self.client.get(path="/v1/media?id=%s" % uuid, name="/media", stream=True, catch_response = True) as resp: + if resp.status_code != 200: + resp.failure("Status was not 200: " + resp.text) + + media = resp.content + + ocrAnswer = self.solve(uuid, media) + + answerBody = {"answer": ocrAnswer,"id": uuid} + with self.client.post(path='/v1/answer', json=answerBody, name="/answer", catch_response=True) as resp: + if resp.status_code != 200: + resp.failure("Status was not 200: " + resp.text) + else: + if resp.json().get("result") != "True": + resp.failure("Answer was not accepted: " + ocrAnswer) + + def solve(self, uuid, media): + mediaFileName = "tests/test-%s.png" % uuid + with open(mediaFileName, "wb") as f: + f.write(media) + #ocrResult = subprocess.Popen("gocr %s" % mediaFileName, shell=True, stdout=subprocess.PIPE) + ocrResult = subprocess.Popen("tesseract %s stdout -l eng" % mediaFileName, shell=True, stdout=subprocess.PIPE) + ocrAnswer = ocrResult.stdout.readlines()[0].strip().decode() + return ocrAnswer + + + +class User(FastHttpUser): + wait_time = between(0.1,0.2) + tasks = [QuickStartUser] + host = "http://localhost:8888" diff --git a/tests/run.sh b/tests/run.sh index 0d10e93..0a81ad4 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -7,9 +7,27 @@ java -jar target/scala-2.13/LibreCaptcha.jar & JAVA_PID=$! sleep 4 -locust --headless -u 300 -r 100 --run-time 4m --stop-timeout 30 -f tests/locustfile.py +locust --only-summary --headless -u 300 -r 100 --run-time 4m --stop-timeout 30 -f tests/locustfile.py status=$? -kill $JAVA_PID +if [ $status != 0 ]; then + exit $status +fi +kill $JAVA_PID +sleep 4 + +echo Run functional test +cp data/config.json data/config.json.bak +cp tests/debug-config.json data/config.json + +java -jar target/scala-2.13/LibreCaptcha.jar & +JAVA_PID=$! +sleep 4 + +locust --only-summary --headless -u 1 -r 1 --run-time 1m --stop-timeout 30 -f tests/locustfile-functional.py +status=$? +mv data/config.json.bak data/config.json + +kill $JAVA_PID exit $status