diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4707b117e0876527bae5a1c6551f3c9f51e0285d..b5457a0c4964d69b96fd9a757d314a5b0b05bdf2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,6 +24,8 @@ stages: artifacts: paths: - $COMPONENT/*/target/*.jar + tags: + - ci .test_template: stage: test @@ -36,7 +38,8 @@ stages: junit: - "$COMPONENT/*/target/surefire-reports/TEST-*.xml" - "$COMPONENT/*/target/failsafe-reports/TEST-*.xml" - + tags: + - ci .docker_template: image: registry.gitlab.inria.fr/stemcovid19/tac-server/docker-image/docker-git:latest @@ -58,6 +61,8 @@ stages: only: - develop - tags + tags: + - ci build_robert-server: extends: .build_template @@ -84,6 +89,16 @@ build_submission-code-server: variables: COMPONENT: "submission-code-server" +build_clea: + extends: .build_template + variables: + COMPONENT: "clea" + +build_analytics-server: + extends: .build_template + variables: + COMPONENT: "analytics-server" + test_tac-warning: extends: .test_template needs: ["build_tac-warning"] @@ -110,6 +125,18 @@ test_submission-code-server: COMPONENT: "submission-code-server" allow_failure: true +test_clea: + extends: .test_template + needs: ["build_clea"] + variables: + COMPONENT: "clea" + +test_analytics-server: + extends: .test_template + needs: [ "build_analytics-server" ] + variables: + COMPONENT: "analytics-server" + docker-robert-crypto-grpc-server: extends: .docker_template needs: [ "build_robert-server" ] @@ -193,6 +220,7 @@ integration_tests: - echo "ROBERT_JWT_PUBLIC_KEY=${CI_ROBERT_JWT_PUBLIC_KEY}" >> .env - echo "ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=${CI_ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID}" >> .env - echo "ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=${CI_ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}" >> .env + - echo "ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=${CI_ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}" >> .env - cat .env - docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY <<< "$CI_REGISTRY_PASSWORD" - echo $PWD @@ -258,3 +286,15 @@ deploy-tac-warning: needs: ["test_tac-warning"] variables: COMPONENT: "tac-warning" + +deploy-clea: + extends: .deploy_template + needs: ["test_clea"] + variables: + COMPONENT: "clea" + +deploy-analytics-server: + extends: .deploy_template + needs: ["test_analytics-server"] + variables: + COMPONENT: "analytics-server" \ No newline at end of file diff --git a/analytics-server/README.md b/analytics-server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a9d39474c41fbd62e2d1d0b1056a03bc81d64669 --- /dev/null +++ b/analytics-server/README.md @@ -0,0 +1,65 @@ +# Introduction + +Ce projet gitlab.inria.fr est un des composants de la solution plus +globale [StopCovid](https://gitlab.inria.fr/stopcovid19/accueil/-/blob/master/README.md). + +Ce composant gère la fonctionnalité appelée "analytics", c'est une fonctionnalité qui permet de recevoir de la part des +applications mobiles leurs statistiques d'usage (quel model de téléphone, quelle version d'OS, à quelle date a été +effectué le dernier status, la liste des erreurs rencontrées...) + +# Architecture générale + + + +L'application repose sur une application springboot en frontal. Celle-ci traite la requête provenant du terminal de +l'utilisateur final. Les principaux traitements effectués par cette application sont : + +- validation du token JWT fourni par l'application robert, à noter qu'un token ne peut être utilisé qu'une unique fois + pour soumettre des analytics. Une base de donnée mongodb est utilisée pour conserver la liste des tokens déjà + utilisée. +- validation du contenu de la requête, on vérifie la structure de la requête vis à vis du contrat d'interface +- envoi des analytics dans un topic kafka. + +Par la suite, le topic kafka est consommé par logstash qui repousse les analytics dans un elasticsearch. Les analytics +sont ensuite analysés grâce à un kibana. + +note : le fichier source du diagramme est fourni [ici](analytics.drawio) + +## contrat d'interface + +le contrat d'interface du service rest exposé est disponible au format +openapi [anaylics_openapi](src/main/doc/openid_analytics.yml) + +# Environment de développement + +Si ce n'est déjà fait, se logguer, sur la registry docker de l'inria. + + docker login registry.gitlab.inria.fr + +Pour fonctionner, cette application à besoin à minima : + +- De mongodb +- De kafka + +La stack complète d'outil tel que décrit dans le schéma d'architecture générale afin de manipuler les analytics qui +auront été stockés dans l'elasticsearch + +Ces différents services sont disponibles dans un environnement docker lançable par : + + docker-compose -f ../environment-setup/dev/compose/docker-compose-analytics-server.yaml up -d + +Les services dockerisés ne sont utilisés que pour pouvoir lancer l'application par elle-même. Les tests untaires +utilisent des services embarqués (embedded mongodb et kafka). + +## Appel rest + +Des appels rest peuvent être effectué sur l'application en utilisant la collection postman +fournie [postmail](src/main/doc/TAC-analytics.postman_collection.json) + +## Spécificité de la configuration sous Windows 10 + +Dans le fichier etc/hosts (c:\windows\system32\drivers\etc\hosts) ajouter l'entrée suivante : + + 127.0.0.1 docker-desktop + +Dans l'application docker desktop settings, placer la quantité de mémoire à 10G. \ No newline at end of file diff --git a/analytics-server/analytics.drawio b/analytics-server/analytics.drawio new file mode 100644 index 0000000000000000000000000000000000000000..819e1af96dcfdc791a5dff7377c10a5ae66095b9 --- /dev/null +++ b/analytics-server/analytics.drawio @@ -0,0 +1 @@ +<mxfile host="app.diagrams.net" modified="2021-03-23T10:31:31.463Z" agent="5.0 (X11)" etag="14338BJJV65bln0yTPUD" version="14.4.6" type="device"><diagram name="Page-1" id="822b0af5-4adb-64df-f703-e8dfc1f81529">7Vtbc6M4Fv41foQSCHF5TCfxTO3MVnVNeqdn+iUlQMZaY0QLfEn/+jnCgAHhxJ3YsaernaoYDpKQzuU7F8kTfLvc/iJpPv+viFk6sVG8neC7iW3byLXhS1GedhQrIO6Okkge17Q94YF/YzUR1dQVj1nRa1gKkZY87xMjkWUsKns0KqXY9JvNRNp/a04TphEeIprq1M88Luc11UJo/+BXxpN5/Wqf1A9CGi0SKVZZ/b6JjWfVZ/d4SZux6vbFnMZi0yHh+wm+lUKUu6vl9palirkN23b9pgeetvOWLCuP6fAFGdFmTR6//JGuv9KP/ufH+N7A9TBrmq5Ys45qtuVTw6FqjUyNgib4w2bOS/aQ00g93YBSAG1eLlO4s+CyKKVYsFuRCln1xm71gSczkZUd+u6j6DxNO/T7O/UH9HpiTJZse3DJVstI0FAmlqyUT9Ck6YAbKdbaabhOTdh0ZN1IaN4Rs205tY7V+pW0o+95DBc1m8dZ7sUBxiEK4sAjhEWRYQUvsxuGAN1nL7O6L5dWvdRNJJY8ah5UEml026olMaVLniqu/MlkTDPaMlzj7ogMDjLc9XCf38S3dX67vkl0jlsNlLyF46NKruv4HyKENQHt84NiPpOwRE0QatkcgOJ3GrL0oyh4yUUGj0JRlmIJDKNFvkOkGd8qOXSF03S+SXmiOpUi16wjExnbSS5Xr1xuE4WxZsRLybdmKTZMPtZzO1raz8r2bcaE3IFwXawbkzsiWZ+cSbD2y9aUC56VTN6vYZVFg1Fd5sW0mLdsPQhktagGWEWQ+gN6OlCRiKmXdtTgoA6N64lYlSnP4O2Nz1MvoXWTdvCh4ojZjEfMjNkavgozAg4+5nOY+SPNYil4/JhLkQup5kDT02iF11hta/HuCMJ6ulI0nufkSoF/KsXFlYIM3YCNL6sUzk+luLhSBEOlsC6MFOSnUlxcKYbuw/EurBSuphT/gxisuLbocFVN6grCQjewhmbdhvcdGTqWiUZCQ3yu0NAfsW03LZXF8jVcJuqyTQNuQKGfQCJFJxvYtYaXdzqMjNGQQjmkDLtelwJdf3rh4OCy6cUxyXoW36gilMLVlBaFYlRXTmzLy78U70yEm/u/q3tiB/X93bZmbnXz1Ln5yCSHpSgBVbTd61msVbS0lL0QKxmxZ9bWlNqoTFjd9UC1QpdYRx5kpHTS0CRLacnX/bmOCal+w0fla/cKAZMZBpGoP8ZukXW3buVrOJI/HMkbjLRjgzZSpTXtwt9QgECaJv3C1AT/8/lTVe9csEzVLiXYvVrktChpudJ9jpJ8BRd9JdM8+xAdljyO1RgfJCv4NxpW4ymFqsKbigfkw4Q8W3Wr669150lb1uwq32ErOmj2yLT9YQxgvU1/miYQzxTsPPI8omp6PDIE2O4hg+d4340MIAL5VAONRRrCbjzHtxvCfsDq7ql7dxqwOVjy7CLNYY99KbDxh8mJg14LNs5wJPLOYKNXxSC6qAyjE+VsOHC1hZ4fAWes5+MLA5k4cJ2+bOyrRxq9cPEbnS2oJrLRlK+XnL4yl9SE3k1yB6El3RTY/Lpiq5Fk9y648RzVCZrGnO33guow9aBCfM92jz3Y7nHAv+i5iIfH9h8c/7Dc37b9cESd4VhvAVmU2/cWTuC8wVv0PQV5Pz/xXNL2oqvYWcWlfIXV7l03aoaH8eSxzsKyXV1j39db6PWO30UC8Wcx15S0MXe+rPbUP1TfN02WisZS1leCTv2Gu3lZqmMAN2rZ9pQvE9ucScbyLIFvoNjI8lGAPLikPIH/C14U8NQACRfK0zEqo7mR1isyFjwEJ2hARpuvQIMNDt6mMAp4P2267BvDhTCKdWKsYU1CGnSZG2rsUtKsyKlBwsC2LBbSIESxb8I0HOQ7vkWwaweBEwRuQCzz/3lyInQjeoKsV9XHsW1PPT26eSdEt36G/G64dkIIaxz2yxjmXhTDHHwyDHP8S2OYXqi5nbNo0UmtVxmPePl0nGaC+cvyWYU9VmEuJl5rePDFxq8VrzYUfmfxNoDSEe8XAXJl+cihjUv7KEk3ZgK51SpUFXpwLyVohBmpws40FtECnE7KQ0mV9BQBcrHpLEAuC0hsRbbnE5dSL2DUCmPfRSimxHJcl3khCqHpt/3Cp8o/meCQzhU829gZC54hj/J1xbWG+fLpDnkcsaH/o5oxHoaHtvNaM9aGwkNEOLcZj6aysQRj+jcZ8YYvOHyJsOAxp1n8lNFlVVKZLprVTKtROyTj5KbqmiToi9Md3XZziWnr6um5ZwsHbT3ZXYqMQ/w8aQ/CHh8YXr+BDsXQHmr4fgMdDuW8t4HqqeBvVb50dfaZSDrjC0D3XRa4ylNBY2VvVT5XpYFq3iYkbyeyOM8jjcns3aNrYlezOAtZY97xXMcVbD396mxmw/Tw9gZ4cN/Nh6H5Q7PJ3TS4OhlvNhuTLkNVKTR4aWaqvjrd5EaNxj2x20hlU1MEHmaqZf4CUlRyQvAlZFhEHjkGGzgmcnQ98G3TDs6lCWNHHn40vHVfTEaOxlttKC22OjfenuB8wdULzPMGXLb8VwuMvDTUmQXW2HlHYJ/q+gKPgd98xhWmIgh4EhGHHXSNaUlDWrDJwR8bXBpuD9RWfaTYKBnt1Fbr5RmZKL6mBuSwq6UCaSEVVrPYaBZrqLOCah/PKOesKGgKs7PJLaRz5OZuwdtxVIxtqF9S5QcaJgYJXRzMUODMHCewTewhgj1wqgRjG/AYw5fjnbDUighkuQN0aCp7PV+PTGfE3bvemTAe66WQT2xbatpUKuL3nUetScfv8Y79Oql/iuwkktC29EZ+weWMANjQL5xOBno9ImZrlop8WcUliGVrLkVW34ks1cuO1yEfKUpaY0ZwKnlpVSTi6FlpC9xvFJiCpvb3kzuU3/9KFd//Aw==</diagram></mxfile> \ No newline at end of file diff --git a/analytics-server/analytics.png b/analytics-server/analytics.png new file mode 100644 index 0000000000000000000000000000000000000000..9123133d96e312380540f1a5d9580606e728964a Binary files /dev/null and b/analytics-server/analytics.png differ diff --git a/analytics-server/mvnw b/analytics-server/mvnw new file mode 100644 index 0000000000000000000000000000000000000000..a16b5431b4c3cab50323a3f558003fd0abd87dad --- /dev/null +++ b/analytics-server/mvnw @@ -0,0 +1,310 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/analytics-server/mvnw.cmd b/analytics-server/mvnw.cmd new file mode 100644 index 0000000000000000000000000000000000000000..c8d43372c986d97911cdc21bd87e0cbe3d83bdda --- /dev/null +++ b/analytics-server/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/analytics-server/pom.xml b/analytics-server/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..da725ccd7f1be95c18b375aaa3a658fbb975d9a0 --- /dev/null +++ b/analytics-server/pom.xml @@ -0,0 +1,242 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-parent</artifactId> + <version>2.4.3</version> + <relativePath/> + </parent> + + <groupId>fr.gouv.tousantic</groupId> + <artifactId>analytics-server</artifactId> + <version>1.0.0-SNAPSHOT</version> + <packaging>jar</packaging> + + + <properties> + <java.version>11</java.version> + + <commons-lang3.version>3.11</commons-lang3.version> + <javax.inject.version>1</javax.inject.version> + <mapstruct.version>1.4.2.Final</mapstruct.version> + <spring-cloud.version>2020.0.1</spring-cloud.version> + <spring-kafka.version>2.6.6</spring-kafka.version> + </properties> + + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-dependencies</artifactId> + <version>${spring-cloud.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> + </dependencyManagement> + + + <dependencies> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-mongodb</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-bootstrap</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-consul-config</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-vault-config</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka</artifactId> + <version>${spring-kafka.version}</version> + </dependency> + + + <dependency> + <groupId>javax.inject</groupId> + <artifactId>javax.inject</artifactId> + <version>${javax.inject.version}</version> + </dependency> + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct</artifactId> + <version>${mapstruct.version}</version> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-lang3</artifactId> + <version>${commons-lang3.version}</version> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <optional>true</optional> + </dependency> + + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>org.junit.vintage</groupId> + <artifactId>junit-vintage-engine</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>org.springframework.security</groupId> + <artifactId>spring-security-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>${awaitility.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka-test</artifactId> + <version>${spring-kafka.version}</version> + </dependency> + <dependency> + <groupId>de.flapdoodle.embed</groupId> + <artifactId>de.flapdoodle.embed.mongo</artifactId> + <scope>test</scope> + </dependency> + + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + <executions> + <execution> + <goals> + <goal>build-info</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>${maven-compiler-plugin.version}</version> + <configuration> + <annotationProcessorPaths> + <path> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <version>${lombok.version}</version> + </path> + <path> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-processor</artifactId> + <version>${mapstruct.version}</version> + </path> + </annotationProcessorPaths> + <compilerArgs> + <compilerArg>-Amapstruct.defaultComponentModel=spring</compilerArg> + <compilerArg>-parameters</compilerArg> + </compilerArgs> + </configuration> + </plugin> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + <version>0.8.5</version> + <configuration> + <output>file</output> + <append>true</append> + </configuration> + <executions> + <execution> + <id>jacoco-initialize</id> + <phase>initialize</phase> + <goals> + <goal>prepare-agent</goal> + </goals> + </execution> + <execution> + <id>jacoco-site</id> + <phase>verify</phase> + <goals> + <goal>report</goal> + </goals> + </execution> + </executions> + </plugin> + + </plugins> + </build> + + + <repositories> + <repository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </repository> + </repositories> + <distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </repository> + <snapshotRepository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </snapshotRepository> + </distributionManagement> + + + <scm> + <connection>scm:git:${gitRepositoryUrl}</connection> + <developerConnection>scm:git:${gitRepositoryUrl}</developerConnection> + <url>${gitRepositoryUrl}</url> + <tag>HEAD</tag> + </scm> + +</project> diff --git a/analytics-server/src/main/doc/TAC-analytics.postman_collection.json b/analytics-server/src/main/doc/TAC-analytics.postman_collection.json new file mode 100644 index 0000000000000000000000000000000000000000..ed1d14ed7a13dd7a51b8702a909d9ac94a619d45 --- /dev/null +++ b/analytics-server/src/main/doc/TAC-analytics.postman_collection.json @@ -0,0 +1,48 @@ +{ + "info": { + "_postman_id": "d4f7ab3f-c595-4cab-91a5-75921c944e41", + "name": "TAC-analytics", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Post - analytics", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwianRpIjoiYWJjZGUifQ.G0oQVGvH1m97TTppY9J8ypdHI0saNjVyDt4wBQ1CV3OYQe9eK3xrQjhs6y0a1YpGlpcMTHYyBeRG15Ze-1XDoH0aKbLiQ-t9RddbyrctDG3gKdJkixfczwFIjt2BTJIpmcZAgx2aBbYU_SObFwn2hDn9GnNy2BPpGxHnPVTdzO9MuIcFZURcLvokiYM3tssue7mff4hep90F55hJPg_UC5ggm3bgQ78Ysu6gsImDDZoc1tn1bVEdXxkQ1LywG9U9zylPER2tczKnr8m0rCK6vNVnzsXjM1YKzJmhbR53BeXbTAGcUqQWZwDej3VvlkJxq3McBmMKihlDw0sov_ABKw", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"installationUuid\": \"toto\",\n \"infos\": {\n \"additionalProp1\": \"string\",\n \"additionalProp2\": \"string\",\n \"additionalProp3\": \"string\"\n },\n \"events\": [\n {\n \"name\": \"string\",\n \"timestamp\": \"2021-02-10T17:46:54.167Z\",\n \"description\": \"string\"\n }\n ],\n \"errors\": [\n {\n \"name\": \"string\",\n \"timestamp\": \"2021-02-10T17:46:54.167Z\",\n \"description\": \"string\"\n }\n ]\n\n\n\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://127.0.0.1:8087/api/v4/analytics", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8087", + "path": [ + "api", + "v4", + "analytics" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/analytics-server/src/main/doc/openid_analytics.yml b/analytics-server/src/main/doc/openid_analytics.yml new file mode 100644 index 0000000000000000000000000000000000000000..6aedbb31863db438b66b8dbd4191590fe3825ab0 --- /dev/null +++ b/analytics-server/src/main/doc/openid_analytics.yml @@ -0,0 +1,137 @@ +openapi: 3.0.0 +info: + description: "#TOUS-ANTI-COVID, Robert analytics Client API" + version: 1.0.0 + title: "#TOUS-ANTI-COVID, Robert analytics" + termsOfService: https://gitlab.inria.fr/stemcovid19/wp3-robert-server/analytics-server + contact: + email: stopcovid@inria.fr + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +tags: [ ] +paths: + /analytics: + post: + tags: [ "analytics" ] + summary: Create analytics + description: |- + Request the creation of analytics. Nothing is returned + + operationId: createAnalytics + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AnalyticsRequest" + + responses: + "200": + description: successful operation + "400": + description: Bad request, something is incorrect in the request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Unauthorized request, bad, missing or invalid token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: An unexpected error occurs + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - bearerAuth: [ ] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + requestBodies: + AnalyticsRequest: + content: + application/json: + schema: + $ref: "#/components/schemas/AnalyticsRequest" + + schemas: + AnalyticsRequest: + type: object + properties: + installationUuid: + type: string + maxLength: 64 + description: the installation unique identifier. This identifier is generated by the mobile application during its installation. + infos: + $ref: "#/components/schemas/Information" + description: + The list of infos. + events: + type: array + items: + $ref: "#/components/schemas/TimestampedEvent" + description: + The events list of the analytics. + errors: + type: array + items: + $ref: "#/components/schemas/TimestampedEvent" + description: + The errors list of the analytics. + required: + - installationUuid + + + Information: + type: object + maxProperties: 20 + additionalProperties: + type: string + maxLength: 512 + + TimestampedEvent: + type: object + properties: + name: + type: string + maxLength: 64 + description: The event name. + timestamp: + type: string + format: date-time + maxLength: 64 + description: The BasicInfo timestamp. Date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z + description: + type: string + maxLength: 512 + description: the basic info description + required: + - name + - timestamp + + ErrorResponse: + type: object + properties: + message: + type: string + description: the error message + timestamp: + type: string + format: date-time + description: the error timestamp + required: + - message + - timestamp + + + + diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..c1e539ab659df3322f2b96d13b5cce1ca8afdb01 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java @@ -0,0 +1,13 @@ +package fr.gouv.tac.analytics.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class AnalyticsServerApplication { + + public static void main(final String[] args) { + SpringApplication.run(AnalyticsServerApplication.class, args); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..63b156389756573940824ab99133bc0f6fd703ab --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java @@ -0,0 +1,38 @@ +package fr.gouv.tac.analytics.server.config.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.Map; + +@Configuration +public class KafkaConfiguration { + + @Value(value = "${spring.kafka.bootstrap-servers}") + private String bootstrapAddress; + + @Value(value = "${spring.kafka.template.default-topic}") + private String defaultTopic; + + @Bean + public KafkaTemplate<String, Analytics> analyticsKafkaTemplate(final ObjectMapper objectMapper) { + final Map<String, Object> props = Map.of(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress); + + final ProducerFactory<String, Analytics> producerFactory = new DefaultKafkaProducerFactory<>(props, + new StringSerializer(), + new JsonSerializer<Analytics>(objectMapper)); + + final KafkaTemplate<String, Analytics> analyticsKafkaTemplate = new KafkaTemplate<>(producerFactory); + analyticsKafkaTemplate.setDefaultTopic(defaultTopic); + return analyticsKafkaTemplate; + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..bdea58bcae753a9078b62073df8c3f03ade11eec --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java @@ -0,0 +1,32 @@ +package fr.gouv.tac.analytics.server.config.mongodb; + +import fr.gouv.tac.analytics.server.config.mongodb.converters.DateToZonedDateTimeConverter; +import fr.gouv.tac.analytics.server.config.mongodb.converters.ZonedDateTimeToDateConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.mapping.event.ValidatingMongoEventListener; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import java.util.Arrays; + + +@Configuration +public class MongoConfiguration { + + @Bean + public MongoCustomConversions customConversions() { + return new MongoCustomConversions( + Arrays.asList( + new DateToZonedDateTimeConverter(), + new ZonedDateTimeToDateConverter()) + ); + } + + @Bean + public ValidatingMongoEventListener validatingMongoEventListener(final LocalValidatorFactoryBean localValidatorFactoryBean) { + return new ValidatingMongoEventListener(localValidatorFactoryBean); + } + + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java new file mode 100644 index 0000000000000000000000000000000000000000..d0705d0c8baa4261b7dc987a83498aefa88e7f2c --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java @@ -0,0 +1,68 @@ +package fr.gouv.tac.analytics.server.config.mongodb; + +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.springframework.util.StopWatch; + +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +@Configuration +public class MongoIndexCreationListener { + + private static final String TOKEN_IDENTIFIER_IDX_NAME = "TokenIdentifierIdentifierUk"; + private static final String TOKEN_IDENTIFIER_EXPIRATION_DATE_IDX_NAME = "TokenIdentifierExpirationDateIdx"; + + private final MongoTemplate mongoTemplate; + + @EventListener(ApplicationReadyEvent.class) + public void ensureIndexesCreationAfterStartup() { + + log.info("Mongo ensureIndexesCreationAfterStartup init"); + final StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + ensureTokenIdentifierCreationDateIndex(); + ensureTokenIdentifierIdentifierUniqueIndex(); + + + stopWatch.stop(); + log.info("Mongo ensureIndexesCreationAfterStartup takes: {} ms", stopWatch.getTotalTimeMillis()); + } + + + public void ensureTokenIdentifierIdentifierUniqueIndex() { + mongoTemplate.indexOps(TokenIdentifier.class).ensureIndex(new Index().named(TOKEN_IDENTIFIER_IDX_NAME).on(TokenIdentifier.IDENTIFIER_FIELD_NAME, Sort.Direction.ASC).unique()); + } + + public void ensureTokenIdentifierCreationDateIndex() { + ensureExpirationIndex(TokenIdentifier.class, TOKEN_IDENTIFIER_EXPIRATION_DATE_IDX_NAME, TokenIdentifier.EXPIRATION_DATE_FIELD_NAME, Duration.ofSeconds(0)); + } + + private void ensureExpirationIndex(final Class entity, final String indexName, final String field, final Duration duration) { + final Optional<IndexInfo> alreadyExistingValidIndex = mongoTemplate.indexOps(entity) + .getIndexInfo() + .stream() + .filter(indexInfo -> indexName.contentEquals(indexInfo.getName()) && indexInfo.getExpireAfter().isPresent() && indexInfo.getExpireAfter().get().equals(duration)) + .findFirst(); + + if (alreadyExistingValidIndex.isEmpty()) { + log.info("No suitable index found with name {}, drop it and recreate it", indexName); + mongoTemplate.indexOps(entity).dropIndex(indexName); + mongoTemplate.indexOps(entity).ensureIndex( + new Index().named(indexName).on(field, Sort.Direction.ASC).expire(duration).background() + ); + log.info("Index {}, created", indexName); + } + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..806777054aec0affba2a790911e299552f638728 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java @@ -0,0 +1,19 @@ +package fr.gouv.tac.analytics.server.config.mongodb.converters; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Date; + +import static java.time.ZonedDateTime.ofInstant; + +@ReadingConverter +public class DateToZonedDateTimeConverter implements Converter<Date, ZonedDateTime> { + + @Override + public ZonedDateTime convert(final Date source) { + return ofInstant(source.toInstant(), ZoneOffset.UTC); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..95ff012291f8a2ed2098a0331088e95192336c9e --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java @@ -0,0 +1,16 @@ +package fr.gouv.tac.analytics.server.config.mongodb.converters; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.WritingConverter; + +import java.time.ZonedDateTime; +import java.util.Date; + +@WritingConverter +public class ZonedDateTimeToDateConverter implements Converter<ZonedDateTime, Date> { + + @Override + public Date convert(final ZonedDateTime source) { + return Date.from(source.toInstant()); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..6b57fefdc3bc41341bfcdefb2164f3378444a906 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java @@ -0,0 +1,14 @@ +package fr.gouv.tac.analytics.server.config.mongodb.datetimeprovider; + +import org.springframework.data.auditing.DateTimeProvider; + +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Optional; + +public class ZoneDateTimeTimeProvider implements DateTimeProvider { + @Override + public Optional<TemporalAccessor> getNow() { + return Optional.of(ZonedDateTime.now()); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..c11cd8826f6fbdc3428b3ec4906549b36c0b1333 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java @@ -0,0 +1,81 @@ +package fr.gouv.tac.analytics.server.config.security; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import javax.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.DelegatingOAuth2JwtTokenValidator; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.ExpirationTokenPresenceOAuth2TokenValidator; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiCanOnlyBeUsedOnceOAuth2TokenValidator; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiPresenceOAuth2TokenValidator; + +@EnableWebSecurity +public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { + + private static final String RSA_ALGORITHM = "RSA"; + + @Autowired + private HandlerExceptionResolver handlerExceptionResolver; + + @Value("${analyticsserver.robert_jwt_analyticspublickey}") + private String robertJwtPublicKeyString; + + private RSAPublicKey robertRSAPublicKey; + + @PostConstruct + public void init() throws NoSuchAlgorithmException, InvalidKeySpecException { + final byte[] robertJwtPublicKeyBytes = Base64.getDecoder().decode(robertJwtPublicKeyString.getBytes(StandardCharsets.UTF_8)); + final X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(robertJwtPublicKeyBytes, RSA_ALGORITHM); + final KeyFactory kf = KeyFactory.getInstance(RSA_ALGORITHM); + robertRSAPublicKey = (RSAPublicKey) kf.generatePublic(X509publicKey); + } + + @Override + protected void configure(final HttpSecurity httpSecurity) throws Exception { + httpSecurity + .httpBasic().disable() + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .authorizeRequests() + .antMatchers("/actuator/**").permitAll() + .anyRequest().authenticated() + .and().oauth2ResourceServer(oauth2 -> oauth2.jwt().and().authenticationEntryPoint(authenticationEntryPoint())) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return (request, response, authException) -> handlerExceptionResolver.resolveException(request, response, null, authException); + } + + @Bean + public JwtDecoder jwtDecoder(final JtiPresenceOAuth2TokenValidator jtiPresenceOAuth2TokenValidator, + final JtiCanOnlyBeUsedOnceOAuth2TokenValidator jtiCanOnlyBeUsedOnceOAuth2TokenValidator, + final ExpirationTokenPresenceOAuth2TokenValidator expirationTokenPresenceOAuth2TokenValidator) { + final NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(robertRSAPublicKey).build(); + final DelegatingOAuth2JwtTokenValidator delegatingTokenValidator = new DelegatingOAuth2JwtTokenValidator(expirationTokenPresenceOAuth2TokenValidator, new JwtTimestampValidator(), jtiPresenceOAuth2TokenValidator, jtiCanOnlyBeUsedOnceOAuth2TokenValidator); + jwtDecoder.setJwtValidator(delegatingTokenValidator); + return jwtDecoder; + } + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..729a54a99bfdea28ff35b09aa453b16c95d5f729 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java @@ -0,0 +1,36 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +public class DelegatingOAuth2JwtTokenValidator implements OAuth2TokenValidator<Jwt> { + + private final List<OAuth2TokenValidator<Jwt>> oAuth2TokenValidators; + + @SafeVarargs + public DelegatingOAuth2JwtTokenValidator(final OAuth2TokenValidator<Jwt>... tokenValidators) { + oAuth2TokenValidators = Arrays.asList(tokenValidators); + } + + @Override + public OAuth2TokenValidatorResult validate(final Jwt token) { + final Collection<OAuth2Error> errors = new ArrayList<>(); + + final Iterator<OAuth2TokenValidator<Jwt>> oAuth2TokenValidatorIterator = oAuth2TokenValidators.iterator(); + + while (oAuth2TokenValidatorIterator.hasNext() && errors.isEmpty()) { + final OAuth2TokenValidator<Jwt> oAuth2TokenValidator = oAuth2TokenValidatorIterator.next(); + errors.addAll(oAuth2TokenValidator.validate(token).getErrors()); + } + + return OAuth2TokenValidatorResult.failure(errors); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..a3894f4f0d192870419e2e1363d0a16790db51b2 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java @@ -0,0 +1,28 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import java.util.Objects; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class ExpirationTokenPresenceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> { + + public static final String ERR_MESSAGE = "The token expiration (exp) is missing"; + static final OAuth2Error EXPIRATION_NOT_FOUND_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null); + + private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(EXPIRATION_NOT_FOUND_OAUTH2ERROR); + private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success(); + + + @Override + public OAuth2TokenValidatorResult validate(final Jwt jwt) { + if (Objects.isNull(jwt.getExpiresAt())) { + return FAILURE_RESULT; + } + return SUCCESS_RESULT; + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..f414eb3a1ca5ad36b649d2fcae097fdc9e0fbe6b --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java @@ -0,0 +1,44 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import javax.inject.Inject; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +import fr.gouv.tac.analytics.server.service.TokenIdentifierService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class JtiCanOnlyBeUsedOnceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> { + + public static final String ERR_MESSAGE = "The token identifier (jti) can only be used once"; + static final OAuth2Error JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null); + + private static final ZoneId UTC_ZONEID = ZoneId.of("UTC"); + + private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR); + private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success(); + + private final TokenIdentifierService tokenIdentifierService; + + @Override + public OAuth2TokenValidatorResult validate(final Jwt jwt) { + + final String jti = jwt.getId(); + + if (!tokenIdentifierService.tokenIdentifierExist(jti)) { + final ZonedDateTime expirationDate = jwt.getExpiresAt().atZone(UTC_ZONEID); + tokenIdentifierService.save(jti, expirationDate); + return SUCCESS_RESULT; + } + + return FAILURE_RESULT; + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..3210f9fc2502f500bc3d375fff1a244f7cf2d1d1 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java @@ -0,0 +1,28 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +import org.apache.commons.lang3.StringUtils; + +@Component +public class JtiPresenceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> { + + public static final String ERR_MESSAGE = "The token identifier (jti) is missing"; + static final OAuth2Error JTI_NOT_FOUND_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null); + + private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(JTI_NOT_FOUND_OAUTH2ERROR); + private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success(); + + + @Override + public OAuth2TokenValidatorResult validate(final Jwt jwt) { + if (StringUtils.isEmpty(jwt.getId())) { + return FAILURE_RESULT; + } + return SUCCESS_RESULT; + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..1cc0b0aad862b8df23d1cc353d0aee968e372258 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java @@ -0,0 +1,14 @@ +package fr.gouv.tac.analytics.server.config.validation; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@Configuration +public class ValidationConfiguration { + + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java new file mode 100644 index 0000000000000000000000000000000000000000..a899fe91b96015bad28662febea9da2b00fdc5fc --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java @@ -0,0 +1,69 @@ +package fr.gouv.tac.analytics.server.config.validation; + +import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; + +@Validated +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +@Component +@ConfigurationProperties(prefix = "analyticsserver.validation.analytics") +public class ValidationParameters { + + @NotNull + @Valid + private InfoValidationParameters information; + + @NotNull + @Valid + private TimestampedEventValidationParameters event; + + @NotNull + @Valid + private TimestampedEventValidationParameters error; + + public TimestampedEventValidationParameters getParameters(final TimestampedEventCollectionType timestampedEventCollectionType) { + + switch (timestampedEventCollectionType) { + case EVENT: + return event; + case ERROR: + return error; + default: + throw new UnsupportedOperationException("This type is not supported : " + timestampedEventCollectionType.toString()); + } + } + + + @AllArgsConstructor + @NoArgsConstructor + @Data + @Builder + public static class InfoValidationParameters { + private int maxInfoAllowed; + private int maxInfoKeyLength; + private int maxInfoValueLength; + } + + @AllArgsConstructor + @NoArgsConstructor + @Data + @Builder + public static class TimestampedEventValidationParameters { + private int maxElementAllowed; + private int maxNameLength; + private int maxDescriptionLength; + } + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java new file mode 100644 index 0000000000000000000000000000000000000000..adc2c0ebec92f626f2b3e9929d9fe31b2d56d066 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java @@ -0,0 +1,22 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = AnalyticsVoInfoSizeValidator.class) +public @interface AnalyticsVoInfoSize { + + String message() default "Too many info"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default { + + }; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..60e0cbbb5b71a1fb5e86673b794998e4e1e71705 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java @@ -0,0 +1,78 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.inject.Inject; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +import fr.gouv.tac.analytics.server.config.validation.ValidationParameters; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class AnalyticsVoInfoSizeValidator implements ConstraintValidator<AnalyticsVoInfoSize, Map<String, String>> { + + public static final String TOO_MANY_INFO_ERROR_MESSAGE = "Too many info, %d found, whereas the maximum allowed is %d"; + public static final String KEY_TOO_LONG_ERROR_MESSAGE = "Key with more than %d characters is not allowed, found %d characters"; + public static final String VALUE_TOO_LONG_ERROR_MESSAGE = "Parameter value with more than %d characters is not allowed, found %d characters"; + + private final ValidationParameters validationParameters; + + @Override + public boolean isValid(final Map<String, String> value, final ConstraintValidatorContext context) { + if (Objects.isNull(value)) { + return true; + } + return isValidMapSize(value, context) && areValidKeySizes(value, context) && areValidValueSizes(value, context); + } + + + private boolean isValidMapSize(final Map<String, String> value, final ConstraintValidatorContext context) { + final int maxInfoAllowed = validationParameters.getInformation().getMaxInfoAllowed(); + if (value.size() > maxInfoAllowed) { + contextConfigurer(context, TOO_MANY_INFO_ERROR_MESSAGE, value.size(), maxInfoAllowed); + return false; + } + return true; + } + + private boolean areValidKeySizes(final Map<String, String> value, final ConstraintValidatorContext context) { + final int maxInfoKeyLength = validationParameters.getInformation().getMaxInfoKeyLength(); + final Optional<String> firstRejectedKey = value.entrySet().stream() + .map(Map.Entry::getKey) + .filter(s -> s.length() > maxInfoKeyLength) + .findFirst(); + + if (firstRejectedKey.isPresent()) { + contextConfigurer(context, KEY_TOO_LONG_ERROR_MESSAGE, maxInfoKeyLength, firstRejectedKey.get().length()); + return false; + } + + return true; + } + + private boolean areValidValueSizes(final Map<String, String> value, final ConstraintValidatorContext context) { + final int maxInfoValueLength = validationParameters.getInformation().getMaxInfoValueLength(); + final Optional<String> firstRejectedValue = value.entrySet().stream() + .map(Map.Entry::getValue) + .filter(s -> s.length() > maxInfoValueLength) + .findFirst(); + + if (firstRejectedValue.isPresent()) { + contextConfigurer(context, VALUE_TOO_LONG_ERROR_MESSAGE, maxInfoValueLength, firstRejectedValue.get().length()); + return false; + } + + return true; + } + + private void contextConfigurer(final ConstraintValidatorContext context, final String messageTemplate, final Object... messageParameters) { + final String errorMessage = String.format(messageTemplate, messageParameters); + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(errorMessage).addConstraintViolation(); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java new file mode 100644 index 0000000000000000000000000000000000000000..caa5e28a31354d0452d36fd9322326124e96ddc5 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java @@ -0,0 +1,25 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TimestampedEventCollectionValidator.class) +public @interface TimestampedEventCollection { + + String message() default "Error validating"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default { + + }; + + TimestampedEventCollectionType type(); + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java new file mode 100644 index 0000000000000000000000000000000000000000..35b46246ca2ed2b59852211bf4609284637b7a24 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java @@ -0,0 +1,6 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +public enum TimestampedEventCollectionType { + EVENT, + ERROR +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..202bbb9629ed9ec525b57c51ca97b0cea3543a7d --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java @@ -0,0 +1,90 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import java.util.Collection; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +import org.springframework.util.CollectionUtils; + +import fr.gouv.tac.analytics.server.config.validation.ValidationParameters; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class TimestampedEventCollectionValidator implements ConstraintValidator<TimestampedEventCollection, Collection<TimestampedEventVo>> { + + public static final String TOO_MANY_ELEMENTS_ERROR_MESSAGE = "Too many %s, %d found, whereas the maximum allowed is %d"; + public static final String NAME_TOO_LONG_ERROR_MESSAGE = "For %s, name with more than %d characters is not allowed, found %d characters"; + public static final String DESCRIPTION_TOO_LONG_ERROR_MESSAGE = "For %s, description with more than %d characters is not allowed, found %d characters"; + + private final ValidationParameters validationParameters; + + private TimestampedEventCollectionType timestampedEventCollectionType; + private ValidationParameters.TimestampedEventValidationParameters parameters; + + @Override + public void initialize(final TimestampedEventCollection timestampedEventCollection) { + timestampedEventCollectionType = timestampedEventCollection.type(); + parameters = validationParameters.getParameters(timestampedEventCollectionType); + } + + @Override + public boolean isValid(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) { + if (CollectionUtils.isEmpty(value)) { + return true; + } + return isValidCollectionSize(value, context) && areNameValidSizes(value, context) && areDescriptionValidSizes(value, context); + } + + private boolean isValidCollectionSize(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) { + if (value.size() > parameters.getMaxElementAllowed()) { + contextConfigurer(context, TOO_MANY_ELEMENTS_ERROR_MESSAGE, timestampedEventCollectionType, value.size(), parameters.getMaxElementAllowed()); + return false; + } + return true; + } + + private boolean areNameValidSizes(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) { + final int maxNameLength = parameters.getMaxNameLength(); + + final Optional<String> firstRejectedName = getFirstAttributeWithInvalidLength(value, TimestampedEventVo::getName, maxNameLength); + + if (firstRejectedName.isPresent()) { + contextConfigurer(context, NAME_TOO_LONG_ERROR_MESSAGE, timestampedEventCollectionType, maxNameLength, firstRejectedName.get().length()); + return false; + } + return true; + } + + private boolean areDescriptionValidSizes(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) { + final int maxDescriptionLength = parameters.getMaxDescriptionLength(); + final Optional<String> firstRejectedDescription = getFirstAttributeWithInvalidLength(value, TimestampedEventVo::getDescription, maxDescriptionLength); + + if (firstRejectedDescription.isPresent()) { + contextConfigurer(context, DESCRIPTION_TOO_LONG_ERROR_MESSAGE, timestampedEventCollectionType, maxDescriptionLength, firstRejectedDescription.get().length()); + return false; + } + return true; + } + + private Optional<String> getFirstAttributeWithInvalidLength(final Collection<TimestampedEventVo> value, final Function<TimestampedEventVo, String> attributeAccessor, final int maxLengthAllowed) { + return value.stream() + .map(attributeAccessor) + .filter(Objects::nonNull) + .filter(n -> n.length() > maxLengthAllowed) + .findFirst(); + } + + private void contextConfigurer(final ConstraintValidatorContext context, final String messageTemplate, final Object... messageParameters) { + final String errorMessage = String.format(messageTemplate, messageParameters); + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(errorMessage).addConstraintViolation(); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java new file mode 100644 index 0000000000000000000000000000000000000000..cd34ee04990d162d4cb724df9cffefd116350290 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java @@ -0,0 +1,32 @@ +package fr.gouv.tac.analytics.server.controller; + +import javax.inject.Inject; +import javax.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import fr.gouv.tac.analytics.server.controller.mapper.AnalyticsMapper; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.service.AnalyticsService; +import lombok.RequiredArgsConstructor; + + +@RestController +@RequestMapping(value = "${analyticsserver.controller.analytics.path}") +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class AnalyticsController { + + private final AnalyticsService analyticsService; + private final AnalyticsMapper analyticsMapper; + + @PostMapping + public ResponseEntity<Void> addAnalytics(@Valid @RequestBody final AnalyticsVo analyticsVo) { + analyticsMapper.map(analyticsVo).ifPresent(analyticsService::createAnalytics); + return ResponseEntity.ok().build(); + } + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..896b7212d42d5e0d99bfad1ba57634c9a330784d --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java @@ -0,0 +1,43 @@ +package fr.gouv.tac.analytics.server.controller; + +import fr.gouv.tac.analytics.server.controller.vo.ErrorVo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.ZonedDateTime; + +@Slf4j +@RestControllerAdvice +public class CustomExceptionHandler { + + + @ExceptionHandler(value = AuthenticationException.class) + public ResponseEntity<ErrorVo> exception(final AuthenticationException e) { + return errorVoBuilder(e, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(value = MethodArgumentNotValidException.class) + public ResponseEntity<ErrorVo> exception(final MethodArgumentNotValidException e) { + return errorVoBuilder(e, HttpStatus.BAD_REQUEST); + } + + + @ExceptionHandler(value = Exception.class) + public ResponseEntity<ErrorVo> exception(final Exception e) { + log.warn("Unexpected error :", e); + return errorVoBuilder(e, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity<ErrorVo> errorVoBuilder(final Exception e, final HttpStatus httpStatus) { + final ErrorVo errorVo = ErrorVo.builder() + .message(e.getMessage()) + .timestamp(ZonedDateTime.now()) + .build(); + return ResponseEntity.status(httpStatus).body(errorVo); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..a2c7efb15a8682e6dcfb584d50c9beb17514ebef --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java @@ -0,0 +1,22 @@ +package fr.gouv.tac.analytics.server.controller.mapper; + +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.Optional; + + +@Mapper(componentModel = "spring", uses = TimestampedEventMapper.class) +public interface AnalyticsMapper { + + + default Optional<Analytics> map(final AnalyticsVo analyticsVo) { + return Optional.ofNullable(this.mapInternal(analyticsVo)); + } + + @Mapping(target = "creationDate", expression = "java( java.time.ZonedDateTime.now() )") + Analytics mapInternal(final AnalyticsVo analyticsVo); + +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..2ec990fa3b98fc8fdd2661446e5ffe115d633743 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java @@ -0,0 +1,14 @@ +package fr.gouv.tac.analytics.server.controller.mapper; + +import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface TimestampedEventMapper { + + TimestampedEvent map(TimestampedEventVo timestampedEventVo); + +} + + diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java new file mode 100644 index 0000000000000000000000000000000000000000..756c9342a4934c6d4fdb6f0a629bda5394ba4194 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java @@ -0,0 +1,39 @@ +package fr.gouv.tac.analytics.server.controller.vo; + + +import java.util.List; +import java.util.Map; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSize; +import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollection; +import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class AnalyticsVo { + + @NotBlank + @Size(max = 64) + private String installationUuid; + + @AnalyticsVoInfoSize + private Map<String, String> infos; + + @Valid + @TimestampedEventCollection(type = TimestampedEventCollectionType.EVENT) + private List<TimestampedEventVo> events; + + @Valid + @TimestampedEventCollection(type = TimestampedEventCollectionType.ERROR) + private List<TimestampedEventVo> errors; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java new file mode 100644 index 0000000000000000000000000000000000000000..53be8cb7fea692f6a5cfe1870e238f3dc331ff16 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java @@ -0,0 +1,19 @@ +package fr.gouv.tac.analytics.server.controller.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class ErrorVo { + + private String message; + + private ZonedDateTime timestamp; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java new file mode 100644 index 0000000000000000000000000000000000000000..c90fcf4dd9e53092bcb8973f89140512f778eef5 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java @@ -0,0 +1,26 @@ +package fr.gouv.tac.analytics.server.controller.vo; + +import java.time.ZonedDateTime; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class TimestampedEventVo { + + @NotBlank + private String name; + + @NotNull + private ZonedDateTime timestamp; + + private String description; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java new file mode 100644 index 0000000000000000000000000000000000000000..2bcdb8e3eb1e208d15b1e9e7db31800eabfbadee --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java @@ -0,0 +1,27 @@ +package fr.gouv.tac.analytics.server.model.kafka; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class Analytics { + + private String installationUuid; + + private Map<String, String> infos; + + private List<TimestampedEvent> events; + + private List<TimestampedEvent> errors; + + private ZonedDateTime creationDate; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java new file mode 100644 index 0000000000000000000000000000000000000000..00c7c41eb3c1aebabe3ee5d27606262f88c3d4e8 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java @@ -0,0 +1,21 @@ +package fr.gouv.tac.analytics.server.model.kafka; + +import java.time.ZonedDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class TimestampedEvent { + + private String name; + + private ZonedDateTime timestamp; + + private String description; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java new file mode 100644 index 0000000000000000000000000000000000000000..aab70e8decd54e527b69e05eec9ef9dbd88fd975 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java @@ -0,0 +1,34 @@ +package fr.gouv.tac.analytics.server.model.mongo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import javax.validation.constraints.NotNull; +import java.time.ZonedDateTime; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +@Document(value = "tokenIdentifier") +public class TokenIdentifier { + + public static final String EXPIRATION_DATE_FIELD_NAME = "expirationDate"; + public static final String IDENTIFIER_FIELD_NAME = "identifier"; + + @Id + private String id; + + @NotNull + @Field(name = IDENTIFIER_FIELD_NAME) + private String identifier; + + @NotNull + @Field(name = EXPIRATION_DATE_FIELD_NAME) + private ZonedDateTime expirationDate; +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..987015b3dc9b2a22a9fb33b0092f6416f86a3e70 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java @@ -0,0 +1,13 @@ +package fr.gouv.tac.analytics.server.repository.mongo; + +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; + +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface TokenIdentifierRepository extends MongoRepository<TokenIdentifier, String> { + + + Optional<TokenIdentifier> findByIdentifier(String identifier); +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java new file mode 100644 index 0000000000000000000000000000000000000000..a9650f117a71e5bc0856d1b16345a0f6fa9a7fea --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java @@ -0,0 +1,33 @@ +package fr.gouv.tac.analytics.server.service; + +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; +import org.springframework.util.concurrent.ListenableFutureCallback; + +import javax.inject.Inject; + +@Slf4j +@Service +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class AnalyticsService { + + private final KafkaTemplate<String, Analytics> kafkaTemplate; + + public void createAnalytics(final Analytics analytics) { + kafkaTemplate.sendDefault(analytics).addCallback(new ListenableFutureCallback<>() { + @Override + public void onFailure(final Throwable throwable) { + log.warn("Error sending message to kafka", throwable); + } + + @Override + public void onSuccess(final SendResult<String, Analytics> sendResult) { + log.debug("Message successfully sent {}", sendResult); + } + }); + } +} diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java new file mode 100644 index 0000000000000000000000000000000000000000..72a44d76cecffccb60ebbdc5b2f62750e4e7ba70 --- /dev/null +++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java @@ -0,0 +1,39 @@ +package fr.gouv.tac.analytics.server.service; + +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; +import fr.gouv.tac.analytics.server.repository.mongo.TokenIdentifierRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.inject.Inject; +import java.time.ZonedDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class TokenIdentifierService { + + private final TokenIdentifierRepository tokenIdentifierRepository; + + @Transactional(readOnly = true) + public boolean tokenIdentifierExist(final String tokenIdentifier) { + return tokenIdentifierRepository.findByIdentifier(tokenIdentifier).isPresent(); + } + + @Transactional + public TokenIdentifier save(final String tokenIdentifier, final ZonedDateTime expirationDate) { + + final TokenIdentifier tokenIdentifierToSave = TokenIdentifier.builder() + .identifier(tokenIdentifier) + .expirationDate(expirationDate) + .build(); + + final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifierToSave); + log.debug("TokenIdentifier saved : {}", savedTokenIdentifier); + + return savedTokenIdentifier; + } + +} diff --git a/analytics-server/src/main/resources/application-dev.properties b/analytics-server/src/main/resources/application-dev.properties new file mode 100644 index 0000000000000000000000000000000000000000..36d3e900eb745bec1f16c99f861f753b40321b99 --- /dev/null +++ b/analytics-server/src/main/resources/application-dev.properties @@ -0,0 +1,20 @@ +server.port=8089 +spring.data.mongodb.uri=mongodb://localhost:27018/protectedAppAnalyticsDB +analyticsserver.robert_jwt_analyticspublickey=MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHoDa/ff6ZfDzUSgjo+AbYhgthpgaBEX3L6YFRr+QfVwa5A7sxp/aze9gKgN8JlaCtNV2M7cor96fLMV71ucWUuPeyZf5CBMokETU9L8AD44KEZDyZFdGLydV3F7mTPIkcQlTjMqWF5V/l7NOQY0FvQpOYZ5Eosi19qsZyeeU5izAgMBAAE= +# related private key +# MIICWgIBAAKBgHoDa/ff6ZfDzUSgjo+AbYhgthpgaBEX3L6YFRr+QfVwa5A7sxp/ +# aze9gKgN8JlaCtNV2M7cor96fLMV71ucWUuPeyZf5CBMokETU9L8AD44KEZDyZFd +# GLydV3F7mTPIkcQlTjMqWF5V/l7NOQY0FvQpOYZ5Eosi19qsZyeeU5izAgMBAAEC +# gYADi+/qf565o55m3UfnkfMdILqKX70GqivnemI6q6NdMAxgX+vf3E5Qi3ie6fDR +# dFWWOQuZT76HVFclmYCgqBXMWwTYTaAHchJvDty7CtAMlr835VWps5hQLy+NvDDj +# 6HmH/9qpp6rk6mFvw/2/scQgF27BlSB69KF0fvd0l9qpeQJBANWHlZ1eOftSy8XO +# w0ZZIZuvMTLN9Z2Hypziqa1PubBI5R6rhlhpVctrx3gJErXZaIxt9R+n2BDLk44J +# asj2nacCQQCSSAyZFp7yUVJ1MPNIHkF1qYIcdvehuyvamGcVj3ouIUx/FpRRY7vn +# 6++zDVKRNnEyyYzLxLmj7tSzrCRmu0YVAkBx3AR5j38XdoTWC3SxFGIJZBe14YEw +# 6PGvSmXz1mlLiPMzsX7HshNfjy8j4KKreSx4hUGKCbP68LLTsE3Srz5NAkBilSng +# Vg1ikwm2LvoVxUYqTMWB928l0OVaGVGHhz05L3nFQGtOep9dSnUtPzJA6Oba6lf3 +# z6moYEF6oO2bSmSNAkBB0sE2OcivffQDWs9Z2zdRbdBWLofQeWypmAPGOAqRE5HE +# RLPXHTKS4C1JFeotdXJ3bRrl1sODweOQghINCTav +# generated with : https://travistidwell.com/jsencrypt/demo/ +spring.kafka.bootstrap-servers=localhost:19092 +spring.kafka.template.default-topic=analyticsTopic diff --git a/analytics-server/src/main/resources/application.properties b/analytics-server/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..4dfccf2202ba27626c476b5f38776d4d4af85ba9 --- /dev/null +++ b/analytics-server/src/main/resources/application.properties @@ -0,0 +1,22 @@ +server.port=${ANALYTICS_SERVER_PORT:8089} +## Use uri to be able to connect to replica sets / sharded clusters +spring.data.mongodb.uri=mongodb://${ANALYTICS_SERVER_DB_HOST}\:${ANALYTICS_SERVER_DB_PORT}/${ANALYTICS_SERVER_DB_NAME:protectedAppAnalyticsDB} +spring.data.mongodb.auto-index-creation=false +# Kafka producer conf +spring.kafka.bootstrap-servers=${ANALYTICS_KAFKA_BOOTSTRAP_SERVERS} +spring.kafka.template.default-topic=${ANALYTICS_KAFKA_DEFAULT_TOPIC:analyticsTopic} +# Available endpoints for the monitoring +management.endpoints.web.exposure.include=${ANALYTICS_SERVER_MONITORING_ENDPOINTS:health,metrics,info,prometheus} +analyticsserver.controller.path.prefix=/api/v1 +analyticsserver.controller.analytics.path=${analyticsserver.controller.path.prefix}/analytics +analyticsserver.robert_jwt_analyticspublickey=${ANALYTICS_JWT_PUBLIC_KEY} +analyticsserver.validation.analytics.information.maxInfoAllowed=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_ELEMENT:20} +analyticsserver.validation.analytics.information.maxInfoKeyLength=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_KEY_LENGTH:64} +analyticsserver.validation.analytics.information.maxInfoValueLength=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_VALUE_LENGTH:512} +analyticsserver.validation.analytics.event.maxElementAllowed=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_ELEMENT:20} +analyticsserver.validation.analytics.event.maxNameLength=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_NAME_LENGTH:64} +analyticsserver.validation.analytics.event.maxDescriptionLength=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_DESCRIPTION_LENGTH:512} +analyticsserver.validation.analytics.error.maxElementAllowed=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_ELEMENT:20} +analyticsserver.validation.analytics.error.maxNameLength=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_NAME_LENGTH:64} +analyticsserver.validation.analytics.error.maxDescriptionLength=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_DESCRIPTION_LENGTH:512} + diff --git a/analytics-server/src/main/resources/bootstrap.yml b/analytics-server/src/main/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..d2ea314042569782d352e6038822d5294788e67a --- /dev/null +++ b/analytics-server/src/main/resources/bootstrap.yml @@ -0,0 +1,17 @@ +spring: + application: + name: analytics-server + cloud: + consul: + enabled: ${CONSUL_ENABLED:false} + host: ${CONSUL_HOST:localhost} + port: ${CONSUL_PORT:8500} + scheme: ${CONSUL_SCHEME:http} + config: + enabled: ${CONSUL_CONFIG_ENABLED:false} + vault: + enabled: ${VAULT_ENABLED:false} + host: ${VAULT_HOST:localhost} + port: ${VAULT_PORT:8200} + token: ${VAULT_TOKEN:token} + scheme: ${VAULT_SCHEME:http} diff --git a/analytics-server/src/main/resources/logback.xml b/analytics-server/src/main/resources/logback.xml new file mode 100644 index 0000000000000000000000000000000000000000..78fd04ab13bc727c6ac80cece8fbcd56768da8ac --- /dev/null +++ b/analytics-server/src/main/resources/logback.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration> + <include resource="org/springframework/boot/logging/logback/defaults.xml"/> + <property name="LOG_DIR" value="${ROBERT_SERVER_LOG_FILE_PATH:-/tmp/logs}"/> + <property name="LOG_FILENAME" value="${ROBERT_SERVER_LOG_FILE_NAME:-analyticsMongo-server-ws-rest}"/> + <property name="ERROR_LOG_FILENAME" + value="${ROBERT_SERVER_ERROR_LOG_FILE_NAME:-analyticsMongo-server-ws-rest}.error"/> + + <appender name="RollingFile" + class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${LOG_DIR}/${LOG_FILENAME}.log</file> + <encoder + class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> + <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> + </encoder> + + <rollingPolicy + class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> + <!-- rollover daily and when the file reaches 10 MegaBytes --> + <fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz + </fileNamePattern> + <maxFileSize>10MB</maxFileSize> + </rollingPolicy> + </appender> + + <appender name="RollingErrorFile" + class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file> + <encoder + class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> + <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> + </encoder> + + <rollingPolicy + class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> + <!-- rollover daily and when the file reaches 10 MegaBytes --> + <fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz + </fileNamePattern> + <maxFileSize>10MB</maxFileSize> + </rollingPolicy> + </appender> + + <springProfile name="dev"> + <include resource="org/springframework/boot/logging/logback/base.xml"/> + <logger name="org.springframework" level="INFO"/> + </springProfile> + + <springProfile name="!dev"> + <!-- LOG everything at INFO level --> + <root level="info"> + <appender-ref ref="RollingFile"/> + </root> + </springProfile> + +</configuration> diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f2633312d13b3918db4e1264a937800ab2441c62 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java @@ -0,0 +1,33 @@ +package fr.gouv.tac.analytics.server.config.mongodb.converters; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.GregorianCalendar; + +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SpringExtension.class) +public class DateToZonedDateTimeConverterTest { + + private final DateToZonedDateTimeConverter dateToZonedDateTimeConverter = new DateToZonedDateTimeConverter(); + + @Test + public void shouldConvertDateToZonedDateTimeWithUTCTimeZone() { + + final ZonedDateTime zdt = ZonedDateTime.parse("2019-04-01T16:24:11.252+02:00"); + final GregorianCalendar calendar = GregorianCalendar.from(zdt); + + final Date date = calendar.getTime(); + + final ZonedDateTime result = dateToZonedDateTimeConverter.convert(date); + Assertions.assertThat(result.getZone()).isEqualTo(ZoneId.of("UTC").normalized()); + Assertions.assertThat(result).isEqualToIgnoringNanos(zdt); + + } + +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..58c6da636b98fd1ad80a7e77d7ac85e399dc1404 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java @@ -0,0 +1,26 @@ +package fr.gouv.tac.analytics.server.config.mongodb.converters; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.ZonedDateTime; +import java.util.Date; + +@ExtendWith(SpringExtension.class) +public class ZonedDateTimeToDateConverterTest { + + private final ZonedDateTimeToDateConverter zonedDateTimeToDateConverter = new ZonedDateTimeToDateConverter(); + + @Test + public void shouldMapZoneDateTimeToDate() { + + final ZonedDateTime zdt = ZonedDateTime.parse("2019-04-01T16:24:11.252+02:00"); + + final Date result = zonedDateTimeToDateConverter.convert(zdt); + + Assertions.assertThat(result).isEqualTo("2019-04-01T16:24:11.252+0200"); + + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..8bd75e1845cb7018a0188c876216f153f29f90b4 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java @@ -0,0 +1,66 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +public class DelegatingOAuth2JwtTokenValidatorTest { + + @Mock + private OAuth2TokenValidator<Jwt> validator1; + + @Mock + private OAuth2TokenValidator<Jwt> validator2; + + @Mock + private Jwt token; + + private DelegatingOAuth2JwtTokenValidator delegatingOAuth2JwtTokenValidator; + + @BeforeEach + public void setUp() { + delegatingOAuth2JwtTokenValidator = new DelegatingOAuth2JwtTokenValidator(validator1, validator2); + } + + @Test + public void shouldCallEachValidatorIfNoError() { + + Mockito.when(validator1.validate(token)).thenReturn(OAuth2TokenValidatorResult.success()); + Mockito.when(validator2.validate(token)).thenReturn(OAuth2TokenValidatorResult.success()); + + final OAuth2TokenValidatorResult result = delegatingOAuth2JwtTokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isFalse(); + + final InOrder inOrder = Mockito.inOrder(validator1, validator2); + + inOrder.verify(validator1, Mockito.times(1)).validate(token); + inOrder.verify(validator2, Mockito.times(1)).validate(token); + } + + @Test + public void shouldStopValidationWhenAnErrorIsEncountered() { + + final OAuth2TokenValidatorResult failure = OAuth2TokenValidatorResult.failure(new OAuth2Error("errorCode", "error message", "some uri")); + + Mockito.when(validator1.validate(token)).thenReturn(failure); + + final OAuth2TokenValidatorResult result = delegatingOAuth2JwtTokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isTrue(); + Assertions.assertThat(result.getErrors()).containsExactlyInAnyOrderElementsOf(failure.getErrors()); + + Mockito.verify(validator2, Mockito.never()).validate(token); + } + +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..854c3b5631d838b53c40909d82ee527f2263d0e9 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java @@ -0,0 +1,50 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import java.time.Instant; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; + + +@ExtendWith(SpringExtension.class) +public class ExpirationTokenPresenceOAuth2TokenValidatorTest { + + @Mock + private Jwt token; + + private final ExpirationTokenPresenceOAuth2TokenValidator expirationTokenPresenceOAuth2TokenValidator = new ExpirationTokenPresenceOAuth2TokenValidator(); + + @Test + public void shouldFailIfNoJtiInTheToken() { + + Mockito.when(token.getExpiresAt()).thenReturn(null); + + final OAuth2TokenValidatorResult result = expirationTokenPresenceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isTrue(); + Assertions.assertThat(result.getErrors()).hasSize(1); + final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next(); + Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(ExpirationTokenPresenceOAuth2TokenValidator.EXPIRATION_NOT_FOUND_OAUTH2ERROR.getErrorCode()); + Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(ExpirationTokenPresenceOAuth2TokenValidator.EXPIRATION_NOT_FOUND_OAUTH2ERROR.getDescription()); + Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull(); + } + + @Test + public void shouldNotFailIfExpirationTokenIsPresent() { + + Mockito.when(token.getExpiresAt()).thenReturn(Instant.now()); + + final OAuth2TokenValidatorResult result = expirationTokenPresenceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isFalse(); + } + +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f8af38b4dad483c46011d8e2fd4388d1096fa225 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java @@ -0,0 +1,68 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + + +import fr.gouv.tac.analytics.server.service.TokenIdentifierService; +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.ZonedDateTime; + +@ExtendWith(SpringExtension.class) +public class JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest { + + @Mock + private Jwt token; + + @Mock + private TokenIdentifierService tokenIdentifierService; + + @InjectMocks + private JtiCanOnlyBeUsedOnceOAuth2TokenValidator jtiCanOnlyBeUsedOnceOAuth2TokenValidator; + + + @Test + public void shouldFailIfTokenIdentifierAlreadyExist() { + + final String jti = "someId"; + final ZonedDateTime expirationDate = ZonedDateTime.now(); + + Mockito.when(token.getId()).thenReturn(jti); + Mockito.when(tokenIdentifierService.tokenIdentifierExist(jti)).thenReturn(true); + + final OAuth2TokenValidatorResult result = jtiCanOnlyBeUsedOnceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isTrue(); + Assertions.assertThat(result.getErrors()).hasSize(1); + final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next(); + Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR.getErrorCode()); + Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR.getDescription()); + Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull(); + + Mockito.verify(tokenIdentifierService, Mockito.never()).save(jti, expirationDate); + } + + @Test + public void shouldSuccessWhenItsTheFirstTokenIdentifierUsage() { + + final String jti = "someId"; + final ZonedDateTime expirationDate = ZonedDateTime.now(); + + Mockito.when(token.getId()).thenReturn(jti); + Mockito.when(token.getExpiresAt()).thenReturn(expirationDate.toInstant()); + Mockito.when(tokenIdentifierService.tokenIdentifierExist(jti)).thenReturn(false); + Mockito.when(tokenIdentifierService.save(jti, expirationDate)).thenReturn(new TokenIdentifier()); + + final OAuth2TokenValidatorResult result = jtiCanOnlyBeUsedOnceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isFalse(); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..471da5645b5b5c1fe30357acf6c04149aac38ddf --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java @@ -0,0 +1,47 @@ +package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +public class JtiPresenceOAuth2TokenValidatorTest { + + @Mock + private Jwt token; + + private final JtiPresenceOAuth2TokenValidator jtiPresenceOAuth2TokenValidator = new JtiPresenceOAuth2TokenValidator(); + + @Test + public void shouldFailIfNoJtiInTheToken() { + + Mockito.when(token.getId()).thenReturn(null); + + final OAuth2TokenValidatorResult result = jtiPresenceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isTrue(); + Assertions.assertThat(result.getErrors()).hasSize(1); + final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next(); + Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(JtiPresenceOAuth2TokenValidator.JTI_NOT_FOUND_OAUTH2ERROR.getErrorCode()); + Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(JtiPresenceOAuth2TokenValidator.JTI_NOT_FOUND_OAUTH2ERROR.getDescription()); + Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull(); + } + + @Test + public void shouldNotFailIfJtiIsPresent() { + + final String jti = "someId"; + Mockito.when(token.getId()).thenReturn(jti); + + final OAuth2TokenValidatorResult result = jtiPresenceOAuth2TokenValidator.validate(token); + + Assertions.assertThat(result.hasErrors()).isFalse(); + } + +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..972df7c44279e8c69cefd474c8f93d2bb6f72f4b --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java @@ -0,0 +1,104 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import static fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSizeValidator.*; + +import java.util.Map; + +import javax.validation.ConstraintValidatorContext; + +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import fr.gouv.tac.analytics.server.config.validation.ValidationParameters; +import org.apache.commons.lang3.RandomStringUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; + +@ExtendWith(SpringExtension.class) +public class AnalyticsMongoVoInfoSizeValidatorTest { + + @Captor + private ArgumentCaptor<String> stringArgumentCaptor; + + @Mock + private ConstraintValidatorContext.ConstraintViolationBuilder constraintViolationBuilder; + + @Mock + private ConstraintValidatorContext constraintValidatorContext; + + @Mock + private ValidationParameters validationParameters; + + @InjectMocks + private AnalyticsVoInfoSizeValidator analyticsVoInfoSizeValidator; + + private ValidationParameters.InfoValidationParameters infoValidationParameters; + + @BeforeEach + public void setUp() { + infoValidationParameters = ValidationParameters.InfoValidationParameters.builder() + .maxInfoAllowed(3) + .maxInfoKeyLength(10) + .maxInfoValueLength(20) + .build(); + Mockito.when(validationParameters.getInformation()).thenReturn(infoValidationParameters); + } + + @Test + public void shouldAcceptNullMap() { + final Map<String, String> info = null; + + final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext); + Assertions.assertThat(result).isTrue(); + } + + @Test + public void shouldAcceptMapWithLessThanMaxAllowedElement() { + final Map<String, String> info = Map.of("key1", "value1", "key2", "value2"); + + final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext); + Assertions.assertThat(result).isTrue(); + } + + @Test + public void shouldRejectMapWithMoreThanMaxAllowedElement() { + final Map<String, String> info = Map.of("key1", "value1", "key2", "value2", "key3", "value3", "key4", "value4"); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext); + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(TOO_MANY_INFO_ERROR_MESSAGE, 4, 3)); + } + + @Test + public void shouldRejectMapWithKeyTooLong() { + final Map<String, String> info = Map.of(RandomStringUtils.random(infoValidationParameters.getMaxInfoKeyLength() + 1), "value1"); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext); + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(KEY_TOO_LONG_ERROR_MESSAGE, 10, 11)); + } + + @Test + public void shouldRejectMapWithValueTooLong() { + final Map<String, String> info = Map.of("key1", RandomStringUtils.random(infoValidationParameters.getMaxInfoValueLength() + 1)); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext); + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(VALUE_TOO_LONG_ERROR_MESSAGE, 20, 21)); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b4f7d10d15ad289bfd9f2054b0dc2d675f65237b --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java @@ -0,0 +1,157 @@ +package fr.gouv.tac.analytics.server.config.validation.validator; + +import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.*; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; + +import javax.validation.ConstraintValidatorContext; + +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import fr.gouv.tac.analytics.server.config.validation.ValidationParameters; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import org.apache.commons.lang3.RandomStringUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; + +@ExtendWith(SpringExtension.class) +public class TimestampedEventCollectionValidatorTest { + + @Captor + private ArgumentCaptor<String> stringArgumentCaptor; + + @Mock + private TimestampedEventCollection timestampedEventCollection; + + @Mock + private ConstraintValidatorContext.ConstraintViolationBuilder constraintViolationBuilder; + + @Mock + private ConstraintValidatorContext constraintValidatorContext; + + @Mock + private ValidationParameters validationParameters; + + @InjectMocks + private TimestampedEventCollectionValidator timestampedEventCollectionSizeValidator; + + private ValidationParameters.TimestampedEventValidationParameters eventsValidationParameters; + + @BeforeEach + public void setUp() { + eventsValidationParameters = ValidationParameters.TimestampedEventValidationParameters.builder() + .maxElementAllowed(3) + .maxDescriptionLength(256) + .maxNameLength(128) + .build(); + + Mockito.when(timestampedEventCollection.type()).thenReturn(TimestampedEventCollectionType.EVENT); + Mockito.when(validationParameters.getParameters(TimestampedEventCollectionType.EVENT)).thenReturn(eventsValidationParameters); + } + + + @Test + public void shouldAcceptNullCollection() { + + final Collection<TimestampedEventVo> value = null; + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isTrue(); + } + + @Test + public void shouldAcceptCollectionWithLessThanMaxAllowedElement() { + + final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVoBuilder()); + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isTrue(); + } + + @Test + public void shouldAcceptCollectionWithNullDescription() { + final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder(); + timestampedEventVo.setDescription(null); + + final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isTrue(); + + } + + @Test + public void shouldRejectCollectionWithTooManyElement() { + + final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder()); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(TOO_MANY_ELEMENTS_ERROR_MESSAGE, "EVENT", 5 , 3)); + } + + @Test + public void shouldRejectCollectionWithNameTooLong() { + final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder(); + timestampedEventVo.setName(RandomStringUtils.random(eventsValidationParameters.getMaxNameLength() + 1)); + + final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "EVENT", 128, 129)); + } + + @Test + public void shouldRejectCollectionWithDescriptionTooLong() { + final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder(); + timestampedEventVo.setDescription(RandomStringUtils.random(eventsValidationParameters.getMaxDescriptionLength() + 1)); + + final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo); + + Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder); + + timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection); + final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext); + + Assertions.assertThat(result).isFalse(); + + Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "EVENT", 256, 257)); + } + + + private TimestampedEventVo timestampedEventVoBuilder() { + return TimestampedEventVo.builder() + .name("valid name") + .timestamp(ZonedDateTime.now()) + .description("some description") + .build(); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..52d8d1166d59a56768beefba6a7c98e24d961487 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java @@ -0,0 +1,46 @@ +package fr.gouv.tac.analytics.server.controller; + +import fr.gouv.tac.analytics.server.controller.mapper.AnalyticsMapper; +import fr.gouv.tac.analytics.server.service.AnalyticsService; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.Optional; + +@ExtendWith(SpringExtension.class) +public class AnalyticsControllerTest { + + @Mock + private AnalyticsService analyticsService; + + @Mock + private AnalyticsMapper analyticsMapper; + + @InjectMocks + private AnalyticsController analyticsController; + + @Test + public void shouldCreateAnalytics() { + + final AnalyticsVo analyticsVo = new AnalyticsVo(); + final Optional<Analytics> analytics = Optional.of(new Analytics()); + + Mockito.when(analyticsMapper.map(analyticsVo)).thenReturn(analytics); + + final ResponseEntity<Void> result = analyticsController.addAnalytics(analyticsVo); + + Assertions.assertThat(result).isNotNull(); + Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + + Mockito.verify(analyticsService).createAnalytics(analytics.get()); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..8e0e0ec6f229a3c2cf6a598af9a7503ef51767e1 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java @@ -0,0 +1,63 @@ +package fr.gouv.tac.analytics.server.controller; + +import fr.gouv.tac.analytics.server.controller.vo.ErrorVo; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.time.ZonedDateTime; + +@ExtendWith(SpringExtension.class) +public class CustomExceptionHandlerTest { + + @Mock + private MethodArgumentNotValidException methodArgumentNotValidException; + + @InjectMocks + private CustomExceptionHandler customExceptionHandler; + + @Test + public void shouldManageAuthenticationException() { + + final OAuth2AuthenticationException oAuth2AuthenticationException = new OAuth2AuthenticationException(new OAuth2Error("someCode"), "someMessage"); + + final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(oAuth2AuthenticationException); + Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + Assertions.assertThat(result.getBody().getMessage()).isEqualTo(oAuth2AuthenticationException.getMessage()); + Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + } + + @Test + public void shouldManageMethodArgumentNotValidException() { + + final String message = "error message"; + Mockito.when(methodArgumentNotValidException.getMessage()).thenReturn(message); + + final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(methodArgumentNotValidException); + Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + Assertions.assertThat(result.getBody().getMessage()).isEqualTo(message); + Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + } + + + @Test + public void shouldManageEveryOtherException() { + + final Exception exception = new Exception("someMessage"); + + final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(exception); + Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + Assertions.assertThat(result.getBody().getMessage()).isEqualTo(exception.getMessage()); + Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + } + +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..9ab7eda0a91ca8373d419bf29f4418eaf5c4cd22 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java @@ -0,0 +1,126 @@ +package fr.gouv.tac.analytics.server.controller.mapper; + +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@ExtendWith(SpringExtension.class) +public class AnalyticsMongoMapperTest { + + private static final AnalyticsMapper mapper = new AnalyticsMapperImpl(); + + @BeforeAll + public static void setUp() { + final TimestampedEventMapper timestampedEventMapper = new TimestampedEventMapperImpl(); + ReflectionTestUtils.setField(mapper, "timestampedEventMapper", timestampedEventMapper); + } + + @Test + public void shouldFailWhenAnalyticsIsNull() { + + // Given + final AnalyticsVo analyticsVo = null; + + // When + final Optional<Analytics> result = mapper.map(analyticsVo); + + // Then + Assertions.assertThat(result).isEmpty(); + } + + @Test + public void shouldSucceedWhenEventsIsNull() { + + // Given + final AnalyticsVo analyticsVo = getAnalytics(); + analyticsVo.setEvents(null); + + // When + final Optional<Analytics> result = mapper.map(analyticsVo); + + // Then + Assertions.assertThat(result).isPresent(); + + Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid()); + Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos()); + Assertions.assertThat(result.get().getEvents()).isNull(); + Assertions.assertThat(result.get().getErrors()).hasSameSizeAs(analyticsVo.getErrors()); + + } + + @Test + public void shouldSucceedWhenErrorsIsNull() { + + // Given + final AnalyticsVo analyticsVo = getAnalytics(); + analyticsVo.setErrors(null); + + // When + final Optional<Analytics> result = mapper.map(analyticsVo); + + // Then + Assertions.assertThat(result).isPresent(); + + Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid()); + Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos()); + Assertions.assertThat(result.get().getEvents()).hasSameSizeAs(analyticsVo.getEvents()); + Assertions.assertThat(result.get().getErrors()).isNull(); + + } + + + @Test + public void shouldSucceedWhenAnalyticsIsValid() { + + // Given + final AnalyticsVo analyticsVo = getAnalytics(); + + // When + final Optional<Analytics> result = mapper.map(analyticsVo); + + // Then + Assertions.assertThat(result).isPresent(); + + Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid()); + Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos()); + Assertions.assertThat(result.get().getEvents()).hasSameSizeAs(analyticsVo.getEvents()); + Assertions.assertThat(result.get().getErrors()).hasSameSizeAs(analyticsVo.getErrors()); + + } + + public AnalyticsVo getAnalytics() { + final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value"); + + final List<TimestampedEventVo> analyticsEvents = new ArrayList<>(); + final List<TimestampedEventVo> analyticsErrors = new ArrayList<>(); + + final TimestampedEventVo event = TimestampedEventVo.builder().name("userAcceptedNotificationsInOnboarding").timestamp(ZonedDateTime.now()).description("some description").build(); + final TimestampedEventVo error = TimestampedEventVo.builder().name("ERR432").timestamp(ZonedDateTime.now()).build(); + analyticsEvents.add(event); + analyticsErrors.add(error); + return AnalyticsVo.builder() + .infos(infos) + .events(analyticsEvents) + .errors(analyticsErrors) + .build(); + + } +} diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..faf9d07670dcee119a231521a1b9eb9230c89f16 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java @@ -0,0 +1,42 @@ +package fr.gouv.tac.analytics.server.controller.mapper; + +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +public class TimestampedEventMapperTest { + + private final TimestampedEventMapper timestampedEventMapper = new TimestampedEventMapperImpl(); + + @Test + public void shouldMapWithDescription() { + final TimestampedEventVo timestampedEventVo = TimestampedEventVo.builder() + .name("some fancy name") + .timestamp(ZonedDateTime.now()) + .description("some description") + .build(); + + final TimestampedEvent result = timestampedEventMapper.map(timestampedEventVo); + + Assertions.assertThat(result.getName()).isEqualTo(timestampedEventVo.getName()); + Assertions.assertThat(result.getTimestamp()).isEqualTo(timestampedEventVo.getTimestamp()); + Assertions.assertThat(result.getDescription()).isEqualTo(timestampedEventVo.getDescription()); + } + + @Test + public void shouldMapWithoutDescription() { + final TimestampedEventVo timestampedEventVo = TimestampedEventVo.builder() + .name("some fancy name") + .timestamp(ZonedDateTime.now()) + .build(); + + final TimestampedEvent result = timestampedEventMapper.map(timestampedEventVo); + + Assertions.assertThat(result.getName()).isEqualTo(timestampedEventVo.getName()); + Assertions.assertThat(result.getTimestamp()).isEqualTo(timestampedEventVo.getTimestamp()); + Assertions.assertThat(result.getDescription()).isNull(); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..89a176c2c50fa6c3602cae5eafce1432f21561f7 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java @@ -0,0 +1,217 @@ +package fr.gouv.tac.analytics.server.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.time.ZonedDateTime; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.concurrent.ListenableFuture; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.tac.analytics.server.AnalyticsServerApplication; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.ExpirationTokenPresenceOAuth2TokenValidator; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiCanOnlyBeUsedOnceOAuth2TokenValidator; +import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiPresenceOAuth2TokenValidator; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.controller.vo.ErrorVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +@ActiveProfiles(value = "test") +@SpringBootTest(classes = AnalyticsServerApplication.class) +@AutoConfigureMockMvc +public class AnalyticsCreationOauth2ErrorTest { + + @MockBean + private KafkaTemplate<String, Analytics> kafkaTemplate; + + @Mock + private ListenableFuture<SendResult<String, Analytics>> listenableFutureMock; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${analyticsserver.controller.analytics.path}") + private String analyticsControllerPath; + + @Value("${analyticsserver.robert_jwt_analyticsprivatekey}") + private String jwtPrivateKey; + + private JwtTokenHelper jwtTokenHelper; + + @BeforeEach + public void setUp() throws InvalidKeySpecException, NoSuchAlgorithmException { + jwtTokenHelper = new JwtTokenHelper(jwtPrivateKey); + } + + + @Test + public void itShouldRejectWhenThereIsNoAuthenticationHeader() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isUnauthorized()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + assertThat(errorVo.getMessage()).isEqualTo("Full authentication is required to access this resource"); + assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + public void itShouldRejectTokenWithoutJTI() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + jwtTokenHelper.withIssueTime(ZonedDateTime.now()); + jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(5)); + final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader(); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isUnauthorized()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + assertThat(errorVo.getMessage()).contains(JtiPresenceOAuth2TokenValidator.ERR_MESSAGE); + assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + + } + + @Test + public void itShouldRejectTokenWithAnAlreadyUsedJTI() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final String jti = UUID.randomUUID().toString(); + jwtTokenHelper.withJti(jti); + jwtTokenHelper.withIssueTime(ZonedDateTime.now()); + jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(10)); + final String authorizationHeader1 = jwtTokenHelper.generateAuthorizationHeader(); + + when(kafkaTemplate.sendDefault(any(Analytics.class))).thenReturn(listenableFutureMock); + + mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader1) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isOk()) + .andExpect(content().string(is(emptyString()))); + + verify(kafkaTemplate, times(1)).sendDefault(any(Analytics.class)); + + jwtTokenHelper.withIssueTime(ZonedDateTime.now()); + final String authorizationHeader2 = jwtTokenHelper.generateAuthorizationHeader(); + + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader2) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isUnauthorized()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + assertThat(errorVo.getMessage()).contains(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.ERR_MESSAGE); + assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + + verify(kafkaTemplate, times(1)).sendDefault(any(Analytics.class)); + + } + + @Test + public void itShouldRejectTokenWithoutTokenExpiration() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + jwtTokenHelper.withJti(UUID.randomUUID().toString()); + jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10)); + + final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader(); + + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isUnauthorized()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + assertThat(errorVo.getMessage()).contains(ExpirationTokenPresenceOAuth2TokenValidator.ERR_MESSAGE); + assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + + @Test + public void itShouldRejectExpiredToken() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + jwtTokenHelper.withJti(UUID.randomUUID().toString()); + jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10)); + jwtTokenHelper.withExpirationDate(ZonedDateTime.now().minusMinutes(2)); + final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader(); + + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isUnauthorized()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + assertThat(errorVo.getMessage()).startsWith("An error occurred while attempting to decode the Jwt: Jwt expired at"); + assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + private AnalyticsVo buildAnalyticsVo() { + return AnalyticsVo.builder() + .installationUuid("some installation uuid") + .build(); + } + +} diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java new file mode 100644 index 0000000000000000000000000000000000000000..431bf479d5e5bc94a37f727ca04fc0049c711ea8 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java @@ -0,0 +1,148 @@ +package fr.gouv.tac.analytics.server.it; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.tac.analytics.server.AnalyticsServerApplication; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.KafkaMessageListenerContainer; +import org.springframework.kafka.listener.MessageListener; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.ContainerTestUtils; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles(value = "test") +@SpringBootTest(classes = AnalyticsServerApplication.class) +@AutoConfigureMockMvc +@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9094", "port=9094"}, topics = "topicNameForTest") +public class AnalyticsCreationOauth2NominalTest { + + private static final int QUEUE_READ_TIMEOUT = 2; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @Autowired + private KafkaProperties kafkaProperties; + + @Value("${analyticsserver.controller.analytics.path}") + private String analyticsControllerPath; + + @Value("${analyticsserver.robert_jwt_analyticsprivatekey}") + private String jwtPrivateKey; + + private JwtTokenHelper jwtTokenHelper; + + private KafkaMessageListenerContainer<String, Analytics> container; + + private final List<Analytics> records = new ArrayList<>(); + + @BeforeEach + public void setUp() throws InvalidKeySpecException, NoSuchAlgorithmException { + records.clear(); + jwtTokenHelper = new JwtTokenHelper(jwtPrivateKey); + final Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(kafkaProperties.getConsumer().getGroupId(), "false", embeddedKafkaBroker); + final DefaultKafkaConsumerFactory<String, Analytics> defaultKafkaConsumerFactory = new DefaultKafkaConsumerFactory<>(consumerProps, new StringDeserializer(), new JsonDeserializer<>(Analytics.class, objectMapper)); + final ContainerProperties containerProperties = new ContainerProperties(kafkaProperties.getTemplate().getDefaultTopic()); + container = new KafkaMessageListenerContainer<>(defaultKafkaConsumerFactory, containerProperties); + container.setupMessageListener((MessageListener<String, Analytics>) message -> records.add(message.value())); + container.start(); + ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic()); + } + + @AfterEach + public void tearDown() { + container.stop(); + } + + + @Test + public void itShouldAcceptValidToken() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + jwtTokenHelper.withIssueTime(ZonedDateTime.now()); + jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(5)); + jwtTokenHelper.withJti(UUID.randomUUID().toString()); + final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader(); + + mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isOk()) + .andExpect(content().string(is(emptyString()))); + + await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).hasSize(1)); + } + + + @Test + public void itShouldAcceptNonExpiredToken() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + jwtTokenHelper.withJti(UUID.randomUUID().toString()); + jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10)); + jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(2)); + final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader(); + + + mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isOk()) + .andExpect(content().string(is(emptyString()))); + + await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).hasSize(1)); + + } + + + private AnalyticsVo buildAnalyticsVo() { + return AnalyticsVo.builder() + .installationUuid("some installation uuid") + .build(); + } + +} diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e1e873abb2927eb432d7ad07c19ea1ac18e90aff --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java @@ -0,0 +1,153 @@ +package fr.gouv.tac.analytics.server.it; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.KafkaMessageListenerContainer; +import org.springframework.kafka.listener.MessageListener; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.ContainerTestUtils; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.tac.analytics.server.AnalyticsServerApplication; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent; +import fr.gouv.tac.analytics.server.utils.TestUtils; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@ActiveProfiles(value = "test") +@SpringBootTest(classes = AnalyticsServerApplication.class) +@AutoConfigureMockMvc +@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9094", "port=9094"}, topics = "topicNameForTest") +public class AnalyticsCreationTest { + + private static final int QUEUE_READ_TIMEOUT = 2; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @Autowired + private KafkaProperties kafkaProperties; + + @Value("${analyticsserver.controller.analytics.path}") + private String analyticsControllerPath; + + private KafkaMessageListenerContainer<String, Analytics> container; + + private final List<Analytics> records = new ArrayList<>(); + + @BeforeEach + public void setUp() { + records.clear(); + final Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(kafkaProperties.getConsumer().getGroupId(), "false", embeddedKafkaBroker); + final DefaultKafkaConsumerFactory<String, Analytics> defaultKafkaConsumerFactory = new DefaultKafkaConsumerFactory<>(consumerProps, new StringDeserializer(), new JsonDeserializer<>(Analytics.class, objectMapper)); + final ContainerProperties containerProperties = new ContainerProperties(kafkaProperties.getTemplate().getDefaultTopic()); + container = new KafkaMessageListenerContainer<>(defaultKafkaConsumerFactory, containerProperties); + container.setupMessageListener((MessageListener<String, Analytics>) message -> records.add(message.value())); + container.start(); + ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic()); + } + + @AfterEach + public void tearDown() { + container.stop(); + } + + @Test + @WithMockUser + public void itShouldStoreValidAnalytics() throws Exception { + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + + final List<TimestampedEvent> expectedEvents = analyticsVo.getEvents().stream() + .map(TestUtils::convertTimestampedEvent) + .collect(Collectors.toList()); + + final List<TimestampedEvent> expectedErrors = analyticsVo.getErrors().stream() + .map(TestUtils::convertTimestampedEvent) + .collect(Collectors.toList()); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + // WHEN + mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isOk()) + .andExpect(content().string(is(emptyString()))); + + + await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).isNotEmpty()); + + assertThat(records).hasSize(1); + final Analytics analyticsResult = records.get(0); + + assertThat(analyticsResult.getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + assertThat(analyticsResult.getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid()); + assertThat(analyticsResult.getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos()); + assertThat(analyticsResult.getEvents()).containsExactlyInAnyOrderElementsOf(expectedEvents); + assertThat(analyticsResult.getErrors()).containsExactlyInAnyOrderElementsOf(expectedErrors); + + } + + + private AnalyticsVo buildAnalyticsVo() { + final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value"); + + final ZonedDateTime timestamp = ZonedDateTime.parse("2020-12-17T10:59:17.123Z[UTC]"); + + final TimestampedEventVo event1 = TimestampedEventVo.builder().name("eventName1").timestamp(timestamp).description("event1 description").build(); + final TimestampedEventVo event2 = TimestampedEventVo.builder().name("eventName2").timestamp(timestamp).build(); + + final TimestampedEventVo error1 = TimestampedEventVo.builder().name("errorName1").timestamp(timestamp).build(); + final TimestampedEventVo error2 = TimestampedEventVo.builder().name("errorName2").timestamp(timestamp).description("error2 description").build(); + + return AnalyticsVo.builder() + .installationUuid("some installation uuid") + .infos(infos) + .events(Arrays.asList(event1, event2)) + .errors(Arrays.asList(error1, error2)) + .build(); + } + + +} + diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e9d71bd3d4df85333c9eb334dcebe17604131d0a --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java @@ -0,0 +1,425 @@ +package fr.gouv.tac.analytics.server.it; + +import static fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSizeValidator.*; +import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.DESCRIPTION_TOO_LONG_ERROR_MESSAGE; +import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.NAME_TOO_LONG_ERROR_MESSAGE; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.concurrent.ListenableFuture; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.tac.analytics.server.AnalyticsServerApplication; +import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo; +import fr.gouv.tac.analytics.server.controller.vo.ErrorVo; +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import fr.gouv.tac.analytics.server.model.kafka.Analytics; +import org.apache.commons.lang3.RandomStringUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +@ActiveProfiles(value = "test") +@SpringBootTest(classes = AnalyticsServerApplication.class) +@AutoConfigureMockMvc +public class AnalyticsCreationValidationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${analyticsserver.controller.analytics.path}") + private String analyticsControllerPath; + + @MockBean + private KafkaTemplate<String, Analytics> kafkaTemplate; + + @Mock + private ListenableFuture<SendResult<String, Analytics>> listenableFutureMock; + + /**************** + * ROOT + ******/ + + @Test + @WithMockUser + public void itShouldAcceptValidAnalytics() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + when(kafkaTemplate.sendDefault(any(Analytics.class))).thenReturn(listenableFutureMock); + + mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isOk()) + .andExpect(content().string(is(emptyString()))); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithoutInstallationUuid() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInstallationUuid(null); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("'analyticsVo' on field 'installationUuid': rejected value [null]"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithEmptyInstallationUuid() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInstallationUuid(""); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("'analyticsVo' on field 'installationUuid': rejected value []"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithTooLongInstallationUuid() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInstallationUuid(RandomStringUtils.random(65)); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'installationUuid': rejected value"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + /**************** + * INFO + ******/ + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithTooManyInfo() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInfos(Map.of("info1", "info1Value", "info2", "info2value", "info3", "info3value")); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(TOO_MANY_INFO_ERROR_MESSAGE, 3, 2)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithInfoKeyTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInfos(Map.of("abcdefghijkl", "info1Value")); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(KEY_TOO_LONG_ERROR_MESSAGE, 10, 12)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithInfoValueTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.setInfos(Map.of("info1", "info1ValueTooLong")); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(VALUE_TOO_LONG_ERROR_MESSAGE, 12, 17)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + /**************** + * EVENT + ******/ + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithEmptyEventName() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getEvents().get(0).setName(""); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'events[0].name': rejected value []"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithEventNameTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getEvents().get(0).setName("Even name too long"); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "EVENT", 10, 18)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithoutEventTimeStamp() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getEvents().get(0).setTimestamp(null); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'events[0].timestamp': rejected value [null]"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithEvenDescriptionTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getEvents().get(0).setDescription("Event description too long"); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "EVENT", 20, 26)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + /**************** + * ERROR + ******/ + + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithEmptyErrorName() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getErrors().get(0).setName(""); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'errors[0].name': rejected value []"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithErrorNameTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getErrors().get(0).setName("Error name too long"); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "ERROR", 10, 19)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithoutErrorTimeStamp() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getErrors().get(0).setTimestamp(null); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'errors[0].timestamp': rejected value [null]"); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + @Test + @WithMockUser + public void itShouldRejectAnalyticsWithErrorDescriptionTooLong() throws Exception { + + final AnalyticsVo analyticsVo = buildAnalyticsVo(); + analyticsVo.getErrors().get(0).setDescription("Error description too long"); + + final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo); + + final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath) + .contentType(MediaType.APPLICATION_JSON) + .content(analyticsAsJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + + final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class); + Assertions.assertThat(errorVo.getMessage()).contains(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "ERROR", 20, 26)); + Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now()); + + verify(kafkaTemplate, never()).sendDefault(any(Analytics.class)); + } + + private AnalyticsVo buildAnalyticsVo() { + final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value"); + + final ZonedDateTime timestamp = ZonedDateTime.parse("2020-12-17T10:59:17.123Z"); + + final TimestampedEventVo event1 = TimestampedEventVo.builder().name("eventName1").timestamp(timestamp).description("event1 description").build(); + final TimestampedEventVo event2 = TimestampedEventVo.builder().name("eventName2").timestamp(timestamp).build(); + + final TimestampedEventVo error1 = TimestampedEventVo.builder().name("errorName1").timestamp(timestamp).build(); + final TimestampedEventVo error2 = TimestampedEventVo.builder().name("errorName2").timestamp(timestamp).description("error2 description").build(); + + return AnalyticsVo.builder() + .installationUuid("some installation uuid") + .infos(infos) + .events(Arrays.asList(event1, event2)) + .errors(Arrays.asList(error1, error2)) + .build(); + } + + +} + diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..88b1daebf4f72860592b68e4f8b06e3563d2fbb9 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java @@ -0,0 +1,67 @@ +package fr.gouv.tac.analytics.server.it; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.springframework.util.Base64Utils; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.ZonedDateTime; +import java.util.Date; + +public class JwtTokenHelper { + + private final RSASSASigner rsassaSigner; + private final JWTClaimsSet.Builder builder; + + public JwtTokenHelper(final String rsaPrivateKey) throws NoSuchAlgorithmException, InvalidKeySpecException { + + final byte[] privateKeyAsByteArray = Base64Utils.decodeFromString(rsaPrivateKey); + + final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyAsByteArray); + final KeyFactory kf = KeyFactory.getInstance("RSA"); + final PrivateKey privateKey = kf.generatePrivate(keySpec); + + rsassaSigner = new RSASSASigner(privateKey); + builder = new JWTClaimsSet.Builder(); + } + + public JwtTokenHelper withExpirationDate(final ZonedDateTime expirationDate) { + final Date expiration = Date.from(expirationDate.toInstant()); + builder.expirationTime(expiration); + return this; + } + + public JwtTokenHelper withIssueTime(final ZonedDateTime issueDate) { + final Date issue = Date.from(issueDate.toInstant()); + builder.issueTime(issue); + return this; + } + + public JwtTokenHelper withJti(final String jti) { + builder.jwtID(jti); + return this; + } + + public String generateToken() throws JOSEException { + + final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).build(); + final JWTClaimsSet body = this.builder.build(); + + final SignedJWT signedJWT = new SignedJWT(header, body); + signedJWT.sign(rsassaSigner); + return signedJWT.serialize(); + } + + public String generateAuthorizationHeader() throws JOSEException { + return "Bearer " + generateToken(); + } + +} diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java new file mode 100644 index 0000000000000000000000000000000000000000..764f7027865b71d014c2f42bfe25edec108846fb --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java @@ -0,0 +1,90 @@ +package fr.gouv.tac.analytics.server.repository.mongo; + +import fr.gouv.tac.analytics.server.config.mongodb.MongoConfiguration; +import fr.gouv.tac.analytics.server.config.mongodb.MongoIndexCreationListener; +import fr.gouv.tac.analytics.server.config.validation.ValidationConfiguration; +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.ActiveProfiles; + +import javax.validation.ConstraintViolationException; +import java.time.ZonedDateTime; +import java.util.Optional; + +@ActiveProfiles(value = "test") +@DataMongoTest +@Import(value = {MongoConfiguration.class, MongoIndexCreationListener.class, ValidationConfiguration.class}) +public class TokenIdentifierRepositoryTest { + + @Autowired + private TokenIdentifierRepository tokenIdentifierRepository; + + @BeforeEach + public void setUp() { + tokenIdentifierRepository.deleteAll(); + } + + @Test + public void shouldSaveTokenIdentifierThenFindItById() { + + final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build(); + + final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifier); + Assertions.assertThat(savedTokenIdentifier.getId()).isNotBlank(); + Assertions.assertThat(savedTokenIdentifier.getIdentifier()).isEqualTo(tokenIdentifier.getIdentifier()); + Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate()); + + + final Optional<TokenIdentifier> result = tokenIdentifierRepository.findById(savedTokenIdentifier.getId()); + Assertions.assertThat(result).isPresent(); + Assertions.assertThat(result.get().getId()).isEqualTo(savedTokenIdentifier.getId()); + Assertions.assertThat(result.get().getIdentifier()).isEqualTo(savedTokenIdentifier.getIdentifier()); + Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate()); + } + + @Test + public void shouldSaveTokenIdentifierThenFindItByIdentifier() { + + final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build(); + + final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifier); + Assertions.assertThat(savedTokenIdentifier.getId()).isNotBlank(); + Assertions.assertThat(savedTokenIdentifier.getIdentifier()).isEqualTo(tokenIdentifier.getIdentifier()); + Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate()); + + + final Optional<TokenIdentifier> result = tokenIdentifierRepository.findByIdentifier(tokenIdentifier.getIdentifier()); + Assertions.assertThat(result).isPresent(); + Assertions.assertThat(result.get().getId()).isEqualTo(savedTokenIdentifier.getId()); + Assertions.assertThat(result.get().getIdentifier()).isEqualTo(savedTokenIdentifier.getIdentifier()); + Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate()); + } + + @Test() + public void shouldNotAllowToSaveTheSameIdentifierTwice() { + + final TokenIdentifier tokenIdentifier1 = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build(); + final TokenIdentifier tokenIdentifier2 = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build(); + + tokenIdentifierRepository.save(tokenIdentifier1); + Assertions.assertThatExceptionOfType(DuplicateKeyException.class).isThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier2)); + } + + @Test + public void shouldNotAllowToSaveWithoutIdentifier() { + final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().expirationDate(ZonedDateTime.now()).build(); + Assertions.assertThatThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier)).isInstanceOf(ConstraintViolationException.class); + } + + @Test + public void shouldNotAllowToSaveWithoutExpiration() { + final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentifier").build(); + Assertions.assertThatThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier)).isInstanceOf(ConstraintViolationException.class); + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ab81e710cb9bd86e68b6270fa6294bc3509daafe --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java @@ -0,0 +1,75 @@ +package fr.gouv.tac.analytics.server.service; + + +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier; +import fr.gouv.tac.analytics.server.repository.mongo.TokenIdentifierRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; + +@ExtendWith(SpringExtension.class) +public class TokenIdentifierServiceTest { + + @Captor + private ArgumentCaptor<TokenIdentifier> tokenIdentifierArgumentCaptor; + + @Mock + private TokenIdentifierRepository tokenIdentifierRepository; + + @InjectMocks + private TokenIdentifierService tokenIdentifierService; + + + @Test + public void shouldSayIdentifierExistWhenItIsInDb() { + + final String tokenIdentifier = "someId"; + + Mockito.when(tokenIdentifierRepository.findByIdentifier(tokenIdentifier)).thenReturn(Optional.of(new TokenIdentifier())); + + final boolean result = tokenIdentifierService.tokenIdentifierExist(tokenIdentifier); + Assertions.assertThat(result).isTrue(); + } + + @Test + public void shouldSayIdentifierDoesNotExistWhenItIsNotInDb() { + + final String tokenIdentifier = "someId"; + + Mockito.when(tokenIdentifierRepository.findByIdentifier(tokenIdentifier)).thenReturn(Optional.empty()); + + final boolean result = tokenIdentifierService.tokenIdentifierExist(tokenIdentifier); + Assertions.assertThat(result).isFalse(); + } + + @Test + public void shouldSaveTokenIdentifierInDb() { + + final String tokenIdentifier = "someId"; + final ZonedDateTime expirationDate = ZonedDateTime.now(); + final TokenIdentifier ti = new TokenIdentifier(); + + Mockito.when(tokenIdentifierRepository.save(tokenIdentifierArgumentCaptor.capture())).thenReturn(ti); + + final TokenIdentifier result = tokenIdentifierService.save(tokenIdentifier, expirationDate); + + Assertions.assertThat(result).isEqualTo(ti); + + final TokenIdentifier capturedTokenIdenfier = tokenIdentifierArgumentCaptor.getValue(); + Assertions.assertThat(capturedTokenIdenfier.getId()).isNull(); + Assertions.assertThat(capturedTokenIdenfier.getIdentifier()).isEqualTo(tokenIdentifier); + Assertions.assertThat(capturedTokenIdenfier.getExpirationDate()).isEqualTo(expirationDate); + + + } +} \ No newline at end of file diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..a86647f8c6010d3b1e33764fe35fccbf659cf2d5 --- /dev/null +++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java @@ -0,0 +1,16 @@ +package fr.gouv.tac.analytics.server.utils; + +import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo; +import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent; + +public class TestUtils { + + public static TimestampedEvent convertTimestampedEvent(final TimestampedEventVo timestampedEventVo) { + return TimestampedEvent.builder() + .name(timestampedEventVo.getName()) + .timestamp(timestampedEventVo.getTimestamp()) + .description(timestampedEventVo.getDescription()) + .build(); + } + +} diff --git a/analytics-server/src/test/resources/application-test.properties b/analytics-server/src/test/resources/application-test.properties new file mode 100644 index 0000000000000000000000000000000000000000..d1a35d42d748d86b155b57d123979bb1ba2b70fc --- /dev/null +++ b/analytics-server/src/test/resources/application-test.properties @@ -0,0 +1,18 @@ +spring.main.banner-mode=off +spring.data.mongodb.uri=mongodb://localhost:27018/protectedAppAnalyticsDBTest +analyticsserver.robert_jwt_analyticspublickey=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0OP2qCk0PHb+i8tZ/yz8A4iFbw2YV9jGuTkxwoquC4bahR6+STEBQdJ9gvshSCQDWFneXL9Takn2Tse/5LwXQf4lpsqo0f4He/uXQqkpX+4RqOT+WMl9Br6xOIWd3Z6EX2rHbDVYBStbltMbuTgbiaq9EkPab9BdeYrwR8uG6lXMB2ftC8JmeFIt9TMU2CAjwFwkLPfnW2e0R3SMshz8hUVeZu8YJCmMmQxzerbSTmywB6x6gr443tVNm2yOsFhJN4YWYIVAgYRkwQctnRYbaD97awLOCmGLwYEChUDcgm/VkMfmVyuVU7+5Cln3By6GRZze9JLc8Gxvxr4J7Gg3dwIDAQAB +# related private key +analyticsserver.robert_jwt_analyticsprivatekey=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQ4/aoKTQ8dv6Ly1n/LPwDiIVvDZhX2Ma5OTHCiq4LhtqFHr5JMQFB0n2C+yFIJANYWd5cv1NqSfZOx7/kvBdB/iWmyqjR/gd7+5dCqSlf7hGo5P5YyX0GvrE4hZ3dnoRfasdsNVgFK1uW0xu5OBuJqr0SQ9pv0F15ivBHy4bqVcwHZ+0LwmZ4Ui31MxTYICPAXCQs9+dbZ7RHdIyyHPyFRV5m7xgkKYyZDHN6ttJObLAHrHqCvjje1U2bbI6wWEk3hhZghUCBhGTBBy2dFhtoP3trAs4KYYvBgQKFQNyCb9WQx+ZXK5VTv7kKWfcHLoZFnN70ktzwbG/GvgnsaDd3AgMBAAECggEAN/eMA5wekcC0DJJsR3EvCGdQkOOMmKTNAZ1wVpY/cXktHROSmhuWIaOa2zgbv69echKKAEGGwOiWJJ9iK4+1j4nfXqPXvYOZT7+l1EdsfXZUpvLLrtA1PlRjOSiblmA9SS9bxQM51RC71loziFmfDzB+veEOKn0iPklafXHrcOcVx3ukus9PZP6gWdC3TwMFsoq9Mrfmvd7BlbC7Hlwerb7YBeoA5wNudvv64fKsOAieP39AytRJappq4w62plGARKqV3BP9BYc0wkEa7euSE3sF7iVjDXw3ugrPi8dTlZAdiRZUQguSc1uuLNUeIbLAbCBfvjIyLC+nfogxQdui0QKBgQD049VOEsyCpkwQb3slGBYaOTxOUMx6zGJwTxVsH3gfEnjdOyrvAjZyJDb6huOsn5QrCJIUOqByAIqp+Nx3P4eXYDnL4G4GmdSJLlm8tKIaV8EYCxsRRhYqTbgjzHnhWL8MkcgltwQkAFBwEnra43iCj1Doz39+hmNK313e4RomHwKBgQDaXgewy+jWnyB4SZAW07O3ezEo1uPy9IAp0qKtLOfE+Le/MgF/u+68tGTGh9Oy45E8lC9Yj69+mYmWAsr3r63xgcNAW6T3QUYukR6W1+I1xK3lgg9nsp7JU6xFbNl6IvlNPjelGB2yRzrVyIre3WkmabMN8dgvgbNdD56SNRdTqQKBgEdAMsOwfIhW0jLF/NJiG6wtkvpGT/g6lzmOCPGYhl6kBT55BJjdz/GNz+E8dem31Ghg6f+wvxXsSmwB2ENp3I2Slb5X4itRfqEbN0jMVY3MkoXoVUvFVJWiXz0rNRr3sz54+/7dLPs8jCrPdadSH7H8+NGD7dhmSWVzb+B1JiKJAoGAeWCCzdbJ2WIh3jqliMqrvnUPYi/wDH+zLYwTOEcZnPbSy8ez5ZhEn0d2LJQbK/gqJo4HsyZK2gfl3ig3QW25NeB28zL9gyIZLJEle5sL9e8Y6dVdUrqKYEXOH9jdGXKPOEw5Cd9ZwlqtbV9HMZHIfL2L9VhUXjOtyzB/Z61zBeECgYEAu4Q89O4ZBNwo0CdK1xMyqs67piY4DEpHx5PBnBqobFGHCOxhgTCCeJvK07KjzfR0OM6P7GpwdPoH49WMECblCn8xfu90Q3OLw7XyUgBQngQ1a7TP1fgX+SxEIpfuKHzXSvuzgWrFSij31x4iVlwqItSGgS+7AZwiwKDk+1L22Zk +analyticsserver.validation.analytics.information.maxInfoAllowed=2 +analyticsserver.validation.analytics.information.maxInfoKeyLength=10 +analyticsserver.validation.analytics.information.maxInfoValueLength=12 +analyticsserver.validation.analytics.event.maxElementAllowed=2 +analyticsserver.validation.analytics.event.maxNameLength=10 +analyticsserver.validation.analytics.event.maxDescriptionLength=20 +analyticsserver.validation.analytics.error.maxElementAllowed=2 +analyticsserver.validation.analytics.error.maxNameLength=10 +analyticsserver.validation.analytics.error.maxDescriptionLength=20 +spring.kafka.bootstrap-servers=localhost:9094 +spring.kafka.consumer.group-id=identifierGroupTest +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.template.default-topic=topicNameForTest diff --git a/analytics-server/src/test/resources/bootstrap.yml b/analytics-server/src/test/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..bbf174bb2753135352f4f2a0fe97d68660b30c57 --- /dev/null +++ b/analytics-server/src/test/resources/bootstrap.yml @@ -0,0 +1,10 @@ +spring: + application: + name: analytics-server + cloud: + consul: + enabled: false + + vault: + enabled: false + diff --git a/analytics-server/src/test/resources/logback-test.xml b/analytics-server/src/test/resources/logback-test.xml new file mode 100644 index 0000000000000000000000000000000000000000..381c9aa65088e83b2f713fc5e7eae60e2f1bc26e --- /dev/null +++ b/analytics-server/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration scan="true" scanPeriod="30 seconds"> + + <property name="PATTERN" + value="%d{yyyy-MM-dd'T'HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/> + + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>${PATTERN}</pattern> + <charset>utf8</charset> + </encoder> + </appender> + + + <!-- Default logger --> + <root level="WARN"> + <appender-ref ref="CONSOLE"/> + </root> + +</configuration> \ No newline at end of file diff --git a/clea/.gitignore b/clea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..549e00a2a96fa9d7c5dbc9859664a78d980158c2 --- /dev/null +++ b/clea/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/clea/.mvn/wrapper/MavenWrapperDownloader.java b/clea/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000000000000000000000000000000000000..a45eb6ba269cd38f8965cef786729790945d9537 --- /dev/null +++ b/clea/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,118 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if (mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if (mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if (!outputFile.getParentFile().exists()) { + if (!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/clea/.mvn/wrapper/maven-wrapper.jar b/clea/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054 Binary files /dev/null and b/clea/.mvn/wrapper/maven-wrapper.jar differ diff --git a/clea/.mvn/wrapper/maven-wrapper.properties b/clea/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000000000000000000000000000000000..642d572ce90e5085986bdd9c9204b9404f028084 --- /dev/null +++ b/clea/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/clea/README.md b/clea/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1b80b15ada7761c6b6ba7aef70e64a19f6c27e42 --- /dev/null +++ b/clea/README.md @@ -0,0 +1,31 @@ +## CLEA coding guidelines + +* Contributions are done through Merge Requests on the develop branch (gitflow). MR must be reviewed by another dev than + the code author. a MR can be merged when approved by at least one reviewer. +* Development happens on feature branches. +* Commit often, small commits, and publish them on the central repositories to share the lastest developments with the + dev team. +* Tests are run as part of the CI pipeline and should stay "green" +* Use Meaningful names (very important): classes, methods, variables +* Unit test the code +* Small methods +* Methods do one thing, only one level of abstraction +* DRY: Don't Repeat Yourself +* SOLID + * S: Single-responsibility principle: a class should only have a single responsibility, that is, only changes to one + part of the software's specification should be able to affect the specification of the class. + * O: Open–closed principle: "software entities ... should be open for extension, but closed for modification." + * L: Liskov substitution principle: "objects in a program should be replaceable with instances of their subtypes + without altering the correctness of that program." + * I: Interface segregation principle: "many client-specific interfaces are better than one general-purpose + interface." + * D: Dependency inversion principle: "depend upon abstractions, [not] concretions." +* Comments + * Do not paraphrase the code. Make the code self-documentating. + * Comment only what is not obvious by reading the code: informative comment, explanation of intent, warning. Use + javadoc for public APIs. +* Formatting + * Naming conventions: Camel case. + * Unit of indentation is 4 spaces. No use of the tab character. + * Curly braces at the end of the line that starts the class, method, loop, etc., and the closing brace is on a line + by itself, lined up vertically with the start of the first line. diff --git a/clea/clea-client/clusters/1/pJivA.json b/clea/clea-client/clusters/1/pJivA.json new file mode 100644 index 0000000000000000000000000000000000000000..754c1accf23095934e1aac776ac66d8ea4d3c09e --- /dev/null +++ b/clea/clea-client/clusters/1/pJivA.json @@ -0,0 +1,8 @@ +[ + { + "ltid":"pJivAjyHMeSsm/SZ/1fcjQ==", + "exp" : [ + {"s": "3824820000", "d" : 3, "r" : 2.0} + ] + } +] diff --git a/clea/clea-client/clusters/index.json b/clea/clea-client/clusters/index.json new file mode 100644 index 0000000000000000000000000000000000000000..d88e13865a6ad7d92db3ff3cf3841825cde5c212 --- /dev/null +++ b/clea/clea-client/clusters/index.json @@ -0,0 +1,6 @@ +{ + "i" : 1, + "c" : [ + "pJivA" + ] +} diff --git a/clea/clea-client/pom.xml b/clea/clea-client/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..857391a51ec965a1e15829c50a844ca1b98a1f4c --- /dev/null +++ b/clea/clea-client/pom.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>fr.gouv.clea</groupId> + <artifactId>clea-server</artifactId> + <version>0.1-SNAPSHOT</version> + </parent> + + <artifactId>clea-client</artifactId> + <name>clea-client</name> + <description>Client simulating status request for Clea</description> + + <properties> + <java.version>11</java.version> + <maven.compiler.source>11</maven.compiler.source> + <maven.compiler.target>11</maven.compiler.target> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + </properties> + + <dependencies> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-jdk14</artifactId> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + </dependencies> +</project> diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..954494f2007347acd60486c70a4ab470a96cd752 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java @@ -0,0 +1,72 @@ +package fr.gouv.clea.client.configuration; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class CleaClientConfiguration { + private static CleaClientConfiguration instance; + private static String configFile = "application.properties"; + private Properties config; + + public CleaClientConfiguration() { + this.config = new Properties(); + } + + public static CleaClientConfiguration getInstance() throws IOException { + if (instance == null) { + instance = new CleaClientConfiguration(); + instance.initialize(); + } + + return instance; + } + + public void initialize() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(configFile); + if (inputStream != null) { + this.config.load(inputStream); + } else { + throw new FileNotFoundException("config file '" + configFile + "' doesn't exists."); + } + inputStream.close(); + } + + public String getBackendUrl() { + return this.config.getProperty("backend_url", ""); + } + + public String getReportPath() { + return this.config.getProperty("report_path", ""); + } + + public String getStatusPath() { + return this.config.getProperty("status_path", ""); + } + + public String getIndexFilename(){ + return this.config.getProperty("index_filename",""); + } + + public String getQrPrefix() { + return this.config.getProperty("qrprefix", ""); + } + + + public int getDurationUnitInSeconds(){ + try{ + return Integer.parseInt(this.config.getProperty("duration_unit", "")); + }catch(NumberFormatException e){ + return 0; + } + } + + public int getDupScanThreshold() { + try { + return Integer.parseInt(this.config.getProperty("dup_scan_threshold", "")); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java new file mode 100644 index 0000000000000000000000000000000000000000..7b7fa1dfc0e27e2c215f7b2fce5e7a00d469737a --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java @@ -0,0 +1,20 @@ +package fr.gouv.clea.client.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Cluster { + @JsonProperty("ltid") + private String locationTemporaryPublicID; //LTid + + @JsonProperty("exp") + private List<ClusterExposition> expositions; +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java new file mode 100644 index 0000000000000000000000000000000000000000..5482e9d785be35dd487417ace3a79bb0b6876261 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java @@ -0,0 +1,29 @@ +package fr.gouv.clea.client.model; + +import java.io.IOException; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import fr.gouv.clea.client.configuration.CleaClientConfiguration; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ClusterExposition { + @JsonProperty("s") + private long startTimeAsNtpTimestamp; + + @JsonProperty("d") + private int nbDurationUnit; + + @JsonProperty("r") + private float risk; + + public boolean isInExposition(long ntpTimestamp) throws IOException { + return ((ntpTimestamp >= startTimeAsNtpTimestamp) + && (ntpTimestamp <= startTimeAsNtpTimestamp + nbDurationUnit * CleaClientConfiguration.getInstance().getDurationUnitInSeconds())); + } +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java new file mode 100644 index 0000000000000000000000000000000000000000..e47fe9fc09868380e8bd0b0bed80536a680744f4 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java @@ -0,0 +1,20 @@ +package fr.gouv.clea.client.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClusterIndex { + @JsonProperty("i") + private int iteration; + + @JsonProperty("c") + private List<String> prefixes; +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java new file mode 100644 index 0000000000000000000000000000000000000000..a91505392a3b9bb551d9a63d59fa0e988dc9d2a4 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java @@ -0,0 +1,24 @@ +package fr.gouv.clea.client.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class Report { + + List<Visit> visits; + + public Report(){ + this.visits = new ArrayList<>(); + } + + public void addVisit(ScannedQrCode scannedQr){ + this.visits.add(new Visit(scannedQr.getQrCode(), scannedQr.getScanTime())); + } + + public void addAllVisits(List<ScannedQrCode> localList) { + localList.forEach(this::addVisit); + } +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..971dafa157775b76052f742fcc22a6d1b3286af0 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java @@ -0,0 +1,13 @@ +package fr.gouv.clea.client.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReportResponse { + private boolean success; + private String message; +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java new file mode 100644 index 0000000000000000000000000000000000000000..3b6d8f6968801bf9060de4b05a77a65cdd302dd0 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java @@ -0,0 +1,45 @@ +package fr.gouv.clea.client.model; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ScannedQrCode { + + private String qrCode; + + @JsonProperty("qrCodeScanTime") + private long scanTime; + + @JsonIgnore + private Optional<String> locationTemporaryId; //LTId + + public ScannedQrCode(String qrCode, long scanTime){ + this.qrCode = qrCode; + this.scanTime = scanTime; + this.locationTemporaryId = Optional.empty(); + } + + public String getLocationTemporaryId() { + return locationTemporaryId.orElse(this.decodeLocationTemporaryId()); + } + + private String decodeLocationTemporaryId() { + byte[] tlIdByte = Arrays.copyOfRange(Base64.getDecoder().decode(qrCode), 1, 17) ; + locationTemporaryId = Optional.of(Base64.getEncoder().encodeToString(tlIdByte)); + return locationTemporaryId.get(); + } + + public boolean startWithPrefix(String prefix){ + return this.getLocationTemporaryId().startsWith(prefix); + } + +} \ No newline at end of file diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java new file mode 100644 index 0000000000000000000000000000000000000000..587eb35674af89bf4cab7e82e0c6f2b4756b1ead --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java @@ -0,0 +1,18 @@ +package fr.gouv.clea.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Visit { + + private String qrCode; + + @JsonProperty("qrCodeScanTime") + private long scanTime; +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java new file mode 100644 index 0000000000000000000000000000000000000000..90bf9ed9a82b532bf7c3bccdc5226298e4024b1b --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java @@ -0,0 +1,66 @@ +package fr.gouv.clea.client.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import fr.gouv.clea.client.configuration.CleaClientConfiguration; +import fr.gouv.clea.client.model.ScannedQrCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CleaClient { + private List<ScannedQrCode> localList; + + public CleaClient() { + this.localList = new ArrayList<>(); + } + + public List<ScannedQrCode> getLocalList() { + return this.localList; + } + + /** + * verify the validity of the qr code and add it to the local list + * @param qrCode : the plain-text representing the scanned qr code. format : PREFIX + BASE64(LSP) + * @param timestamp : timestamp (in seconds) of the scan + * @return true if qr code has been added to the local list, false otherwise + */ + public boolean scanQrCode(String qrCode, long timestamp) { + CleaClientConfiguration configuration; + try { + configuration = CleaClientConfiguration.getInstance(); + } catch (IOException e) { + log.error("Can't access config file, scanning can't proceed."); + return false; + } + + //check if prefix is present then removes it + if (!qrCode.startsWith(configuration.getQrPrefix())) { + return false; + } + qrCode = qrCode.substring(configuration.getQrPrefix().length()); + ScannedQrCode scannedQr = new ScannedQrCode(qrCode, timestamp); + + //Check for duplicate in local list + for (ScannedQrCode prevQR : this.localList) { + if(scannedQr.getLocationTemporaryId().equals(prevQR.getLocationTemporaryId()) && Math.abs(prevQR.getScanTime() - timestamp) <= configuration.getDupScanThreshold()){ + return false; + } + } + localList.add(scannedQr); + return true; + } + + /** + * Add a batch of Qr code to the local list, verifying their validity beforehand + * @param qrcodes : list of qr code and their timestamp to add to the local list + */ + public void batchScanQrCode(Map<Long, String> qrcodes) { + for (Entry<Long, String> qrcode : qrcodes.entrySet()) { + scanQrCode(qrcode.getValue(),qrcode.getKey()); + } + } +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java new file mode 100644 index 0000000000000000000000000000000000000000..f47e3da89f5d001f1116afecc91f05effd169380 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java @@ -0,0 +1,46 @@ +package fr.gouv.clea.client.service; + +import java.io.IOException; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import fr.gouv.clea.client.model.Report; +import fr.gouv.clea.client.model.ReportResponse; +import fr.gouv.clea.client.model.ScannedQrCode; +import fr.gouv.clea.client.utils.HttpClientWrapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ReportService { + private HttpClientWrapper httpClient; + private String reportEndPoint; + + public ReportService(String reportEndPoint) throws IOException { + this(reportEndPoint, new HttpClientWrapper()); + } + + public ReportService(String reportEndPoint, HttpClientWrapper httpClient) throws IOException { + this.httpClient = httpClient; + this.reportEndPoint = reportEndPoint; + } + + /** + * report a list of qr code to the backend server + */ + public ReportResponse report(List<ScannedQrCode> localList) throws IOException, InterruptedException { + String jsonRequest; + Report reportRequest = new Report(); + reportRequest.addAllVisits(localList); + + log.info("Reporting {} visits to {}", localList.size(), this.reportEndPoint); + jsonRequest = new ObjectMapper().writeValueAsString(reportRequest); + log.info(jsonRequest); + return this.post(jsonRequest); + } + + protected ReportResponse post(String jsonRequest) throws IOException, InterruptedException { + return this.httpClient.post(this.reportEndPoint, jsonRequest, ReportResponse.class); + } + +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java new file mode 100644 index 0000000000000000000000000000000000000000..a7e886a3cfeee8c666d582c2cb5216bcb704015a --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java @@ -0,0 +1,123 @@ +package fr.gouv.clea.client.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import fr.gouv.clea.client.configuration.CleaClientConfiguration; +import fr.gouv.clea.client.model.Cluster; +import fr.gouv.clea.client.model.ClusterExposition; +import fr.gouv.clea.client.model.ClusterIndex; +import fr.gouv.clea.client.model.ScannedQrCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class StatusService { + + private String indexPath; + private String indexFilename; + private ObjectMapper objectMapper; + + public StatusService() throws IOException { + CleaClientConfiguration config = CleaClientConfiguration.getInstance(); + this.indexPath = config.getStatusPath(); + this.indexFilename = config.getIndexFilename(); + objectMapper = new ObjectMapper(); + } + + /** + * Retrieve the cluster list and compare it to the local list to compute the + * risk score, returning the risk Level. + * + * @param localList : LocalList of the simulated device, containing all the + * scanned QR code + * @return true if this localList is at risk, false otherwise + * @throws IOException + */ + public float status(List<ScannedQrCode> localList) throws IOException { + ClusterIndex clusterIndex = this.getClusterIndex().orElseThrow(); + Set<String> matchingPrefixes = this.getClusterFilesMatchingPrefix(localList, clusterIndex); + int iteration = clusterIndex.getIteration(); + List<Float> scores = new ArrayList<>(); + // gather all potential clusters + for (String prefix : matchingPrefixes) { + Path currentPath = Path.of(this.indexPath, Integer.toString(iteration), prefix + ".json"); + List<Cluster> clusters = this.readClusterFile(currentPath); + for (Cluster cluster : clusters) { + for (ScannedQrCode qr : localList) { + this.getQrRiskLevel(qr, cluster).ifPresent(risk -> scores.add(risk)); + } + } + } + + return scores.stream().max(Comparator.naturalOrder()).orElse(0f); + } + + protected Optional<Float> getQrRiskLevel(ScannedQrCode qr, Cluster cluster) throws IOException { + Optional<Float> result = Optional.empty(); + if (qr.getLocationTemporaryId().equals(cluster.getLocationTemporaryPublicID())) { + for (ClusterExposition exposition : cluster.getExpositions()) { + if (exposition.isInExposition(qr.getScanTime())) { + float newRisk = Math.max(result.orElse(0f), exposition.getRisk()); + result = Optional.of(newRisk); + } + } + } + return result; + } + + protected Set<String> getClusterFilesMatchingPrefix(List<ScannedQrCode> localList, ClusterIndex clusterIndex) { + Set<String> matchingPrefixes = new HashSet<>(); + for (String prefix : clusterIndex.getPrefixes()) { + for (ScannedQrCode qr : localList) { + if (qr.startWithPrefix(prefix)) { + matchingPrefixes.add(prefix); + break; + } + } + } + return matchingPrefixes; + } + + protected Optional<ClusterIndex> getClusterIndex() { + String indexString; + try { + indexString = Files.readString(Path.of(this.indexPath, this.indexFilename), StandardCharsets.UTF_8); + } catch (IOException e) { + log.error("Error retrieving index file.", e); + return Optional.empty(); + } + ClusterIndex clusterIndex; + try { + clusterIndex = objectMapper.readValue(indexString, ClusterIndex.class); + } catch (JsonProcessingException e) { + log.error("Error parsing JSON to create ClusterIndex Object.", e); + return Optional.empty(); + } + return Optional.of(clusterIndex); + } + + protected List<Cluster> readClusterFile(Path path) { + try { + String clustersString = Files.readString(path, StandardCharsets.UTF_8); + return objectMapper.readValue(clustersString, new TypeReference<List<Cluster>>() {}); + } catch (IOException e) { + log.error("Could not open file :" + path.toString() + " computed score might be false.", e); + return Collections.emptyList(); + } + + } + +} diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java new file mode 100644 index 0000000000000000000000000000000000000000..8a8fe6049cf0ca4d953eff3a9e35c49d1ac4fa95 --- /dev/null +++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java @@ -0,0 +1,35 @@ +package fr.gouv.clea.client.utils; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class HttpClientWrapper { + private HttpClient client; + + public HttpClientWrapper(){ + this.client = HttpClient.newHttpClient(); + } + + public <T> T get(String uri, Class<T> returnType) throws IOException, InterruptedException{ + HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).GET().build(); + HttpResponse<String> response = this.client.send(request, HttpResponse.BodyHandlers.ofString()); + return (T) new ObjectMapper().readValue(response.body(), returnType); + } + + public <T> T post(String uri, String body, Class<T> returnType) throws IOException, InterruptedException{ + HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse<String> response = this.client.send(request, HttpResponse.BodyHandlers.ofString()); + return (T) new ObjectMapper().readValue(response.body(), returnType); + } + + public int postStatusCode(String uri, String body) throws IOException, InterruptedException{ + HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse<Void> response = this.client.send(request, HttpResponse.BodyHandlers.discarding()); + return response.statusCode(); + } +} diff --git a/clea/clea-client/src/main/resources/application.properties b/clea/clea-client/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..716aa48829636c9d4362d0111c86a21cf88a126a --- /dev/null +++ b/clea/clea-client/src/main/resources/application.properties @@ -0,0 +1,8 @@ +#Client Component Properties +backend_url=http://localhost:8080/api/v1 +status_path=./clusters/ +index_filename=index.json +report_path=/report +dup_scan_threshold=10800 +duration_unit=3600 +qrprefix=https://tac.gouv.fr/ \ No newline at end of file diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java new file mode 100644 index 0000000000000000000000000000000000000000..86f2b120da7ad73d65ade127b198e9e3c36ed54d --- /dev/null +++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java @@ -0,0 +1,69 @@ +package fr.gouv.clea.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import fr.gouv.clea.client.model.ScannedQrCode; +import fr.gouv.clea.client.service.CleaClient; + +/** + * Basic Unit Test for Client + */ +public class LocalListTest +{ + private final String prefix = "https://tac.gouv.fr/"; + private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY="; + private final String qrCode2 = "AKSYrwI8hzHkrJv0mf9X3I0KXTn4TUzSX7aM4pfWCpsb7CPSLULz1FBWh9+7RP0hU0VxTb15uDJXY61itwy9yJzDbkz8FGXUZra0LBwCg3D8EbSZsBk/g/havNababZULUxXs8IEaMaims2BnOY="; + private final String tlId = "pJivAjyHMeSsm/SZ/1fcjQ=="; + private final Instant now = Instant.now(); + + /** + * Test : simulating Scanning a QR code and saving it to the LocalList + */ + @Test + public void shouldAddScannedQrCode() + { + CleaClient cleaClient = new CleaClient(); + + cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond()); + + List<ScannedQrCode> localList = cleaClient.getLocalList(); + assertThat(localList.size()).isEqualTo(1); + ScannedQrCode scanned = localList.get(0); + assertThat(scanned.getQrCode()).isEqualTo(qrCode); + assertThat(scanned.getScanTime()).isEqualTo(now.getEpochSecond()); + assertThat(scanned.getLocationTemporaryId()).isEqualTo(tlId); + } + + /** + * Test : scanning a qr code with the same Tlid before DUPTHRESHOLD second should not be added to the list + */ + @Test + public void shouldNotAddTwice(){ + CleaClient cleaClient = new CleaClient(); + + cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond()); + cleaClient.scanQrCode(prefix.concat(qrCode2),now.plusSeconds(3600).getEpochSecond()); + + List<ScannedQrCode> localList = cleaClient.getLocalList(); + assertThat(localList.size()).isEqualTo(1); + } + + /** + * Test : scanning a qr code with the same Tlid after DUPTHRESHOLD second should be added to the list + */ + @Test + public void shouldAddTwice(){ + CleaClient cleaClient = new CleaClient(); + + cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond()); + cleaClient.scanQrCode(prefix.concat(qrCode2),now.plusSeconds(4*3600).getEpochSecond()); + + List<ScannedQrCode> localList = cleaClient.getLocalList(); + assertThat(localList.size()).isEqualTo(2); + } +} diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..9b736a9d27d8dfbd50f2e6db1448403ea3f7ca63 --- /dev/null +++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java @@ -0,0 +1,48 @@ +package fr.gouv.clea.client.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import fr.gouv.clea.client.configuration.CleaClientConfiguration; +import fr.gouv.clea.client.model.ReportResponse; +import fr.gouv.clea.client.model.ScannedQrCode; +import fr.gouv.clea.client.utils.HttpClientWrapper; + +public class ReportServiceTest { + private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY="; + private final String qrCode2 = "AAXpe5EhZz3nv3hF8TtpMguUdtQ3lwlpUG7rG0lu3RtbKJlIIiTpHBllKCkLyrpbRcGTXBtfc3GlO3WsRSxyeBT3ngqYI8sgh7lIMDADHzLI5/V3mf/OiYjOLwurVedWzrrCUG2wkLr8Pc2WuAM="; + private final Instant now = Instant.ofEpochSecond(3824820600L); + private ReportService backend; + private List<ScannedQrCode> localList; + + @BeforeEach + public void setup() throws IOException, InterruptedException { + CleaClientConfiguration config = CleaClientConfiguration.getInstance(); + HttpClientWrapper httpClient = mock(HttpClientWrapper.class); + backend = new ReportService(config.getBackendUrl() + config.getReportPath(), httpClient); + when(httpClient.post(anyString(),anyString(),any())).thenReturn(new ReportResponse(true, "")); + localList = new ArrayList<>(); + } + + @Test + public void testCanReportInfectedVisits() throws Exception { + localList.add(new ScannedQrCode(qrCode, now.getEpochSecond() - 200)); + localList.add(new ScannedQrCode(qrCode2, now.getEpochSecond())); + + ReportResponse response = backend.report(localList); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getMessage()).isEmpty(); + } +} diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..30f13d60152939b8dcf352d043fa7c6ea2d08ab2 --- /dev/null +++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java @@ -0,0 +1,44 @@ +package fr.gouv.clea.client.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import fr.gouv.clea.client.model.ScannedQrCode; + +public class StatusServiceTest { + private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY="; + private final String qrCode2 = "AAXpe5EhZz3nv3hF8TtpMguUdtQ3lwlpUG7rG0lu3RtbKJlIIiTpHBllKCkLyrpbRcGTXBtfc3GlO3WsRSxyeBT3ngqYI8sgh7lIMDADHzLI5/V3mf/OiYjOLwurVedWzrrCUG2wkLr8Pc2WuAM="; + private final Instant now = Instant.ofEpochSecond(3824820600L); + private ScannedQrCode qr; + private ScannedQrCode qr2; + + @BeforeEach + public void setup(){ + qr = new ScannedQrCode(qrCode, now.getEpochSecond()); + qr2 = new ScannedQrCode(qrCode2, now.getEpochSecond()); + } + + @Test + public void statusShouldReturnAtRisk() throws Exception { + List<ScannedQrCode> localList = new ArrayList<>(); + localList.add(qr); + StatusService statusService = new StatusService(); + assertThat(statusService.status(localList)).isGreaterThan(0f); + } + + @Test + public void statusShouldReturnNoRisk() throws Exception { + List<ScannedQrCode> localList = new ArrayList<>(); + localList.add(qr2); + StatusService statusService = new StatusService(); + assertThat(statusService.status(localList)).isEqualTo(0f); + } +} + + diff --git a/clea/clea-qr-simulator/pom.xml b/clea/clea-qr-simulator/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..6b6e103fb902be5229707ad44c97b341d0f48d6b --- /dev/null +++ b/clea/clea-qr-simulator/pom.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>fr.gouv.clea</groupId> + <artifactId>clea-server</artifactId> + <version>0.1-SNAPSHOT</version> + </parent> + + <artifactId>clea-qr-simulator</artifactId> + <name>qr-simulator</name> + <description>Simulator for QR code generation for Clea (static and dynamic generation)</description> + + <dependencies> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>fr.inria.clea</groupId> + <artifactId>clea-crypto</artifactId> + </dependency> + </dependencies> +</project> diff --git a/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java new file mode 100644 index 0000000000000000000000000000000000000000..2a44c8261ceb1a7495b891841dc4944825f4a25e --- /dev/null +++ b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java @@ -0,0 +1,204 @@ +package fr.gouv.tacw.qr; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import fr.gouv.tacw.qr.model.QRCode; +import fr.inria.clea.lsp.CleaEncryptionException; +import fr.inria.clea.lsp.Location; +import fr.inria.clea.lsp.LocationContact; +import fr.inria.clea.lsp.LocationSpecificPart; +import fr.inria.clea.lsp.Location.LocationBuilder; +import fr.inria.clea.lsp.utils.TimeUtils; +import lombok.Builder; + +public class LocationQrCodeGenerator { + private final long qrCodeRenewalInterval; + private final long periodDuration; + private final Location location; + private long periodStartTime; + private List<QRCode> generatedQRs; + + @Builder + private LocationQrCodeGenerator(Instant periodStartTime, int periodDuration, + int qrCodeRenewalIntervalExponentCompact, boolean staff, int countryCode, int venueType, int venueCategory1, + int venueCategory2, String manualContactTracingAuthorityPublicKey, String serverAuthorityPublicKey, + String permanentLocationSecretKey, String locationPhone, String locationPin) + throws CleaEncryptionException { + // TODO: check data validity (eg. < periodDuration <= 255) + if (qrCodeRenewalIntervalExponentCompact == 0x1F) + this.qrCodeRenewalInterval = 0; + else + this.qrCodeRenewalInterval = 1 << qrCodeRenewalIntervalExponentCompact; + this.periodDuration = periodDuration * TimeUtils.NB_SECONDS_PER_HOUR; + this.periodStartTime = TimeUtils.hourRoundedTimestamp(TimeUtils.ntpTimestampFromInstant(periodStartTime)); + + LocationSpecificPart locationSpecificPart = this.createLocationSpecificPart(staff, periodDuration, + qrCodeRenewalIntervalExponentCompact, countryCode, venueType, venueCategory1, venueCategory2); + LocationContact contact = this.createLocationContact(this.periodStartTime, locationPhone, locationPin); + this.location = this.createLocation(locationSpecificPart, contact, manualContactTracingAuthorityPublicKey, + serverAuthorityPublicKey, permanentLocationSecretKey); + this.generatedQRs = new ArrayList<>(); + this.generateQRCode(this.periodStartTime); + + } + + private Location createLocation(LocationSpecificPart lsp, LocationContact contact, + String manualContactTracingAuthorityPublicKey, String serverAuthorityPublicKey, + String permanentLocationSecretKey) { + LocationBuilder locationBuilder = Location.builder() + .locationSpecificPart(lsp) + .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey) + .serverAuthorityPublicKey(serverAuthorityPublicKey) + .permanentLocationSecretKey(permanentLocationSecretKey); + if (Objects.nonNull(contact)) { + locationBuilder.contact(contact); + } + return locationBuilder.build(); + } + + private LocationSpecificPart createLocationSpecificPart(boolean staff, int periodDuration, + int qrCodeRenewalIntervalExponentCompact, int countryCode, int venueType, int venueCategory1, + int venueCategory2) { + LocationSpecificPart lsp = LocationSpecificPart.builder() + .staff(staff) + .periodDuration(periodDuration) + .countryCode(countryCode) + .qrCodeRenewalIntervalExponentCompact(qrCodeRenewalIntervalExponentCompact) + .venueType(venueType) + .venueCategory1(venueCategory1) + .venueCategory2(venueCategory2) + .build(); + return lsp; + } + + private LocationContact createLocationContact(long periodStartTime, String locationPhone, String locationPin) { + LocationContact contact = null; + if (locationPhone != null && locationPin != null) { + contact = LocationContact.builder() + .locationPhone(locationPhone) + .locationPin(locationPin) + .periodStartTime((int) periodStartTime) + .build(); + } + return contact; + } + + /** + * find a valid QRCode for the provided timestamp in the already generated one. + * + * @param timestamp timestamp at which the QRCode should be valid. + * @return a valid QRCode or null if none were found. + */ + public QRCode findExistingQrCode(long timestamp) { + for (QRCode qr : generatedQRs) { + if (qr.isValidScanTime(timestamp)) { + return qr; + } + } + return null; + } + + /** + * find a valid QRCode for the provided timestamp in the already generated one. + * + * @param instant instant at which the QRCode should be valid + * @return a valid QRCode or null if none were found. + */ + public QRCode findExistingQrCode(Instant instant) { + return this.findExistingQrCode(TimeUtils.ntpTimestampFromInstant(instant)); + } + + private QRCode generateQRCode(long timestamp) throws CleaEncryptionException { + QRCode qr; + if (this.qrCodeRenewalInterval == 0) { + String deepLink = this.location.newDeepLink((int) this.periodStartTime); + qr = new QRCode(deepLink, this.periodStartTime, this.qrCodeRenewalInterval); + } else { + timestamp = timestamp - ((timestamp - this.periodStartTime) % this.qrCodeRenewalInterval); + String deepLink = this.location.newDeepLink((int) this.periodStartTime, (int) timestamp); + qr = new QRCode(deepLink, timestamp, this.qrCodeRenewalInterval); + } + this.generatedQRs.add(qr); + return qr; + } + + /** + * get a valid QR code for the provided NTP timestamp + * + * @param timestamp timestamp at which the returned QRCode should be valid + * @return a valid QRCode for the provided timestamp + * @throws InvalidTimestampException + * @throws CleaEncryptionException + */ + public QRCode getQrCodeAt(long timestamp) throws InvalidTimestampException, CleaEncryptionException { + if (timestamp < this.periodStartTime || timestamp > this.periodStartTime + this.periodDuration) { + throw new InvalidTimestampException("timestamp : " + timestamp + + " not in the current period of the generator. consider starting a new period first.\n"); + } + // check if qr already exists for timestamp + QRCode qr = this.findExistingQrCode(timestamp); + if (Objects.nonNull(qr)) { + return qr; + } + // generate a new one + qr = this.generateQRCode(timestamp); + return qr; + } + + /** + * start a new period and return its first valid QRCode + * + * @param periodStart NTP timestamp at which the period started. + * @return a QRCode with the provided timestamp (rounded to the nearest hour) as + * starting validity + * @throws InvalidTimestampException + * @throws CleaEncryptionException + */ + public QRCode startNewPeriod(long periodStart) throws InvalidTimestampException, CleaEncryptionException { + this.periodStartTime = TimeUtils.hourRoundedTimestamp(periodStart); + this.generatedQRs.clear(); + return this.generateQRCode(periodStart); + } + + /** + * start a new period and return its first valid QRCode + * + * @param instant : instant at which the new period should start, will be + * rounded to the nearest hour. + * @return a QRCode with the provided instant (rounded to the nearest hour) as + * starting validity + * @throws InvalidTimestampException + * @throws CleaEncryptionException + */ + public QRCode startNewPeriod(Instant instant) throws InvalidTimestampException, CleaEncryptionException { + return this.startNewPeriod(TimeUtils.ntpTimestampFromInstant(instant)); + } + + /** + * get a valid QR code for the provided instant + * + * @param instant : instant at which the returned QRCode should be valid + * @return a valid QRCode for the provided instant + * @throws InvalidTimestampException if instant is not included in the current + * period + * @throws CleaEncryptionException + */ + public QRCode getQrCodeAt(Instant instant) throws InvalidTimestampException, CleaEncryptionException { + return this.getQrCodeAt(TimeUtils.ntpTimestampFromInstant(instant)); + } + + public long getPeriodStart() { + return this.periodStartTime; + } + + public static class InvalidTimestampException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + public InvalidTimestampException(String msg) { + super(msg); + } + } +} diff --git a/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java new file mode 100644 index 0000000000000000000000000000000000000000..6043a19f6cfb9f518b7acbd8c6010f6b1c0ee373 --- /dev/null +++ b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java @@ -0,0 +1,31 @@ +package fr.gouv.tacw.qr.model; + +import java.util.Arrays; +import java.util.Base64; + +import fr.inria.clea.lsp.Location; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class QRCode { + private String qrCode; + private long qrCodeValidityStartTime; + private long qrCodeRenewalInterval; + + public boolean isValidScanTime(long timestamp){ + if( this.qrCodeRenewalInterval > 0) + return (timestamp >= qrCodeValidityStartTime) && (timestamp <= qrCodeValidityStartTime + qrCodeRenewalInterval); + else + return (timestamp >= qrCodeValidityStartTime); + } + + public String getLocationTemporaryPublicID(){ + byte[] locationTemporaryPublicIDByte = Arrays.copyOfRange(Base64.getDecoder().decode(this.qrCode.substring(Location.COUNTRY_SPECIFIC_PREFIX.length())), 1, 17) ; + return Base64.getEncoder().encodeToString(locationTemporaryPublicIDByte); + } + +} diff --git a/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java b/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..169e42da5a7033bc53ab0ddbabfa959f4ab5d392 --- /dev/null +++ b/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java @@ -0,0 +1,89 @@ +package fr.gouv.tacw.qr; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import fr.gouv.tacw.qr.model.QRCode; + +import org.junit.jupiter.api.BeforeEach; + +public class LocationQrCodeGeneratorTest { + + private static final String manualContactTracingAuthorityPublicKey = "04bd776a941090db1c90057401043babafc77164efedad1cbfbab2edec53c5afaff718a33e4cc8f2e9514b162dd4700e517ad341e80f47d49dc0b7e70b30ca4781"; + private static final String permanentLocationSecretKey = "2d576fddb3b721ef86c1512f1ed95452faa5ec6faba0c7a226ad2ac050ed6d49"; + private static final String serverAuthorityPublicKey = "04bd776a941090db1c90057401043babafc77164efedad1cbfbab2edec53c5afaff718a33e4cc8f2e9514b162dd4700e517ad341e80f47d49dc0b7e70b30ca4781"; + private static final Instant now = Instant.now(); + private LocationQrCodeGenerator staticGenerator; + private LocationQrCodeGenerator dynamicGenerator; + + @BeforeEach + public void setUp() throws Exception{ + staticGenerator = LocationQrCodeGenerator.builder() + .countryCode(250) + .staff(false) + .periodStartTime(now) + .periodDuration(8) + .venueCategory1(0) + .venueCategory2(1) + .venueType(3) + .qrCodeRenewalIntervalExponentCompact(0x1F) + .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey) + .permanentLocationSecretKey(permanentLocationSecretKey) + .serverAuthorityPublicKey(serverAuthorityPublicKey) + .locationPhone("0123456789") + .locationPin("1234") + .build(); + dynamicGenerator = LocationQrCodeGenerator.builder() + .countryCode(250) + .staff(false) + .periodStartTime(now) + .periodDuration(8) + .venueCategory1(0) + .venueCategory2(1) + .venueType(3) + .qrCodeRenewalIntervalExponentCompact(10) + .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey) + .permanentLocationSecretKey(permanentLocationSecretKey) + .serverAuthorityPublicKey(serverAuthorityPublicKey) + .locationPhone("0123456789") + .locationPin("1234") + .build(); + } + + @Test + public void shouldGenerateQR() throws Exception{ + QRCode qr = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart()); + assertThat(qr.getQrCode()).isNotEmpty(); + } + + @Test + public void shouldGenerateOnlyOnce() throws Exception{ + QRCode qr = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart()); + QRCode qr2 = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart()+600); + assertThat(qr2).isEqualTo(qr); + + qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()); + qr2 = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()+600); + assertThat(qr2).isEqualTo(qr); + } + + @Test + public void shouldBeSameTLid() throws Exception{ + QRCode qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()); + QRCode qr2 = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()+3600); + + assertThat(qr.getLocationTemporaryPublicID()).isEqualTo(qr2.getLocationTemporaryPublicID()); + + } + + @Test + public void startingNewPeriod() throws Exception{ + QRCode qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()); + QRCode qr2 = dynamicGenerator.startNewPeriod(now.plusSeconds(3600*24)); + assertThat(qr).isNotEqualTo(qr2); + assertThat(qr.getLocationTemporaryPublicID()).isNotEqualTo(qr2.getLocationTemporaryPublicID()); + } +} diff --git a/clea/clea-venue-consumer/Dockerfile b/clea/clea-venue-consumer/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..07aeefec0d181310ad26ef579da285b905f03ab7 --- /dev/null +++ b/clea/clea-venue-consumer/Dockerfile @@ -0,0 +1,10 @@ +FROM azul/zulu-openjdk-alpine:11 + +RUN adduser -D javaapp + +COPY ./target/*-exec.jar /home/javaapp/clea-venue-consumer.jar + +WORKDIR /home/javaapp + +ENTRYPOINT ["java","-jar","clea-venue-consumer.jar"] +EXPOSE 8080 diff --git a/clea/clea-venue-consumer/pom.xml b/clea/clea-venue-consumer/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..d829948630fe882d77ff4bc9b09ce68e0a70a634 --- /dev/null +++ b/clea/clea-venue-consumer/pom.xml @@ -0,0 +1,127 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>fr.gouv.clea</groupId> + <artifactId>clea-server</artifactId> + <version>0.1-SNAPSHOT</version> + </parent> + + <artifactId>clea-venue-consumer</artifactId> + <name>clea-venue-consumer</name> + <description>Clea (Cluster Exposure Verification) Venue consumer used to decode, verify and store infected visits. + </description> + + <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-jpa</artifactId> + </dependency> + <dependency> + <groupId>com.h2database</groupId> + <artifactId>h2</artifactId> + <scope>test</scope><!-- change to runtime to use h2 db (with local profile) --> + </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>kafka</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>fr.inria.clea</groupId> + <artifactId>clea-crypto</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>org.junit.vintage</groupId> + <artifactId>junit-vintage-engine</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-core</artifactId> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-bootstrap</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-vault-config-consul</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-consul-config</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-lang3</artifactId> + </dependency> + </dependencies> + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + </plugin> + </plugins> + </build> +</project> \ No newline at end of file diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..6d289309759818c45bb0c1db7968e83f53a40f9e --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java @@ -0,0 +1,12 @@ +package fr.gouv.clea.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "fr.gouv.clea.consumer") +public class CleaVenueConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(CleaVenueConsumerApplication.class, args); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..47a01fdc0640edfbec9aeaad88f68a0712f6039b --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java @@ -0,0 +1,11 @@ +package fr.gouv.clea.consumer.configuration; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@ConditionalOnProperty(value = "clea.conf.scheduling.purge.enabled", havingValue = "true") +@Configuration +@EnableScheduling +public class SchedulingConfiguration { +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..d44a08d7af9ab4e7a6715b44c911887a25da4682 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java @@ -0,0 +1,30 @@ +package fr.gouv.clea.consumer.configuration; + +import fr.inria.clea.lsp.CleaEciesEncoder; +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SecurityConfiguration { + + private final String serverAuthoritySecretKey; + + @Autowired + public SecurityConfiguration( + @Value("${clea.conf.security.crypto.manualCTAuthoritySecretKey}") String serverAuthoritySecretKey) { + this.serverAuthoritySecretKey = serverAuthoritySecretKey; + } + + @Bean + public LocationSpecificPartDecoder getLocationSpecificPartDecoder() { + return new LocationSpecificPartDecoder(serverAuthoritySecretKey); + } + + @Bean + public CleaEciesEncoder getCleaEciesEncoder() { + return new CleaEciesEncoder(); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java new file mode 100644 index 0000000000000000000000000000000000000000..49f5664726481cbd51b7ba63b3a0c96678d3a321 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java @@ -0,0 +1,23 @@ +package fr.gouv.clea.consumer.model; + +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.time.Instant; + +@AllArgsConstructor +@Builder +@Getter +@ToString +public class DecodedVisit { + private final Instant qrCodeScanTime; // t_qrScan + private final EncryptedLocationSpecificPart encryptedLocationSpecificPart; + private final boolean isBackward; + + public String getStringLocationTemporaryPublicId() { + return this.encryptedLocationSpecificPart.getLocationTemporaryPublicId().toString(); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..2695d022c6ece2af1034fbc76da822b061b81634 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java @@ -0,0 +1,55 @@ +package fr.gouv.clea.consumer.model; + +import java.time.Instant; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.UpdateTimestamp; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "EXPOSED_VISITS") +public class ExposedVisitEntity { + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "org.hibernate.id.UUIDGenerator") + private String id; + + @Column(name="LTId") + @Type(type="pg-uuid") + private UUID locationTemporaryPublicId; + + private int venueType; + private int venueCategory1; + private int venueCategory2; + private long periodStart; + @Column(name="timeslot") + private int timeSlot; + private long backwardVisits; + private long forwardVisits; + + private Instant qrCodeScanTime; // for purge + + @CreationTimestamp + private Instant createdAt; // for db ops/maintenance + + @UpdateTimestamp + private Instant updatedAt; // for db ops/maintenance +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java new file mode 100644 index 0000000000000000000000000000000000000000..f46f7eba3c65ad4d1347da200d7b61a908b6c64e --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java @@ -0,0 +1,45 @@ +package fr.gouv.clea.consumer.model; + +import fr.inria.clea.lsp.LocationSpecificPart; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +import java.time.Instant; + +@SuperBuilder +@Getter +@Setter +@ToString +public class Visit extends LocationSpecificPart { + + protected Instant qrCodeScanTime; + protected boolean isBackward; + + public static Visit from(LocationSpecificPart lsp, DecodedVisit decodedVisit) { + return builder() + .version(lsp.getVersion()) + .type(lsp.getType()) + .countryCode(lsp.getCountryCode()) + .locationTemporaryPublicId(lsp.getLocationTemporaryPublicId()) + .qrCodeRenewalIntervalExponentCompact(lsp.getQrCodeRenewalIntervalExponentCompact()) + .venueType(lsp.getVenueType()) + .venueCategory1(lsp.getVenueCategory1()) + .venueCategory2(lsp.getVenueCategory2()) + .periodDuration(lsp.getPeriodDuration()) + .compressedPeriodStartTime(lsp.getCompressedPeriodStartTime()) + .qrCodeValidityStartTime(lsp.getQrCodeValidityStartTime()) + .locationTemporarySecretKey(lsp.getLocationTemporarySecretKey()) + .encryptedLocationContactMessage(lsp.getEncryptedLocationContactMessage()) + .qrCodeScanTime(decodedVisit.getQrCodeScanTime()) + .isBackward(decodedVisit.isBackward()) + .build(); + } + + public String getStringLocationTemporaryPublicId() { + return this.getLocationTemporaryPublicId().toString(); + } + +} + diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..c33ec90ebf11d56a831b3ed82d0f12107d497071 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java @@ -0,0 +1,16 @@ +package fr.gouv.clea.consumer.repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import fr.gouv.clea.consumer.model.ExposedVisitEntity; + +public interface IExposedVisitRepository extends JpaRepository<ExposedVisitEntity, String> { + + int deleteAllByQrCodeScanTimeBefore(Instant qrCodeScanTime); + + List<ExposedVisitEntity> findAllByLocationTemporaryPublicIdAndPeriodStart(UUID locationTemporaryPublicId, long periodStart); +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java new file mode 100644 index 0000000000000000000000000000000000000000..3e568b618fa6a50da0e0b4fc4833934919877bf3 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java @@ -0,0 +1,8 @@ +package fr.gouv.clea.consumer.service; + +import fr.gouv.clea.consumer.model.DecodedVisit; + +public interface IConsumerService { + + void consume(DecodedVisit decodedVisit); +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java new file mode 100644 index 0000000000000000000000000000000000000000..dc90ffbc971a94e0cfc4e7561e19821ff3dfb3d5 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java @@ -0,0 +1,11 @@ +package fr.gouv.clea.consumer.service; + +import java.util.Optional; + +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.gouv.clea.consumer.model.Visit; + +public interface IDecodedVisitService { + + Optional<Visit> decryptAndValidate(DecodedVisit decodedVisit); +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java new file mode 100644 index 0000000000000000000000000000000000000000..d9440896cac6cf7cc707bbf129dd180b30d86118 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java @@ -0,0 +1,7 @@ +package fr.gouv.clea.consumer.service; + +public interface IExposedVisitEntityService { + + void deleteOutdatedExposedVisits(); + +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java new file mode 100644 index 0000000000000000000000000000000000000000..89c482af554ba4e83fcab078e63163e391ef85c9 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java @@ -0,0 +1,8 @@ +package fr.gouv.clea.consumer.service; + +import fr.gouv.clea.consumer.model.Visit; + +public interface IVisitExpositionAggregatorService { + + void updateExposureCount(Visit visit); +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java new file mode 100644 index 0000000000000000000000000000000000000000..154519848900ba88d53ada2e2b1120115f2aa651 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java @@ -0,0 +1,38 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.gouv.clea.consumer.model.Visit; +import fr.gouv.clea.consumer.service.IConsumerService; +import fr.gouv.clea.consumer.service.IDecodedVisitService; +import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService; +import fr.gouv.clea.consumer.utils.MessageFormatter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@Slf4j +public class ConsumerService implements IConsumerService { + + private final IDecodedVisitService decodedVisitService; + private final IVisitExpositionAggregatorService visitExpositionAggregatorService; + + @Autowired + public ConsumerService( + IDecodedVisitService decodedVisitService, + IVisitExpositionAggregatorService visitExpositionAggregatorService) { + this.decodedVisitService = decodedVisitService; + this.visitExpositionAggregatorService = visitExpositionAggregatorService; + } + + @Override + @KafkaListener(topics = "${spring.kafka.template.default-topic}") + public void consume(DecodedVisit decodedVisit) { + log.info("[locationTemporaryPublicId: {}, qrCodeScanTime: {}] retrieved from queue", MessageFormatter.truncateUUID(decodedVisit.getStringLocationTemporaryPublicId()), decodedVisit.getQrCodeScanTime()); + Optional<Visit> optionalVisit = decodedVisitService.decryptAndValidate(decodedVisit); + optionalVisit.ifPresent(visitExpositionAggregatorService::updateExposureCount); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java new file mode 100644 index 0000000000000000000000000000000000000000..c79db9663f2c5e779e8c18a9a9de9206c01e3ba3 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java @@ -0,0 +1,87 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.gouv.clea.consumer.model.Visit; +import fr.gouv.clea.consumer.service.IDecodedVisitService; +import fr.gouv.clea.consumer.utils.MessageFormatter; +import fr.inria.clea.lsp.CleaEciesEncoder; +import fr.inria.clea.lsp.CleaEncryptionException; +import fr.inria.clea.lsp.LocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +@Component +@Slf4j +public class DecodedVisitService implements IDecodedVisitService { + + private final LocationSpecificPartDecoder decoder; + + private final CleaEciesEncoder cleaEciesEncoder; + + private final int driftBetweenDeviceAndOfficialTimeInSecs; + + private final int cleaClockDriftInSecs; + + @Autowired + public DecodedVisitService( + LocationSpecificPartDecoder decoder, + CleaEciesEncoder cleaEciesEncoder, + @Value("${clea.conf.driftBetweenDeviceAndOfficialTimeInSecs}") int driftBetweenDeviceAndOfficialTimeInSecs, + @Value("${clea.conf.cleaClockDriftInSecs}") int cleaClockDriftInSecs + ) { + this.decoder = decoder; + this.cleaEciesEncoder = cleaEciesEncoder; + this.driftBetweenDeviceAndOfficialTimeInSecs = driftBetweenDeviceAndOfficialTimeInSecs; + this.cleaClockDriftInSecs = cleaClockDriftInSecs; + } + + @Override + public Optional<Visit> decryptAndValidate(DecodedVisit decodedVisit) { + try { + LocationSpecificPart lsp = this.decoder.decrypt(decodedVisit.getEncryptedLocationSpecificPart()); + Visit visit = Visit.from(lsp, decodedVisit); + return this.verify(visit); + } catch (Exception e) { + log.warn("error decrypting [locationTemporaryPublicId: {}, qrCodeScanTime: {}, message: {}]", MessageFormatter.truncateUUID(decodedVisit.getStringLocationTemporaryPublicId()), decodedVisit.getQrCodeScanTime(), e.getLocalizedMessage()); + return Optional.empty(); + } + } + + private Optional<Visit> verify(Visit visit) { + if (!this.isFresh(visit)) { + log.warn("drift check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime()); + return Optional.empty(); + } else if (!this.hasValidTemporaryLocationPublicId(visit)) { + log.warn("locationTemporaryPublicId check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime()); + return Optional.empty(); + } + return Optional.of(visit); + } + + private boolean hasValidTemporaryLocationPublicId(Visit visit) { + try { + UUID computed = cleaEciesEncoder.computeLocationTemporaryPublicId(visit.getLocationTemporarySecretKey()); + return computed.equals(visit.getLocationTemporaryPublicId()); + } catch (CleaEncryptionException e) { + log.warn("locationTemporaryPublicId check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime()); + return false; + } + } + + private boolean isFresh(Visit visit) { + return true; + /* + double qrCodeRenewalInterval = (visit.getQrCodeRenewalIntervalExponentCompact() == 0x1F) + ? 0 : Math.pow(2, visit.getQrCodeRenewalIntervalExponentCompact()); + if (qrCodeRenewalInterval == 0) + return true; + return Math.abs(TimeUtils.ntpTimestampFromInstant(visit.getQrCodeScanTime()) - visit.getQrCodeValidityStartTime()) < (qrCodeRenewalInterval + driftBetweenDeviceAndOfficialTimeInSecs + cleaClockDriftInSecs); + */ + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java new file mode 100644 index 0000000000000000000000000000000000000000..720ed1771384ae93aca33a7442c1dbd6eccb4924 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java @@ -0,0 +1,44 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.repository.IExposedVisitRepository; +import fr.gouv.clea.consumer.service.IExposedVisitEntityService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.transaction.Transactional; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Component +@Slf4j +public class ExposedVisitEntityService implements IExposedVisitEntityService { + + private final IExposedVisitRepository repository; + + private final int retentionDurationInDays; + + @Autowired + public ExposedVisitEntityService( + IExposedVisitRepository repository, + @Value("${clea.conf.retentionDurationInDays}") int retentionDurationInDays + ) { + this.repository = repository; + this.retentionDurationInDays = retentionDurationInDays; + } + + @Override + @Transactional + @Scheduled(cron = "${clea.conf.scheduling.purge.cron}") + public void deleteOutdatedExposedVisits() { + try { + int count = this.repository.deleteAllByQrCodeScanTimeBefore(Instant.now().minus(retentionDurationInDays, ChronoUnit.DAYS)); + log.info("successfully purged {} entries from DB", count); + } catch (Exception e) { + log.error("error during purge"); + throw e; + } + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java new file mode 100644 index 0000000000000000000000000000000000000000..4c78856dd693b39580e5049cb954622e772c1bd4 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java @@ -0,0 +1,102 @@ +package fr.gouv.clea.consumer.service.impl; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import fr.gouv.clea.consumer.model.ExposedVisitEntity; +import fr.gouv.clea.consumer.model.Visit; +import fr.gouv.clea.consumer.repository.IExposedVisitRepository; +import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService; +import fr.inria.clea.lsp.utils.TimeUtils; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class VisitExpositionAggregatorService implements IVisitExpositionAggregatorService { + + private final static long EXPOSURE_TIME_UNIT = TimeUtils.NB_SECONDS_PER_HOUR; + private final IExposedVisitRepository repository; + + @Autowired + public VisitExpositionAggregatorService(IExposedVisitRepository repository) { + this.repository = repository; + } + + @Override + public void updateExposureCount(Visit visit) { + Instant periodStartInstant = periodStartInstant(visit); + long scanTimeSlot = Duration.between(periodStartInstant, visit.getQrCodeScanTime()).toSeconds() / EXPOSURE_TIME_UNIT; + int exposureTime = this.getExposureTime(visit.getVenueType(), visit.getVenueCategory1(), visit.getVenueCategory2(), visit.isStaff()); + int firstExposedSlot = Math.max(0, (int) scanTimeSlot - exposureTime); + int lastExposedSlot = Math.min(visit.getPeriodDuration(), (int) scanTimeSlot + exposureTime); + + List<ExposedVisitEntity> exposedVisits = repository.findAllByLocationTemporaryPublicIdAndPeriodStart(visit.getLocationTemporaryPublicId(), periodStartInstant.toEpochMilli()); + + List<ExposedVisitEntity> toUpdate = new ArrayList<>(); + List<ExposedVisitEntity> toPersist = new ArrayList<>(); + + IntStream.rangeClosed(Math.min(firstExposedSlot, lastExposedSlot), Math.max(firstExposedSlot, lastExposedSlot)) + .forEach(slotIndex -> + exposedVisits.stream() + .filter(exposedVisit -> exposedVisit.getTimeSlot() == slotIndex) + .findFirst() + .ifPresentOrElse( + exposedVisit -> toUpdate.add(this.updateExposedVisit(visit, exposedVisit)), + () -> toPersist.add(this.newExposedVisit(visit, slotIndex)) + ) + ); + + List<ExposedVisitEntity> merged = Stream.concat(toUpdate.stream(), toPersist.stream()).collect(Collectors.toList()); + if (!merged.isEmpty()) { + log.info("Persisting {} new visits!", toPersist.size()); + log.info("Updating {} existing visits!", toUpdate.size()); + repository.saveAll(merged); + } + } + + protected Instant periodStartInstant(Visit visit) { + return TimeUtils.instantFromTimestamp((long) visit.getCompressedPeriodStartTime() * TimeUtils.NB_SECONDS_PER_HOUR); + } + + protected ExposedVisitEntity updateExposedVisit(Visit visit, ExposedVisitEntity exposedVisit) { + if (visit.isBackward()) { + exposedVisit.setBackwardVisits(exposedVisit.getBackwardVisits() + 1); + } else { + exposedVisit.setForwardVisits(exposedVisit.getForwardVisits() + 1); + } + return exposedVisit; + } + + protected ExposedVisitEntity newExposedVisit(Visit visit, int slotIndex) { + // TODO: visit.getPeriodStart returning an Instant + long periodStart = this.periodStartInstant(visit).toEpochMilli(); + return ExposedVisitEntity.builder() + .locationTemporaryPublicId(visit.getLocationTemporaryPublicId()) + .venueType(visit.getVenueType()) + .venueCategory1(visit.getVenueCategory1()) + .venueCategory2(visit.getVenueCategory2()) + .periodStart(periodStart) + .timeSlot(slotIndex) + .backwardVisits(visit.isBackward() ? 1 : 0) + .forwardVisits(visit.isBackward() ? 0 : 1) + .qrCodeScanTime(visit.getQrCodeScanTime()) + .build(); + } + + /** + * @return The exposure time of a visit expressed as the number of EXPOSURE_TIME_UNIT. + * e.g. if EXPOSURE_TIME_UNIT is 3600 sec (one hour), an exposure time equals to 3 means 3 hours + * if EXPOSURE_TIME_UNIT is 1800 sec (30 minutes), an exposure time equals to 3 means 1,5 hour. + */ + protected int getExposureTime(int venueType, int venueCategory1, int venueCategory2, boolean staff) { + return 3; + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java new file mode 100644 index 0000000000000000000000000000000000000000..d06900411f6758f016058ba8887ea607f82a75b7 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java @@ -0,0 +1,67 @@ +package fr.gouv.clea.consumer.utils; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +public class KafkaDeserializer implements Deserializer<DecodedVisit> { + + @Override + public DecodedVisit deserialize(String topic, byte[] data) { + if (data == null) + return null; + try { + return new ObjectMapper() + .registerModule(new SimpleModule().addDeserializer(DecodedVisit.class, new CustomJacksonDeserializer())) + .readValue(data, DecodedVisit.class); + } catch (IOException e) { + throw new SerializationException("Error deserializing JSON message", e); + } + } +} + +class CustomJacksonDeserializer extends StdDeserializer<DecodedVisit> { + + private static final long serialVersionUID = 1L; + + public CustomJacksonDeserializer() { + this(null); + } + + public CustomJacksonDeserializer(Class<DecodedVisit> t) { + super(t); + } + + @Override + public DecodedVisit deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + long qrCodeScanTime = node.get("qrCodeScanTime").asLong(); + boolean isBackward = node.get("isBackward").asBoolean(); + int version = node.get("version").asInt(); + int type = node.get("type").asInt(); + UUID locationTemporaryPublicId = UUID.fromString(node.get("locationTemporaryPublicId").asText()); + byte[] encryptedLocationMessage = node.get("encryptedLocationMessage").binaryValue(); + return new DecodedVisit( + Instant.ofEpochMilli(qrCodeScanTime).truncatedTo(ChronoUnit.SECONDS), + EncryptedLocationSpecificPart.builder() + .version(version) + .type(type) + .locationTemporaryPublicId(locationTemporaryPublicId) + .encryptedLocationMessage(encryptedLocationMessage) + .build(), + isBackward + ); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java new file mode 100644 index 0000000000000000000000000000000000000000..15f9396c8d39612f54ccb94b91dd235c95697901 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java @@ -0,0 +1,58 @@ +package fr.gouv.clea.consumer.utils; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Serializer; + +import java.io.IOException; + +public class KafkaSerializer implements Serializer<DecodedVisit> { + + @Override + public byte[] serialize(String topic, DecodedVisit data) { + if (data == null) + return null; + try { + return new ObjectMapper() + .registerModule(new SimpleModule().addSerializer(DecodedVisit.class, new CustomJacksonSerializer())) + .writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException("Error serializing JSON message", e); + } + } +} + +class CustomJacksonSerializer extends StdSerializer<DecodedVisit> { + + private static final long serialVersionUID = 1L; + + public CustomJacksonSerializer() { + this(null); + } + + public CustomJacksonSerializer(Class<DecodedVisit> t) { + super(t); + } + + @Override + public void serialize(DecodedVisit visit, JsonGenerator generator, SerializerProvider provider) throws IOException { + long qrCodeScanTime = visit.getQrCodeScanTime().toEpochMilli(); + boolean isBackward = visit.isBackward(); + EncryptedLocationSpecificPart enc = visit.getEncryptedLocationSpecificPart(); + generator.writeStartObject(); + generator.writeNumberField("qrCodeScanTime", qrCodeScanTime); + generator.writeBooleanField("isBackward", isBackward); + generator.writeNumberField("version", enc.getVersion()); + generator.writeNumberField("type", enc.getType()); + generator.writeStringField("locationTemporaryPublicId", enc.getLocationTemporaryPublicId().toString()); + generator.writeBinaryField("encryptedLocationMessage", enc.getEncryptedLocationMessage()); + generator.writeEndObject(); + } +} diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java new file mode 100644 index 0000000000000000000000000000000000000000..8a7c543343ade996f5b2e22e93cf1cfcf8f8fcd2 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java @@ -0,0 +1,8 @@ +package fr.gouv.clea.consumer.utils; + +public class MessageFormatter { + + public static String truncateUUID(String message) { + return message.substring(0, Math.min(message.length(), 10)).concat("..."); + } +} diff --git a/clea/clea-venue-consumer/src/main/resources/application-docker.yml b/clea/clea-venue-consumer/src/main/resources/application-docker.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a3a7f0edd7eb5047e3b2d35414ee0ffb7725e18 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/resources/application-docker.yml @@ -0,0 +1,23 @@ +server: + port: 8080 + +spring: + datasource: + driverClassName: org.postgresql.Driver + url: "${DB_URL:jdbc:postgresql://postgres:5432/cleadb}" + username: "${DB_USER:user}" + password: "${DB_PASSWORD:pass}" + h2: + console: + enabled: false + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:create} + kafka: + bootstrap-servers: "${KAFKA_URL:kafka:29092}" + +clea: + conf: + security: + crypto: + manualCTAuthoritySecretKey: ${CLEA_CRYPTO_AUTHORITY_SECRET:f55d39487b91c00ad24d4fc70f54511539ed308e954ab859734598d16d047bdf} diff --git a/clea/clea-venue-consumer/src/main/resources/application-local.yml b/clea/clea-venue-consumer/src/main/resources/application-local.yml new file mode 100644 index 0000000000000000000000000000000000000000..23f9a96bac405d3968b2434559c680d0dcc94907 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/resources/application-local.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driverClassName: org.h2.Driver + password: + url: "jdbc:h2:mem:cleadb" + username: sa + h2: + console: + enabled: true + path: /h2 + settings: + trace: false + web-allow-others: true + jpa: + hibernate: + ddl-auto: create-drop diff --git a/clea/clea-venue-consumer/src/main/resources/application.yml b/clea/clea-venue-consumer/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..dc7dd76f2d1e366180ae9b9e4d33d04b4e135c84 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/resources/application.yml @@ -0,0 +1,45 @@ +# Available endpoints for the monitoring +management: + endpoints: + web: + exposure: + include: ${CLEA_SERVER_MONITORING_ENDPOINTS:health,metrics} + +clea: + conf: + retentionDurationInDays: 14 + driftBetweenDeviceAndOfficialTimeInSecs: 300 + cleaClockDriftInSecs: 300 + scheduling: + purge: + cron: "0 0 1 * * *" + enabled: false + security: + crypto: + manualCTAuthoritySecretKey: "${CLEA_CRYPTO_AUTHORITY_SECRET:f55d39487b91c00ad24d4fc70f54511539ed308e954ab859734598d16d047bdf}" + +logging: + level: + fr.gouv.clea.consumer: INFO + +spring: + jpa: + generate-ddl: true + open-in-view: false + database-platform: org.hibernate.dialect.PostgreSQL95Dialect # use postgres because of @Type(pg-uuid) even with h2 Datasource + hibernate: + ddl-auto: ${JPA_DDL_AUTO:create} + kafka: + bootstrap-servers: "${KAFKA_URL:localhost:9092}" + consumer: + auto-offset-reset: earliest + group-id: ${KAFKA_GROUP_ID:group1} + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: fr.gouv.clea.consumer.utils.KafkaDeserializer + template: + default-topic: ${KAFKA_TOPIC:cleaQrCodes} + main: + web-application-type: none + output: + ansi: + enabled: ALWAYS diff --git a/clea/clea-venue-consumer/src/main/resources/banner.txt b/clea/clea-venue-consumer/src/main/resources/banner.txt new file mode 100644 index 0000000000000000000000000000000000000000..46db76f673fd4fe4cad0316cd5349733807cd6ec --- /dev/null +++ b/clea/clea-venue-consumer/src/main/resources/banner.txt @@ -0,0 +1,8 @@ + ___ _ ___ + / __| |___ __ _ ___ / __|___ _ _ ____ _ _ __ ___ _ _ + | (__| / -_) _` |___| (__/ _ \ ' \(_-< || | ' \/ -_) '_| + \___|_\___\__,_| \___\___/_||_/__/\_,_|_|_|_\___|_| + +${application.title}:${application.version} + Powered-by : Spring-Boot ${spring-boot.formatted-version} + Run with : Java ${java.version} ${java.vm.version} diff --git a/clea/clea-venue-consumer/src/main/resources/bootstrap.yml b/clea/clea-venue-consumer/src/main/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..17a99ea2cce65ad9f1b2075191c957d8f7185205 --- /dev/null +++ b/clea/clea-venue-consumer/src/main/resources/bootstrap.yml @@ -0,0 +1,17 @@ +spring: + application: + name: clea-venue-consumer + cloud: + consul: + enabled: ${CONSUL_ENABLED:false} + host: ${CONSUL_HOST:localhost} + port: ${CONSUL_PORT:8500} + scheme: ${CONSUL_SCHEME:http} + config: + enabled: ${CONSUL_CONFIG_ENABLED:false} + vault: + enabled: ${VAULT_ENABLED:false} + host: ${VAULT_HOST:localhost} + port: ${VAULT_PORT:8200} + token: ${VAULT_TOKEN:token} + scheme: ${VAULT_SCHEME:http} diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ab7c1e9b0c66d1c5a2240667c2690c4c86d9fc7e --- /dev/null +++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java @@ -0,0 +1,88 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.gouv.clea.consumer.service.IConsumerService; +import fr.gouv.clea.consumer.utils.KafkaSerializer; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.commons.lang3.RandomUtils; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@DirtiesContext +@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9092", "port=9092"}) +@TestPropertySource(properties = {"kafka.bootstrapAddress=localhost:9092"}) +class ConsumerServiceTest { + + @Value("${spring.kafka.template.default-topic}") + private String topicName; + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @SpyBean + private IConsumerService consumerService; + + private Producer<String, DecodedVisit> producer; + + @BeforeEach + void init() { + Map<String, Object> configs = new HashMap<>(KafkaTestUtils.producerProps(embeddedKafkaBroker)); + producer = new DefaultKafkaProducerFactory<>(configs, new StringSerializer(), new KafkaSerializer()).createProducer(); + } + + @AfterEach + void clean() { + producer.close(); + } + + @Test + @DisplayName("test that kafka listener triggers when something is sent to the queue") + void testCanConsumeMessageSentinDefaultQueue() { + DecodedVisit decodedVisit = new DecodedVisit( + Instant.now(), + EncryptedLocationSpecificPart.builder() + .version(RandomUtils.nextInt()) + .type(RandomUtils.nextInt()) + .locationTemporaryPublicId(UUID.randomUUID()) + .encryptedLocationMessage(RandomUtils.nextBytes(20)) + .build(), + RandomUtils.nextBoolean() + ); + + producer.send(new ProducerRecord<>(topicName, decodedVisit)); + producer.flush(); + + await().atMost(30, TimeUnit.SECONDS) + .untilAsserted( + () -> verify(consumerService, times(1)) + .consume(any(DecodedVisit.class)) + ); + } +} \ No newline at end of file diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..d35cae5f4552066bdc164f48057eb0fd039197e7 --- /dev/null +++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java @@ -0,0 +1,219 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.DecodedVisit; +import fr.gouv.clea.consumer.model.Visit; +import fr.gouv.clea.consumer.service.IDecodedVisitService; +import fr.inria.clea.lsp.CleaEciesEncoder; +import fr.inria.clea.lsp.CleaEncodingException; +import fr.inria.clea.lsp.CleaEncryptionException; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import fr.inria.clea.lsp.utils.TimeUtils; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DecodedVisitServiceTest { + + private final LocationSpecificPartDecoder decoder = mock(LocationSpecificPartDecoder.class); + private final CleaEciesEncoder cleaEciesEncoder = mock(CleaEciesEncoder.class); + private final int driftBetweenDeviceAndOfficialTimeInSecs = 300; + private final int cleaClockDriftInSecs = 300; + private final IDecodedVisitService decodedVisitService = new DecodedVisitService(decoder, cleaEciesEncoder, driftBetweenDeviceAndOfficialTimeInSecs, cleaClockDriftInSecs); + + @Test + @DisplayName("check with max CRIexp for LSP") + void maxCRIexp() throws CleaEncryptionException, CleaEncodingException { + int CRIexp = 0x1F; // qrCodeRenewalInterval = 0 + Instant now = Instant.now(); + UUID uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenReturn( + LocationSpecificPart.builder() + .qrCodeRenewalIntervalExponentCompact(CRIexp) + .locationTemporaryPublicId(uuid) + .locationTemporarySecretKey(locationTemporarySecretKey) + .build() + ); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenReturn(uuid); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isPresent(); + } + + @Disabled + @Test + @DisplayName("check with drifting LSP") + void drift() throws CleaEncryptionException, CleaEncodingException { + int CRIexp = 10; // qrCodeRenewalInterval=2^10(=1024). 1024+300+300=1624 + Instant now = Instant.now(); + long qrCodeValidityStartTime = TimeUtils.currentNtpTime() + 2000; + UUID uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenReturn( + LocationSpecificPart.builder() + .qrCodeValidityStartTime(0) + .qrCodeRenewalIntervalExponentCompact(CRIexp) + // .qrCodeValidityStartTime(qrCodeValidityStartTime) + .locationTemporaryPublicId(uuid) + .locationTemporarySecretKey(locationTemporarySecretKey) + .build() + ); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenReturn(uuid); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isEmpty(); + } + + @Test + @DisplayName("check with non valid temporaryLocationPublicId") + void nonValidTemporaryLocationPublicId() throws CleaEncryptionException, CleaEncodingException { + Instant now = Instant.now(); + UUID uuid = UUID.randomUUID(); + UUID _uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenReturn( + LocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .locationTemporarySecretKey(locationTemporarySecretKey) + .build() + ); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenReturn(_uuid); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isNotPresent(); + } + + @Test + @DisplayName("check with CleaEncryptionException when verifying temporaryLocationPublicId") + void cleaEncryptionExceptionForTLId() throws CleaEncryptionException, CleaEncodingException { + Instant now = Instant.now(); + UUID uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenReturn( + LocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .locationTemporarySecretKey(locationTemporarySecretKey) + .build() + ); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenThrow(new CleaEncryptionException("")); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isNotPresent(); + } + + @Test + @DisplayName("check with CleaEncryptionException when decrypting") + void cleaEncryptionException() throws CleaEncryptionException, CleaEncodingException { + Instant now = Instant.now(); + UUID uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenThrow(new CleaEncryptionException("")); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenReturn(uuid); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isNotPresent(); + } + + @Test + @DisplayName("check with CleaEncodingException when decrypting") + void cleaEncodingException() throws CleaEncryptionException, CleaEncodingException { + Instant now = Instant.now(); + UUID uuid = UUID.randomUUID(); + byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20); + + when(decoder.decrypt(any(EncryptedLocationSpecificPart.class))) + .thenThrow(new CleaEncodingException("")); + + when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey)) + .thenReturn(uuid); + + Optional<Visit> optional = decodedVisitService.decryptAndValidate( + new DecodedVisit( + now, + EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(), + RandomUtils.nextBoolean() + ) + ); + + assertThat(optional).isNotPresent(); + } +} \ No newline at end of file diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..9f49345f6af55274f039205a50399693fb60951d --- /dev/null +++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java @@ -0,0 +1,102 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.ExposedVisitEntity; +import fr.gouv.clea.consumer.repository.IExposedVisitRepository; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.scheduling.config.CronTask; +import org.springframework.scheduling.config.ScheduledTask; +import org.springframework.scheduling.config.ScheduledTaskHolder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@DirtiesContext +@TestPropertySource(properties = {"clea.conf.scheduling.purge.cron=*/10 * * * * *", "clea.conf.scheduling.purge.enabled=true"}) +class ExposedVisitEntityServiceSchedulingTest { + + @Value("${clea.conf.scheduling.purge.cron}") + private String cronValue; + + @Autowired + private IExposedVisitRepository repository; + + @Autowired + private ScheduledTaskHolder scheduledTaskHolder; + + private static ExposedVisitEntity createExposedVisit(Instant qrCodeScanTime) { + return new ExposedVisitEntity( + null, // handled by db + UUID.randomUUID(), + RandomUtils.nextInt(), + RandomUtils.nextInt(), + RandomUtils.nextInt(), + RandomUtils.nextLong(), + RandomUtils.nextInt(), + RandomUtils.nextLong(), + RandomUtils.nextLong(), + qrCodeScanTime, + null, // handled by db + null // handled by db + ); + } + + @AfterEach + void clean() { + repository.deleteAll(); + } + + @Test + @DisplayName("check that croned job is active") + void testCronIsActive() { + Set<ScheduledTask> scheduledTasks = scheduledTaskHolder.getScheduledTasks(); + scheduledTasks.forEach(scheduledTask -> scheduledTask.getTask().getRunnable().getClass().getDeclaredMethods()); + long count = scheduledTasks.stream() + .filter(scheduledTask -> scheduledTask.getTask() instanceof CronTask) + .map(scheduledTask -> (CronTask) scheduledTask.getTask()) + .filter(cronTask -> cronTask.getExpression().equals(cronValue) + && cronTask.toString().equals("fr.gouv.clea.consumer.service.impl.ExposedVisitEntityService.deleteOutdatedExposedVisits")) + .count(); + + assertThat(count).isEqualTo(1L); + } + + @Test + @DisplayName("check that croned job remove outdated exposed visits from DB") + void deleteOutdatedExposedVisits() { + Instant now = Instant.now(); + Instant yesterday = now.minus(1, ChronoUnit.DAYS); + Instant _14DaysAgo = now.minus(14, ChronoUnit.DAYS); + Instant _15DaysAgo = now.minus(15, ChronoUnit.DAYS); + repository.saveAll( + List.of( + createExposedVisit(yesterday), // keep + createExposedVisit(_14DaysAgo), // remove, will always be < now + createExposedVisit(_15DaysAgo) // remove + ) + ); + assertThat(repository.count()).isEqualTo(3); + + await().atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(repository.count()).isEqualTo(1)); + + assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(yesterday))).isTrue(); + assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(_14DaysAgo))).isFalse(); + assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(_15DaysAgo))).isFalse(); + } +} \ No newline at end of file diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..0981d4adb174d5036a1b3241ac2d81329f8c8caf --- /dev/null +++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java @@ -0,0 +1,176 @@ +package fr.gouv.clea.consumer.service.impl; + +import fr.gouv.clea.consumer.model.ExposedVisitEntity; +import fr.gouv.clea.consumer.model.Visit; +import fr.gouv.clea.consumer.repository.IExposedVisitRepository; +import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@SpringBootTest +@DirtiesContext +class VisitExpositionAggregatorServiceTest { + + @Autowired + private IExposedVisitRepository repository; + + @Autowired + private IVisitExpositionAggregatorService service; + + private Instant yesterday; + private UUID uuid; + private byte[] locationTemporarySecretKey; + private byte[] encryptedLocationContactMessage; + + @BeforeEach + void init() { + yesterday = Instant.now().minus(1, ChronoUnit.DAYS); + uuid = UUID.randomUUID(); + locationTemporarySecretKey = RandomUtils.nextBytes(20); + encryptedLocationContactMessage = RandomUtils.nextBytes(20); + } + + @AfterEach + void clean() { + repository.deleteAll(); + } + + @Test + @DisplayName("visits with no existing context should be saved in DB") + void saveWithNoContext() { + Visit visit = Visit.builder() + .version(0) + .type(0) + .countryCode(33) + .staff(true) + .locationTemporaryPublicId(uuid) + .qrCodeRenewalIntervalExponentCompact(2) + .venueType(4) + .venueCategory1(0) + .venueCategory2(0) + .periodDuration(3) + .compressedPeriodStartTime(1062707) + .qrCodeValidityStartTime(0) + .locationTemporarySecretKey(locationTemporarySecretKey) + .encryptedLocationContactMessage(encryptedLocationContactMessage) + .qrCodeScanTime(yesterday) + .isBackward(true) + .build(); + service.updateExposureCount(visit); + + // assertThat(repository.count()).isEqualTo(21L); + List<ExposedVisitEntity> entities = repository.findAll(); + entities.forEach(it -> { + assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid); + assertThat(it.getBackwardVisits()).isEqualTo(1); + } + ); + } + + @Test + @DisplayName("visits with existing context should be updated in DB") + void updateWithExistingContext() { + Visit visit = Visit.builder() + .version(0) + .type(0) + .countryCode(33) + .staff(true) + .locationTemporaryPublicId(uuid) + .qrCodeRenewalIntervalExponentCompact(2) + .venueType(4) + .venueCategory1(0) + .venueCategory2(0) + .periodDuration(3) + .compressedPeriodStartTime(1062707) + .qrCodeValidityStartTime(0) + .locationTemporarySecretKey(locationTemporarySecretKey) + .encryptedLocationContactMessage(encryptedLocationContactMessage) + .qrCodeScanTime(yesterday) + .isBackward(true) + .build(); + service.updateExposureCount(visit); + service.updateExposureCount(visit); + + // assertThat(repository.count()).isEqualTo(21L); + List<ExposedVisitEntity> entities = repository.findAll(); + entities.forEach(it -> { + assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid); + assertThat(it.getBackwardVisits()).isEqualTo(2); + } + ); + } + + @Test + @DisplayName("new visits should be saved while existing be updated in DB") + void mixedContext() { + Visit visit = Visit.builder() + .version(0) + .type(0) + .countryCode(33) + .staff(true) + .locationTemporaryPublicId(uuid) + .qrCodeRenewalIntervalExponentCompact(2) + .venueType(4) + .venueCategory1(0) + .venueCategory2(0) + .periodDuration(3) + .compressedPeriodStartTime(1062707) + .qrCodeValidityStartTime(0) + .locationTemporarySecretKey(locationTemporarySecretKey) + .encryptedLocationContactMessage(encryptedLocationContactMessage) + .qrCodeScanTime(yesterday) + .isBackward(true) + .build(); + service.updateExposureCount(visit); + + visit.setBackward(false); + service.updateExposureCount(visit); + + UUID newUUID = UUID.randomUUID(); + visit.setLocationTemporaryPublicId(newUUID); + visit.setBackward(true); + service.updateExposureCount(visit); + + // assertThat(repository.count()).isEqualTo(42L); + List<ExposedVisitEntity> entities = repository.findAll(); + entities.stream() + .filter(it -> it.getLocationTemporaryPublicId().equals(uuid)) + .forEach(it -> { + assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid); + assertThat(it.getBackwardVisits()).isEqualTo(1); + assertThat(it.getForwardVisits()).isEqualTo(1); + } + ); + + entities.stream() + .filter(it -> it.getLocationTemporaryPublicId().equals(newUUID)) + .forEach(it -> { + assertThat(it.getLocationTemporaryPublicId()).isEqualTo(newUUID); + assertThat(it.getBackwardVisits()).isEqualTo(1); + assertThat(it.getForwardVisits()).isEqualTo(0); + } + ); + } + + @Disabled + @Test + @DisplayName("test how many slots are generated for a given visit") + void testSlotGeneration() { + fail("Not tested yet"); + } +} \ No newline at end of file diff --git a/clea/clea-ws-rest/Dockerfile b/clea/clea-ws-rest/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..836a07d803f06bbdadd753a4eac50484495d16ff --- /dev/null +++ b/clea/clea-ws-rest/Dockerfile @@ -0,0 +1,10 @@ +FROM azul/zulu-openjdk-alpine:11 + +RUN adduser -D javaapp + +COPY ./target/*-exec.jar /home/javaapp/clea-ws-rest.jar + +WORKDIR /home/javaapp + +ENTRYPOINT ["java","-jar","clea-ws-rest.jar"] +EXPOSE 8080 diff --git a/clea/clea-ws-rest/README.md b/clea/clea-ws-rest/README.md new file mode 100644 index 0000000000000000000000000000000000000000..298124310f38aaa24da24d557c8b86451143699f --- /dev/null +++ b/clea/clea-ws-rest/README.md @@ -0,0 +1,9 @@ +Run the Clea WS ReST application: + +```bash +mvn spring-boot:run +``` + +Display the Clea API and use it: http://localhost:8088/swagger-ui/ + +Display the Clea OpenAPI generated from the code: http://localhost:8088/v3/api-docs?group=clea \ No newline at end of file diff --git a/clea/clea-ws-rest/pom.xml b/clea/clea-ws-rest/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..32894f0edc68c62449c72887f4747f741750ab02 --- /dev/null +++ b/clea/clea-ws-rest/pom.xml @@ -0,0 +1,142 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>fr.gouv.clea</groupId> + <artifactId>clea-server</artifactId> + <version>0.1-SNAPSHOT</version> + </parent> + + <artifactId>clea-ws-rest</artifactId> + <name>clea-ws-rest</name> + <description>ReST API module for Clea (Cluster Exposure Verification)</description> + + <properties> + <springfox-boot-starter.version>3.0.0</springfox-boot-starter.version> + <io.jsonwebtoken.version>0.11.2</io.jsonwebtoken.version> + </properties> + + <dependencies> + <dependency> + <groupId>io.springfox</groupId> + <artifactId>springfox-boot-starter</artifactId> + <version>${springfox-boot-starter.version}</version> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-api</artifactId> + <version>${io.jsonwebtoken.version}</version> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-impl</artifactId> + <version>${io.jsonwebtoken.version}</version> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-jackson</artifactId> + <version>${io.jsonwebtoken.version}</version> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>kafka</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>fr.inria.clea</groupId> + <artifactId>clea-crypto</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>org.junit.vintage</groupId> + <artifactId>junit-vintage-engine</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-core</artifactId> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-bootstrap</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-vault-config-consul</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-consul-config</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-lang3</artifactId> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + </plugin> + </plugins> + </build> + +</project> diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..dfe92d4dd35506c4075297b27758a6eb32b4b601 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java @@ -0,0 +1,12 @@ +package fr.gouv.clea.ws; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "fr.gouv.clea.ws") +public class CleaWsRestApplication { + + public static void main(String[] args) { + SpringApplication.run(CleaWsRestApplication.class, args); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java new file mode 100644 index 0000000000000000000000000000000000000000..3438c32cf9b67ac26578f4a6d9934373ed8f5748 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java @@ -0,0 +1,54 @@ +package fr.gouv.clea.ws.api; + +import fr.gouv.clea.ws.dto.ReportResponse; +import fr.gouv.clea.ws.vo.ReportRequest; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import io.swagger.annotations.Example; +import io.swagger.annotations.ExampleProperty; +import org.springframework.http.MediaType; + +@Api( + tags = "clea", + description = "Clea API", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE +) +public interface CleaWsRestAPI { + + @ApiOperation( + value = "Upload locations history", + notes = "" + + "Upload a list of {qrCode, timestamp} tuples where :\n" + + "* **qrCode**: QR code content encoded in Base64\n" + + "* **qrCodeScanTime**: NTP timestamp when a user terminal scans a given QR code\n" + + "", + httpMethod = "POST", + response = ReportResponse.class, + protocols = "https" + ) + @ApiResponses( + value = { + @ApiResponse( + code = 200, + message = "Successful Operation", + response = ReportResponse.class, + examples = @Example( + @ExampleProperty( + value = "{\n" + + " \"success\": \"true\",\n" + + " \"message\": \"2 qr processed, 0 rejected\"\n" + + "}", + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ) + ), + @ApiResponse(code = 400, message = "Bad Request"), + @ApiResponse(code = 401, message = "Invalid Authentication"), + @ApiResponse(code = 500, message = "Internal Error") + } + ) + ReportResponse report(ReportRequest reportRequestVo); +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..0c6cbcd706371739c5465c2c0cfef08bbc923a5e --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java @@ -0,0 +1,14 @@ +package fr.gouv.clea.ws.configuration; + +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SecurityConfiguration { + + @Bean + public LocationSpecificPartDecoder getLocationSpecificPartDecoder() { + return new LocationSpecificPartDecoder(null); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..c7a8fa67f80c753184d1ed2197155aa6f8ad1758 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java @@ -0,0 +1,65 @@ +package fr.gouv.clea.ws.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.service.ApiKey; +import springfox.documentation.service.AuthorizationScope; +import springfox.documentation.service.Contact; +import springfox.documentation.service.SecurityReference; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spi.service.contexts.SecurityContext; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +import java.util.List; + +import static springfox.documentation.builders.RequestHandlerSelectors.basePackage; + +@Configuration +@EnableSwagger2 +public class SwaggerConfiguration { + + private ApiKey apiKey() { + return new ApiKey("Authorization", "Authorization", "header"); + } + + private SecurityContext securityContext() { + return SecurityContext.builder().securityReferences(defaultAuth()).build(); + } + + private List<SecurityReference> defaultAuth() { + AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything"); + AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; + authorizationScopes[0] = authorizationScope; + return List.of(new SecurityReference("Authorization", authorizationScopes)); + } + + @Bean + public Docket cleaApi() { + return new Docket(DocumentationType.OAS_30) + .select() + .apis(basePackage("fr.gouv.clea.ws")) + .paths(PathSelectors.regex("/api/.*")) + .build() + .apiInfo( + new ApiInfo( + "Tous AntiCovid Cluster Exposure Verification (Cléa)", + "#TOUSANTICOVID, Cléa API", + "1.0.0", + null, + new Contact(null, null, "stopcovid@inria.fr"), + "Apache 2.0", + "http://www.apache.org/licenses/LICENSE-2.0.html", + List.of() + ) + ) + .groupName("clea") + .securityContexts(List.of(securityContext())) + .securitySchemes(List.of(apiKey())) + .globalResponses(HttpMethod.GET, List.of()) + .globalResponses(HttpMethod.POST, List.of()); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java new file mode 100644 index 0000000000000000000000000000000000000000..88902fa70ed68cd75519a4c6608b1b254da73522 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java @@ -0,0 +1,121 @@ +package fr.gouv.clea.ws.controller; + +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; +import fr.gouv.clea.ws.api.CleaWsRestAPI; +import fr.gouv.clea.ws.dto.ReportResponse; +import fr.gouv.clea.ws.service.IAuthorizationService; +import fr.gouv.clea.ws.service.IReportService; +import fr.gouv.clea.ws.utils.BadArgumentsLoggerService; +import fr.gouv.clea.ws.utils.UriConstants; +import fr.gouv.clea.ws.vo.ReportRequest; +import fr.gouv.clea.ws.vo.Visit; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.server.ResponseStatusException; + +import javax.annotation.PostConstruct; +import javax.validation.ConstraintViolation; +import javax.validation.Valid; +import javax.validation.Validator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@RestController +@RequestMapping(path = "${controller.path.prefix}") +@Slf4j +public class CleaController implements CleaWsRestAPI { + + public static final String MALFORMED_VISIT_LOG_MESSAGE = "Filtered out %d malformed visits of %d while Exposure Status Request"; + private final IReportService reportService; + private final IAuthorizationService authorizationService; + private final BadArgumentsLoggerService badArgumentsLoggerService; + private final WebRequest webRequest; + private final ObjectMapper objectMapper; + private final Validator validator; + + @Autowired + public CleaController( + IReportService reportService, + IAuthorizationService authorizationService, + BadArgumentsLoggerService badArgumentsLoggerService, + WebRequest webRequest, + ObjectMapper objectMapper, + Validator validator + ) { + this.reportService = reportService; + this.authorizationService = authorizationService; + this.badArgumentsLoggerService = badArgumentsLoggerService; + this.webRequest = webRequest; + this.objectMapper = objectMapper; + this.validator = validator; + } + + @Override + @PostMapping( + path = UriConstants.API_V1 + UriConstants.REPORT, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + // TODO: Also we should switch from AuthorizationService to SpringSecurity using jwtDecoder + public ReportResponse report(@RequestBody @Valid ReportRequest reportRequestVo) { + String auth = webRequest.getHeader("Authorization"); + this.authorizationService.checkAuthorization(auth); + ReportRequest filtered = this.filterReports(reportRequestVo, webRequest); + reportService.report(filtered); + String message = String.format("%s reports processed, %s rejected", filtered.getVisits().size(), reportRequestVo.getVisits().size() - filtered.getVisits().size()); + log.info(message); + return new ReportResponse(true, message); + } + + protected ReportRequest filterReports(ReportRequest report, WebRequest webRequest) { + Set<ConstraintViolation<ReportRequest>> superViolations = validator.validate(report); + if (!superViolations.isEmpty()) { + this.badArgumentsLoggerService.logValidationErrorMessage(superViolations, webRequest); + log.warn(String.format(MALFORMED_VISIT_LOG_MESSAGE, report.getVisits().size(), report.getVisits().size())); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to validate request"); + } else { + List<Visit> validVisits = report.getVisits().stream() + .filter(visit -> { + Set<ConstraintViolation<Visit>> subViolations = validator.validate(visit); + if (!subViolations.isEmpty()) { + this.badArgumentsLoggerService.logValidationErrorMessage(subViolations, webRequest); + return false; + } else { + return true; + } + }).collect(Collectors.toList()); + int nbVisits = report.getVisits().size(); + int nbFilteredVisits = nbVisits - validVisits.size(); + if (nbFilteredVisits > 0) { + log.warn(String.format(MALFORMED_VISIT_LOG_MESSAGE, nbFilteredVisits, nbVisits)); + } + return new ReportRequest(validVisits, report.getPivotDateAsNtpTimestamp()); + } + } + + @PostConstruct + private void disableAutomaticJsonDeserialization() { + objectMapper.addHandler(new DeserializationProblemHandler() { + @Override + public Object handleWeirdStringValue(DeserializationContext ctxt, Class<?> targetType, String valueToConvert, String failureMsg) { + return null; + } + + @Override + public Object handleWeirdNumberValue(DeserializationContext ctxt, Class<?> targetType, Number valueToConvert, String failureMsg) { + return null; + } + }); + } + +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..8f2235ee12483a9f0df4796902456c57c9d081d6 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java @@ -0,0 +1,13 @@ +package fr.gouv.clea.ws.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReportResponse { + private boolean success; + private String message; +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java new file mode 100644 index 0000000000000000000000000000000000000000..ee372ad973880e72a6933c2e362f9a4ac25d68fc --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java @@ -0,0 +1,15 @@ +package fr.gouv.clea.ws.exception; + +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@NoArgsConstructor +@ResponseStatus(HttpStatus.FORBIDDEN) +public class CleaUnauthorizedException extends RuntimeException { + private static final long serialVersionUID = -2742936878446030656L; + + public CleaUnauthorizedException(String message) { + super(message); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..55966375de61652de24c391cc1c78af542ee630d --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java @@ -0,0 +1,76 @@ +package fr.gouv.clea.ws.exception; + +import fr.gouv.clea.ws.utils.BadArgumentsLoggerService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@ControllerAdvice +@Slf4j +public class CustomRestExceptionHandler extends ResponseEntityExceptionHandler { + + public static final String ERROR_MESSAGE_TEMPLATE = "%s, requested uri: %s"; + + private final BadArgumentsLoggerService badArgumentsLoggerService; + + public CustomRestExceptionHandler(BadArgumentsLoggerService badArgumentsLoggerService) { + this.badArgumentsLoggerService = badArgumentsLoggerService; + } + + /** + * A general handler for all uncaught exceptions + * + * @throws Exception + */ + @ExceptionHandler({Exception.class}) + public ResponseEntity<Object> handleAllExceptions(Exception exception, WebRequest webRequest) { + String message = exception.getLocalizedMessage(); + if (message == null) + message = exception.toString(); + final String path = webRequest.getDescription(false); + ResponseStatus responseStatus = exception.getClass().getAnnotation(ResponseStatus.class); + final HttpStatus status = responseStatus != null ? responseStatus.value() : HttpStatus.INTERNAL_SERVER_ERROR; + log.error(String.format(ERROR_MESSAGE_TEMPLATE, message, path), exception); + return this.newResponseEntity(null, status); + } + + /** + * Handle BAD REQUEST exceptions + */ + @Override + protected ResponseEntity<Object> handleMethodArgumentNotValid( + MethodArgumentNotValidException exception, + HttpHeaders headers, + HttpStatus status, + WebRequest webRequest) { + this.badArgumentsLoggerService.logValidationErrorMessage(exception.getBindingResult(), webRequest); + return this.newResponseEntity(null, status); + } + + @Override + protected ResponseEntity<Object> handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpHeaders headers, + HttpStatus status, + WebRequest request) { + log.warn("Bad Request: ", ex.getMessage()); + log.debug("Bad Request: ", ex); + + return this.newResponseEntity(null, HttpStatus.BAD_REQUEST); + } + + protected ResponseEntity<Object> newResponseEntity(Object body, HttpStatus status) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new ResponseEntity<>(null, headers, status); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java new file mode 100644 index 0000000000000000000000000000000000000000..8e6f8650364515bc44e93ec93c031be26782744f --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java @@ -0,0 +1,26 @@ +package fr.gouv.clea.ws.model; + +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +import java.time.Instant; +import java.util.UUID; + +@AllArgsConstructor +@Getter +@ToString +public class DecodedVisit { + private final Instant qrCodeScanTime; // t_qrScan + private final EncryptedLocationSpecificPart encryptedLocationSpecificPart; + private final boolean isBackward; + + public UUID getLocationTemporaryPublicId() { + return this.encryptedLocationSpecificPart.getLocationTemporaryPublicId(); + } + + public String getStringLocationTemporaryPublicId() { + return this.getLocationTemporaryPublicId().toString(); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java new file mode 100644 index 0000000000000000000000000000000000000000..44684c9556bd13f504c1de5dc47df5dd72b81afd --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java @@ -0,0 +1,8 @@ +package fr.gouv.clea.ws.service; + +import fr.gouv.clea.ws.exception.CleaUnauthorizedException; + +public interface IAuthorizationService { + + boolean checkAuthorization(String jwtToken) throws CleaUnauthorizedException; +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java new file mode 100644 index 0000000000000000000000000000000000000000..f68a91295a04602ef820268617f249008673fa8c --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java @@ -0,0 +1,10 @@ +package fr.gouv.clea.ws.service; + +import fr.gouv.clea.ws.model.DecodedVisit; + +import java.util.List; + +public interface IDecodedVisitProducerService { + + void produce(List<DecodedVisit> decodedVisits); +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java new file mode 100644 index 0000000000000000000000000000000000000000..21b7e67b8e74355bf8e0b334aecb9b71f639477e --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java @@ -0,0 +1,10 @@ +package fr.gouv.clea.ws.service; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.gouv.clea.ws.vo.ReportRequest; + +import java.util.List; + +public interface IReportService { + List<DecodedVisit> report(ReportRequest reportRequestVo); +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java new file mode 100644 index 0000000000000000000000000000000000000000..af7a4e77044c8ef12d54b4e625636601a190c653 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java @@ -0,0 +1,58 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.exception.CleaUnauthorizedException; +import fr.gouv.clea.ws.service.IAuthorizationService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; + +@Service +@Slf4j +public class AuthorizationService implements IAuthorizationService { + + private final boolean checkAuthorization; + + private final String robertJwtPublicKey; + + @Autowired + public AuthorizationService( + @Value("${clea.conf.security.report.checkAuthorization}") boolean checkAuthorization, + @Value("${clea.conf.security.report.robertJwtPublicKey}") String robertJwtPublicKey) { + this.checkAuthorization = checkAuthorization; + this.robertJwtPublicKey = robertJwtPublicKey; + } + + public boolean checkAuthorization(String jwtToken) throws CleaUnauthorizedException { + if (this.checkAuthorization) { + if (jwtToken == null) { + log.warn("Missing Authorisation header!"); + throw new CleaUnauthorizedException("Missing Authorisation header!"); + } + jwtToken = jwtToken.replace("Bearer ", ""); + this.verifyJWT(jwtToken); + } + return true; + } + + private void verifyJWT(String token) throws CleaUnauthorizedException { + PublicKey jwtPublicKey; + try { + byte[] encoded = Decoders.BASE64.decode(this.robertJwtPublicKey); + KeyFactory keyFactory = KeyFactory.getInstance(SignatureAlgorithm.RS256.getFamilyName()); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded); + jwtPublicKey = keyFactory.generatePublic(keySpec); + Jwts.parserBuilder().setSigningKey(jwtPublicKey).build().parseClaimsJws(token); + } catch (Exception e) { + log.warn("Failed to verify JWT token!", e); + throw new CleaUnauthorizedException(); + } + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java new file mode 100644 index 0000000000000000000000000000000000000000..ff932106e39c7bbac96a9c2a682356af8d071f47 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java @@ -0,0 +1,46 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.gouv.clea.ws.service.IDecodedVisitProducerService; +import fr.gouv.clea.ws.utils.MessageFormatter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; +import org.springframework.util.concurrent.ListenableFutureCallback; + +import java.util.List; + +@Service +@Slf4j +public class DecodedVisitProducerService implements IDecodedVisitProducerService { + + private final KafkaTemplate<String, DecodedVisit> kafkaTemplate; + + @Autowired + public DecodedVisitProducerService( + KafkaTemplate<String, DecodedVisit> kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @Override + public void produce(List<DecodedVisit> serializableDecodedVisits) { + serializableDecodedVisits.forEach( + it -> kafkaTemplate.sendDefault(it).addCallback( + new ListenableFutureCallback<>() { + @Override + public void onFailure(Throwable ex) { + // TODO: Do we want a mechanism to do not loose the message (e.g. spring retry)? + log.error("error sending [locationTemporaryPublicId: {}, qrCodeScanTime: {}] to queue. message: {}", MessageFormatter.truncateUUID(it.getStringLocationTemporaryPublicId()), it.getQrCodeScanTime(), ex.getLocalizedMessage()); + } + + @Override + public void onSuccess(SendResult<String, DecodedVisit> result) { + log.info("[locationTemporaryPublicId: {}, qrCodeScanTime: {}] sent to queue", MessageFormatter.truncateUUID(it.getStringLocationTemporaryPublicId()), it.getQrCodeScanTime()); + } + } + ) + ); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java new file mode 100644 index 0000000000000000000000000000000000000000..d137b0796107866e9bef2084782d42e82faff18d --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java @@ -0,0 +1,118 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.gouv.clea.ws.service.IDecodedVisitProducerService; +import fr.gouv.clea.ws.service.IReportService; +import fr.gouv.clea.ws.utils.MessageFormatter; +import fr.gouv.clea.ws.vo.ReportRequest; +import fr.gouv.clea.ws.vo.Visit; +import fr.inria.clea.lsp.CleaEncodingException; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import fr.inria.clea.lsp.utils.TimeUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ReportService implements IReportService { + + private final int retentionDurationInDays; + + private final long duplicateScanThresholdInSeconds; + + private final LocationSpecificPartDecoder decoder; + + private final IDecodedVisitProducerService processService; + + @Autowired + public ReportService( + @Value("${clea.conf.retentionDurationInDays}") int retentionDuration, + @Value("${clea.conf.duplicateScanThresholdInSeconds}") long duplicateScanThreshold, + LocationSpecificPartDecoder decoder, + IDecodedVisitProducerService processService) { + this.retentionDurationInDays = retentionDuration; + this.duplicateScanThresholdInSeconds = duplicateScanThreshold; + this.decoder = decoder; + this.processService = processService; + } + + @Override + public List<DecodedVisit> report(ReportRequest reportRequestVo) { + List<DecodedVisit> verified = reportRequestVo.getVisits().stream() + .filter(visit -> !this.isOutdated(visit)) + .filter(visit -> !this.isFuture(visit)) + .map(it -> this.decode(it, reportRequestVo.getPivotDateAsNtpTimestamp())) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + List<DecodedVisit> pruned = this.pruneDuplicates(verified); + processService.produce(pruned); + return pruned; + } + + private DecodedVisit decode(Visit visit, long pivotDate) { + try { + byte[] binaryLocationSpecificPart = Base64.getDecoder().decode(visit.getQrCode()); + EncryptedLocationSpecificPart encryptedLocationSpecificPart = decoder.decodeHeader(binaryLocationSpecificPart); + Instant qrCodeScanTime = TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp()); + return new DecodedVisit(qrCodeScanTime, encryptedLocationSpecificPart, visit.getQrCodeScanTimeAsNtpTimestamp() < pivotDate); + } catch (CleaEncodingException e) { + log.warn("report: {} rejected: Invalid format", MessageFormatter.truncateQrCode(visit.getQrCode())); + return null; + } + } + + private boolean isOutdated(Visit visit) { + boolean outdated = ChronoUnit.DAYS.between(TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp()), Instant.now()) > retentionDurationInDays; + if (outdated) { + log.warn("report: {} rejected: Outdated", MessageFormatter.truncateQrCode(visit.getQrCode())); + } + return outdated; + } + + private boolean isFuture(Visit visit) { + boolean future = TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp()).isAfter(Instant.now()); + if (future) { + log.warn("report: {} rejected: In future", MessageFormatter.truncateQrCode(visit.getQrCode())); + } + return future; + } + + private boolean isDuplicatedScan(DecodedVisit lsp, List<DecodedVisit> cleaned) { + return cleaned.stream().anyMatch(cleanedLsp -> this.isDuplicatedScan(lsp, cleanedLsp)); + } + + private boolean isDuplicatedScan(DecodedVisit one, DecodedVisit other) { + if (one.getLocationTemporaryPublicId() != other.getLocationTemporaryPublicId()) { + return false; + } + + long secondsBetweenScans = Duration.between(one.getQrCodeScanTime(), other.getQrCodeScanTime()).abs().toSeconds(); + if (secondsBetweenScans <= duplicateScanThresholdInSeconds) { + log.warn("report: {} {} rejected: Duplicate", MessageFormatter.truncateUUID(one.getStringLocationTemporaryPublicId()), one.getQrCodeScanTime()); + return true; + } + return false; + } + + private List<DecodedVisit> pruneDuplicates(List<DecodedVisit> locationSpecificParts) { + List<DecodedVisit> cleaned = new ArrayList<>(); + locationSpecificParts.forEach(it -> { + if (!this.isDuplicatedScan(it, cleaned)) { + cleaned.add(it); + } + }); + return cleaned; + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java new file mode 100644 index 0000000000000000000000000000000000000000..de9873c884af302629f52b3b8a926932d2ecf884 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java @@ -0,0 +1,27 @@ +package fr.gouv.clea.ws.utils; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.BindingResult; +import org.springframework.web.context.request.WebRequest; + +import javax.validation.ConstraintViolation; +import java.util.Set; + +@Slf4j +@Service +public class BadArgumentsLoggerService { + public static final String INVALID_INPUT_TEMPLATE = "Invalid input data: %s, requested uri: %s"; + + public <T> void logValidationErrorMessage(Set<ConstraintViolation<T>> violations, WebRequest webRequest) { + final String path = webRequest.getDescription(false); + String message = violations.toString(); + log.error(String.format(INVALID_INPUT_TEMPLATE, message, path)); + } + + public void logValidationErrorMessage(BindingResult bindingResult, WebRequest webRequest) { + final String path = webRequest.getDescription(false); + String message = bindingResult.toString(); + log.error(String.format(INVALID_INPUT_TEMPLATE, message, path)); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java new file mode 100644 index 0000000000000000000000000000000000000000..35a9f8d6b0205dd5768ee2b614e1cbbdeb0959ca --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java @@ -0,0 +1,67 @@ +package fr.gouv.clea.ws.utils; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +public class KafkaDeserializer implements Deserializer<DecodedVisit> { + + @Override + public DecodedVisit deserialize(String topic, byte[] data) { + if (data == null) + return null; + try { + return new ObjectMapper() + .registerModule(new SimpleModule().addDeserializer(DecodedVisit.class, new CustomJacksonDeserializer())) + .readValue(data, DecodedVisit.class); + } catch (IOException e) { + throw new SerializationException("Error deserializing JSON message", e); + } + } +} + +class CustomJacksonDeserializer extends StdDeserializer<DecodedVisit> { + + private static final long serialVersionUID = 1L; + + public CustomJacksonDeserializer() { + this(null); + } + + public CustomJacksonDeserializer(Class<DecodedVisit> t) { + super(t); + } + + @Override + public DecodedVisit deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + long qrCodeScanTime = node.get("qrCodeScanTime").asLong(); + boolean isBackward = node.get("isBackward").asBoolean(); + int version = node.get("version").asInt(); + int type = node.get("type").asInt(); + UUID locationTemporaryPublicId = UUID.fromString(node.get("locationTemporaryPublicId").asText()); + byte[] encryptedLocationMessage = node.get("encryptedLocationMessage").binaryValue(); + return new DecodedVisit( + Instant.ofEpochMilli(qrCodeScanTime).truncatedTo(ChronoUnit.SECONDS), + EncryptedLocationSpecificPart.builder() + .version(version) + .type(type) + .locationTemporaryPublicId(locationTemporaryPublicId) + .encryptedLocationMessage(encryptedLocationMessage) + .build(), + isBackward + ); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java new file mode 100644 index 0000000000000000000000000000000000000000..c3564e35adbd6fe8e5b7774552f3f84133c8c748 --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java @@ -0,0 +1,58 @@ +package fr.gouv.clea.ws.utils; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Serializer; + +import java.io.IOException; + +public class KafkaSerializer implements Serializer<DecodedVisit> { + + @Override + public byte[] serialize(String topic, DecodedVisit data) { + if (data == null) + return null; + try { + return new ObjectMapper() + .registerModule(new SimpleModule().addSerializer(DecodedVisit.class, new CustomJacksonSerializer())) + .writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException("Error serializing JSON message", e); + } + } +} + +class CustomJacksonSerializer extends StdSerializer<DecodedVisit> { + + private static final long serialVersionUID = 1L; + + public CustomJacksonSerializer() { + this(null); + } + + public CustomJacksonSerializer(Class<DecodedVisit> t) { + super(t); + } + + @Override + public void serialize(DecodedVisit visit, JsonGenerator generator, SerializerProvider provider) throws IOException { + long qrCodeScanTime = visit.getQrCodeScanTime().toEpochMilli(); + boolean isBackward = visit.isBackward(); + EncryptedLocationSpecificPart enc = visit.getEncryptedLocationSpecificPart(); + generator.writeStartObject(); + generator.writeNumberField("qrCodeScanTime", qrCodeScanTime); + generator.writeBooleanField("isBackward", isBackward); + generator.writeNumberField("version", enc.getVersion()); + generator.writeNumberField("type", enc.getType()); + generator.writeStringField("locationTemporaryPublicId", enc.getLocationTemporaryPublicId().toString()); + generator.writeBinaryField("encryptedLocationMessage", enc.getEncryptedLocationMessage()); + generator.writeEndObject(); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java new file mode 100644 index 0000000000000000000000000000000000000000..94bfef5c4141e878048aa7c7f93f1de693d253ea --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java @@ -0,0 +1,12 @@ +package fr.gouv.clea.ws.utils; + +public class MessageFormatter { + + public static String truncateUUID(String message) { + return message.substring(0, Math.min(message.length(), 10)).concat("..."); + } + + public static String truncateQrCode(String qrCode) { + return qrCode.substring(0, Math.min(qrCode.length(), 25)).concat("..."); + } +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java new file mode 100644 index 0000000000000000000000000000000000000000..690a89489e3b38157db66c2e07b32e66e3cbec3a --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java @@ -0,0 +1,8 @@ +package fr.gouv.clea.ws.utils; + +public class UriConstants { + + public static final String REPORT = "/wreport"; + + public static final String API_V1 = "/v1"; +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..5f66a53d17fe7a98d57853013f031492245f71ce --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java @@ -0,0 +1,25 @@ +package fr.gouv.clea.ws.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Valid +public class ReportRequest { + @NotNull + @NotEmpty + private List<Visit> visits; + + @NotNull + @JsonProperty("pivotDate") + private Long pivotDateAsNtpTimestamp; +} diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java new file mode 100644 index 0000000000000000000000000000000000000000..1d5dbcb2c7764539cf88080d795464c0cb7c9e9d --- /dev/null +++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java @@ -0,0 +1,24 @@ +package fr.gouv.clea.ws.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Visit { + @NotNull + @NotEmpty + @NotBlank + private String qrCode; + + @NotNull + @JsonProperty("qrCodeScanTime") + private Long qrCodeScanTimeAsNtpTimestamp; // t_qrScan +} diff --git a/clea/clea-ws-rest/src/main/resources/api-report-v1.yml b/clea/clea-ws-rest/src/main/resources/api-report-v1.yml new file mode 100644 index 0000000000000000000000000000000000000000..460d7986a0f462ca16613a94bc4b2e53bb78a0fb --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/api-report-v1.yml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: Tous AntiCovid Cluster Exposure Verification (Cléa) + description: "#TOUSANTICOVID, Cléa API" + contact: + email: stopcovid@inria.fr + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.0 +servers: + - url: http://localhost:8088 + description: Inferred Url +tags: + - name: clea + description: Clea API +paths: + "/api/clea/v1/wreport": + post: + tags: + - clea + summary: Upload locations history + description: | + Upload a list of {qrCode, timestamp} tuples where : + * **qrCode**: QR code content encoded in Base64 + * **qrCodeScanTime**: NTP timestamp when a user terminal scans a given QR code + operationId: reportUsingPOST + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/ReportRequest" + responses: + '200': + description: Successful Operation + content: + application/json: + schema: + "$ref": "#/components/schemas/ReportResponse" + examples: + example-0: + value: |- + { + "success": "true", + "message": "2 qr processed, 0 rejected" + } + '400': + description: Bad Request + '401': + description: Invalid Authentication + '500': + description: Internal Error + security: + - Authorization: + - global +components: + schemas: + ReportRequest: + title: ReportRequest + required: + - visits + type: object + properties: + pivotDate: + type: integer + format: int64 + visits: + type: array + items: + "$ref": "#/components/schemas/Visit" + ReportResponse: + title: ReportResponse + type: object + properties: + message: + type: string + success: + type: boolean + Visit: + title: Visit + required: + - qrCode + - qrCodeScanTime + type: object + properties: + qrCode: + type: string + qrCodeScanTime: + type: integer + format: int64 + securitySchemes: + Authorization: + type: apiKey + name: Authorization + in: header diff --git a/clea/clea-ws-rest/src/main/resources/application-docker.yml b/clea/clea-ws-rest/src/main/resources/application-docker.yml new file mode 100644 index 0000000000000000000000000000000000000000..82ad33a1d542216dfe4928d525cb1886b7edc8a2 --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/application-docker.yml @@ -0,0 +1,9 @@ +server: + port: 8080 + +clea: + conf: + security: + report: + checkAuthorization: false + diff --git a/clea/clea-ws-rest/src/main/resources/application-local.yml b/clea/clea-ws-rest/src/main/resources/application-local.yml new file mode 100644 index 0000000000000000000000000000000000000000..01105b346ec264e8354b274916c8351a0237329d --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/application-local.yml @@ -0,0 +1,5 @@ +clea: + conf: + security: + report: + checkAuthorization: false \ No newline at end of file diff --git a/clea/clea-ws-rest/src/main/resources/application.yml b/clea/clea-ws-rest/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..aee6355acf1d1f82f9270b914bb767f9d9d6066b --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/application.yml @@ -0,0 +1,37 @@ +controller: + path: + prefix: /api/clea +server: + port: 8088 + +# Available endpoints for the monitoring +management: + endpoints: + web: + exposure: + include: ${CLEA_SERVER_MONITORING_ENDPOINTS:health,metrics} + +clea: + conf: + duplicateScanThresholdInSeconds: 10800 + retentionDurationInDays: 14 + security: + report: + checkAuthorization: true # secured by default + robertJwtPublicKey: ${CLEA_SECURITY_JWT_KEY:TODO} + +logging: + level: + fr.gouv.clea.ws: INFO + +spring: + kafka: + bootstrap-servers: "${KAFKA_URL:localhost:9092}" + consumer: + group-id: ${KAFKA_GROUPID:group1} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: fr.gouv.clea.ws.utils.KafkaSerializer + template: + default-topic: ${KAFKA_TOPIC:cleaQrCodes} + diff --git a/clea/clea-ws-rest/src/main/resources/banner.txt b/clea/clea-ws-rest/src/main/resources/banner.txt new file mode 100644 index 0000000000000000000000000000000000000000..8f714fefab79608e831b35a3360c80ef53abe17f --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/banner.txt @@ -0,0 +1,8 @@ + ___ _ __ __ ___ _ + / __| |___ __ _ __\ \ / /_____| _ \___ __| |_ + | (__| / -_) _` |___\ \/\/ (_-<___| / -_|_-< _| + \___|_\___\__,_| \_/\_//__/ |_|_\___/__/\__| + +${application.title}:${application.version} + Powered-by : Spring-Boot ${spring-boot.formatted-version} + Run with : Java ${java.version} ${java.vm.version} diff --git a/clea/clea-ws-rest/src/main/resources/bootstrap.yml b/clea/clea-ws-rest/src/main/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..10d63aa9e038b0331a0fecd80a693d02f2eabc63 --- /dev/null +++ b/clea/clea-ws-rest/src/main/resources/bootstrap.yml @@ -0,0 +1,18 @@ +spring: + application: + name: clea-server + cloud: + consul: + enabled: ${CONSUL_ENABLED:false} + host: ${CONSUL_HOST:localhost} + port: ${CONSUL_PORT:8500} + token: ${CLEA_SERVER_CONSUL_ACL_TOKEN:token} + scheme: ${CONSUL_SCHEME:http} + config: + enabled: ${CONSUL_CONFIG_ENABLED:false} + vault: + enabled: ${VAULT_ENABLED:false} + host: ${VAULT_HOST:localhost} + port: ${VAULT_PORT:8200} + token: ${VAULT_TOKEN:token} + scheme: ${VAULT_SCHEME:http} diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java new file mode 100644 index 0000000000000000000000000000000000000000..92887ce4ac9086d40468eb8e8712413577b477db --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java @@ -0,0 +1,62 @@ +package fr.gouv.clea.ws.controller; + +import fr.gouv.clea.ws.utils.UriConstants; +import fr.gouv.clea.ws.vo.ReportRequest; +import fr.gouv.clea.ws.vo.Visit; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = {"clea.conf.security.report.checkAuthorization=true"}) +public class CleaControllerAuthEnabledTest { + + @Value("${controller.path.prefix}" + UriConstants.API_V1) + private String pathPrefix; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void testWhenReportRequestWithMissingAuthenticationThenGetBadRequest() { + List<Visit> visits = List.of( + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()), + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()), + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()) + ); + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, 0L), CleaControllerTest.newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void testWhenReportRequestWithNonValidAuthenticationThenGetUnauthorized() { + List<Visit> visits = List.of( + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()), + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()), + new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()) + ); + HttpHeaders headers = CleaControllerTest.newJsonHeader(); + headers.setBearerAuth("invalid JWT token"); + HttpEntity<ReportRequest> reportEntity = new HttpEntity<>(new ReportRequest(visits, 0L), headers); + + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, reportEntity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } +} diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ca7ce33a8e3a2938faf1c4cbd1e9355f387f1020 --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java @@ -0,0 +1,214 @@ +package fr.gouv.clea.ws.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gouv.clea.ws.service.impl.ReportService; +import fr.gouv.clea.ws.utils.UriConstants; +import fr.gouv.clea.ws.vo.ReportRequest; +import fr.gouv.clea.ws.vo.Visit; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public class CleaControllerTest { + + @Captor + ArgumentCaptor<ReportRequest> reportRequestArgumentCaptor; + @Value("${controller.path.prefix}" + UriConstants.API_V1) + private String pathPrefix; + @Autowired + private TestRestTemplate restTemplate; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private ReportService reportService; + + public static HttpHeaders newJsonHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + @Test + public void testInfectedUserCanReportHimselfAsInfected() { + List<Visit> visits = List.of(new Visit("qrCode", 0L)); + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, 0L), newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void testWhenReportRequestWithInvalidMediaTypeThenGetUnsupportedMediaType() { + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, "foo", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + verifyNoMoreInteractions(reportService); + } + + @Test + public void testWhenReportRequestWithNullVisitListThenGetBadRequest() { + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(null, 0L), newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + public void testWhenReportRequestWithInvalidJsonDataThenGetBadRequest() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", 1); + ResponseEntity<String> response = restTemplate.postForEntity( + pathPrefix + UriConstants.REPORT, + new HttpEntity<>(jsonObject.toString(), newJsonHeader()), + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + @DisplayName("when pivotDate is null, reject everything") + void nullPivotDate() { + List<Visit> visits = List.of(new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong())); + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, null), newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + @DisplayName("when pivotDate is not numeric, reject everything") + void notNumericPivotDate() throws JsonProcessingException { + ReportRequest reportRequest = new ReportRequest(List.of(new Visit(RandomStringUtils.randomAlphanumeric(20), 1L)), 0L); + String json = objectMapper.writeValueAsString(reportRequest); + String badJson = json.replace("0", "a"); + HttpEntity<String> request = new HttpEntity<>(badJson, newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + @DisplayName("when visit list is null, reject everything") + void nullVisitList() { + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(null, 0L), newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + @DisplayName("when visit list is empty, reject everything") + void emptyVisitList() { + HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(List.of(), 0L), newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } + + @Test + @DisplayName("when a qrCode is null reject just the visit") + void nullQrCode() { + HttpEntity<ReportRequest> request = new HttpEntity<>( + new ReportRequest(List.of(new Visit("qr1", 1L), new Visit(null, 2L)), 3L), + newJsonHeader() + ); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture()); + assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode() == null).findAny()).isEmpty(); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1"); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("when a qrCode is empty reject just the visit") + void emptyQrCode() { + HttpEntity<ReportRequest> request = new HttpEntity<>( + new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("", 2L)), 3L), + newJsonHeader() + ); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture()); + assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode().isEmpty()).findAny()).isEmpty(); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1"); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("when a qrCode is blank reject just the visit") + void blankQrCode() { + HttpEntity<ReportRequest> request = new HttpEntity<>( + new ReportRequest(List.of(new Visit("qr1", 1L), new Visit(" ", 2L)), 3L), + newJsonHeader() + ); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture()); + assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode().isBlank()).findAny()).isEmpty(); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1"); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("when a qrScan is null reject just the visit") + void nullQrScan() { + HttpEntity<ReportRequest> request = new HttpEntity<>( + new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("qr2", null)), 3L), + newJsonHeader() + ); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture()); + assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCodeScanTimeAsNtpTimestamp() == null).findAny()).isEmpty(); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1"); + assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("when a qrScan is not numeric reject everything") + void notNumericQrScan() throws JsonProcessingException { + ReportRequest reportRequest = new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("qr2", 2L)), 3L); + String json = objectMapper.writeValueAsString(reportRequest); + String badJson = json.replace("2", "a"); + HttpEntity<String> request = new HttpEntity<>(badJson, newJsonHeader()); + ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoMoreInteractions(reportService); + } +} diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..bed92e62f238b7cfea560f36ae2f9de1164d44d5 --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java @@ -0,0 +1,84 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.exception.CleaUnauthorizedException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Encoders; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.fail; + +class AuthorizationServiceTest { + + private AuthorizationService authorizationService; + + private AuthorizationService disabledAuthorizationService; + + private KeyPair keyPair; + + @BeforeEach + void init() { + keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); + String jwtPublicKey = Encoders.BASE64.encode(keyPair.getPublic().getEncoded()); + authorizationService = new AuthorizationService(true, jwtPublicKey); + disabledAuthorizationService = new AuthorizationService(false, jwtPublicKey); + } + + @Test + @DisplayName("if auth is activated in conf, null header should throw CleaUnauthorizedException") + void testGiventAuthActivatedAndNullTokenThenCleaUnauthorizedExpcetionThrown() { + assertThatExceptionOfType(CleaUnauthorizedException.class) + .isThrownBy(() -> authorizationService.checkAuthorization(null)); + } + + @Test + @DisplayName("if auth is deactivated in conf, null header should have no impact") + void testGiventAuthDeactivatedAndNullTokenThenNoExceptionThrown() { + try { + disabledAuthorizationService.checkAuthorization(null); + } catch (Exception e) { + fail("if auth is deactivated in conf, null header should have no impact"); + } + } + + @Test + void testGivenAnInvalidJwtBearerWhenRequestingAuthorizationThenAuthorizationFails() { + assertThatExceptionOfType(CleaUnauthorizedException.class) + .isThrownBy(() -> authorizationService.checkAuthorization("unauthorized")); + } + + @Test + void testGivenAValidJwtBearerWhenRequestingAuthorizationThenAuthorizationSucceeds() { + long jwtLifeTime = 5; + Instant now = Instant.now(); + Instant expiration = now.plus(jwtLifeTime, ChronoUnit.MINUTES); + boolean isAuthorized = authorizationService.checkAuthorization(this.newJwtToken(now, expiration)); + assertThat(isAuthorized).isTrue(); + } + + @Test + public void testGivenAValidJwtBearerAlreadyExpiredWhenRequestingAuthorizationThenAuthorizationFails() { + Instant now = Instant.now(); + assertThatExceptionOfType(CleaUnauthorizedException.class) + .isThrownBy(() -> authorizationService.checkAuthorization(this.newJwtToken(now, now))); + } + + protected String newJwtToken(Instant now, Instant expiration) { + return Jwts.builder() + .setHeaderParam("type", "JWT") + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiration)) + .signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256) + .compact(); + } +} diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..62989fac3a55e44f49c868e1d60ce444ef5df10a --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java @@ -0,0 +1,135 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.gouv.clea.ws.service.IDecodedVisitProducerService; +import fr.gouv.clea.ws.utils.KafkaDeserializer; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.commons.lang3.RandomUtils; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DirtiesContext +@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9092", "port=9092"}) +@TestPropertySource(properties = {"kafka.bootstrapAddress=localhost:9092"}) +class DecodedVisitProducerServiceTest { + + @Autowired + private IDecodedVisitProducerService decodedVisitProducerService; + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + private Consumer<String, DecodedVisit> consumer; + + @Value("${spring.kafka.template.default-topic}") + private String defaultTopic; + + private static DecodedVisit createSerializableDecodedVisit(Instant qrCodeScanTime, boolean isBackward, UUID locationTemporaryPublicId, byte[] encryptedLocationMessage) { + return new DecodedVisit( + qrCodeScanTime, + EncryptedLocationSpecificPart.builder() + .version(RandomUtils.nextInt()) + .type(RandomUtils.nextInt()) + .locationTemporaryPublicId(locationTemporaryPublicId) + .encryptedLocationMessage(encryptedLocationMessage) + .build(), + isBackward + ); + } + + @BeforeEach + void init() { + Map<String, Object> configs = new HashMap<>(KafkaTestUtils.consumerProps("consumer", "false", embeddedKafkaBroker)); + consumer = new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), new KafkaDeserializer()).createConsumer(); + consumer.subscribe(Collections.singleton(defaultTopic)); + } + + @Test + @DisplayName("test that produce send decoded lsps to kafka and that we can read them back") + void testProduce() { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + UUID uuid3 = UUID.randomUUID(); + + byte[] encryptedLocationMessage1 = RandomUtils.nextBytes(21); + byte[] encryptedLocationMessage2 = RandomUtils.nextBytes(22); + byte[] encryptedLocationMessage3 = RandomUtils.nextBytes(23); + + boolean isBackward1 = RandomUtils.nextBoolean(); + boolean isBackward2 = RandomUtils.nextBoolean(); + boolean isBackward3 = RandomUtils.nextBoolean(); + + Instant qrCodeScanTime1 = newRandomInstant(); + Instant qrCodeScanTime2 = newRandomInstant(); + Instant qrCodeScanTime3 = newRandomInstant(); + + List<DecodedVisit> decoded = List.of( + createSerializableDecodedVisit(qrCodeScanTime1, isBackward1, uuid1, encryptedLocationMessage1), + createSerializableDecodedVisit(qrCodeScanTime2, isBackward2, uuid2, encryptedLocationMessage2), + createSerializableDecodedVisit(qrCodeScanTime3, isBackward3, uuid3, encryptedLocationMessage3) + ); + + decodedVisitProducerService.produce(decoded); + + ConsumerRecords<String, DecodedVisit> records = KafkaTestUtils.getRecords(consumer); + assertThat(records.count()).isEqualTo(3); + + List<DecodedVisit> extracted = StreamSupport + .stream(records.spliterator(), true) + .map(ConsumerRecord::value) + .collect(Collectors.toList()); + assertThat(extracted.size()).isEqualTo(3); + + DecodedVisit visit1 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findFirst().orElse(null); + assertThat(visit1).isNotNull(); + assertThat(visit1.getLocationTemporaryPublicId()).isEqualTo(uuid1); + assertThat(visit1.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage1); + assertThat(visit1.getQrCodeScanTime()).isEqualTo(qrCodeScanTime1); + assertThat(visit1.isBackward()).isEqualTo(isBackward1); + + DecodedVisit visit2 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findFirst().orElse(null); + assertThat(visit2).isNotNull(); + assertThat(visit2.getLocationTemporaryPublicId()).isEqualTo(uuid2); + assertThat(visit2.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage2); + assertThat(visit2.getQrCodeScanTime()).isEqualTo(qrCodeScanTime2); + assertThat(visit2.isBackward()).isEqualTo(isBackward2); + + DecodedVisit visit3 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findFirst().orElse(null); + assertThat(visit3).isNotNull(); + assertThat(visit3.getLocationTemporaryPublicId()).isEqualTo(uuid3); + assertThat(visit3.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage3); + assertThat(visit3.getQrCodeScanTime()).isEqualTo(qrCodeScanTime3); + assertThat(visit3.isBackward()).isEqualTo(isBackward3); + } + + protected Instant newRandomInstant() { + return Instant.ofEpochSecond(RandomUtils.nextLong(0, Instant.now().getEpochSecond())); + } + +} \ No newline at end of file diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..21c4c22dcec6722f6cd4531400ac97908fe33950 --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java @@ -0,0 +1,164 @@ +package fr.gouv.clea.ws.service.impl; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.gouv.clea.ws.service.IDecodedVisitProducerService; +import fr.gouv.clea.ws.service.IReportService; +import fr.gouv.clea.ws.vo.ReportRequest; +import fr.gouv.clea.ws.vo.Visit; +import fr.inria.clea.lsp.CleaEncodingException; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPart; +import fr.inria.clea.lsp.LocationSpecificPartDecoder; +import fr.inria.clea.lsp.LocationSpecificPartEncoder; +import fr.inria.clea.lsp.utils.TimeUtils; +import org.apache.tomcat.util.codec.binary.Base64; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ReportServiceTest { + + private final int retentionDuration = 14; + private final long duplicateScanThresholdInSeconds = 10800L; + private final LocationSpecificPartDecoder decoder = mock(LocationSpecificPartDecoder.class); + private final IDecodedVisitProducerService processService = mock(IDecodedVisitProducerService.class); + private final IReportService reportService = new ReportService(retentionDuration, duplicateScanThresholdInSeconds, decoder, processService); + private Instant now; + + @BeforeEach + void init() { + now = Instant.now(); + assertThat(decoder).isNotNull(); + assertThat(processService).isNotNull(); + assertThat(reportService).isNotNull(); + doNothing().when(processService).produce(anyList()); + } + + @Test + @DisplayName("test successful report with no rejection") + void report() throws CleaEncodingException { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + UUID uuid3 = UUID.randomUUID(); + List<Visit> visits = List.of( + newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(2, ChronoUnit.DAYS))), // pass + newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.minus(1, ChronoUnit.DAYS))), // pass + newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now)) /* pass */); + + List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L)); + + assertThat(processed.size()).isEqualTo(3); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isPresent(); + } + + @Test + @DisplayName("test report with non valid qr codes") + void testWithNonValidReports() throws CleaEncodingException { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + UUID uuid3 = UUID.randomUUID(); + List<Visit> visits = List.of( + newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(1, ChronoUnit.DAYS))), // pass + newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now)), // pass + newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now.plus(1, ChronoUnit.DAYS))) /* don't pass */); + + + List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L)); + + assertThat(processed.size()).isEqualTo(2); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isNotPresent(); + } + + @Test + @DisplayName("test report with outdated scans") + void testWithOutdatedReports() throws CleaEncodingException { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + UUID uuid3 = UUID.randomUUID(); + UUID uuid4 = UUID.randomUUID(); + List<Visit> visits = List.of( + newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(15, ChronoUnit.DAYS))), // don't pass + newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.minus(14, ChronoUnit.DAYS))), // pass + newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now.minus(2, ChronoUnit.DAYS))), // pass + newVisit(uuid4, TimeUtils.ntpTimestampFromInstant(now)) /* pass */); + + + List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L)); + + assertThat(processed.size()).isEqualTo(3); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isNotPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid4)).findAny()).isPresent(); + } + + @Test + @DisplayName("test report with future scans") + void testWithFutureReports() throws CleaEncodingException { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + List<Visit> visits = List.of( + newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now)), // pass + newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.plus(1, ChronoUnit.SECONDS))) /* don't pass */); + + List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L)); + + assertThat(processed.size()).isEqualTo(1); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent(); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isNotPresent(); + } + + @Test + @DisplayName("test report with duplicated qr codes") + void testWithDuplicates() throws CleaEncodingException { + UUID uuidA = UUID.randomUUID(); + UUID uuidB = UUID.randomUUID(); + UUID uuidC = UUID.randomUUID(); + List<Visit> visits = List.of( + newVisit(uuidA, TimeUtils.ntpTimestampFromInstant(now.minus(4, ChronoUnit.HOURS))), // pass + newVisit(uuidA, TimeUtils.ntpTimestampFromInstant(now)), // pass + newVisit(uuidB, TimeUtils.ntpTimestampFromInstant(now.minus(3, ChronoUnit.HOURS))), // pass + newVisit(uuidB, TimeUtils.ntpTimestampFromInstant(now)), // don't pass + newVisit(uuidC, TimeUtils.ntpTimestampFromInstant(now)), // pass + newVisit(uuidC, TimeUtils.ntpTimestampFromInstant(now)) /* don't pass */); + + List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L)); + + assertThat(processed.size()).isEqualTo(4); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidA)).count()).isEqualTo(2); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidB)).count()).isEqualTo(1); + assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidC)).count()).isEqualTo(1); + } + + private EncryptedLocationSpecificPart createEncryptedLocationSpecificPart(UUID locationTemporaryPublicId) { + return EncryptedLocationSpecificPart.builder() + .locationTemporaryPublicId(locationTemporaryPublicId) + .build(); + } + + private Visit newVisit(UUID uuid, Long qrCodeScanTime) throws CleaEncodingException { + LocationSpecificPart lsp = LocationSpecificPart.builder() + .locationTemporaryPublicId(uuid) + .build(); + byte[] qrCodeHeader = new LocationSpecificPartEncoder(null).binaryEncodedHeader(lsp); + String qrCode = Base64.encodeBase64String(qrCodeHeader); + when(decoder.decodeHeader(qrCodeHeader)).thenReturn(createEncryptedLocationSpecificPart(uuid)); + return new Visit(qrCode, qrCodeScanTime); + } + +} \ No newline at end of file diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..1aa24517d4a564fcb041f9d6f987d897a7ec0b7a --- /dev/null +++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java @@ -0,0 +1,41 @@ +package fr.gouv.clea.ws.utils; + +import fr.gouv.clea.ws.model.DecodedVisit; +import fr.inria.clea.lsp.EncryptedLocationSpecificPart; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaSerializerTest { + + @Test + void testCanSerializeAndDeserializeAVisit() { + DecodedVisit decoded = new DecodedVisit( + Instant.now().truncatedTo(ChronoUnit.SECONDS), + EncryptedLocationSpecificPart.builder() + .version(RandomUtils.nextInt()) + .type(RandomUtils.nextInt()) + .locationTemporaryPublicId(UUID.randomUUID()) + .encryptedLocationMessage(RandomUtils.nextBytes(20)) + .build(), + RandomUtils.nextBoolean() + ); + KafkaSerializer serializer = new KafkaSerializer(); + KafkaDeserializer deserializer = new KafkaDeserializer(); + + byte[] serializedVisit = serializer.serialize("", decoded); + DecodedVisit deserializedVisit = deserializer.deserialize("", serializedVisit); + + assertThat(decoded.getQrCodeScanTime().truncatedTo(ChronoUnit.SECONDS)).isEqualTo(deserializedVisit.getQrCodeScanTime().truncatedTo(ChronoUnit.SECONDS)); + assertThat(decoded.isBackward()).isEqualTo(deserializedVisit.isBackward()); + assertThat(decoded.getEncryptedLocationSpecificPart()).isEqualTo(deserializedVisit.getEncryptedLocationSpecificPart()); + serializer.close(); + deserializer.close(); + } + +} \ No newline at end of file diff --git a/clea/clea-ws-rest/src/test/resources/application.properties b/clea/clea-ws-rest/src/test/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..d436a313295e831c319a835b75aea5da046f922c --- /dev/null +++ b/clea/clea-ws-rest/src/test/resources/application.properties @@ -0,0 +1,2 @@ +# For Unit-test, remove need for a JWT +clea.conf.security.report.checkAuthorization=false \ No newline at end of file diff --git a/clea/docker-compose.yml b/clea/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..ef8bc6901443b23a06daabb3384805cb53bf1cdc --- /dev/null +++ b/clea/docker-compose.yml @@ -0,0 +1,113 @@ +services: + + clea-ws-rest: + container_name: clea-ws-rest + image: clea-ws-rest:latest + build: ./clea-ws-rest/ + environment: + SPRING_PROFILES_ACTIVE: docker + KAFKA_URL: kafka:29092 + depends_on: + - kafka + ports: + - "8080:8080" + networks: + - clea-network + restart: always + + clea-venue-consumer: + container_name: clea-venue-consumer + image: clea-venue-consumer:latest + build: ./clea-venue-consumer/ + environment: + SPRING_PROFILES_ACTIVE: docker + DB_URL: jdbc:postgresql://postgres:5432/cleadb + DB_USER: user + DB_PASSWORD: pass + KAFKA_URL: kafka:29092 + depends_on: + - postgres + - kafka + ports: + - "7070:8080" + networks: + - clea-network + restart: always + + kafka: + container_name: kafka + image: "wurstmeister/kafka:latest" + environment: + KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL" + KAFKA_LISTENERS: "INTERNAL://:29092,EXTERNAL://:9092" + KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:29092,EXTERNAL://localhost:9092" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT" + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + depends_on: + - zookeeper + ports: + - "9092:9092" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - clea-network + restart: always + + zookeeper: + container_name: zookeeper + image: wurstmeister/zookeeper:latest + ports: + - "2181:2181" + networks: + - clea-network + restart: always + + kafdrop: + container_name: kafdrop + image: "obsidiandynamics/kafdrop:latest" + environment: + JVM_OPTS: "-Xms16M -Xmx48M -Xss180K -XX:-TieredCompilation -XX:+UseStringDeduplication -noverify" + KAFKA_BROKERCONNECT: "kafka:29092" + depends_on: + - kafka + ports: + - "9000:9000" + networks: + - clea-network + restart: always + + postgres: + container_name: postgres + image: "postgres:latest" + environment: + POSTGRES_DB: cleadb + POSTGRES_PASSWORD: pass + POSTGRES_USER: user + ports: + - "5432:5432" + networks: + - clea-network + restart: always + + pgadmin: + container_name: pgadmin + image: "dpage/pgadmin4:5.1" + environment: + PGADMIN_DEFAULT_EMAIL: user@pgadmin.com + PGADMIN_DEFAULT_PASSWORD: pass + PGADMIN_LISTEN_PORT: 80 + depends_on: + - postgres + ports: + - "8081:80" + networks: + - clea-network + restart: always + logging: + driver: none + +networks: + clea-network: + driver: bridge + name: clea-network diff --git a/clea/mvnw b/clea/mvnw new file mode 100755 index 0000000000000000000000000000000000000000..3c8a5537314954d53ec2fb774b34fe5d5a5f253a --- /dev/null +++ b/clea/mvnw @@ -0,0 +1,322 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ]; then + + if [ -f /etc/mavenrc ]; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ]; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false +darwin=false +mingw=false +case "$(uname)" in +CYGWIN*) cygwin=true ;; +MINGW*) mingw=true ;; +Darwin*) + darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="$(/usr/libexec/java_home)" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ]; then + if [ -r /etc/gentoo-release ]; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +if [ -z "$M2_HOME" ]; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ]; do + ls=$(ls -ld "$PRG") + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' >/dev/null; then + PRG="$link" + else + PRG="$(dirname "$PRG")/$link" + fi + done + + saveddir=$(pwd) + + M2_HOME=$(dirname "$PRG")/.. + + # make it fully qualified + M2_HOME=$(cd "$M2_HOME" && pwd) + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=$(cygpath --unix "$M2_HOME") + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw; then + [ -n "$M2_HOME" ] && + M2_HOME="$( ( + cd "$M2_HOME" + pwd + ))" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="$( ( + cd "$JAVA_HOME" + pwd + ))" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr \"$javaExecutable\" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! $(expr "$readLink" : '\([^ ]*\)') = "no" ]; then + if $darwin; then + javaHome="$(dirname \"$javaExecutable\")" + javaExecutable="$(cd \"$javaHome\" && pwd -P)/javac" + else + javaExecutable="$(readlink -f \"$javaExecutable\")" + fi + javaHome="$(dirname \"$javaExecutable\")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ]; then + if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(which java)" + fi +fi + +if [ ! -x "$JAVACMD" ]; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ]; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ]; then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ]; do + if [ -d "$wdir"/.mvn ]; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$( + cd "$wdir/.." + pwd + ) + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' <"$1")" + fi +} + +BASE_DIR=$(find_maven_basedir "$(pwd)") +if [ -z "$BASE_DIR" ]; then + exit 1 +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + fi + while IFS="=" read key value; do + case "$key" in wrapperUrl) + jarUrl="$value" + break + ;; + esac + done <"$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget >/dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl >/dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=$(cygpath --path --windows "$M2_HOME") + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/clea/mvnw.cmd b/clea/mvnw.cmd new file mode 100644 index 0000000000000000000000000000000000000000..c8d43372c986d97911cdc21bd87e0cbe3d83bdda --- /dev/null +++ b/clea/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/clea/pom.xml b/clea/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..f5362f2eda1067775e48a5be4d3c34f278e10add --- /dev/null +++ b/clea/pom.xml @@ -0,0 +1,212 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://maven.apache.org/POM/4.0.0" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <licenses> + <license> + <name>Mozilla Public License, Version 2.0</name> + <url>https://www.mozilla.org/en-US/MPL/2.0/</url> + </license> + </licenses> + + <parent> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-parent</artifactId> + <version>2.4.3</version> + <relativePath/> <!-- lookup parent from repository --> + </parent> + + <groupId>fr.gouv.clea</groupId> + <artifactId>clea-server</artifactId> + <version>0.1-SNAPSHOT</version> + <name>clea-server</name> + <packaging>pom</packaging> + <description>Tous Anti-Covid Cluster Exposure Verification (Clea) server project</description> + + <modules> + <module>clea-ws-rest</module> + <module>clea-venue-consumer</module> + <module>clea-client</module> + <module>clea-qr-simulator</module> + </modules> + + <properties> + <java.version>11</java.version> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> + <clea-crypto.version>0.0.1-SNAPSHOT</clea-crypto.version> + <maven.jar.plugin.version>3.2.0</maven.jar.plugin.version> + <postgresql.version>42.2.19</postgresql.version> + <spring-cloud.version>2020.0.1</spring-cloud.version> + <!-- + inherited from spring-cloud-dependencies + <spring-kafka.version>2.6.6</spring-kafka.version> + --> + <springfox-boot-starter.version>3.0.0</springfox-boot-starter.version> + <mockito-core.version>3.8.0</mockito-core.version> + <assertj-core.version>3.19.0</assertj-core.version> + <awaitility.version>4.0.3</awaitility.version> + <commons-lang3.version>3.12.0</commons-lang3.version> + <kafka-embedded.version>1.15.2</kafka-embedded.version> + <maven-surefire-plugin.version>3.0.0-M5</maven-surefire-plugin.version> + <jacoco-maven-plugin.version>0.8.6</jacoco-maven-plugin.version> + <maven-site-plugin.version>3.7.1</maven-site-plugin.version> + <maven-jxr-plugin.version>2.1</maven-jxr-plugin.version> + </properties> + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>fr.inria.clea</groupId> + <artifactId>clea-crypto</artifactId> + <version>${clea-crypto.version}</version> + </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + <version>${postgresql.version}</version> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-dependencies</artifactId> + <version>${spring-cloud.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>${mockito-core.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <version>${assertj-core.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>${awaitility.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-lang3</artifactId> + <version>${commons-lang3.version}</version> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>kafka</artifactId> + <version>${kafka-embedded.version}</version> + <scope>test</scope> + </dependency> + </dependencies> + </dependencyManagement> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + </plugin> + </plugins> + <pluginManagement> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + <configuration> + <classifier>exec</classifier> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <version>${maven.jar.plugin.version}</version> + </plugin> + </plugins> + </pluginManagement> + </build> + + <profiles> + <profile> + <id>report</id> + <activation> + <activeByDefault>true</activeByDefault> + </activation> + <build> + <plugins> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + <version>${jacoco-maven-plugin.version}</version> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-site-plugin</artifactId> + <version>${maven-site-plugin.version}</version> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + </plugin> + </plugins> + </build> + <reporting> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-report-plugin</artifactId> + <configuration> + <aggregate>true</aggregate> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jxr-plugin</artifactId> + <version>${maven-jxr-plugin.version}</version> + </plugin> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + </plugin> + </plugins> + </reporting> + </profile> + </profiles> + + <repositories> + <repository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </repository> + <repository> + <id>gitlab-maven-clea-crypto</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/28288/packages/maven</url> + </repository> + </repositories> + <distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </repository> + <snapshotRepository> + <id>gitlab-maven</id> + <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url> + </snapshotRepository> + </distributionManagement> + + <scm> + <connection>scm:git:${gitRepositoryUrl}</connection> + <developerConnection>scm:git:${gitRepositoryUrl}</developerConnection> + <url>${gitRepositoryUrl}</url> + <tag>HEAD</tag> + </scm> + +</project> diff --git a/environment-setup/dev/compose/docker-compose-analytics-server.yaml b/environment-setup/dev/compose/docker-compose-analytics-server.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a4af60bd2f398b284a5bd5897cf77da5e445794f --- /dev/null +++ b/environment-setup/dev/compose/docker-compose-analytics-server.yaml @@ -0,0 +1,98 @@ +# This docker compose file allow to deploy all mandatory components that are not easy to launch from your IDE of your +# development laptop. + +version: '3.5' +services: + mongoanalytics: + image: 'mongo:4.2.5' + ports: + - "27018:27017" + esanalytics: + image: docker.elastic.co/elasticsearch/elasticsearch:7.6.2 + container_name: esanalytics + environment: + - node.name=esanalytics + - discovery.type=single-node + - cluster.name=elasticsearchanalytics + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - esanalytics:/usr/share/elasticsearch/data/analytics + ports: + - 9200:9200 + networks: + - network_analytics + kibanalytics: + image: docker.elastic.co/kibana/kibana:7.6.2 + container_name: kibanalytics + ports: + - 5601:5601 + environment: + ELASTICSEARCH_URL: http://esanalytics:9200 + ELASTICSEARCH_HOSTS: '["http://esanalytics:9200"]' + networks: + - network_analytics + depends_on: + - esanalytics + logstashanalytics: + image: docker.elastic.co/logstash/logstash:7.6.2 + volumes: + - type: bind + source: ./logstash/config/logstash.yml + target: /usr/share/logstash/config/logstash.yml + read_only: true + - type: bind + source: ./logstash/pipeline + target: /usr/share/logstash/pipeline + read_only: true + environment: + LS_JAVA_OPTS: "-Xmx512m -Xms512m" + networks: + - network_analytics + depends_on: + - esanalytics + - kafkaanalytics + zookeeperanalytics: + image: wurstmeister/zookeeper:3.4.6 + ports: + - "2181:2181" + networks: + - network_analytics + kafkaanalytics: + image: wurstmeister/kafka:2.13-2.7.0 + ports: + - "19092:19092" + environment: + HOSTNAME_COMMAND: "docker info | grep ^Name: | cut -d' ' -f 2" + KAFKA_LISTENERS: INSIDE://:9092,OUTSIDE://:19092 + KAFKA_ADVERTISED_LISTENERS: INSIDE://kafkaanalytics:9092,OUTSIDE://_{HOSTNAME_COMMAND}:19092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_ZOOKEEPER_CONNECT: zookeeperanalytics:2181 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - network_analytics + kafdropanalytics: + image: obsidiandynamics/kafdrop:3.27.0 + ports: + - "9000:9000" + environment: + KAFKA_BROKERCONNECT: "kafkaanalytics:19092" + JVM_OPTS: "-Xms128M -Xmx512M -Xss512K -XX:-TieredCompilation -XX:+UseStringDeduplication -noverify" + networks: + - network_analytics + depends_on: + - kafkaanalytics + +volumes: + esanalytics: + driver: local + +networks: + network_analytics: + driver: bridge diff --git a/environment-setup/dev/compose/logstash/config/logstash.yml b/environment-setup/dev/compose/logstash/config/logstash.yml new file mode 100644 index 0000000000000000000000000000000000000000..aa1904465dd9671a4f0220a1ff4b93d0f6d750cb --- /dev/null +++ b/environment-setup/dev/compose/logstash/config/logstash.yml @@ -0,0 +1,8 @@ +--- +## Default Logstash configuration from Logstash base image. +## https://github.com/elastic/logstash/blob/master/docker/data/logstash/config/logstash-full.yml +# + +http.host: "0.0.0.0" +xpack.monitoring.enabled: "false" +log.level: info diff --git a/environment-setup/dev/compose/logstash/pipeline/logstash.conf b/environment-setup/dev/compose/logstash/pipeline/logstash.conf new file mode 100644 index 0000000000000000000000000000000000000000..5dc05c5ad4c7f07d596063670e8c8ffee77d44df --- /dev/null +++ b/environment-setup/dev/compose/logstash/pipeline/logstash.conf @@ -0,0 +1,19 @@ +input { + kafka { + bootstrap_servers => ["kafkaanalytics:9092"] + topics => "analyticsTopic" + codec => json + } +} + +## Add your filters / logstash plugins configuration here + +output { + + stdout { } + elasticsearch { + hosts => "http://esanalytics:9200" + index => "analytics" + } + +} diff --git a/environment-setup/integration/compose/.env.template b/environment-setup/integration/compose/.env.template index 96e6dd78a00d86fc4e7796c073b3d82970cb600e..cb440c6836917381783a7cb7dc8040e35f973f19 100644 --- a/environment-setup/integration/compose/.env.template +++ b/environment-setup/integration/compose/.env.template @@ -14,3 +14,4 @@ ROBERT_JWT_PRIVATE_KEY=tochange ROBERT_JWT_PUBLIC_KEY=tochange ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=tochange ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=tochange +ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=tochange diff --git a/environment-setup/integration/compose/docker-compose-robert-server.yaml b/environment-setup/integration/compose/docker-compose-robert-server.yaml index 24caecc91b9973601c560430d7abe053ffb51c65..25522c2c9ef264587d9475c4fa15efd8761fd168 100644 --- a/environment-setup/integration/compose/docker-compose-robert-server.yaml +++ b/environment-setup/integration/compose/docker-compose-robert-server.yaml @@ -14,6 +14,7 @@ services: - ROBERT_JWT_PRIVATE_KEY=${ROBERT_JWT_PRIVATE_KEY} - ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID} - ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY} + - ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY} ports: - "8086:8086" volumes: diff --git a/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties b/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties index c17fb7ed17fa23fb2e28f0851862cce20bc155be..771d704e8003cfa55a9a3c889e20eb3e6153a9c7 100644 --- a/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties +++ b/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties @@ -97,3 +97,6 @@ captcha.gateway.enabled=false robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID} robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY} +robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY} +robert.jwt.analytics.token.lifetime=360 + diff --git a/pom.xml b/pom.xml index ecb15882841df2849d197901553c95ae82838806..e6f0172fa5ff1953439dd9e8a45f1cfed19304e3 100644 --- a/pom.xml +++ b/pom.xml @@ -1,27 +1,29 @@ <?xml version="1.0" encoding="UTF-8"?> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> - <modelVersion>4.0.0</modelVersion> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> - <licenses> - <license> - <name>Mozilla Public License, Version 2.0</name> - <url>https://www.mozilla.org/en-US/MPL/2.0/</url> - <distribution>repo</distribution> - </license> - </licenses> + <licenses> + <license> + <name>Mozilla Public License, Version 2.0</name> + <url>https://www.mozilla.org/en-US/MPL/2.0/</url> + <distribution>repo</distribution> + </license> + </licenses> + + <groupId>fr.gouv.stopc</groupId> + <artifactId>tac-server-root</artifactId> + <version>0.0.1-SNAPSHOT</version> + <name>tac-server-root</name> + <packaging>pom</packaging> + <description>Root Integration Project</description> + + <modules> + <module>robert-server</module> + <module>analytics-server</module> + </modules> - <groupId>fr.gouv.stopc</groupId> - <artifactId>tac-server-root</artifactId> - <version>0.0.1-SNAPSHOT</version> - <name>tac-server-root</name> - <packaging>pom</packaging> - <description>Root Integration Project</description> - <modules> - <module>robert-server</module> - </modules> - - <profiles> <profile> <id>report</id> @@ -46,7 +48,7 @@ </plugin> </plugins> </build> - <reporting> + <reporting> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> diff --git a/robert-server/pom.xml b/robert-server/pom.xml index 7299ae92291ac6b2bb5fd9a9553fe7070821d6b4..bf2493dae6fd191ceeb53345ccb26048068fcaa8 100644 --- a/robert-server/pom.xml +++ b/robert-server/pom.xml @@ -19,7 +19,7 @@ <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> <name>robert-server</name> <packaging>pom</packaging> <description>Projet principal</description> diff --git a/robert-server/robert-api-spec/openapi.yaml b/robert-server/robert-api-spec/openapi.yaml index 66550e22fb6dfb40dd9d9e7d01789b9accbcc102..6ab476293d97603552ac2c3245eb1652fecf7e0c 100644 --- a/robert-server/robert-api-spec/openapi.yaml +++ b/robert-server/robert-api-spec/openapi.yaml @@ -364,6 +364,10 @@ components: description: >- A JWT token used for exposition declaration to CNAM. Contains timestamps of the last contact date and notification date. + analyticsToken: + type: string + description: >- + A JWT token used for analytics. config: $ref: "#/components/schemas/ClientConfiguration" required: diff --git a/robert-server/robert-crypto-grpc-server-messaging/pom.xml b/robert-server/robert-crypto-grpc-server-messaging/pom.xml index 4de017f12332625f843c70271056a2a8977cf3ae..6e75f6998ce6fbbd7ada23c28113ce65b5d03d56 100644 --- a/robert-server/robert-crypto-grpc-server-messaging/pom.xml +++ b/robert-server/robert-crypto-grpc-server-messaging/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-crypto-grpc-server-messaging</artifactId> diff --git a/robert-server/robert-crypto-grpc-server-storage/pom.xml b/robert-server/robert-crypto-grpc-server-storage/pom.xml index dc4136d52189e5f1140251c502602b55d54f0764..65f1651a9ada4e44234b5554aef7dda3b867197c 100644 --- a/robert-server/robert-crypto-grpc-server-storage/pom.xml +++ b/robert-server/robert-crypto-grpc-server-storage/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> diff --git a/robert-server/robert-crypto-grpc-server/pom.xml b/robert-server/robert-crypto-grpc-server/pom.xml index 2cab543047ab750f227b4db80e43cce6f9ceea20..d452e4fa4eeb946cc4e09ce4480cf0c1b4a54982 100644 --- a/robert-server/robert-crypto-grpc-server/pom.xml +++ b/robert-server/robert-crypto-grpc-server/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-crypto-grpc-server</artifactId> diff --git a/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml b/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml index b9e29a2c7cc19ead9b53121aba02be9ce6b74193..5631419399a2e90c5f72d16642c40d2ac4378285 100644 --- a/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml +++ b/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml @@ -1,65 +1,65 @@ <?xml version="1.0" encoding="UTF-8"?> <configuration> - <property name="LOG_DIR" - value="${ROBERT_CRYPTO_SERVER_LOG_FILE_PATH:-/logs}" /> - <property name="LOG_FILENAME" - value="${ROBERT_CRYPTO_SERVER_LOG_FILE_NAME:-robert-crypto-grpc-server}" /> - <property name="ERROR_LOG_FILENAME" - value="${ROBERT_CRYPTO_SERVER_ERROR_LOG_FILE_NAME:-robert-crypto-grpc-server}.error" /> + <property name="LOG_DIR" + value="${ROBERT_CRYPTO_SERVER_LOG_FILE_PATH:-/logs}" /> + <property name="LOG_FILENAME" + value="${ROBERT_CRYPTO_SERVER_LOG_FILE_NAME:-robert-crypto-grpc-server}" /> + <property name="ERROR_LOG_FILENAME" + value="${ROBERT_CRYPTO_SERVER_ERROR_LOG_FILE_NAME:-robert-crypto-grpc-server}.error" /> - <appender name="RollingFile" - class="ch.qos.logback.core.rolling.RollingFileAppender"> - <file>${LOG_DIR}/${LOG_FILENAME}.log</file> - <encoder - class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> - <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> - </encoder> + <appender name="RollingFile" + class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${LOG_DIR}/${LOG_FILENAME}.log</file> + <encoder + class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> + <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> + </encoder> - <rollingPolicy - class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> - <!-- rollover daily and when the file reaches 10 MegaBytes --> - <fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz - </fileNamePattern> - <maxFileSize>10MB</maxFileSize> - </rollingPolicy> - </appender> + <rollingPolicy + class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> + <!-- rollover daily and when the file reaches 10 MegaBytes --> + <fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz + </fileNamePattern> + <maxFileSize>10MB</maxFileSize> + </rollingPolicy> + </appender> - <appender name="RollingErrorFile" - class="ch.qos.logback.core.rolling.RollingFileAppender"> - <file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file> - <encoder - class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> - <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> - </encoder> + <appender name="RollingErrorFile" + class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file> + <encoder + class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> + <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern> + </encoder> - <rollingPolicy - class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> - <!-- rollover daily and when the file reaches 10 MegaBytes --> - <fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz - </fileNamePattern> - <maxFileSize>10MB</maxFileSize> - </rollingPolicy> - </appender> + <rollingPolicy + class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> + <!-- rollover daily and when the file reaches 10 MegaBytes --> + <fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz + </fileNamePattern> + <maxFileSize>10MB</maxFileSize> + </rollingPolicy> + </appender> - <!-- LOG everything at INFO level --> - <root level="info"> - <appender-ref ref="RollingFile" /> - </root> + <!-- LOG everything at INFO level --> + <root level="info"> + <appender-ref ref="RollingFile" /> + </root> - <!-- at TRACE level --> - <logger name="trace" level="trace" additivity="false"> - <appender-ref ref="RollingFile" /> - </logger> + <!-- at TRACE level --> + <logger name="trace" level="trace" additivity="false"> + <appender-ref ref="RollingFile" /> + </logger> - <!-- at WARN level --> - <logger name="warn" level="warn" additivity="false"> - <appender-ref ref="RollingFile" /> - </logger> + <!-- at WARN level --> + <logger name="warn" level="warn" additivity="false"> + <appender-ref ref="RollingFile" /> + </logger> - <!-- at ERROR level --> - <logger name="error" level="error" additivity="true"> - <appender-ref ref="RollingErrorFile" /> - </logger> + <!-- at ERROR level --> + <logger name="error" level="error" additivity="true"> + <appender-ref ref="RollingErrorFile" /> + </logger> </configuration> diff --git a/robert-server/robert-server-batch/pom.xml b/robert-server/robert-server-batch/pom.xml index 5eed7a978821eb7faaa01e27c736faac8f4c1843..0e3b3c8325032d2c9b4a6f2d89d8087286a67d98 100644 --- a/robert-server/robert-server-batch/pom.xml +++ b/robert-server/robert-server-batch/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-server-batch</artifactId> diff --git a/robert-server/robert-server-common/pom.xml b/robert-server/robert-server-common/pom.xml index bc14fad35f644af87301ff9f8f867f93f2254801..6852348b4811e0d933e9b20285e2b5527f7c3e8d 100644 --- a/robert-server/robert-server-common/pom.xml +++ b/robert-server/robert-server-common/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-server-common</artifactId> diff --git a/robert-server/robert-server-crypto/pom.xml b/robert-server/robert-server-crypto/pom.xml index 8737127ac99ebb0a68cf57d7f652a4ac1b8cc964..cc49821296b5e2ddfad0ed0f3ef0d8d88c9397d3 100644 --- a/robert-server/robert-server-crypto/pom.xml +++ b/robert-server/robert-server-crypto/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-server-crypto</artifactId> diff --git a/robert-server/robert-server-database/pom.xml b/robert-server/robert-server-database/pom.xml index 028956080b148e162f3b5e1871f1031231e8aae7..89e6af4027d7ddbe0a7c000fd992218ca2fafe65 100644 --- a/robert-server/robert-server-database/pom.xml +++ b/robert-server/robert-server-database/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> diff --git a/robert-server/robert-server-dataset-injector/pom.xml b/robert-server/robert-server-dataset-injector/pom.xml index d1b52e251f81b1dfa4ffc8b80986d950c2e1f70a..b86505713370bca37c90e20aa05c93572a0d8b45 100644 --- a/robert-server/robert-server-dataset-injector/pom.xml +++ b/robert-server/robert-server-dataset-injector/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> diff --git a/robert-server/robert-server-ws-rest/pom.xml b/robert-server/robert-server-ws-rest/pom.xml index bf09de62f35ac0fc69e42318c117c20bd5557afb..c6ea98e0f6ba3f08c3dd06770af9bb407e9e3db5 100644 --- a/robert-server/robert-server-ws-rest/pom.xml +++ b/robert-server/robert-server-ws-rest/pom.xml @@ -13,7 +13,7 @@ <parent> <groupId>fr.gouv.stopc</groupId> <artifactId>robert-server</artifactId> - <version>1.10.1</version> + <version>1.11.0</version> </parent> <artifactId>robert-server-ws-rest</artifactId> diff --git a/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile b/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile index 8b836a59430e4179c108ef843c1da20d5049a4a7..0e2eec0ce02dc16bba7bdb9e7b786f2a85d57efe 100644 --- a/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile +++ b/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile @@ -5,4 +5,5 @@ ADD ./src/main/resources/application.properties /work/config/application.propert ENV ROBERT_JWT_PRIVATE_KEY changeit ENV ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY changeit ENV ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID changeit -CMD ["java","-jar", "/app.jar", "--spring.config.location=file:/work/config/application.properties", "-Dspring-boot.run.arguments=--robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY},--robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID},--robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}"] +ENV ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY changeit +CMD ["java","-jar", "/app.jar", "--spring.config.location=file:/work/config/application.properties", "-Dspring-boot.run.arguments=--robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY},--robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID},--robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY},--robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}"] diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java index c6ebf51bfd9eb2859504508dfb1c1c82c810e3f2..a3bd093a0bbe0ab9cb84a5c1e7d6040e1ff47001 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java @@ -35,4 +35,10 @@ public class WsServerConfiguration { @Value("${robert.jwt.declare.private-key}") private String declareTokenPrivateKey; + + @Value("${robert.jwt.analytics.token.private-key}") + private String analyticsTokenPrivateKey; + + @Value("${robert.jwt.analytics.token.lifetime}") + private Long analyticsTokenLifeTime; } diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java index 7a7644d68fc54069916e8bfaadd3a69075c2299d..26a65886cf58001e1bfdb2745966e336583491d6 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java @@ -22,7 +22,8 @@ import fr.gouv.stopc.robertserver.ws.vo.CaptchaCreationVo; @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, "${controller.path.prefix}" + UriConstants.API_V3, "${controller.path.prefix}" + UriConstants.API_V4, - "${controller.path.prefix}" + UriConstants.API_V5}) + "${controller.path.prefix}" + UriConstants.API_V5, + "${controller.path.prefix}" + UriConstants.API_V6}) public interface ICaptchaController { @PostMapping(value = UriConstants.CAPTCHA) diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java index 1b37fb739b0f5cce32881544c2daa9277a2cbc51..27932facd21f320ccc1989e702505ec841f1dcd5 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java @@ -19,7 +19,7 @@ import fr.gouv.stopc.robertserver.ws.vo.DeleteHistoryRequestVo; @RestController @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, "${controller.path.prefix}" + UriConstants.API_V3, "${controller.path.prefix}" + UriConstants.API_V4, - "${controller.path.prefix}" + UriConstants.API_V5 }) + "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 }) @Consumes(MediaType.APPLICATION_JSON_VALUE) @Produces(MediaType.APPLICATION_JSON_VALUE) public interface IDeleteHistoryController { diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java index d2238f56d6ab707246117496f41d20ba7752f79e..740efbac09698c74e46d4f536f0c3cdc41a15495 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java @@ -4,7 +4,6 @@ import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.Produces; -import fr.gouv.stopc.robertserver.ws.vo.RegisterVo; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -15,10 +14,11 @@ import org.springframework.web.bind.annotation.RestController; import fr.gouv.stopc.robertserver.ws.dto.RegisterResponseDto; import fr.gouv.stopc.robertserver.ws.exception.RobertServerException; import fr.gouv.stopc.robertserver.ws.utils.UriConstants; +import fr.gouv.stopc.robertserver.ws.vo.RegisterVo; @RestController @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, "${controller.path.prefix}" + UriConstants.API_V3, - "${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5 }) + "${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 }) @Consumes(MediaType.APPLICATION_JSON_VALUE) @Produces(MediaType.APPLICATION_JSON_VALUE) public interface IRegisterController { diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java index 77055f382e24dfd03f43802feb712ac0d975cb66..445e341524823a2b7d470a05cdc2674d999a70af 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java @@ -15,7 +15,7 @@ import fr.gouv.stopc.robertserver.ws.utils.UriConstants; import fr.gouv.stopc.robertserver.ws.vo.ReportBatchRequestVo; @RestController -@RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5 }, +@RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 }, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public interface IReportControllerV4 { diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java index 79e85236f8f06ead5d98df6f912903a5ff29574b..597595caf45031df38b37bd4f14c27fc349dae41 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RestController; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4; +import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5; import fr.gouv.stopc.robertserver.ws.exception.RobertServerException; import fr.gouv.stopc.robertserver.ws.utils.UriConstants; import fr.gouv.stopc.robertserver.ws.vo.StatusVo; @@ -25,6 +26,10 @@ public interface IStatusController { throws RobertServerException; @PostMapping(path = UriConstants.API_V5 + UriConstants.STATUS) + ResponseEntity<StatusResponseDtoV5> getStatusV5(@Valid @RequestBody(required = true) StatusVo statusVo) + throws RobertServerException; + + @PostMapping(path = UriConstants.API_V6 + UriConstants.STATUS) ResponseEntity<StatusResponseDto> getStatus(@Valid @RequestBody(required = true) StatusVo statusVo) throws RobertServerException; diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java index 45d9de6a0f5e11b25e12800684e5b81a86b7e00b..ec1b759ca659ec35aac1f1a9e168a1e7eedb48a3 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java @@ -18,7 +18,7 @@ import fr.gouv.stopc.robertserver.ws.vo.UnregisterRequestVo; @RestController @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, "${controller.path.prefix}" + UriConstants.API_V3, "${controller.path.prefix}" + UriConstants.API_V4, - "${controller.path.prefix}" + UriConstants.API_V5 }) + "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 }) @Consumes(MediaType.APPLICATION_JSON_VALUE) @Produces(MediaType.APPLICATION_JSON_VALUE) public interface IUnregisterController { diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java index 019312158dcb7b2a949cd7d566ed07c6dcf46602..b139e8779df8cf529f22bc4638ce04b44a11f0a2 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java @@ -27,6 +27,7 @@ import fr.gouv.stopc.robertserver.ws.dto.ClientConfigDto; import fr.gouv.stopc.robertserver.ws.dto.RiskLevel; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4; +import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5; import fr.gouv.stopc.robertserver.ws.dto.declaration.GenerateDeclarationTokenRequest; import fr.gouv.stopc.robertserver.ws.exception.RobertServerException; import fr.gouv.stopc.robertserver.ws.service.AuthRequestValidationService; @@ -98,7 +99,32 @@ public class StatusControllerImpl implements IStatusController { .tuples(status.getTuples()) .build()); } - + + @Override + public ResponseEntity<StatusResponseDtoV5> getStatusV5(@Valid StatusVo statusVo) throws RobertServerException { + ResponseEntity<StatusResponseDto> statusResponse = this.getStatus(statusVo); + if (Objects.isNull(statusResponse) || Objects.isNull(statusResponse.getStatusCode())) { + log.error("The response of the status must not be null"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + if (statusResponse.getStatusCode().isError()) { + log.warn("Status HTTP response code is equal to : {}", statusResponse.getStatusCode()); + return ResponseEntity.status(statusResponse.getStatusCode()).build(); + } + + StatusResponseDto status = statusResponse.getBody(); + return ResponseEntity.ok( + StatusResponseDtoV5.builder() + .riskLevel(status.getRiskLevel()) + .config(status.getConfig()) + .tuples(status.getTuples()) + .declarationToken(status.getDeclarationToken()) + .lastContactDate(status.getLastContactDate()) + .lastRiskScoringDate(status.getLastRiskScoringDate()) + .build()); + } + @Override public ResponseEntity<StatusResponseDto> getStatus(StatusVo statusVo) { AuthRequestValidationService.ValidationResult<GetIdFromStatusResponse> validationResult = @@ -226,20 +252,20 @@ public class StatusControllerImpl implements IStatusController { if (riskLevel != RiskLevel.NONE) { record.setNotified(true); - // Include lastContactDate only if any + // Include lastContactDate only if any and if user is evaluated at risk if (record.getLastContactTimestamp() > 0) { statusResponse.setLastContactDate(Long.toString(record.getLastContactTimestamp())); } - // Include lastRiskScoringDate only if any + // Include lastRiskScoringDate only if any and if user is evaluated at risk if (record.getLatestRiskEpoch() > 0) { long serviceTimeStart = serverConfigurationService.getServiceTimeStart(); statusResponse.setLastRiskScoringDate(Long.toString(TimeUtils.getNtpSeconds(record.getLatestRiskEpoch(), serviceTimeStart))); } } - //TODO: Test this in integration tests and update api spec - //Generate a declaration token if there is a RiskLevel > 0 and an associated lastContactDate + + //Generate declaration token (for CNAM) and atRisk token (for Analytics) if (!RiskLevel.NONE.equals(riskLevel) && record.getLastContactTimestamp() > 0) { long lastStatusRequestTimestamp = TimeUtils.getNtpSeconds( record.getLastStatusRequestEpoch(), @@ -253,8 +279,14 @@ public class StatusControllerImpl implements IStatusController { .build(); String declarationToken = declarationService.generateDeclarationToken(request).orElse(null); statusResponse.setDeclarationToken(declarationToken); + log.debug("Declaration token generated : {}", declarationToken); + } + String analyticsToken = declarationService.generateAnalyticsToken().orElse(null); + statusResponse.setAnalyticsToken(analyticsToken); + log.debug("analytics token generated : {}", analyticsToken); + // Save changes to the record this.registrationService.saveRegistration(record); diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java index 5a4ff1ed5542b1712c9b8a9969be0ebc5724e1bd..585a01366910b12d649a47b7942f8b345d92e7d2 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java @@ -28,4 +28,7 @@ public class StatusResponseDto { private String lastRiskScoringDate; private String declarationToken; + + private String analyticsToken; + } diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java new file mode 100644 index 0000000000000000000000000000000000000000..4395660b45ce84e6eb8289ca4348bba70f3ccd40 --- /dev/null +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java @@ -0,0 +1,37 @@ +package fr.gouv.stopc.robertserver.ws.dto; + +import java.util.List; + +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Singular; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class StatusResponseDtoV5 { + @NotNull + private RiskLevel riskLevel; + + @NotNull + private String tuples; + + @Singular("config") + private List<ClientConfigDto> config; + + private String message; + + private String lastContactDate; + + private String lastRiskScoringDate; + + private String declarationToken; + +} diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java index d3e070edb07b891d93cf9ec47ff59d128aa73844..2de359fde329037a74e03b18ea89975ab1b85b7f 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java @@ -6,5 +6,18 @@ import java.util.Optional; public interface DeclarationService { + /** + * Generate a declaration token (used for CNAM) + * The custom JTI ensure that two tokens can be consumed with exactly the same exposition context + * @param request business and technical infos + * @return the token + */ Optional<String> generateDeclarationToken(GenerateDeclarationTokenRequest request); + + /** + * Generate a simple token (used for Analytics) + * @return the token + */ + Optional<String> generateAnalyticsToken(); + } diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java index a562a208ad4b8a6e9f588e2b807a7fdb443c1aca..20f6bb47169ee2a5ba08998ea4bc9f489d00c1b0 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java @@ -1,5 +1,19 @@ package fr.gouv.stopc.robertserver.ws.service.impl; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; + import fr.gouv.stopc.robertserver.ws.config.WsServerConfiguration; import fr.gouv.stopc.robertserver.ws.dto.declaration.GenerateDeclarationTokenRequest; import fr.gouv.stopc.robertserver.ws.service.DeclarationService; @@ -9,23 +23,11 @@ import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; -import org.springframework.stereotype.Service; - -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Date; -import java.util.Optional; @Service @Slf4j public class DeclarationServiceImpl implements DeclarationService { - //TODO: Test this class - public static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256; private final WsServerConfiguration configuration; @@ -64,6 +66,34 @@ public class DeclarationServiceImpl implements DeclarationService { } } + @Override + public Optional<String> generateAnalyticsToken() { + + log.debug("Generating simple analytics token"); + + try { + Date issuedAt = Date.from(ZonedDateTime.now().toInstant()); + String jti = UUID.randomUUID().toString(); + Date expiredAt = Date.from( + issuedAt.toInstant() + .plus(configuration.getAnalyticsTokenLifeTime(), ChronoUnit.MINUTES)); + + return Optional.of( + Jwts.builder() + .setHeaderParam("type", "JWT") + .setId(jti) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .setIssuer("robert-server") + .signWith(getAnalyticsTokenPrivateKey(), SIGNATURE_ALGORITHM) + .compact()); + + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.error("Creation of analytics JWT token failed ", e); + return Optional.empty(); + } + } + private PrivateKey getDeclarePrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException { if (configuration.getJwtUseTransientKey()) { @@ -79,4 +109,19 @@ public class DeclarationServiceImpl implements DeclarationService { } + private PrivateKey getAnalyticsTokenPrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + + if (configuration.getJwtUseTransientKey()) { + // In test mode, we generate a transient key + KeyPair keyPair = Keys.keyPairFor(SIGNATURE_ALGORITHM); + return keyPair.getPrivate(); + } else { + byte[] encoded = Decoders.BASE64.decode(configuration.getAnalyticsTokenPrivateKey()); + KeyFactory keyFactory = KeyFactory.getInstance(SIGNATURE_ALGORITHM.getFamilyName()); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + return keyFactory.generatePrivate(keySpec); + } + + } + } diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java index 35a2ce503ab4d9d402d27142706c0265e7ba2511..18137f7caa077f027b3cfb89efcf2a3dfab1d09c 100644 --- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java +++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java @@ -29,4 +29,6 @@ public final class UriConstants { public static final String API_V5 = "/v5"; + public static final String API_V6 = "/v6"; + } diff --git a/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties b/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties index 210fd6de2385dd7aaf670caed0c239929f016b04..59e0ea46ea9d7e068d0a02a7d51016dfa68c9300 100644 --- a/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties +++ b/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties @@ -78,3 +78,7 @@ robert.epoch-bundle-duration-in-days=4 #JWT declare token robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID:public-kid} robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY:declare-private-key} + +#JWT analytics +robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY} +robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360} diff --git a/robert-server/robert-server-ws-rest/src/main/resources/application.properties b/robert-server/robert-server-ws-rest/src/main/resources/application.properties index f603764b6a2615c068bebfb2509d6734a6e2a881..012b573cabe718a41d931f5bd3ecaee0634ede55 100644 --- a/robert-server/robert-server-ws-rest/src/main/resources/application.properties +++ b/robert-server/robert-server-ws-rest/src/main/resources/application.properties @@ -77,6 +77,9 @@ robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY} captcha.gateway.enabled=true #JWT declare token -# TODO move both to Vault and Consul robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID} -robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY} \ No newline at end of file +robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY} + +#JWT analytics +robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY} +robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360} \ No newline at end of file diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java index 6c65483b83f13f5c638494c753ec164a6bd92b2a..20ec7bb0c82d720375a253b9ed0e86def357c317 100644 --- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java @@ -1,5 +1,37 @@ package test.fr.gouv.stopc.robertserver.ws; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import javax.crypto.KeyGenerator; +import javax.inject.Inject; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + import com.google.protobuf.ByteString; import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient; import fr.gouv.stopc.robert.crypto.grpc.server.messaging.GetIdFromAuthResponse; @@ -23,27 +55,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; -import org.springframework.test.context.TestPropertySource; -import org.springframework.web.util.UriComponentsBuilder; - -import javax.crypto.KeyGenerator; -import javax.inject.Inject; -import java.net.URI; -import java.security.Key; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -62,6 +73,9 @@ public class DeleteHistoryControllerWsRestTest { private String pathPrefixV4; @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Value("${controller.path.prefix}" + UriConstants.API_V6) private String pathPrefix; @Inject @@ -174,6 +188,12 @@ public class DeleteHistoryControllerWsRestTest { deleteHistoryWithExposedEpochsSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.DELETE_HISTORY).build().encode().toUri()); } + /** Test the access for API V5, should not be used since API V6 */ + @Test + public void testAccessV5() { + deleteHistoryWithExposedEpochsSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.DELETE_HISTORY).build().encode().toUri()); + } + /** {@link #deleteHistoryWithExposedEpochsSucceeds(URI)} and shortcut to test for API V2 exposure */ @Test public void testDeleteHistoryWithExposedEpochsSucceedsV3() { diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java index e6e317822d4de6c09898f2dd5cd886bb6ce7a3be..ea0e843e171916836e97dec585d67656e119032e 100644 --- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java @@ -1,5 +1,28 @@ package test.fr.gouv.stopc.robertserver.ws; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.util.Optional; + +import javax.inject.Inject; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + import com.google.protobuf.ByteString; import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient; import fr.gouv.stopc.robert.crypto.grpc.server.messaging.CreateRegistrationResponse; @@ -20,22 +43,6 @@ import org.bson.internal.Base64; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; -import org.springframework.test.context.TestPropertySource; -import org.springframework.web.util.UriComponentsBuilder; - -import javax.inject.Inject; -import java.net.URI; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -53,6 +60,9 @@ public class RegisterControllerWsRestTest { private String pathPrefixV4; @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Value("${controller.path.prefix}" + UriConstants.API_V6) private String pathPrefix; @Inject @@ -275,7 +285,13 @@ public class RegisterControllerWsRestTest { testRegisterSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REGISTER).build().toUri() .toString()); } - + + @Test + public void testSuccessV5() { + testRegisterSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REGISTER).build().toUri() + .toString()); + } + @Test public void testSuccess() { testRegisterSucceeds(this.targetUrl.toString()); diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java index 9703b2c29ba057d5e59717eb80554c68d4709807..7a3db7535268a571aaa8a93a3ef4b2d6c0204201 100644 --- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java @@ -1,5 +1,32 @@ package test.fr.gouv.stopc.robertserver.ws; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + import fr.gouv.stopc.robertserver.ws.RobertServerWsRestApplication; import fr.gouv.stopc.robertserver.ws.config.RobertServerWsConfiguration; import fr.gouv.stopc.robertserver.ws.dto.ReportBatchResponseDto; @@ -18,27 +45,6 @@ import fr.gouv.stopc.robertserver.ws.vo.ReportBatchRequestVo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; -import org.springframework.test.context.TestPropertySource; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource("classpath:application.properties") @@ -70,6 +76,9 @@ public class ReportControllerWsRestTest { private String pathPrefixV4; @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Value("${controller.path.prefix}" + UriConstants.API_V6) private String pathPrefix; @Value("${robert.server.disable-check-token}") @@ -256,7 +265,13 @@ public class ReportControllerWsRestTest { public void testAccessV4WithV3Dto() { reportContactHistoryV2OrV3Succeeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REPORT).build().encode().toUri()); } - + + /** Test the access for API V5 with old DTO, should not be used since API V6. */ + @Test + public void testAccessV5WithV3Dto() { + reportContactHistoryV2OrV3Succeeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REPORT).build().encode().toUri()); + } + /** {@link #reportContactHistoryV2OrV3Succeeds(URI)} Test the access for API V5 with old DTO */ @Test public void testReportContactHistorySucceedsWithV3Dto() { @@ -293,8 +308,14 @@ public class ReportControllerWsRestTest { public void testAccessV4() { reportContactHistorySucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REPORT).build().encode().toUri()); } - - /** {@link #reportContactHistorySucceeds(URI)} Test the access for API V5 */ + + /** Test the access for API V5 with old DTO, should not be used since API V6. */ + @Test + public void testAccessV5() { + reportContactHistorySucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REPORT).build().encode().toUri()); + } + + /** {@link #reportContactHistorySucceeds(URI)} Test the access for API V6 */ @Test public void testReportContactHistorySucceeds() { reportContactHistorySucceeds(this.targetUrl); diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java index 6a4335683d671abbb7d0128eeb78eaa0a20b88e4..54ba5042afbb8c91f0a132cc1271c05e47d7a593 100644 --- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java @@ -1,5 +1,38 @@ package test.fr.gouv.stopc.robertserver.ws; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.security.Key; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import javax.crypto.KeyGenerator; +import javax.inject.Inject; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + import com.google.protobuf.ByteString; import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient; import fr.gouv.stopc.robert.crypto.grpc.server.messaging.GetIdFromStatusResponse; @@ -19,6 +52,7 @@ import fr.gouv.stopc.robertserver.ws.config.WsServerConfiguration; import fr.gouv.stopc.robertserver.ws.dto.RiskLevel; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto; import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4; +import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5; import fr.gouv.stopc.robertserver.ws.service.IRestApiService; import fr.gouv.stopc.robertserver.ws.utils.PropertyLoader; import fr.gouv.stopc.robertserver.ws.utils.UriConstants; @@ -34,28 +68,6 @@ import org.bson.internal.Base64; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; -import org.springframework.test.context.TestPropertySource; -import org.springframework.web.util.UriComponentsBuilder; - -import javax.crypto.KeyGenerator; -import javax.inject.Inject; -import java.net.URI; -import java.security.Key; -import java.security.KeyPair; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -76,6 +88,9 @@ public class StatusControllerWsRestTest { private String pathPrefixV4; @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Value("${controller.path.prefix}" + UriConstants.API_V6) private String pathPrefix; @Value("${robert.server.status-request-minimum-epoch-gap}") @@ -201,6 +216,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(oldEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -629,9 +646,15 @@ public class StatusControllerWsRestTest { statusRequestAtRiskSucceedsV1ToV4(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.STATUS).build().encode().toUri()); } - /** {@link #statusRequestAtRiskSucceeds(URI)} and shortcut to test for API V5 exposure */ + /** Test the access for API V5, should not be used since API V6 */ + @Test + public void testAccessV5() { + statusRequestAtRiskSucceedsV5(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.STATUS).build().encode().toUri()); + } + + /** {@link #statusRequestAtRiskSucceeds(URI)} and shortcut to test for API V6 exposure */ @Test - public void testStatusRequestAtRiskSucceedsV5() { + public void testStatusRequestAtRiskSucceedsV6() { statusRequestAtRiskSucceeds(this.targetUrl); } @@ -695,7 +718,34 @@ public class StatusControllerWsRestTest { verify(this.registrationService, times(2)).saveRegistration(reg); verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class)); } - + + protected void statusRequestAtRiskSucceedsV5(URI targetUrl) { + // Given + byte[] idA = this.generateKey(5); + Registration reg = this.statusRequestAtRiskSucceedsSetUp(targetUrl, idA); + + when(this.wsServerConfiguration.getDeclareTokenKid()).thenReturn("kid"); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + + // When + ResponseEntity<StatusResponseDtoV5> response = this.restTemplate.exchange(targetUrl.toString(), + HttpMethod.POST, this.requestEntity, StatusResponseDtoV5.class); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(RiskLevel.HIGH, response.getBody().getRiskLevel()); + assertNotNull(response.getBody().getTuples()); + assertEquals(Long.toString(reg.getLastContactTimestamp()), response.getBody().getLastContactDate()); + assertNotNull(response.getBody().getLastRiskScoringDate()); + assertTrue(Long.parseLong(response.getBody().getLastRiskScoringDate()) > 0); + assertTrue(reg.isNotified()); + assertTrue(currentEpoch - 3 < reg.getLastStatusRequestEpoch()); + verify(this.registrationService, times(2)).findById(idA); + verify(this.registrationService, times(2)).saveRegistration(reg); + verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class)); + } + + protected void statusRequestAtRiskSucceeds(URI targetUrl) { // Given byte[] idA = this.generateKey(5); @@ -720,6 +770,7 @@ public class StatusControllerWsRestTest { verify(this.registrationService, times(2)).findById(idA); verify(this.registrationService, times(2)).saveRegistration(reg); verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class)); + assertNotNull(response.getBody().getAnalyticsToken()); } @Test @@ -749,6 +800,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -782,6 +835,8 @@ public class StatusControllerWsRestTest { byte[] idA = this.generateKey(5); byte[] kA = this.generateKA(); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + List<EpochExposition> epochExpositions = new ArrayList<>(); // Before latest notification @@ -892,6 +947,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -1075,6 +1132,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -1136,6 +1195,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -1207,6 +1268,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -1319,6 +1382,8 @@ public class StatusControllerWsRestTest { System.arraycopy(idA, 0, decryptedEbid, 3, 5); System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() @@ -1356,6 +1421,8 @@ public class StatusControllerWsRestTest { when(this.wsServerConfiguration.getDeclareTokenPrivateKey()).thenReturn(Encoders.BASE64.encode(keyPair.getPrivate().getEncoded())); when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(false); + when(this.wsServerConfiguration.getAnalyticsTokenPrivateKey()).thenReturn(Encoders.BASE64.encode(keyPair.getPrivate().getEncoded())); + // When ResponseEntity<StatusResponseDto> response = this.restTemplate.exchange(targetUrl.toString(), HttpMethod.POST, this.requestEntity, StatusResponseDto.class); @@ -1407,6 +1474,53 @@ public class StatusControllerWsRestTest { } + @Test + public void when_calling_status_should_return_an_analytics_token() { + // Given + byte[] idA = this.generateKey(5); + byte[] kA = this.generateKA(); + + Registration reg = Registration.builder() + .permanentIdentifier(idA) + .atRisk(false) + .isNotified(false) + .lastStatusRequestEpoch(currentEpoch - 3).build(); + + byte[][] reqContent = createEBIDTimeMACFor(idA, kA, currentEpoch); + + statusBody = StatusVo.builder() + .ebid(Base64.encode(reqContent[0])) + .epochId(currentEpoch) + .time(Base64.encode(reqContent[1])) + .mac(Base64.encode(reqContent[2])).build(); + + this.requestEntity = new HttpEntity<>(this.statusBody, this.headers); + + byte[] decryptedEbid = new byte[8]; + System.arraycopy(idA, 0, decryptedEbid, 3, 5); + System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3); + + when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true); + + doReturn(Optional.of(reg)).when(this.registrationService).findById(idA); + + doReturn(Optional.of(GetIdFromStatusResponse.newBuilder() + .setEpochId(currentEpoch) + .setIdA(ByteString.copyFrom(idA)) + .setTuples(ByteString.copyFrom("Base64encodedEncryptedJSONStringWithTuples".getBytes())) + .build())) + .when(this.cryptoServerClient).getIdFromStatus(any()); + + this.requestEntity = new HttpEntity<>(this.statusBody, this.headers); + + // When + ResponseEntity<StatusResponseDto> response = this.restTemplate.exchange(this.targetUrl.toString(), + HttpMethod.POST, this.requestEntity, StatusResponseDto.class); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody().getAnalyticsToken()); + } } diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..495239ab988a343e305b8fde2c518066fbe43dd6 --- /dev/null +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java @@ -0,0 +1,108 @@ +package test.fr.gouv.stopc.robertserver.ws; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.net.URI; + +import javax.inject.Inject; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + +import fr.gouv.stopc.robertserver.ws.RobertServerWsRestApplication; +import fr.gouv.stopc.robertserver.ws.controller.impl.StatusControllerImpl; +import fr.gouv.stopc.robertserver.ws.service.IRestApiService; +import fr.gouv.stopc.robertserver.ws.utils.UriConstants; +import fr.gouv.stopc.robertserver.ws.vo.StatusVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SpringBootTest(classes = { + RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource("classpath:application.properties") +public class StatusControllerWsRestV5ErrorsTest { + + @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Inject + private TestRestTemplate restTemplate; + + HttpEntity<StatusVo> requestEntity; + + private URI targetUrl; + + private StatusVo statusBody; + + private HttpHeaders headers; + + @MockBean + private IRestApiService restApiService; + + @SpyBean + private StatusControllerImpl statusController; + + @BeforeEach + public void setUp() { + this.targetUrl = UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.STATUS).build().encode().toUri(); + this.statusBody = StatusVo.builder() + .ebid("012345678912") + .epochId(1) + .time("12345678") + .mac("01234567890123456789012345678901234567891234") + .build(); + this.requestEntity = new HttpEntity<>(this.statusBody, this.headers); + } + + @Test + public void testWhenGetStatusReturnsBadRequestThenGetStatusV5ReturnsBadRequest() { + when(statusController.getStatus(any())).thenReturn(ResponseEntity.badRequest().build()); + + ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST, + this.requestEntity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + public void testWhenGetStatusReturnsInternalServerErrorThenGetStatusV5ReturnsInternalServerError() { + when(statusController.getStatus(any())).thenReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); + + ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST, + this.requestEntity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Test + public void testWhenGetStatusReturnsNotFoundThenGetStatusV5ReturnsNotFound() { + when(statusController.getStatus(any())).thenReturn(ResponseEntity.status(HttpStatus.NOT_FOUND).build()); + + ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST, + this.requestEntity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testWhenGetStatusReturnsNullThenGetStatusV5ReturnsInternalServerError() { + when(statusController.getStatus(any())).thenReturn(null); + + ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST, + this.requestEntity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java index 275abcd290c22ba55ee793cfbced68571c8a8c6e..f74af0d7cfcf09cdbf4d70c6880fd645416033db 100644 --- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java +++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java @@ -1,5 +1,34 @@ package test.fr.gouv.stopc.robertserver.ws; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Optional; + +import javax.crypto.KeyGenerator; +import javax.inject.Inject; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.util.UriComponentsBuilder; + import com.google.protobuf.ByteString; import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient; import fr.gouv.stopc.robert.crypto.grpc.server.messaging.DeleteIdResponse; @@ -24,28 +53,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; import org.mockito.MockitoAnnotations; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; -import org.springframework.test.context.TestPropertySource; -import org.springframework.web.util.UriComponentsBuilder; - -import javax.crypto.KeyGenerator; -import javax.inject.Inject; -import java.net.URI; -import java.security.Key; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -63,6 +70,9 @@ public class UnregisterControllerWsRestTest { private String pathPrefixV4; @Value("${controller.path.prefix}" + UriConstants.API_V5) + private String pathPrefixV5; + + @Value("${controller.path.prefix}" + UriConstants.API_V6) private String pathPrefix; @Inject @@ -159,12 +169,18 @@ public class UnregisterControllerWsRestTest { acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV3).path(UriConstants.UNREGISTER).build().encode().toUri()); } - /** Test the access for API V3, should not be used since API V4 */ + /** Test the access for API V4, should not be used since API V5 */ @Test public void testAccessV4() { acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.UNREGISTER).build().encode().toUri()); } + /** Test the access for API V5, should not be used since API V6 */ + @Test + public void testAccessV5() { + acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.UNREGISTER).build().encode().toUri()); + } + /** {@link #acceptOldEBIDValueEpochSucceeds(URI)} and shortcut to test for API V4 exposure */ @Test public void testAcceptOldEBIDValueEpochSucceeds() { diff --git a/robert-server/robert-server-ws-rest/src/test/resources/application.properties b/robert-server/robert-server-ws-rest/src/test/resources/application.properties index 283f6a9f5c79982094f43f037c805313400c7662..588ffdf2ac431bed1cc32b7c1205cca8f6df5e44 100644 --- a/robert-server/robert-server-ws-rest/src/test/resources/application.properties +++ b/robert-server/robert-server-ws-rest/src/test/resources/application.properties @@ -71,6 +71,6 @@ robert.jwt.lifetime=${ROBERT_JWT_TOKEN_LIFETIME:5} robert.server.disable-check-captcha=false robert.server.disable-check-token=false -#JWT declare token -robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID:public-kid} -robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY:declarationPrivateKey} +#JWT analytics +robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY:analytics-private-key} +robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360} diff --git a/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java b/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java index 66d56da9900e989492974eaf67085b17ebdaf08c..e184941cb72daa74a39bb22a216a8aba021061d8 100644 --- a/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java +++ b/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java @@ -1,16 +1,25 @@ package fr.gouv.tac.systemtest; -import org.junit.Test; +import static org.junit.Assert.assertEquals; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.Date; +import java.util.TimeZone; -import static org.junit.Assert.assertEquals; +import org.junit.After; +import org.junit.Test; public class TimeUtilTest { + private TimeZone origTimeZone = TimeZone.getDefault(); + + @After + public void resetTimeZone() { + TimeZone.setDefault(origTimeZone); + } + @Test public void dateToTimestampTestAtOriginOneMinute() { LocalDateTime localDateTime= LocalDateTime.parse("1900-01-01T00:01:00"); @@ -49,18 +58,20 @@ public class TimeUtilTest { @Test public void naturalLanguageDateStringToNTPTimestampTest() { + //use UTC timezone because on Europe/Paris timezone, there are time changes twice a year leading to the failure of this test. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); Long today = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Today at noon") ; - Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at noon"); + Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at noon"); assertEquals(new Long(24*3600), new Long(today - yesterday) ); - } @Test public void naturalLanguageDateStringToNTPTimestampTest2() { + //use UTC timezone because on Europe/Paris timezone, there are time changes twice a year leading to the failure of this test. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("12:30, 2 day ago") ; Long today = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at 12:30"); assertEquals(new Long(24*3600), new Long(today - yesterday) ); - } @Test