diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ace721ed3a064686f4d3bf538de3cb362f660eba
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+qa/.gradle
+qa/build
+.idea
+*.class
+qa/bin/
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c5e56334007f7f9a4f8b5f0435fc426db40e890b..c7ff5029ef413a8280ff4b29fc45de21f032ca80 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -20,3 +20,25 @@ create_php_code_sniffer_rapport:
   script:
     - find simple-plugin -type f -name '*.php' -o -name '*.inc' > ./filelist
     - /root/.composer/vendor/bin/phpcs --standard=php-codesniffer-rules/FDStandard/ruleset.xml --file-list=./filelist
+
+# Java lint
+create_java_spotbugs_rapport:
+  image: gradle:8.10-jdk17
+  stage: lint
+  only:
+    - branches
+    - tags
+  script:
+    - cd qa/
+    - gradle spotbugsTest
+
+# Java codesniffer
+create_java_checkstyle_rapport:
+  image: gradle:8.10-jdk17
+  stage: lint
+  only:
+    - branches
+    - tags
+  script:
+    - cd qa/
+    - gradle checkstyleTest
\ No newline at end of file
diff --git a/README.md b/README.md
index 6ca5e79b281469b2dbeec80554a25c1285ad7698..497c87b153ded6b0647682ad65b292bb7a3cbdb4 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ This is an ensemble of scripts and tools to help coding plugins for FusionDirect
  - php-codesniffer-rules : rules for [PHP_codeSniffer] to be used in ci and development
  - phpstan : rules for the static analyser [phpstan]
  - simple-plugin : simple demo plugin of FusionDirectory API functionalities
+ - qa : structure of the test framework used by FusionDirectory along with a small functional test class
 
 ## Get help
 
diff --git a/qa.iml b/qa.iml
new file mode 100644
index 0000000000000000000000000000000000000000..f3a81a2ffed3d774d22e8e4d7173f0bf3f85db66
--- /dev/null
+++ b/qa.iml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module external.linked.project.id="qa" external.linked.project.path="$MODULE_DIR$/qa" external.root.project.path="$MODULE_DIR$/qa" external.system.id="GRADLE" external.system.module.group="org.fd" external.system.module.version="1.0-SNAPSHOT" type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$/qa">
+      <excludeFolder url="file://$MODULE_DIR$/qa/.gradle" />
+      <excludeFolder url="file://$MODULE_DIR$/qa/build" />
+    </content>
+    <orderEntry type="jdk" jdkName="17" jdkType="JavaSDK" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>
\ No newline at end of file
diff --git a/qa/README.md b/qa/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9ab23cedf906fc024a43054095c713ccdb4f64ab
--- /dev/null
+++ b/qa/README.md
@@ -0,0 +1,38 @@
+## General
+For the whole structure of the project gradle is used. This helps for importing the necessary dependencies automatically and to run the tests with just one command.
+When running the tests you might want to log out from fusion directory browser to not interfere with the tests.
+When running the tests you need to edit the `qa/src/resources/testsConfig.ini` file with the according values (do not change the keys, they will not work otherwise). All keys are needed, otherwise the tests will not work because the fields have no default values (except for screenshotDir).
+Before running the tests start the selenium server (I used the 4.23.1 version). The version can be downloaded from https://www.selenium.dev/downloads/. Run it with `java -jar ./selenium-server-4.23.1.jar standalone`. In order for the tests to run you need to have java 17 installed on your machine. Use this link for how to install it https://computingforgeeks.com/install-oracle-java-openjdk-on-debian-linux/. Make sure you have version 0.35 of geckodriver installed. For the Orchestrator tests make sure to have `tester` + `tester` in the DSA part (this is how the tests are configured to work).
+
+### Gradle commands
+We are using a plugin (defined in `build.gradle` file in `plugins` section). This plugin allows us a nicer show of the test results. The default version that we are using is showing the tests that have passed along with the time they took. This plugin can be customized as documented in https://github.com/radarsh/gradle-test-logger-plugin. <br />
+All commands must be run from inside the automated-testing/qa folder
+- `./gradlew clean` command cleans the gradle folder (usually use this before running tests - not necessary tho)
+- `./gradlew test` command runs all the tests inside the project (meaning all methods annotated with @Test)
+- `./gradlew test --tests "path to test class"` will run a specific test class (the path will begin with org.fd.tests.)
+- `./gradlew test --tests "path to test class.test method name"` will run a specific test method (the path is the same as before and test method name is the name of the method from that class that you want to run)
+
+## Project structure
+All testing code and testing logic is inside the `qa/` folder
+`qa.iml` file is needed for IntelliJ to structure the project nicely when opening it. It is not used for anything else except this.
+
+### qa/ folder
+`build.gradle` file has all the dependencies of the project written in gradle format (if any additional jar is needed the format is usually found on maven repository https://mvnrepository.com) <br />
+`gradlew` (for linux/ macOS) and `gradlew.bat` (for Windows) files are the scripts that actually runs for the gradle commands <br />
+`settings.gradle` file is defining the name of the root project (in this case qa) <br />
+`gradle/` folder contains the  gradle jar <br />
+`src/` folder contains the core of the tests
+- `main/` folder is empty because the backend is not written here
+- `test/` folder contains a `java/org/fd` folder and a `resources` folder
+- `test/resources` folder contains al the necessary extra files to run the tests (eg: the ldif files for ldap, the ini file for configuration)
+- `test/java` folder has all the java files for the tests
+- `Assertions.java` file contains all the methods that assert something (their names start with assert)
+- `LdapConnection.java` file contains all the methods that are used to configure the ldap connection (eg: inserting the ldif files, emptying the ldap)
+- `FusionDirectoryTestCase.java` file represents the big template of writing a basic test, this includes all methods related to access the web interface (eg: clicking buttons, filling in values)
+- `ScreenshotTestWatcher.java` file is an additional file used for defining what will happen when the test fails (we are taking a screenshot), succeeds and aborts (just quit the driver)
+- `test/java/org/fd/tests/` folder has all the test classes in the project (this can be structured more by having `core`/`plugins` folders)
+
+### qa/config folder
+- this folder contains 2 .xml files that are used by the gitlab-ci.yml file to run the checkstyle pipeline for the whole project
+- `checkstyle.xml` file defines all the rules that the pipeline will look for (they check for correct indentation, correct order of methods, java docs, etc)
+- `suppressions.xml` file defines all the exceptions to the rules (here we defined that Test classes and methods do not need java docs)/
\ No newline at end of file
diff --git a/qa/build.gradle b/qa/build.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..b2132c872c8a1459dbe213e8db4245687516fb48
--- /dev/null
+++ b/qa/build.gradle
@@ -0,0 +1,42 @@
+plugins {
+    id 'java'
+    id 'com.adarshr.test-logger' version '4.0.0'
+    id 'checkstyle'
+    id 'com.github.spotbugs' version '5.0.14'
+}
+
+compileJava.options.encoding = 'UTF-8'
+
+tasks.withType(JavaCompile).configureEach {
+    options.encoding = 'UTF-8'
+}
+
+checkstyle {
+    toolVersion = '10.18.0'
+}
+
+spotbugs {
+    toolVersion = '4.7.3'
+    ignoreFailures = false
+}
+
+group = 'org.fd'
+version = '1.0-SNAPSHOT'
+
+repositories {
+    mavenCentral()
+}
+
+dependencies {
+    testImplementation platform('org.junit:junit-bom:5.10.0')
+    testImplementation 'org.junit.jupiter:junit-jupiter'
+
+    testImplementation 'org.seleniumhq.selenium:selenium-java:4.24.0'
+    implementation 'com.unboundid:unboundid-ldapsdk:6.0.3'
+    implementation 'com.puppycrawl.tools:checkstyle:10.18.1'
+    implementation 'com.github.spotbugs:spotbugs:4.8.6'
+}
+
+test {
+    useJUnitPlatform()
+}
\ No newline at end of file
diff --git a/qa/config/checkstyle/checkstyle.xml b/qa/config/checkstyle/checkstyle.xml
new file mode 100644
index 0000000000000000000000000000000000000000..aa904ce5a798e5e5498b9b7587a2b4cd20be9f4e
--- /dev/null
+++ b/qa/config/checkstyle/checkstyle.xml
@@ -0,0 +1,417 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE module PUBLIC
+        "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
+        "https://checkstyle.org/dtds/configuration_1_3.dtd">
+
+<!--
+    This configuration file enforces rules for the coding standard at
+    https://se-education.org/guides/conventions/java/intermediate.html
+-->
+
+<module name="Checker">
+
+    <module name="FileTabCharacter">
+        <!-- Checks that there are no tab characters in the file. -->
+    </module>
+
+    <module name="NewlineAtEndOfFile">
+        <!-- Accept LF, CR or CRLF to accomodate devs who prefer different line endings -->
+        <property name="lineSeparator" value="lf_cr_crlf"/>
+    </module>
+
+    <module name="RegexpSingleline">
+        <!-- Checks that FIXME is not used in comments.  TODO is preferred. -->
+        <property name="format" value="((//.*)|(\*.*))FIXME" />
+        <property name="message" value='TODO is preferred to FIXME."' />
+    </module>
+
+    <module name="SuppressionFilter">
+        <property name="file" value="${config_loc}/suppressions.xml"/>
+    </module>
+
+    <module name="LineLength">
+        <!-- Checks if a line is too long. -->
+        <property name="max" value="120"/>
+    </module>
+
+    <!-- All Java AST specific tests live under TreeWalker module. -->
+    <module name="TreeWalker">
+
+        <!-- Required to allow exceptions in code style -->
+        <module name="SuppressionCommentFilter">
+            <property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)"/>
+            <property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)"/>
+            <property name="checkFormat" value="$1"/>
+        </module>
+
+        <!--
+        IMPORT CHECKS
+        -->
+
+        <!-- Checks for redundant import statements.
+        An import statement is redundant if:
+          * It is a duplicate of another import. This is, when a class is imported more than once.
+          * The class non-statically imported is from the java.lang package, e.g. importing java.lang.String.
+          * The class non-statically imported is from the same package as the current package.
+        -->
+        <module name="RedundantImport"/>
+
+        <!-- Checks for unused import statements.
+        An import statement is unused if:
+          It's not referenced in the file.
+        -->
+        <module name="UnusedImports"/>
+
+        <!--
+        NAMING CHECKS
+        -->
+
+        <!-- Validate abbreviations (consecutive capital letters) length in identifier name -->
+        <module name="AbbreviationAsWordInName">
+            <property name="ignoreFinal" value="false"/>
+            <property name="allowedAbbreviationLength" value="1"/>
+        </module>
+
+        <module name="PackageName">
+            <!-- Validates identifiers for package names against the supplied expression. -->
+            <property name="format" value="^[a-z]+(\.[a-z][a-z0-9]{1,})*$"/>
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="TypeName">
+            <!-- Validates static, final fields against the expression "^[A-Z][a-zA-Z0-9]*$". -->
+            <metadata name="altname" value="TypeName"/>
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="ConstantName">
+            <!-- Validates non-private, static, final fields against the expression "^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$". -->
+            <metadata name="altname" value="ConstantName"/>
+            <property name="applyToPrivate" value="false"/>
+            <message key="name.invalidPattern"
+                     value="Variable ''{0}'' should be in ALL_CAPS (if it is a constant) or be private (otherwise)."/>
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="StaticVariableName">
+            <!-- Validates static, non-final fields against the supplied expression. -->
+            <metadata name="altname" value="StaticVariableName"/>
+            <property name="format" value="^[a-z][a-zA-Z0-9]*_?$"/>
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="MemberName">
+            <!-- Validates non-static members against the supplied expression. -->
+            <metadata name="altname" value="MemberName"/>
+            <property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="MethodName">
+            <!-- Validates identifiers for method names against the supplied expression. -->
+            <metadata name="altname" value="MethodName"/>
+            <property name="format" value="^[a-z][a-zA-Z0-9]*(_[a-z][a-zA-Z0-9]+){0,2}$"/>
+        </module>
+
+        <module name="ParameterName">
+            <!-- Validates identifiers for method parameters against the expression "^[a-z][a-zA-Z0-9]*$". -->
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="LocalFinalVariableName">
+            <!-- Validates identifiers for local final variables against the expression "^[a-z][a-zA-Z0-9]*$". -->
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="LocalVariableName">
+            <!-- Validates identifiers for local variables against the expression "^[a-z][a-zA-Z0-9]*$". -->
+            <property name="severity" value="warning"/>
+        </module>
+
+
+        <!--
+        LENGTH and CODING CHECKS
+        -->
+
+        <!-- Checks that array type declarations follow Java Style
+          Java style: public static void main(String[] args) // Allowed
+          C style:    public static void main(String args[]) // Not allowed
+        -->
+        <module name="ArrayTypeStyle"/>
+
+        <!-- Checks if a catch block is empty and does not contain any comments. -->
+        <module name="EmptyCatchBlock"/>
+
+        <module name="LeftCurly">
+            <!-- Checks for placement of the left curly brace ('{'). -->
+            <property name="severity" value="warning"/>
+        </module>
+
+        <module name="RightCurly">
+            <!-- Checks right curlies on CATCH, ELSE, and TRY blocks are on
+            the same line. e.g., the following example is fine:
+            <pre>
+              if {
+                ...
+              } else
+            </pre>
+            -->
+            <!-- This next example is not fine:
+            <pre>
+              if {
+                ...
+              }
+              else
+            </pre>
+            -->
+            <property name="severity" value="warning"/>
+        </module>
+
+        <!-- Checks for braces around loop blocks -->
+        <module name="NeedBraces">
+            <!--
+            if (true) return 1; // Not allowed
+
+            if (true) { return 1; } // Not allowed
+
+            else if {
+              return 1; // else if should always be multi line
+            }
+
+            if (true)
+              return 1; // Not allowed
+            -->
+            <property name="allowEmptyLoopBody" value="true"/>
+        </module>
+
+        <!-- Checks that each variable declaration is in its own statement and on its own line. -->
+        <module name="MultipleVariableDeclarations"/>
+
+        <module name="OneStatementPerLine"/>
+
+        <!-- Checks that long constants are defined with an upper ell.-->
+        <module name="UpperEll" />
+
+        <module name="FallThrough">
+            <!-- Warn about falling through to the next case statement.  Similar to
+            javac -Xlint:fallthrough, but the check is suppressed if a single-line comment
+            on the last non-blank line preceding the fallen-into case contains 'fall through' (or
+            some other variants which we don't publicized to promote consistency).
+            -->
+            <property name="reliefPattern"
+                      value="fall through|Fall through|fallthru|Fallthru|falls through|Falls through|fallthrough|Fallthrough|No break|NO break|no break|continue on"/>
+        </module>
+
+        <module name="MissingSwitchDefault"/>
+
+        <!-- Checks that Class variables should never be declared public. -->
+        <module name="VisibilityModifier">
+            <property name="protectedAllowed" value="true"/>
+            <property name="allowPublicFinalFields" value="true"/>
+            <property name="ignoreAnnotationCanonicalNames" value="RegisterExtension, TempDir"/>
+        </module>
+
+        <!--
+        ORDER CHECKS
+        -->
+
+        <!-- Checks that the order of at-clauses follows the tagOrder default property value order.
+             @author, @version, @param, @return, @throws, @exception, @see, @since, @serial, @serialField, @serialData, @deprecated
+        -->
+        <module name="AtclauseOrder"/>
+
+        <!-- Checks if the Class and Interface declarations is organized in this order
+          1. Class (static) variables. Order: public, protected, package level (no access modifier), private.
+          2. Instance variables. Order: public, protected, package level (no access modifier), private.
+          3. Constructors
+          4. Methods
+        -->
+        <module name ="DeclarationOrder"/>
+
+        <!-- Checks that default is after all cases in a switch statement -->
+        <module name="DefaultComesLast"/>
+
+        <module name="ModifierOrder">
+            <!-- Warn if modifier order is inconsistent with JLS3 8.1.1, 8.3.1, and
+                 8.4.3.  The prescribed order is:
+                 public, protected, private, abstract, static, final, transient, volatile,
+                 synchronized, native, strictfp
+              -->
+        </module>
+
+        <module name="OverloadMethodsDeclarationOrder"/>
+
+        <!--
+        WHITESPACE CHECKS
+        -->
+
+        <!-- Checks that comments are indented relative to their position in the code -->
+        <module name="CommentsIndentation"/>
+
+        <module name="WhitespaceAround">
+            <!-- Checks that various tokens are surrounded by whitespace.
+                 This includes most binary operators and keywords followed
+                 by regular or curly braces.
+            -->
+            <property name="tokens" value="ASSIGN, BAND, BAND_ASSIGN, BOR,
+        BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR, BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN,
+        EQUAL, GE, GT, LAND, LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE,
+        LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF, LITERAL_RETURN,
+        LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS,
+        MINUS_ASSIGN, MOD, MOD_ASSIGN, NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION,
+        RCURLY, SL, SLIST, SL_ASSIGN, SR_ASSIGN, STAR, STAR_ASSIGN"/>
+            <!-- Allow empty constructors e.g. MyClass() {} -->
+            <property name="allowEmptyConstructors" value="true" />
+            <!-- Allow empty methods e.g. void func() {} -->
+            <property name="allowEmptyMethods" value="true" />
+            <!-- Allow empty types e.g. class Foo {}, enum Foo {} -->
+            <property name="allowEmptyTypes" value="true" />
+            <!-- Allow empty loops e.g. for (int i = 1; i > 1; i++) {} -->
+            <property name="allowEmptyLoops" value="true" />
+            <!-- Allow empty lambdas e.g. () -> {} -->
+            <property name="allowEmptyLambdas" value="true" />
+        </module>
+
+        <module name="WhitespaceAfter">
+            <!-- Checks that commas, semicolons and typecasts are followed by whitespace. -->
+            <property name="tokens" value="COMMA, SEMI, TYPECAST"/>
+        </module>
+
+        <module name="NoWhitespaceAfter">
+            <!-- Checks that there is no whitespace after various unary operators. Linebreaks are allowed. -->
+            <property name="tokens" value="BNOT, DEC, DOT, INC, LNOT, UNARY_MINUS,
+        UNARY_PLUS"/>
+            <property name="allowLineBreaks" value="true"/>
+        </module>
+
+        <!-- No trailing whitespace -->
+        <module name="Regexp">
+            <property name="format" value="[ \t]+$"/>
+            <property name="illegalPattern" value="true"/>
+            <property name="message" value="Trailing whitespace"/>
+        </module>
+
+        <module name="OperatorWrap">
+            <!-- Checks that the non-assignment type operator is at the next line in a line wrap.
+                 This includes "?", ":", "==", "!=", "/", "+", "-", "*", "%", ">>", ">>>",
+                 ">=", ">", "<<", "<=", "<", "^", "|", "||", "&", "&&", "instanceof",
+                 "&" when used in a generic upper or lower bounds constraints,
+                   e.g. <T extends Foo & Bar>
+                 "::" when used as a reference to a method or constructor without arguments.
+                   e.g. String::compareToIgnoreCase
+            -->
+            <property name="tokens" value="QUESTION, COLON, EQUAL, NOT_EQUAL, DIV, PLUS, MINUS, STAR, MOD, SR, BSR,
+        GE, GT, SL, LE, LT, BXOR, BOR, LOR, BAND, LAND, LITERAL_INSTANCEOF, TYPE_EXTENSION_AND, METHOD_REF"/>
+            <property name="option" value="nl"/>
+        </module>
+        <module name="OperatorWrap">
+            <!-- Checks that the assignment type operator is at the previous end of line in a line wrap.
+                 This includes "=", "/=", "+=", "-=", "*=", "%=", ">>=", ">>>=", "<<=", "^=", "&=".
+            -->
+            <property name="tokens" value="ASSIGN, DIV_ASSIGN, PLUS_ASSIGN, MINUS_ASSIGN, STAR_ASSIGN, MOD_ASSIGN,
+        SR_ASSIGN, BSR_ASSIGN, SL_ASSIGN, BXOR_ASSIGN, BOR_ASSIGN, BAND_ASSIGN"/>
+            <property name="option" value="eol"/>
+        </module>
+
+        <module name="SeparatorWrap">
+            <!-- Checks that the ".", "@", "]", "[", "...", "(" is at the next line in a line wrap. -->
+            <property name="tokens" value="DOT, AT, RBRACK, ARRAY_DECLARATOR, ELLIPSIS, LPAREN"/>
+            <property name="option" value="nl"/>
+        </module>
+        <module name="SeparatorWrap">
+            <!-- Checks that the ",", ";" is at the previous end of line in a line wrap. -->
+            <property name="tokens" value="COMMA, SEMI"/>
+            <property name="option" value="eol"/>
+        </module>
+
+        <module name="Indentation">
+            <property name="caseIndent" value="0" />
+            <property name="throwsIndent" value="8" />
+        </module>
+
+        <module name="NoWhitespaceBefore">
+            <!-- Checks that there is no whitespace before various unary operators. Linebreaks are allowed. -->
+            <property name="tokens" value="SEMI, DOT, POST_DEC, POST_INC"/>
+            <property name="allowLineBreaks" value="true"/>
+        </module>
+
+        <module name="NoWhitespaceBeforeCaseDefaultColon"/>
+
+        <!-- Checks that there is no whitespace between method/constructor name and open parenthesis. -->
+        <module name="MethodParamPad"/>
+
+        <module name="ParenPad">
+            <!-- Checks that there is no whitespace before close parenthesis or after open parenthesis. -->
+            <property name="severity" value="warning"/>
+        </module>
+
+        <!-- Checks that non-whitespace characters are separated by no more than one whitespace character.
+             a = 1; // Allowed
+             a  = 1; // Not allowed (more than one space before =)
+        -->
+        <module name="SingleSpaceSeparator">
+            <!-- Validate whitespace surrounding comments as well.
+
+                 a = 1; // Allowed (single space before start of comment)
+                 a = 1; /* Allowed (single space before start of comment) */
+                 /* Allowed (single space after end of comment) */ a = 1;
+                 a = 1;  // Not allowed (more than one space before start of comment)
+                 a = 1;  /* Not allowed (more than one space before start of comment) */
+                 /* Not allowed (more than one space after end of comment) */  a = 1;
+
+                 This doesn't validate whitespace within comments so a comment /* like  this */ is allowed.
+            -->
+            <property name="validateComments" value="true"/>
+        </module>
+
+        <!--
+        JAVADOC CHECKS
+        -->
+
+        <!-- Checks that all block-tags are ordered correctly. -->
+        <module name="AtclauseOrder"/>
+
+        <!-- Checks that Javadoc block tags appear only at the beginning of the line. -->
+        <module name="JavadocBlockTagLocation"/>
+
+        <!-- Checks that all Javadoc comments start from the second line. -->
+        <module name="JavadocContentLocationCheck" />
+
+        <!-- Checks that each line in Javadoc has leading asterisks. -->
+        <module name="JavadocMissingLeadingAsterisk"/>
+
+        <!-- Checks that each non-empty line in Javadoc has whitespace after leading asterisk. -->
+        <module name="JavadocMissingWhitespaceAfterAsterisk"/>
+
+        <!-- Checks that for block tags, indentation of continuation lines is at least 4 spaces. -->
+        <module name="JavadocTagContinuationIndentation"/>
+
+        <!-- Checks the Javadoc's format for every class, enumeration and interface. -->
+        <module name="JavadocType">
+            <property name="allowMissingParamTags" value="false"/>
+        </module>
+
+        <!-- Checks the Javadoc's format for every public method (excluding getters, setters and constructors). -->
+        <module name="JavadocMethod">
+            <property name="allowedAnnotations" value="Override, Test, BeforeAll, BeforeEach, AfterAll, AfterEach, Subscribe"/>
+            <property name="accessModifiers" value="public"/>
+            <property name="validateThrows" value="true"/>
+            <property name="allowMissingParamTags" value="true"/>
+            <property name="allowMissingReturnTag" value="true"/>
+            <property name="tokens" value="METHOD_DEF, ANNOTATION_FIELD_DEF"/>
+        </module>
+
+        <module name="InvalidJavadocPosition"/>
+
+        <!-- Checks that every public method (excluding getters, setters and constructors) has a header comment. -->
+        <module name="MissingJavadocMethodCheck">
+            <property name="minLineCount" value="1"/>
+            <property name="allowMissingPropertyJavadoc" value="true"/>
+            <property name="ignoreMethodNamesRegex" value="(set.*|get.*|main)"/>
+        </module>
+
+        <!-- Checks that every public class, enumeration and interface has a header comment. -->
+        <module name="MissingJavadocType"/>
+
+    </module>
+</module>
\ No newline at end of file
diff --git a/qa/config/checkstyle/suppressions.xml b/qa/config/checkstyle/suppressions.xml
new file mode 100644
index 0000000000000000000000000000000000000000..135ea49ee03307fe07666a419ccdd42d3b39f6f3
--- /dev/null
+++ b/qa/config/checkstyle/suppressions.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+
+<!DOCTYPE suppressions PUBLIC
+        "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
+        "https://checkstyle.org/dtds/suppressions_1_2.dtd">
+
+<suppressions>
+    <suppress checks="JavadocType" files=".*Test\.java"/>
+    <suppress checks="MissingJavadocMethodCheck" files=".*Test\.java"/>
+</suppressions>
\ No newline at end of file
diff --git a/qa/gradle/wrapper/gradle-wrapper.jar b/qa/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196
Binary files /dev/null and b/qa/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/qa/gradle/wrapper/gradle-wrapper.properties b/qa/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000000000000000000000000000000000..9a54d88204b72b56abff55afa11332a245990b20
--- /dev/null
+++ b/qa/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Feb 03 09:59:16 EET 2025
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/qa/gradlew b/qa/gradlew
new file mode 100755
index 0000000000000000000000000000000000000000..1b6c787337ffb79f0e3cf8b1e9f00f680a959de1
--- /dev/null
+++ b/qa/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original 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.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+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
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/qa/gradlew.bat b/qa/gradlew.bat
new file mode 100644
index 0000000000000000000000000000000000000000..107acd32c4e687021ef32db511e8a206129b88ec
--- /dev/null
+++ b/qa/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem 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, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/qa/settings.gradle b/qa/settings.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..cfbd3c70b6a0a09bb6a07986dd72bd4a17ec2390
--- /dev/null
+++ b/qa/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'qa'
+
diff --git a/qa/src/test/java/org/fd/Assertions.java b/qa/src/test/java/org/fd/Assertions.java
new file mode 100644
index 0000000000000000000000000000000000000000..f6795d71710c14b3374656a32c5b5c20f281d297
--- /dev/null
+++ b/qa/src/test/java/org/fd/Assertions.java
@@ -0,0 +1,61 @@
+package org.fd;
+
+import org.openqa.selenium.*;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Consists of all the methods that assert something
+ */
+public class Assertions {
+    private static final Logger LOGGER = Logger.getLogger(Assertions.class.getName());
+    private final WebDriver driver;
+    private final WebDriverWait wait;
+
+    /**
+     * Constructs an Assertions instance
+     *
+     * @param driver      the driver where the tests run
+     * @param wait        the waiter of the driver
+     * @param downloadDir the directory for downloading files
+     * @param vnuJar      the path to the vnu jar
+     */
+    public Assertions(WebDriver driver, WebDriverWait wait, String downloadDir, String vnuJar) {
+        this.driver = driver;
+        this.wait = wait;
+        Utils.customizeLogger(LOGGER);
+    }
+
+    /**
+     * Checks if the user with the given username is logged in
+     *
+     * @param username the username of the desired logged in user
+     * @throws AssertionError when an unexpected user is logged in
+     */
+    public void assertLoggedIn(String username) {
+        wait.until(ExpectedConditions.urlContains("/fusiondirectory/main.php"));
+        if (username != null) {
+            wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("header-right")));
+            WebElement element = driver.findElement(By.id("header-right"))
+                    .findElement(By.tagName("a"))
+                    .findElement(By.tagName("b"));
+            if (!username.equals(element.getText())) {
+                throw new AssertionError("Expected user: " + username + ", but found: " + element.getText());
+            }
+        }
+    }
+
+    /**
+     * Checks if the login has failed
+     */
+    public void assertLoginFailed() {
+        String url = driver.getCurrentUrl();
+        assertTrue(url.contains("/fusiondirectory/index.php"));
+        WebElement element = driver.findElement(By.id("window-footer"));
+        assertEquals("Invalid credentials", element.getText());
+    }
+}
diff --git a/qa/src/test/java/org/fd/FusionDirectoryTestCase.java b/qa/src/test/java/org/fd/FusionDirectoryTestCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..3339e592403670d8a95a519820da17054e54ae50
--- /dev/null
+++ b/qa/src/test/java/org/fd/FusionDirectoryTestCase.java
@@ -0,0 +1,155 @@
+package org.fd;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.openqa.selenium.*;
+import org.openqa.selenium.firefox.FirefoxOptions;
+import org.openqa.selenium.firefox.FirefoxProfile;
+import org.openqa.selenium.remote.RemoteWebDriver;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+import java.io.*;
+import java.net.URL;
+import java.time.Duration;
+import java.util.*;
+import java.util.logging.Logger;
+
+/**
+ * Represents the basic template of a test class
+ */
+@ExtendWith(ScreenshotTestWatcher.class)
+public class FusionDirectoryTestCase extends LdapConnection {
+    private static final Logger LOGGER = Logger.getLogger(FusionDirectoryTestCase.class.getName());
+    private static WebDriver driver;
+    protected String[] initLdifs;
+    private WebDriverWait wait;
+    private Assertions assertions;
+
+    /**
+     * Defines the initial setup of the tests
+     */
+    @BeforeAll
+    public static void initialSetUp() {
+        new Utils().readConfig();
+    }
+
+    /**
+     * Does the initial setup before each test
+     *
+     * @throws IOException if insertLDIF method fails or if host cannot be converted to URL
+     */
+    @BeforeEach
+    protected void setUp() throws IOException {
+        new FileOutputStream(Utils.getApacheLogFile()).close();
+        new FileOutputStream(Utils.getLogPath() + "tests.log").close();
+        Utils.customizeLogger(LOGGER);
+        Utils.setPortNumber(Integer.parseInt(Utils.getLdapHost().substring(Utils.getLdapHost().indexOf(":") + 1)));
+        emptyLdap();
+        resetGosaAclEntry();
+        activateOlcAttributeOptions("x-");
+
+        for (String ldifFile : initLdifs) {
+            insertLdif(Objects.requireNonNull(getClass().getClassLoader().getResource("ldifs/" + ldifFile)).getPath());
+        }
+
+        initDriver();
+        assertions = new Assertions(driver, wait, Utils.getDownloadDir(), Utils.getVnuJar());
+    }
+
+    protected void initDriver() throws IOException {
+        FirefoxOptions firefoxOptions = getFirefoxOptions();
+        driver = new RemoteWebDriver(new URL(Utils.getSeleniumHost()), firefoxOptions);
+        wait = new WebDriverWait(driver, Duration.ofSeconds(5));
+    }
+
+    /**
+     * Constructs the firefox options for the driver
+     *
+     * @return the Firefox options for the driver
+     */
+    private FirefoxOptions getFirefoxOptions() {
+        FirefoxOptions firefoxOptions = new FirefoxOptions();
+        if (Utils.isHeadless()) {
+            firefoxOptions.addArguments("--headless");
+        }
+
+        FirefoxProfile profile = new FirefoxProfile();
+        profile.setPreference("browser.download.folderList", 2);
+        profile.setPreference("browser.download.dir", Utils.getDownloadDir());
+        profile.setPreference("browser.download.manager.showWhenStarting", false);
+        profile.setPreference("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream, text/x-csv");
+        profile.setPreference("browser.helperApps.alwaysAsk.force", false);
+
+        firefoxOptions.setProfile(profile);
+        return firefoxOptions;
+    }
+
+    /**
+     * Provides access to the methods inside the assertions object
+     *
+     * @return the assertions object
+     */
+    public Assertions getAssertions() {
+        return assertions;
+    }
+
+    /**
+     * Checks if the values from the fields are the same as the passed arguments
+     *
+     * @param username the username of the user
+     * @param password the password of the user
+     */
+    private void verifyIdFields(String username, String password) {
+        WebElement usernameField = driver.findElement(By.id("username"));
+        WebElement passwordField = driver.findElement(By.id("password"));
+
+        if (!usernameField.getAttribute("value").equals(username)) {
+            throw new RuntimeException("Username field was not filled correctly");
+        }
+        if (!passwordField.getAttribute("value").equals(password)) {
+            throw new RuntimeException("Password field was not filled correctly");
+        }
+    }
+
+    /**
+     * On the web interface, fills the correct fields with the given values
+     *
+     * @param fields map of string to string, where key is the name of field and value is the value desired for field
+     */
+    protected void fillFields(Map<String, String> fields) {
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            if (entry.getValue() != null) {
+                WebElement element = driver.findElement(By.id(entry.getKey()));
+                element.clear();
+                element.sendKeys(entry.getValue());
+            }
+        }
+    }
+
+    /**
+     * Attempts logging in the user with the given credentials
+     *
+     * @param username the username of the login user
+     * @param password the password of the login user
+     */
+    protected void login(String username, String password) {
+        driver.get("http://" + Utils.getFdHost() + "/fusiondirectory/index.php");
+
+        wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("username")));
+        wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("password")));
+
+        Map<String, String> fields = new HashMap<>();
+        fields.put("username", username);
+        fields.put("password", password);
+        fillFields(fields);
+
+        verifyIdFields(username, password);
+
+        wait.until(ExpectedConditions.visibilityOfElementLocated(By.name("login")));
+
+        WebElement loginButton = driver.findElement(By.name("login"));
+        loginButton.click();
+    }
+}
diff --git a/qa/src/test/java/org/fd/LdapConnection.java b/qa/src/test/java/org/fd/LdapConnection.java
new file mode 100644
index 0000000000000000000000000000000000000000..dac440a6910dac1e55f0d1668744ef8de40ae6f1
--- /dev/null
+++ b/qa/src/test/java/org/fd/LdapConnection.java
@@ -0,0 +1,221 @@
+package org.fd;
+
+import com.unboundid.ldap.sdk.*;
+import com.unboundid.ldif.LDIFException;
+import com.unboundid.ldif.LDIFReader;
+import com.unboundid.util.Base64;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Consists of all the methods that manipulate the ldap connection
+ */
+public class LdapConnection {
+    private static final Logger LOGGER = Logger.getLogger(LdapConnection.class.getName());
+
+    protected final String ldapAdminConf = "cn=admin,cn=config";
+    protected final String ldapPwdConf = "tester";
+    protected final String ldapBaseConf = "cn=config";
+
+    public LdapConnection() {
+        Utils.customizeLogger(LOGGER);
+    }
+
+    /**
+     * Inserts the .ldif file given as parameter into a ldap server
+     *
+     * @param filename the name of the .ldif file
+     * @throws IOException if file operations fail
+     */
+    protected void insertLdif(String filename) throws IOException {
+        BufferedReader file = new BufferedReader(new InputStreamReader(new FileInputStream(filename),
+                StandardCharsets.UTF_8));
+        BufferedWriter tmpFile = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("tmp.ldif"),
+                StandardCharsets.UTF_8));
+        String line;
+
+        while ((line = file.readLine()) != null) {
+            line = line.replaceAll("<LDAP_BASE>", Utils.getLdapBase());
+            line = line.replaceAll("<LDAP_HOST>", Utils.getLdapHost());
+            line = line.replaceAll("<LDAP_PWD>", Utils.getLdapPwd());
+            tmpFile.write(line);
+            tmpFile.write("\n");
+        }
+
+        file.close();
+        tmpFile.close();
+
+        try {
+            LDAPConnection connection = new LDAPConnection("localhost",
+                    Utils.getPortNumber(), Utils.getLdapAdmin(), Utils.getLdapPwd());
+            LDIFReader ldifReader = new LDIFReader("tmp.ldif");
+
+            Entry ldifEntry;
+            while ((ldifEntry = ldifReader.readEntry()) != null) {
+                AddRequest addRequest = new AddRequest(ldifEntry.getDN(), ldifEntry.getAttributes());
+                LDAPResult addResult = connection.add(addRequest);
+                LOGGER.info("Added entry: " + ldifEntry.getDN() + ", Result: " + addResult.getResultCode());
+            }
+
+            ldifReader.close();
+            connection.close();
+            boolean deleted = new File("tmp.ldif").delete();
+            if (!deleted) {
+                LOGGER.log(Level.WARNING, "Tmp file tmp.ldif was not deleted");
+            }
+        } catch (LDAPException | IOException | LDIFException e) {
+            LOGGER.log(Level.SEVERE, e.getMessage());
+            throw new RuntimeException(e.getMessage());
+        }
+    }
+
+    /**
+     * Deletes a ldap entry from the given connection
+     *
+     * @param connection the connection to the ldap server
+     * @param dn the name of dn
+     * @param recursive true if recursive is needed, false otherwise
+     * @throws LDAPException if the ldap operations fail
+     */
+    private void deleteLdapEntry(LDAPConnection connection, String dn, boolean recursive) throws LDAPException {
+        if (!recursive) {
+            connection.delete(dn);
+        } else {
+            SearchResult searchResult = connection.search(dn, SearchScope.ONE, "(objectclass=*)");
+            for (SearchResultEntry entry : searchResult.getSearchEntries()) {
+                deleteLdapEntry(connection, entry.getDN(), recursive);
+            }
+            connection.delete(dn);
+        }
+    }
+
+    /**
+     * Empties the ldap server by deleting all entries
+     */
+    protected void emptyLdap() {
+        try (LDAPConnection connection = new LDAPConnection("localhost", 389,
+                Utils.getLdapAdmin(), Utils.getLdapPwd())) {
+            SearchResult searchResult = connection.search(Utils.getLdapBase(), SearchScope.ONE, "(objectclass=*)");
+
+            for (SearchResultEntry entry : searchResult.getSearchEntries()) {
+                String dn = entry.getDN();
+                if (!dn.equals("cn=admin," + Utils.getLdapBase())) {
+                    deleteLdapEntry(connection, dn, true);
+                }
+            }
+        } catch (LDAPException e) {
+            LOGGER.log(Level.SEVERE, e.getMessage());
+            throw new AssertionError(e.getMessage());
+        }
+    }
+
+    /**
+     * Resets the gosa acl entry
+     */
+    protected void resetGosaAclEntry() {
+        try (LDAPConnection connection = new LDAPConnection("localhost",
+                Utils.getPortNumber(), Utils.getLdapAdmin(), Utils.getLdapPwd())) {
+            BindResult bindResult = connection.bind(Utils.getLdapAdmin(), Utils.getLdapPwd());
+
+            if (bindResult.getResultCode() == ResultCode.SUCCESS) {
+                SearchResult searchResult = connection.search(Utils.getLdapBase(), SearchScope.SUB, "(objectclass=*)");
+
+                if (searchResult.getEntryCount() > 0) {
+                    SearchResultEntry entry = searchResult.getSearchEntries().get(0);
+                    String gosaAclEntryValue = "0:subtree:" + encodeBase64("cn=admin,ou=aclroles,"
+                            + Utils.getLdapBase())
+                            + ":" + encodeBase64("uid=fd-admin,ou=people," + Utils.getLdapBase());
+
+                    if (entry.hasAttribute("gosaaclentry")) {
+                        ModifyRequest modifyRequest = new ModifyRequest(Utils.getLdapBase(),
+                                new Modification(ModificationType.REPLACE, "gosaaclentry", gosaAclEntryValue));
+                        connection.modify(modifyRequest);
+                        LOGGER.log(Level.INFO, "Successfully replaced gosaAclEntry");
+                    } else {
+                        ModifyRequest modifyRequest = new ModifyRequest(Utils.getLdapBase(),
+                                new Modification(ModificationType.ADD, "gosaaclentry", gosaAclEntryValue));
+                        connection.modify(modifyRequest);
+                        LOGGER.log(Level.INFO, "Successfully added gosaAclEntry");
+                    }
+                } else {
+                    LOGGER.log(Level.WARNING, "No entry found for the base " + Utils.getLdapBase());
+                }
+            } else {
+                LOGGER.log(Level.SEVERE, "Failed to bind to LDAP with admin credentials");
+            }
+
+        } catch (LDAPException e) {
+            LOGGER.log(Level.SEVERE, "LDAP Exception: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Checks if the attribute is not set
+     *
+     * @param attrTag the name of the attribute
+     * @return true if the attribute is not set, false otherwise
+     */
+    protected boolean isOlcAttributeOptionNotSet(String attrTag) {
+        try (LDAPConnection connection = new LDAPConnection("localhost",
+                Utils.getPortNumber(), ldapAdminConf, ldapPwdConf)) {
+            SearchResult searchResult = connection.search(ldapBaseConf, SearchScope.SUB, "(olcAttributeOptions=*)");
+
+            if (searchResult.getEntryCount() > 0) {
+                SearchResultEntry entry = searchResult.getSearchEntries().get(0);
+                Attribute attribute = entry.getAttribute("olcAttributeOptions");
+
+                if (attribute != null) {
+                    for (String value : attribute.getValues()) {
+                        if (value.equals(attrTag)) {
+                            return false;
+                        }
+                    }
+                }
+            }
+            return true;
+
+        } catch (LDAPException e) {
+            LOGGER.log(Level.SEVERE, "Error in LDAP operation: " + e.getMessage());
+            return true;
+        }
+    }
+
+    /**
+     * Activates an attribute
+     *
+     * @param attrTag the name of the attribute
+     * @param controls extra controls to be included
+     * @return true if the update is successful, false otherwise
+     */
+    protected boolean activateOlcAttributeOptions(String attrTag, Control... controls) {
+        try (LDAPConnection connection = new LDAPConnection("localhost",
+                Utils.getPortNumber(), ldapAdminConf, ldapPwdConf)) {
+
+            if (isOlcAttributeOptionNotSet(attrTag)) {
+                Modification modification = new Modification(ModificationType.ADD, "olcAttributeOptions", attrTag);
+                ModifyRequest request = new ModifyRequest(ldapBaseConf, modification, controls);
+                LDAPResult result = connection.modify(request);
+                return result.getResultCode() == ResultCode.SUCCESS;
+            }
+
+            return false;
+
+        } catch (LDAPException e) {
+            LOGGER.log(Level.SEVERE, "Error in LDAP operation: " + e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * Encodes a string with the base64 encoding
+     *
+     * @param input the input to be encoded
+     * @return the encoded string
+     */
+    private static String encodeBase64(String input) {
+        return Base64.encode(input.getBytes(StandardCharsets.UTF_8));
+    }
+}
diff --git a/qa/src/test/java/org/fd/ScreenshotTestWatcher.java b/qa/src/test/java/org/fd/ScreenshotTestWatcher.java
new file mode 100644
index 0000000000000000000000000000000000000000..7cf0f2cda9d774b721e969f64b7c1921f809674d
--- /dev/null
+++ b/qa/src/test/java/org/fd/ScreenshotTestWatcher.java
@@ -0,0 +1,166 @@
+package org.fd;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestWatcher;
+import org.openqa.selenium.OutputType;
+import org.openqa.selenium.TakesScreenshot;
+import org.openqa.selenium.WebDriver;
+
+import java.io.*;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Consists of all the methods that define actions when test is finished
+ */
+public class ScreenshotTestWatcher implements TestWatcher {
+    private static final Logger LOGGER = Logger.getLogger(ScreenshotTestWatcher.class.getName());
+
+    public ScreenshotTestWatcher() {
+        Utils.customizeLogger(LOGGER);
+    }
+
+    /**
+     * Copies the log file to a txt file in the screenshotDir
+     *
+     * @param methodName the name of the method that triggered the copy log
+     */
+    private void copyLogFile(String methodName) {
+        Path sourcePath = Paths.get(Utils.getApacheLogFile());
+        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
+        LocalDateTime now = LocalDateTime.now();
+        String timestamp = dtf.format(now);
+        Path destinationPath = Paths.get(Utils.getScreenshotDir() + timestamp + "_" + methodName + ".apacheLogs.txt");
+
+        try {
+            Files.copy(sourcePath, destinationPath, StandardCopyOption.REPLACE_EXISTING);
+            LOGGER.log(Level.INFO, "Log file copied successfully at " + destinationPath);
+        } catch (IOException e) {
+            LOGGER.log(Level.WARNING, "Log file could not e copied because " + e.getMessage());
+        }
+
+        destinationPath = Paths.get(Utils.getScreenshotDir() + timestamp + "_" + methodName + ".testLogs.txt");
+        try {
+            Files.copy(Paths.get(Utils.getLogPath() + "tests.log"), destinationPath,
+                    StandardCopyOption.REPLACE_EXISTING);
+            LOGGER.log(Level.INFO, "Log file copied successfully at " + destinationPath);
+        } catch (IOException e) {
+            LOGGER.log(Level.WARNING, "Log file could not e copied because " + e.getMessage());
+        }
+    }
+
+    /**
+     * Takes a screenshot of the driver and saves it as a .png file
+     *
+     * @param methodName the name of the method that triggered the screenshot
+     * @param driver the WebDriver for which to take screenshot
+     */
+    private void captureScreenshot(String methodName, WebDriver driver) {
+        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
+        LocalDateTime now = LocalDateTime.now();
+        String timestamp = dtf.format(now);
+        String screenshotFileName = methodName + "_" + timestamp + ".png";
+        File screenshotFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
+
+        Path screenshotPath = Paths.get(Utils.getScreenshotDir() + screenshotFileName);
+
+        try {
+            Files.move(screenshotFile.toPath(), screenshotPath);
+            LOGGER.info("Screenshot saved: " + screenshotPath);
+        } catch (IOException e) {
+            LOGGER.log(Level.SEVERE, "Failed to save screenshot: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Extract the driver from the testInstance passed as parameter at runtime
+     *
+     * @param testInstance the instance of the running test
+     * @return the driver field as WebDriver object
+     */
+    private static WebDriver getDriver(FusionDirectoryTestCase testInstance) {
+        try {
+            Field field = FusionDirectoryTestCase.class.getDeclaredField("driver");
+            field.setAccessible(true);
+            return (WebDriver) field.get(testInstance);
+        } catch (NoSuchFieldException | IllegalAccessException e) {
+            LOGGER.log(Level.WARNING, "Driver field was not retrieved correctly");
+            return null;
+        }
+    }
+
+    /**
+     * Quits the driver if the test is successful or aborted
+     *
+     * @param context the context of the test case
+     */
+    private static void quitDriver(ExtensionContext context) {
+        Object testInstance = context.getTestInstance().orElse(null);
+
+        if (testInstance instanceof FusionDirectoryTestCase) {
+            WebDriver driver = getDriver((FusionDirectoryTestCase) testInstance);
+            quitDriver(driver);
+        }
+    }
+
+    /**
+     * Quits the driver passed as parameter
+     *
+     * @param driver the driver to be quited
+     */
+    private static void quitDriver(WebDriver driver) {
+        if (driver != null) {
+            driver.quit();
+            LOGGER.log(Level.INFO, "Driver was quited successfully");
+        } else {
+            LOGGER.log(Level.WARNING, "Driver was null, could not quit");
+        }
+    }
+
+    /**
+     * Defines the actions to do when a test is failing
+     *
+     * @param context the current extension context; never {@code null}
+     * @param cause the throwable that caused test failure; may be {@code null}
+     */
+    @Override
+    public void testFailed(ExtensionContext context, Throwable cause) {
+        LOGGER.log(Level.SEVERE, cause.getMessage());
+        Object testInstance = context.getTestInstance().orElse(null);
+
+        if (testInstance instanceof FusionDirectoryTestCase) {
+            WebDriver driver = getDriver((FusionDirectoryTestCase) testInstance);
+            captureScreenshot(context.getDisplayName(), driver);
+            quitDriver(driver);
+            copyLogFile(context.getDisplayName());
+        }
+    }
+
+    /**
+     * Defines the actions to be done when a test is successful
+     *
+     * @param context the current extension context; never {@code null}
+     */
+    @Override
+    public void testSuccessful(ExtensionContext context) {
+        quitDriver(context);
+    }
+
+    /**
+     * Defines the actions to be done when a test is aborted
+     *
+     * @param context the current extension context; never {@code null}
+     * @param cause the throwable responsible for the test being aborted; may be {@code null}
+     */
+    @Override
+    public void testAborted(ExtensionContext context, Throwable cause) {
+        quitDriver(context);
+    }
+}
diff --git a/qa/src/test/java/org/fd/Utils.java b/qa/src/test/java/org/fd/Utils.java
new file mode 100644
index 0000000000000000000000000000000000000000..e3645d55d02e8f4fd9e8216f584d943a094add57
--- /dev/null
+++ b/qa/src/test/java/org/fd/Utils.java
@@ -0,0 +1,173 @@
+package org.fd;
+
+import java.io.*;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.logging.FileHandler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.logging.SimpleFormatter;
+
+/**
+ * Includes methods that are useful for multiple classes
+ */
+public class Utils {
+    private static final Logger LOGGER = Logger.getLogger(Utils.class.getName());
+    private static String ldapBase;
+    private static String ldapAdmin;
+    private static String ldapPwd;
+    private static String ldapHost;
+    private static int portNumber;
+    private static String vnuJar;
+    private static String apacheLogFile;
+    private static String seleniumHost;
+    private static String fdHost;
+    private static boolean headless;
+    private static String downloadDir;
+    private static String hookDirectory;
+    private static String screenshotDir = "/var/www/html/";
+    private static String logPath = "/var/log/";
+    private static FileHandler fileHandler;
+
+    static {
+        try {
+            fileHandler = new FileHandler(logPath + "tests.log", true);
+            fileHandler.setFormatter(new SimpleFormatter());
+        } catch (IOException e) {
+            LOGGER.severe("Failed to initialize log file handler: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Reads the ini config file and updates fields
+     *
+     * @throws RuntimeException when the config file is not found
+     */
+    public void readConfig() {
+        customizeLogger(LOGGER);
+        Properties properties = new Properties();
+        String configFile = Objects.requireNonNull(getClass().getClassLoader().getResource("testsConfig.ini"))
+                .getPath();
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(configFile),
+                StandardCharsets.UTF_8))) {
+            properties.load(reader);
+        } catch (IOException e) {
+            LOGGER.log(Level.SEVERE, "Config file was not found");
+            throw new RuntimeException("Config file was not found");
+        }
+        properties.stringPropertyNames().forEach(property -> {
+            try {
+                Field field = findField(property);
+                if (field == null) {
+                    LOGGER.log(Level.WARNING, "Property " + property + " was not found");
+                    return;
+                }
+                field.setAccessible(true);
+                if (field.getType().equals(String.class)) {
+                    field.set(this, properties.getProperty(property));
+                }
+                if (field.getType().equals(boolean.class)) {
+                    boolean value = Boolean.parseBoolean(properties.getProperty(property));
+                    field.setBoolean(this, value);
+                }
+                LOGGER.log(Level.INFO, "Set from config file " + field.getName() + "="
+                        + properties.getProperty(property));
+            } catch (Exception e) {
+                LOGGER.log(Level.WARNING, "Could not treat key " + property + "(exception: " + e.getMessage() + ")");
+            }
+        });
+    }
+
+    /**
+     * Finds a field by field name in the tree structure of this class
+     *
+     * @param fieldName the name of the field to be found
+     * @return the field object with the given name
+     */
+    private static Field findField(String fieldName) {
+        Class<?> clazz = Utils.class;
+        while (clazz != null) {
+            try {
+                return clazz.getDeclaredField(fieldName);
+            } catch (NoSuchFieldException e) {
+                clazz = clazz.getSuperclass();
+            }
+        }
+
+        return null;
+    }
+
+    public static String getLdapBase() {
+        return ldapBase;
+    }
+
+    public static String getLdapAdmin() {
+        return ldapAdmin;
+    }
+
+    public static String getLdapPwd() {
+        return ldapPwd;
+    }
+
+    public static String getLdapHost() {
+        return ldapHost;
+    }
+
+    public static int getPortNumber() {
+        return portNumber;
+    }
+
+    public static String getVnuJar() {
+        return vnuJar;
+    }
+
+    public static String getApacheLogFile() {
+        return apacheLogFile;
+    }
+
+    public static String getSeleniumHost() {
+        return seleniumHost;
+    }
+
+    public static String getFdHost() {
+        return fdHost;
+    }
+
+    public static boolean isHeadless() {
+        return headless;
+    }
+
+    public static String getDownloadDir() {
+        return downloadDir;
+    }
+
+    public static String getHookDirectory() {
+        return hookDirectory;
+    }
+
+    public static String getScreenshotDir() {
+        return screenshotDir;
+    }
+
+    public static String getLogPath() {
+        return logPath;
+    }
+
+    public static void setPortNumber(int portNumber) {
+        Utils.portNumber = portNumber;
+    }
+
+    /**
+     * Adds a handler to the logger to write logs in file
+     *
+     * @param logger the logger to be customized
+     */
+    public static void customizeLogger(Logger logger) {
+        if (fileHandler != null && logger.getHandlers().length == 0) {
+            logger.addHandler(fileHandler);
+        }
+        logger.setUseParentHandlers(false);
+    }
+}
diff --git a/qa/src/test/java/org/fd/tests/LoginTest.java b/qa/src/test/java/org/fd/tests/LoginTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..3c4d31097ae4207602ee163b8e7d51becde31f97
--- /dev/null
+++ b/qa/src/test/java/org/fd/tests/LoginTest.java
@@ -0,0 +1,28 @@
+package org.fd.tests;
+
+import org.fd.FusionDirectoryTestCase;
+import org.junit.jupiter.api.Test;
+
+public class LoginTest extends FusionDirectoryTestCase {
+    public LoginTest() {
+        initLdifs = new String[]{"default.ldif"};
+    }
+
+    @Test
+    public void testGoodLogin() {
+        login("fd-admin", "adminpwd");
+        getAssertions().assertLoggedIn("fd-admin");
+    }
+
+    @Test
+    public void testBadLogin() {
+        login("fd-admin", "wrongpwd");
+        getAssertions().assertLoginFailed();
+    }
+
+    @Test
+    public void testBadLogin2() {
+        login("fdadmin", "adminpwd");
+        getAssertions().assertLoginFailed();
+    }
+}
diff --git a/qa/src/test/resources/ldifs/default.ldif b/qa/src/test/resources/ldifs/default.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..948e551da82e67ca7402a43a016c9a3d78b78bf1
--- /dev/null
+++ b/qa/src/test/resources/ldifs/default.ldif
@@ -0,0 +1,83 @@
+dn: ou=fusiondirectory,<LDAP_BASE>
+objectClass: organizationalUnit
+ou: fusiondirectory
+
+dn: cn=config,ou=fusiondirectory,<LDAP_BASE>
+fdPasswordDefaultHash: ssha
+fdUserRDN: ou=people
+fdGroupRDN: ou=groups
+fdAclRoleRDN: ou=aclroles
+fdGidNumberBase: 1100
+fdUidNumberBase: 1100
+fdAccountPrimaryAttribute: uid
+fdLoginAttribute: uid
+fdTimezone: Europe/Brussels
+fdStrictNamingRules: TRUE
+fdHandleExpiredAccounts: FALSE
+fdEnableSnapshots: TRUE
+fdSnapshotBase: ou=snapshots,<LDAP_BASE>
+fdLanguage: en_US
+fdTheme: breezy
+fdStoreFilterSettings: TRUE
+fdModificationDetectionAttribute: entryCSN
+fdListSummary: TRUE
+fdLdapStats: FALSE
+fdWarnSSL: TRUE
+fdForceSSL: FALSE
+fdSchemaCheck: TRUE
+fdLogging: TRUE
+fdDisplayErrors: TRUE
+fdSessionLifeTime: 1800
+cn: config
+fusionConfigMd5: 761886590b77a1f11cbf3b543af36477
+fdForcePasswordDefaultHash: FALSE
+fdLdapSizeLimit: 200
+fdDisplayHookOutput: FALSE
+fdShells: /bin/ash
+fdShells: /bin/bash
+fdShells: /bin/csh
+fdShells: /bin/sh
+fdShells: /bin/ksh
+fdShells: /bin/tcsh
+fdShells: /bin/dash
+fdShells: /bin/zsh
+fdMinId: 100
+fdIdAllocationMethod: traditional
+objectClass: fusionDirectoryConf
+objectClass: fusionDirectoryPluginsConf
+fdOGroupRDN: ou=groups
+fdCnPattern: %givenName% %sn%
+
+dn: ou=people,<LDAP_BASE>
+objectClass: organizationalUnit
+ou: people
+
+dn: uid=fd-admin,ou=people,<LDAP_BASE>
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+givenName: System
+sn: Administrator
+cn: System Administrator-fd-admin
+uid: fd-admin
+userPassword:: e1NTSEF9cXdUcStXRmNSN3JEbG1JYTM3T0hDMmNoNmJaMFBVY0I=
+
+dn: ou=aclroles,<LDAP_BASE>
+objectClass: organizationalUnit
+ou: aclroles
+
+dn: cn=admin,ou=aclroles,<LDAP_BASE>
+cn: admin
+objectClass: top
+objectClass: gosaRole
+gosaAclTemplate: 0:all;cmdrw
+
+dn: ou=locks,ou=fusiondirectory,<LDAP_BASE>
+objectClass: organizationalUnit
+ou: locks
+
+dn: ou=tokens,ou=fusiondirectory,<LDAP_BASE>
+objectClass: organizationalUnit
+ou: tokens
+
diff --git a/qa/src/test/resources/testsConfig.ini b/qa/src/test/resources/testsConfig.ini
new file mode 100644
index 0000000000000000000000000000000000000000..1485295dd1b21d25afdba024c0a1b334bb50f49b
--- /dev/null
+++ b/qa/src/test/resources/testsConfig.ini
@@ -0,0 +1,11 @@
+ldapBase=dc=nodomain
+ldapAdmin=cn=admin,dc=nodomain
+ldapPwd=tester
+ldapHost=localhost:389
+fdHost=localhost:80
+headless=true
+downloadDir=/var/www/html/downloads
+vnuJar=/automated-testing/dist/vnu.jar
+apacheLogFile=/var/log/apache2/error.log
+seleniumHost=http://localhost:4444/wd/hub
+hookDirectory=/var/www/html/