Move the content of the full folder to the root folder (close #644).

Change-Id: Id9243839b6288fa4cd9ea250ad9f659a0ece613c
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/APIAuthenticationTest.java b/src/test/java/de/ids_mannheim/korap/authentication/APIAuthenticationTest.java
new file mode 100644
index 0000000..eeb3ea6
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/authentication/APIAuthenticationTest.java
@@ -0,0 +1,69 @@
+package de.ids_mannheim.korap.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import com.nimbusds.jose.JOSEException;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.FullConfiguration;
+import de.ids_mannheim.korap.constant.TokenType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.security.context.TokenContext;
+import de.ids_mannheim.korap.user.KorAPUser;
+import de.ids_mannheim.korap.user.User;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.TimeUtils;
+import de.ids_mannheim.korap.web.controller.OAuth2TestBase;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class APIAuthenticationTest extends OAuth2TestBase {
+
+    @Autowired
+    private FullConfiguration config;
+
+    @Test
+    public void testDeprecatedService () throws KustvaktException {
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+        Response response = target().path(API_VERSION).path("auth")
+                .path("apiToken").request()
+                .header(Attributes.AUTHORIZATION, userAuthHeader)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DEPRECATED, node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testCreateGetTokenContext () throws KustvaktException,
+            IOException, InterruptedException, JOSEException {
+        User user = new KorAPUser();
+        user.setUsername("testUser");
+        Map<String, Object> attr = new HashMap<>();
+        attr.put(Attributes.HOST, "localhost");
+        attr.put(Attributes.USER_AGENT, "java");
+        attr.put(Attributes.AUTHENTICATION_TIME, TimeUtils.getNow().toDate());
+        APIAuthentication auth = new APIAuthentication(config);
+        TokenContext context = auth.createTokenContext(user, attr);
+        // get token context
+        String authToken = context.getToken();
+        // System.out.println(authToken);
+        context = auth.getTokenContext(authToken);
+        TokenType tokenType = context.getTokenType();
+        assertEquals(TokenType.API, tokenType);
+        assertEquals(context.getUsername(), "testUser");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/AuthenticationFilterTest.java b/src/test/java/de/ids_mannheim/korap/authentication/AuthenticationFilterTest.java
new file mode 100644
index 0000000..9e6f120
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/authentication/AuthenticationFilterTest.java
@@ -0,0 +1,29 @@
+package de.ids_mannheim.korap.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.Response;
+
+public class AuthenticationFilterTest extends SpringJerseyTest {
+
+    @Test
+    public void testAuthenticationWithUnknownScheme ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request().header(Attributes.AUTHORIZATION, "Blah blah").get();
+        String entity = response.readEntity(String.class);
+        JsonNode n = JsonUtils.readTree(entity);
+        assertEquals(n.at("/errors/0/0").asText(), "2001");
+        assertEquals(n.at("/errors/0/1").asText(),
+                "Authentication scheme is not supported.");
+        assertEquals(n.at("/errors/0/2").asText(), "Blah");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java b/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java
new file mode 100644
index 0000000..0580bba
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java
@@ -0,0 +1,232 @@
+package de.ids_mannheim.korap.authentication;
+
+import com.unboundid.ldap.listener.InMemoryDirectoryServer;
+import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
+import com.unboundid.ldap.listener.InMemoryListenerConfig;
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.util.Base64;
+import com.unboundid.util.StaticUtils;
+import com.unboundid.util.ssl.KeyStoreKeyManager;
+import com.unboundid.util.ssl.SSLUtil;
+import com.unboundid.util.ssl.TrustAllTrustManager;
+import com.unboundid.util.ssl.TrustStoreTrustManager;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.security.GeneralSecurityException;
+
+import static de.ids_mannheim.korap.authentication.LdapAuth3.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class LdapAuth3Test {
+
+    public static final String TEST_LDAP_CONF = "src/test/resources/test-ldap.conf";
+
+    public static final String TEST_LDAPS_CONF = "src/test/resources/test-ldaps.conf";
+
+    public static final String TEST_LDAPS_TS_CONF = "src/test/resources/test-ldaps-with-truststore.conf";
+
+    public static final String TEST_LDAP_USERS_LDIF = "src/test/resources/test-ldap-users.ldif";
+
+    private static final String keyStorePath = "src/test/resources/keystore.p12";
+
+    static InMemoryDirectoryServer server;
+
+    @BeforeAll
+    static void startDirectoryServer ()
+            throws LDAPException, GeneralSecurityException {
+        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(
+                "dc=example,dc=com");
+        config.addAdditionalBindCredentials("cn=admin,dc=example,dc=com",
+                "adminpassword");
+        config.setSchema(null);
+        final SSLUtil serverSSLUtil = new SSLUtil(
+                new KeyStoreKeyManager(keyStorePath, "password".toCharArray(),
+                        "PKCS12", "server-cert"),
+                new TrustStoreTrustManager(keyStorePath));
+        final SSLUtil clientSslUtil = new SSLUtil(new TrustAllTrustManager());
+        config.setListenerConfigs(// Listener name
+                InMemoryListenerConfig.createLDAPConfig(// Listener name
+                        "LDAP", // Listen address. (null = listen on all interfaces)
+                        null, // Listen port (0 = automatically choose an available port)
+                        3268, // StartTLS factory
+                        clientSslUtil.createSSLSocketFactory()), // Listener name
+                InMemoryListenerConfig.createLDAPSConfig(// Listener name
+                        "LDAPS", // Listen address. (null = listen on all interfaces)
+                        null, // Listen port (0 = automatically choose an available port)
+                        3269, serverSSLUtil.createSSLServerSocketFactory(),
+                        clientSslUtil.createSSLSocketFactory()));
+        server = new InMemoryDirectoryServer(config);
+        String configPath = TEST_LDAP_USERS_LDIF;
+        server.importFromLDIF(true, configPath);
+        server.startListening();
+    }
+
+    @AfterAll
+    static void shutDownDirectoryServer () {
+        server.shutDown(true);
+    }
+
+    @Test
+    public void loginWithExtraProfileNameWorks () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser123", "password", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithUidWorks () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithUidAndBase64PasswordWorks () throws LDAPException {
+        final byte[] passwordBytes = StaticUtils.getBytes("password");
+        String pw = Base64.encode(passwordBytes);
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", pw, TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithEmailWorks () throws LDAPException {
+        final byte[] passwordBytes = StaticUtils.getBytes("password");
+        String pw = Base64.encode(passwordBytes);
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser@example.com", pw, TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void allLoginPasswordCombinationsWork () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("uid", "userPassword", TEST_LDAP_CONF));
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("uid", "extraPassword", TEST_LDAP_CONF));
+        assertEquals(LDAP_AUTH_ROK, LdapAuth3.login("mail@example.org",
+                "userPassword", TEST_LDAP_CONF));
+        assertEquals(LDAP_AUTH_ROK, LdapAuth3.login("mail@example.org",
+                "extraPassword", TEST_LDAP_CONF));
+        assertEquals(LDAP_AUTH_ROK, LdapAuth3.login("extraProfile",
+                "userPassword", TEST_LDAP_CONF));
+        assertEquals(LDAP_AUTH_ROK, LdapAuth3.login("extraProfile",
+                "extraPassword", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithWrongEmailFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN, LdapAuth3
+                .login("notestuser@example.com", "topsecret", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithEmailAndWrongPasswordFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN, LdapAuth3.login("testuser@example.com",
+                "wrongpw", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithUsernameAndWrongPasswordFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("testuser", "wrongpw", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginOfNotRegisteredUserFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RNOTREG, LdapAuth3.login("not_registered_user",
+                "topsecret", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void blockedUserIsRefused () throws LDAPException {
+        assertEquals(LDAP_AUTH_RLOCKED, LdapAuth3.login("nameOfBlockedUser",
+                "topsecret", TEST_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithUsernameOverSSLWorks () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAPS_CONF));
+    }
+
+    @Test
+    public void loginOnTrustedServerWorks () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAPS_TS_CONF));
+    }
+
+    @Test
+    public void loginOnTrustedServerWithWrongPassswordFails ()
+            throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("testuser", "topsecrets", TEST_LDAPS_TS_CONF));
+    }
+
+    @Test
+    public void passwordWithAsteriskWorks () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("test", "top*ecret", TEST_LDAPS_CONF));
+    }
+
+    @Test
+    public void passwordWithGlobOperatorFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("testuser", "passw*", TEST_LDAPS_TS_CONF));
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAPS_TS_CONF));
+    }
+
+    @Test
+    public void passwordWithExistenceOperatorFails () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("testuser", "*", TEST_LDAPS_TS_CONF));
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAPS_TS_CONF));
+    }
+
+    @Test
+    public void gettingMailAttributeForUid () throws LDAPException {
+        assertEquals(LdapAuth3.getEmail("testuser", TEST_LDAP_CONF),
+                "testuser@example.com");
+        assertEquals(LdapAuth3.getEmail("testuser2", TEST_LDAPS_CONF),
+                "peter@example.org");
+        assertEquals(null, LdapAuth3.getEmail("non-exsting", TEST_LDAPS_CONF));
+    }
+
+    @Test
+    public void gettingUsernameForEmail () throws LDAPException {
+        assertEquals(
+                LdapAuth3.getUsername("testuser@example.com", TEST_LDAP_CONF),
+                "idsTestUser");
+        assertEquals(
+                LdapAuth3.getUsername("peter@example.org", TEST_LDAPS_CONF),
+                "testuser2");
+        assertEquals(null,
+                LdapAuth3.getUsername("non-exsting", TEST_LDAPS_CONF));
+        assertEquals(LdapAuth3.getUsername("testUser2", TEST_LDAPS_CONF),
+                "testuser2");
+        // login with uid, get idsC2Profile username
+        assertEquals(LdapAuth3.getUsername("testUser", TEST_LDAPS_CONF),
+                "idsTestUser");
+    }
+
+    @Test
+    public void gettingMailAttributeForNotRegisteredUserWorks ()
+            throws LDAPException {
+        assertEquals(LdapAuth3.getEmail("not_registered_user", TEST_LDAP_CONF),
+                "not_registered_user@example.com");
+    }
+
+    @Test
+    public void gettingMailAttributeForBlockedUserWorks ()
+            throws LDAPException {
+        assertEquals(LdapAuth3.getEmail("nameOfBlockedUser", TEST_LDAP_CONF),
+                "nameOfBlockedUser@example.com");
+    }
+
+    @Test
+    public void canLoadLdapConfig () {
+        LDAPConfig ldapConfig = new LDAPConfig(TEST_LDAPS_CONF);
+        assertEquals(3269, ldapConfig.port);
+        assertEquals(ldapConfig.host, "localhost");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/LdapOAuth2Test.java b/src/test/java/de/ids_mannheim/korap/authentication/LdapOAuth2Test.java
new file mode 100644
index 0000000..c45cc37
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/authentication/LdapOAuth2Test.java
@@ -0,0 +1,176 @@
+package de.ids_mannheim.korap.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.net.URI;
+import java.security.GeneralSecurityException;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import com.unboundid.ldap.sdk.LDAPException;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.FullConfiguration;
+import de.ids_mannheim.korap.constant.AuthenticationMethod;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.oauth2.dao.AccessTokenDao;
+import de.ids_mannheim.korap.oauth2.dao.OAuth2ClientDao;
+import de.ids_mannheim.korap.oauth2.dao.RefreshTokenDao;
+import de.ids_mannheim.korap.oauth2.entity.AccessToken;
+import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
+import de.ids_mannheim.korap.oauth2.entity.RefreshToken;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.controller.OAuth2TestBase;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class LdapOAuth2Test extends OAuth2TestBase {
+
+    @Autowired
+    private FullConfiguration config;
+
+    @Autowired
+    private AccessTokenDao accessDao;
+
+    @Autowired
+    private RefreshTokenDao refreshDao;
+
+    @Autowired
+    private OAuth2ClientDao clientDao;
+
+    private String testUsername = "idsTestUser";
+
+    private String testUserEmail = "testuser@example.com";
+
+    private String redirectUri = "https://client.com/redirect";
+
+    @BeforeAll
+    static void startTestLDAPServer ()
+            throws LDAPException, GeneralSecurityException {
+        LdapAuth3Test.startDirectoryServer();
+    }
+
+    @AfterAll
+    static void stopTestLDAPServer () {
+        LdapAuth3Test.shutDownDirectoryServer();
+    }
+
+    @BeforeEach
+    public void setLDAPAuthentication () {
+        config.setOAuth2passwordAuthentication(AuthenticationMethod.LDAP);
+    }
+
+    @AfterEach
+    public void resetAuthenticationMethod () {
+        config.setOAuth2passwordAuthentication(AuthenticationMethod.TEST);
+    }
+
+    @Test
+    public void testRequestTokenPasswordUnknownUser ()
+            throws KustvaktException {
+        Response response = requestTokenWithPassword(superClientId,
+                clientSecret, "unknown", "password");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2023, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "LDAP Authentication failed due to unknown user or password!");
+    }
+
+    @Test
+    public void testMapEmailToUsername () throws KustvaktException {
+        Response response = requestTokenWithPassword(superClientId,
+                clientSecret, testUserEmail, "password");
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String accessToken = node.at("/access_token").asText();
+        AccessToken accessTokenObj = accessDao.retrieveAccessToken(accessToken);
+        assertEquals(testUsername, accessTokenObj.getUserId());
+        String refreshToken = node.at("/refresh_token").asText();
+        RefreshToken rt = refreshDao.retrieveRefreshToken(refreshToken);
+        assertEquals(testUsername, rt.getUserId());
+        testRegisterPublicClient(accessToken);
+        node = testRegisterConfidentialClient(accessToken);
+        String clientId = node.at("/client_id").asText();
+        String clientSecret = node.at("/client_secret").asText();
+        testRequestTokenWithAuthorization(clientId, clientSecret, accessToken);
+    }
+
+    private void testRegisterPublicClient (String accessToken)
+            throws KustvaktException {
+        OAuth2ClientJson json = new OAuth2ClientJson();
+        json.setName("LDAP test client");
+        json.setType(OAuth2ClientType.PUBLIC);
+        json.setDescription(
+                "Test registering a public client with LDAP authentication");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("register").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .post(Entity.json(json));
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String clientId = node.at("/client_id").asText();
+        OAuth2Client client = clientDao.retrieveClientById(clientId);
+        assertEquals(testUsername, client.getRegisteredBy());
+    }
+
+    private JsonNode testRegisterConfidentialClient (String accessToken)
+            throws KustvaktException {
+        OAuth2ClientJson json = new OAuth2ClientJson();
+        json.setName("LDAP test client");
+        json.setType(OAuth2ClientType.CONFIDENTIAL);
+        json.setRedirectURI(redirectUri);
+        json.setDescription(
+                "Test registering a confidential client with LDAP authentication");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("register").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .post(Entity.json(json));
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String clientId = node.at("/client_id").asText();
+        OAuth2Client client = clientDao.retrieveClientById(clientId);
+        assertEquals(testUsername, client.getRegisteredBy());
+        return node;
+    }
+
+    private void testRequestTokenWithAuthorization (String clientId,
+            String clientSecret, String accessToken) throws KustvaktException {
+        String authHeader = "Bearer " + accessToken;
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("authorize").queryParam("response_type", "code")
+                .queryParam("client_id", clientId)
+                .queryParam("client_secret", clientSecret)
+                .queryParam("scope", "search match_info").request()
+                .header(Attributes.AUTHORIZATION, authHeader).get();
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        String code = params.getFirst("code");
+        response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String at = node.at("/access_token").asText();
+        AccessToken accessTokenObj = accessDao.retrieveAccessToken(at);
+        assertEquals(testUsername, accessTokenObj.getUserId());
+        String refreshToken = node.at("/refresh_token").asText();
+        RefreshToken rt = refreshDao.retrieveRefreshToken(refreshToken);
+        assertEquals(testUsername, rt.getUserId());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/RandomCodeGeneratorTest.java b/src/test/java/de/ids_mannheim/korap/authentication/RandomCodeGeneratorTest.java
new file mode 100644
index 0000000..9ffe776
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/authentication/RandomCodeGeneratorTest.java
@@ -0,0 +1,48 @@
+package de.ids_mannheim.korap.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.security.NoSuchAlgorithmException;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.encryption.RandomCodeGenerator;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+
+public class RandomCodeGeneratorTest extends SpringJerseyTest {
+
+    @Autowired
+    private RandomCodeGenerator random;
+
+    @Test
+    public void testRandomGenerator ()
+            throws NoSuchAlgorithmException, KustvaktException {
+        String value = random.createRandomCode();
+        assertEquals(22, value.length());
+        // System.out.println(value);
+    }
+
+    @Disabled
+    public void testRandomGeneratorPerformance ()
+            throws NoSuchAlgorithmException, KustvaktException {
+        long min = Integer.MAX_VALUE, max = Integer.MIN_VALUE;
+        String code;
+        while (true) {
+            long start = System.currentTimeMillis();
+            for (int i = 0; i < 10000; i++) {
+                code = random.createRandomCode();
+                code = random.filterRandomCode(code);
+            }
+            long end = System.currentTimeMillis();
+            long duration = end - start;
+            if (duration < min)
+                min = duration;
+            else if (duration > max)
+                max = duration;
+            System.out.println(
+                    "d : " + duration + " min :" + min + ", max: " + max);
+        }
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/cache/NamedVCLoaderTest.java b/src/test/java/de/ids_mannheim/korap/cache/NamedVCLoaderTest.java
new file mode 100644
index 0000000..7b7db2f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/cache/NamedVCLoaderTest.java
@@ -0,0 +1,45 @@
+package de.ids_mannheim.korap.cache;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.collection.DocBits;
+import de.ids_mannheim.korap.config.NamedVCLoader;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.dao.QueryDao;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.util.QueryException;
+
+public class NamedVCLoaderTest extends SpringJerseyTest {
+
+    @Autowired
+    private NamedVCLoader vcLoader;
+
+    @Autowired
+    private QueryDao dao;
+
+    @Test
+    public void testNamedVCLoader ()
+            throws IOException, QueryException, KustvaktException {
+        String vcId = "named-vc1";
+        vcLoader.loadVCToCache(vcId, "/vc/named-vc1.jsonld");
+        assertTrue(VirtualCorpusCache.contains(vcId));
+        Map<String, DocBits> cachedData = VirtualCorpusCache.retrieve(vcId);
+        assertTrue(cachedData.size() > 0);
+        VirtualCorpusCache.delete(vcId);
+        assertFalse(VirtualCorpusCache.contains(vcId));
+        QueryDO vc = dao.retrieveQueryByName(vcId, "system");
+        assertNotNull(vc);
+        dao.deleteQuery(vc);
+        vc = dao.retrieveQueryByName(vcId, "system");
+        assertNull(vc);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/cache/TotalResultTest.java b/src/test/java/de/ids_mannheim/korap/cache/TotalResultTest.java
new file mode 100644
index 0000000..819f7a3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/cache/TotalResultTest.java
@@ -0,0 +1,111 @@
+package de.ids_mannheim.korap.cache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.core.service.SearchService;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class TotalResultTest extends SpringJerseyTest {
+
+    @Autowired
+    private SearchService searchService;
+
+    @Test
+    public void testClearCache () {
+        for (int i = 1; i < 10; i++) {
+            searchService.getTotalResultCache().storeInCache(i, "10");
+        }
+
+        searchService.getTotalResultCache().clearCache();
+
+        assertEquals(0, searchService.getTotalResultCache()
+                .getAllCacheElements().size());
+    }
+
+    @Test
+    public void testSearchWithPaging () throws KustvaktException {
+        searchService.getTotalResultCache().clearCache();
+
+        assertEquals(0, searchService.getTotalResultCache()
+                .getAllCacheElements().size());
+
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "1").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/meta/totalResults").isNumber(),
+                "totalResults should be a number");
+        int totalResults = node.at("/meta/totalResults").asInt();
+        assertEquals(1, searchService.getTotalResultCache()
+                .getAllCacheElements().size());
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "2").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/meta/totalResults").isNumber(),
+                "totalResults should be a number");
+        assertEquals(totalResults, node.at("/meta/totalResults").asInt());
+        assertEquals(1, searchService.getTotalResultCache()
+                .getAllCacheElements().size());
+        assertTrue(node.at("/meta/cutOff").isMissingNode());
+        testSearchWithCutOff();
+    }
+
+    @Test
+    public void testSearchWithCutOffTrue () throws KustvaktException {
+        int cacheSize = searchService.getTotalResultCache()
+                .getAllCacheElements().size();
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "ich").queryParam("ql", "poliqarp")
+                .queryParam("page", "2").queryParam("cutoff", "true").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = "{\"meta\":{\"startPage\":2,\"tokens\":false,\"cutOff\":"
+                + "true,\"snippets\":true,\"timeout\":10000},\"query\":{\"@type\":"
+                + "\"koral:token\",\"wrap\":{\"@type\":\"koral:term\",\"match\":"
+                + "\"match:eq\",\"layer\":\"orth\",\"key\":\"ich\",\"foundry\":"
+                + "\"opennlp\",\"rewrites\":[{\"@type\":\"koral:rewrite\",\"src\":"
+                + "\"Kustvakt\",\"operation\":\"operation:injection\",\"scope\":"
+                + "\"foundry\"}]}},\"@context\":\"http://korap.ids-mannheim.de/ns"
+                + "/koral/0.3/context.jsonld\",\"collection\":{\"@type\":\"koral:"
+                + "doc\",\"match\":\"match:eq\",\"type\":\"type:regex\",\"value\":"
+                + "\"CC-BY.*\",\"key\":\"availability\",\"rewrites\":[{\"@type\":"
+                + "\"koral:rewrite\",\"src\":\"Kustvakt\",\"operation\":\"operation:"
+                + "insertion\",\"scope\":\"availability(FREE)\"}]}}";
+        int cacheKey = searchService.createTotalResultCacheKey(query);
+        assertEquals(null,
+                searchService.getTotalResultCache().getCacheValue(cacheKey));
+        assertEquals(cacheSize, searchService.getTotalResultCache()
+                .getAllCacheElements().size());
+    }
+
+    private void testSearchWithCutOff () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "3").queryParam("cutoff", "false").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/meta/cutOff").isMissingNode());
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "4").queryParam("cutoff", "true").request()
+                .get();
+        entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/meta/cutOff").asBoolean());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/config/LiteJerseyTest.java b/src/test/java/de/ids_mannheim/korap/config/LiteJerseyTest.java
new file mode 100644
index 0000000..80b00a5
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/config/LiteJerseyTest.java
@@ -0,0 +1,58 @@
+package de.ids_mannheim.korap.config;
+
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.servlet.ServletContainer;
+import org.glassfish.jersey.test.DeploymentContext;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.ServletDeploymentContext;
+import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
+import org.glassfish.jersey.test.spi.TestContainerException;
+import org.glassfish.jersey.test.spi.TestContainerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.context.support.GenericWebApplicationContext;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config-lite.xml")
+public abstract class LiteJerseyTest extends JerseyTest {
+
+    public static final String API_VERSION = "v1.0";
+
+    @Autowired
+    protected GenericApplicationContext applicationContext;
+
+    public static String[] classPackages = new String[] {
+            "de.ids_mannheim.korap.core.web",
+            "de.ids_mannheim.korap.web.filter",
+            "de.ids_mannheim.korap.web.utils", "de.ids_mannheim.korap.test",
+            "com.fasterxml.jackson.jaxrs.json" };
+
+    @Override
+    protected TestContainerFactory getTestContainerFactory ()
+            throws TestContainerException {
+        return new GrizzlyWebTestContainerFactory();
+    }
+
+    @BeforeEach
+    @Override
+    public void setUp () throws Exception {
+        GenericWebApplicationContext genericContext = new GenericWebApplicationContext();
+        genericContext.setParent(this.applicationContext);
+        genericContext.setClassLoader(this.applicationContext.getClassLoader());
+        StaticContextLoaderListener.applicationContext = genericContext;
+        super.setUp();
+    }
+
+    @Override
+    protected DeploymentContext configureDeployment () {
+        return ServletDeploymentContext
+                .forServlet(new ServletContainer(
+                        new ResourceConfig().packages(classPackages)))
+                .addListener(StaticContextLoaderListener.class)
+                .contextParam("adminToken", "secret").build();
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/config/SpringJerseyTest.java b/src/test/java/de/ids_mannheim/korap/config/SpringJerseyTest.java
new file mode 100644
index 0000000..a1baccb
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/config/SpringJerseyTest.java
@@ -0,0 +1,59 @@
+package de.ids_mannheim.korap.config;
+
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.servlet.ServletContainer;
+import org.glassfish.jersey.test.DeploymentContext;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.ServletDeploymentContext;
+import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
+import org.glassfish.jersey.test.spi.TestContainerException;
+import org.glassfish.jersey.test.spi.TestContainerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.annotation.DirtiesContext.ClassMode;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.context.support.GenericWebApplicationContext;
+
+@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public abstract class SpringJerseyTest extends JerseyTest {
+
+    public final static String API_VERSION = "v1.0";
+
+    @Autowired
+    protected GenericApplicationContext applicationContext;
+
+    public static String[] classPackages = new String[] {
+            "de.ids_mannheim.korap.web", "de.ids_mannheim.korap.core.web",
+            "de.ids_mannheim.korap.test", "com.fasterxml.jackson.jaxrs.json" };
+
+    @Override
+    protected TestContainerFactory getTestContainerFactory ()
+            throws TestContainerException {
+        return new GrizzlyWebTestContainerFactory();
+    }
+
+    @BeforeEach
+    @Override
+    public void setUp () throws Exception {
+        GenericWebApplicationContext genericContext = new GenericWebApplicationContext();
+        genericContext.setParent(this.applicationContext);
+        genericContext.setClassLoader(this.applicationContext.getClassLoader());
+        StaticContextLoaderListener.applicationContext = genericContext;
+        super.setUp();
+    }
+
+    @Override
+    protected DeploymentContext configureDeployment () {
+        return ServletDeploymentContext
+                .forServlet(new ServletContainer(
+                        new ResourceConfig().packages(classPackages)))
+                .addListener(StaticContextLoaderListener.class)
+                .contextParam("adminToken", "secret").build();
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/config/StaticContextLoaderListener.java b/src/test/java/de/ids_mannheim/korap/config/StaticContextLoaderListener.java
new file mode 100644
index 0000000..018bd6e
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/config/StaticContextLoaderListener.java
@@ -0,0 +1,46 @@
+package de.ids_mannheim.korap.config;
+
+import org.springframework.web.context.ContextLoaderListener;
+import org.springframework.web.context.WebApplicationContext;
+
+import jakarta.servlet.ServletContextEvent;
+
+/**
+ * A hack to inject the application context generated by
+ * SpringJUnit4ClassRunner in the test suite.
+ * 
+ * @author margaretha
+ *
+ */
+public class StaticContextLoaderListener extends ContextLoaderListener {
+
+    public static WebApplicationContext applicationContext;
+
+    private ClassLoader contextClassLoader;
+
+    public StaticContextLoaderListener () {
+        super(applicationContext);
+    }
+
+    @Override
+    public void contextInitialized (ServletContextEvent event) {
+        contextClassLoader = Thread.currentThread().getContextClassLoader();
+        super.contextInitialized(event);
+    }
+
+    @Override
+    public void contextDestroyed (ServletContextEvent event) {
+        // Perform the destruction with the same contextual ClassLoader that was present
+        // during initialization.
+        // This a workaround for a bug in org.glassfish.grizzly.servlet.WebappContext
+        // that causes a memory leak in org.springframework.web.context.ContextLoader.
+        // This logic should be moved to WebappContext.contextDestroyed(). Until this
+        // is fixed in Grizzly; This is a good solution.
+        ClassLoader loader = Thread.currentThread().getContextClassLoader();
+        Thread.currentThread().setContextClassLoader(contextClassLoader);
+
+        super.contextDestroyed(event);
+
+        Thread.currentThread().setContextClassLoader(loader);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/config/TestVariables.java b/src/test/java/de/ids_mannheim/korap/config/TestVariables.java
new file mode 100644
index 0000000..e4658ce
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/config/TestVariables.java
@@ -0,0 +1,355 @@
+package de.ids_mannheim.korap.config;
+
+/**
+ * Created by hanl on 30.05.16.
+ */
+public class TestVariables {
+
+    public static final String SIMPLE_ADD_QUERY = "[pos=ADJA]";
+
+    public static final String RESULT = "{\n"
+            + "\t\"@context\": \"http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld\",\n"
+            + "\t\"meta\": {\n" + "\t\t\"count\": 25,\n"
+            + "\t\t\"startIndex\": 0,\n" + "\t\t\"timeout\": 120000,\n"
+            + "\t\t\"context\": {\n" + "\t\t\t\"left\": [\"token\", 6],\n"
+            + "\t\t\t\"right\": [\"token\", 6]\n" + "\t\t},\n"
+            + "\t\t\"fields\": [\"pubDate\", \"subTitle\", \"author\", \"pubPlace\", \"title\", \"textSigle\", \"UID\", \"ID\", \"layerInfos\", \"corpusSigle\", \"docSigle\", \"corpusID\", \"textClass\"],\n"
+            + "\t\t\"version\": \"0.55.5\",\n"
+            + "\t\t\"benchmark\": \"0.117436617 s\",\n"
+            + "\t\t\"totalResults\": 31,\n"
+            + "\t\t\"serialQuery\": \"tokens:s:das\",\n"
+            + "\t\t\"itemsPerPage\": 25\n" + "\t},\n" + "\t\"query\": {\n"
+            + "\t\t\"@type\": \"koral:token\",\n" + "\t\t\"wrap\": {\n"
+            + "\t\t\t\"@type\": \"koral:term\",\n" + "\t\t\t\"key\": \"das\",\n"
+            + "\t\t\t\"layer\": \"orth\",\n"
+            + "\t\t\t\"match\": \"match:eq\",\n"
+            + "\t\t\t\"foundry\": \"opennlp\",\n" + "\t\t\t\"rewrites\": [{\n"
+            + "\t\t\t\t\"@type\": \"koral:rewrite\",\n"
+            + "\t\t\t\t\"src\": \"Kustvakt\",\n"
+            + "\t\t\t\t\"operation\": \"operation:injection\",\n"
+            + "\t\t\t\t\"scope\": \"foundry\"\n" + "\t\t\t}]\n" + "\t\t},\n"
+            + "\t\t\"idn\": \"das_0\",\n" + "\t\t\"rewrites\": [{\n"
+            + "\t\t\t\"@type\": \"koral:rewrite\",\n"
+            + "\t\t\t\"src\": \"Kustvakt\",\n"
+            + "\t\t\t\"operation\": \"operation:injection\",\n"
+            + "\t\t\t\"scope\": \"idn\"\n" + "\t\t}]\n" + "\t},\n"
+            + "\t\"collection\": {\n" + "\t\t\"@type\": \"koral:docGroup\",\n"
+            + "\t\t\"operation\": \"operation:or\",\n"
+            + "\t\t\"operands\": [{\n" + "\t\t\t\"@type\": \"koral:doc\",\n"
+            + "\t\t\t\"key\": \"corpusSigle\",\n"
+            + "\t\t\t\"value\": \"GOE\",\n" + "\t\t\t\"match\": \"match:eq\"\n"
+            + "\t\t}, {\n" + "\t\t\t\"@type\": \"koral:doc\",\n"
+            + "\t\t\t\"key\": \"corpusSigle\",\n"
+            + "\t\t\t\"value\": \"WPD\",\n" + "\t\t\t\"match\": \"match:eq\"\n"
+            + "\t\t}],\n" + "\t\t\"rewrites\": [{\n"
+            + "\t\t\t\"@type\": \"koral:rewrite\",\n"
+            + "\t\t\t\"src\": \"Kustvakt\",\n"
+            + "\t\t\t\"operation\": \"operation:insertion\",\n"
+            + "\t\t\t\"scope\": \"corpusSigle\"\n" + "\t\t}]\n" + "\t},\n"
+            + "\t\"matches\": [{\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00004\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n"
+            + "\t\t\"title\": \"A (Logik)\",\n"
+            + "\t\t\"author\": \"Zenogantner; ElRaki; 1\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>z.B. mit dem Wort Barbara&quot; bezeichnet, </span><mark>das</mark><span class=\\\"context-right\\\"> dreimal den Buchstaben a enthält, aber<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00004-p195-196\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"kultur musik freizeit-unterhaltung reisen\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.02439\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n"
+            + "\t\t\"title\": \"Aegukka\",\n"
+            + "\t\t\"author\": \"ThorstenS; 2\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>wörtlich &quot;Das Lied der Liebe für </span><mark>das</mark><span class=\\\"context-right\\\"> Land&quot;, oder &quot;Das patriotische Lied&quot;. Es<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.02439-p22-23\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"kultur musik freizeit-unterhaltung reisen\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.02439\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n"
+            + "\t\t\"title\": \"Aegukka\",\n"
+            + "\t\t\"author\": \"ThorstenS; 2\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Besatzungszeit von 1910 bis 1945 wurde </span><mark>das</mark><span class=\\\"context-right\\\"> Lied verboten. Im Ausland lebende Koreaner<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.02439-p74-75\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"kultur musik freizeit-unterhaltung reisen\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.02439\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n"
+            + "\t\t\"title\": \"Aegukka\",\n"
+            + "\t\t\"author\": \"ThorstenS; 2\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>3. Deutsche Übersetzung 1. Strophe Bis </span><mark>das</mark><span class=\\\"context-right\\\"> Meer des Ostens ausdörrt und der<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.02439-p298-299\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Ausnahme von Fremdwörtern und Namen ist </span><mark>das</mark><span class=\\\"context-right\\\"> A der einzige Buchstabe im Deutschen<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p41-42\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>flache Stellung niedergedrückt wird. Stellt man </span><mark>das</mark><span class=\\\"context-right\\\"> Verhältnis der drei Hauptvokale a, i<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p107-108\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>kommt i als der hellste an </span><mark>das</mark><span class=\\\"context-right\\\"> obere, u als der dumpfeste an<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p127-128\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>obere, u als der dumpfeste an </span><mark>das</mark><span class=\\\"context-right\\\"> untere Ende, a als der mittlere<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p134-135\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>im 9. Jahrhundert v. Chr. war </span><mark>das</mark><span class=\\\"context-right\\\"> Schriftzeichen bereits stark stilisiert, die Hörner<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p271-272\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>im Alphabet inne. Als die Griechen </span><mark>das</mark><span class=\\\"context-right\\\"> Phönizische Alphabet übernamen, drehten sie das<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p313-314\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>das Phönizische Alphabet übernamen, drehten sie </span><mark>das</mark><span class=\\\"context-right\\\"> Zeichen um 90 Grad und machten<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p319-320\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>um 90 Grad und machten daraus </span><mark>das</mark><span class=\\\"context-right\\\"> Alpha. Da das Griechische reich an<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p327-328\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>und machten daraus das Alpha. Da </span><mark>das</mark><span class=\\\"context-right\\\"> Griechische reich an Vokalen war, verwendeten<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p330-331\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>reich an Vokalen war, verwendeten sie </span><mark>das</mark><span class=\\\"context-right\\\"> Zeichen für den Lautwert a. Die<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p338-339\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>den Lautwert a. Die Etrusker übernahmen </span><mark>das</mark><span class=\\\"context-right\\\"> frühgriechische Alpha und ließen es größtenteils<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p347-348\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>von rechts nach links) versahen sie </span><mark>das</mark><span class=\\\"context-right\\\"> Zeichen mit einem Abschwung nach links<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p365-366\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Abschwung nach links. Als die Römer </span><mark>das</mark><span class=\\\"context-right\\\"> lateinische Alphabet schufen, verwendeten sie das<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p375-376\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>das lateinische Alphabet schufen, verwendeten sie </span><mark>das</mark><span class=\\\"context-right\\\"> A aus dem etruskischen Alphabet, der<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p381-382\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>in der Biologie steht A für </span><mark>das</mark><span class=\\\"context-right\\\"> Nukleosid Adenosin steht A die Base<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p404-405\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Wert 10, siehe auch Hexadezimalsystem. steht </span><mark>das</mark><span class=\\\"context-right\\\"> Symbol ∀ (ein auf den Kopf gestelltes<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p526-527\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>für die Vorsilbe Atto ist A </span><mark>das</mark><span class=\\\"context-right\\\"> Einheitensymbol der elektrischen Stromstärke Ampere in<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p624-625\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Anordnung (Rechtswesen), Agent (Börse), Aufzeichung (Rechtsw.), </span><mark>das</mark><span class=\\\"context-right\\\"> Flächenmaß Acre und Ausgabe A ist<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p757-758\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Band). in Redewendungen für den Anfang (</span><mark>das</mark><span class=\\\"context-right\\\"> A und O, von A bis<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p777-778\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>Z). a ist die Abkürzung für </span><mark>das</mark><span class=\\\"context-right\\\"> Flächenmaß Ar in der Kaufmannssprache bedeutet<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p790-791\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}, {\n" + "\t\t\"field\": \"tokens\",\n"
+            + "\t\t\"textClass\": \"freizeit-unterhaltung reisen wissenschaft populaerwissenschaft\",\n"
+            + "\t\t\"pubPlace\": \"URL:http://de.wikipedia.org\",\n"
+            + "\t\t\"textSigle\": \"WPD_AAA.00001\",\n"
+            + "\t\t\"docSigle\": \"WPD_AAA\",\n"
+            + "\t\t\"corpusSigle\": \"WPD\",\n" + "\t\t\"title\": \"A\",\n"
+            + "\t\t\"author\": \"Ruru; Jens.Ol; Aglarech; u.a.\",\n"
+            + "\t\t\"layerInfos\": \"base/s=spans cnx/c=spans cnx/l=tokens cnx/m=tokens cnx/p=tokens cnx/s=spans cnx/syn=tokens corenlp/s=spans mate/l=tokens mate/m=tokens mate/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans xip/c=spans xip/l=tokens xip/p=tokens xip/s=spans\",\n"
+            + "\t\t\"startMore\": true,\n" + "\t\t\"endMore\": true,\n"
+            + "\t\t\"snippet\": \"<span class=\\\"context-left\\\"><span class=\\\"more\\\"></span>ad zu) ‚[das Stück] zu...‘ für </span><mark>das</mark><span class=\\\"context-right\\\"> Französische à „nach“, z. B. in<span class=\\\"more\\\"></span></span>\",\n"
+            + "\t\t\"matchID\": \"match-WPD_AAA.00001-p805-806\",\n"
+            + "\t\t\"UID\": 0,\n" + "\t\t\"pubDate\": \"2005-03-28\"\n"
+            + "\t}]\n" + "}";
+
+}
diff --git a/src/test/java/de/ids_mannheim/korap/dao/RolePrivilegeDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/RolePrivilegeDaoTest.java
new file mode 100644
index 0000000..13b2c91
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/RolePrivilegeDaoTest.java
@@ -0,0 +1,78 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.constant.PrivilegeType;
+import de.ids_mannheim.korap.entity.Privilege;
+import de.ids_mannheim.korap.entity.Role;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class RolePrivilegeDaoTest {
+
+    @Autowired
+    private RoleDao roleDao;
+
+    @Autowired
+    private PrivilegeDao privilegeDao;
+
+    @Test
+    public void retrievePredefinedRole () {
+        Role r = roleDao
+                .retrieveRoleById(PredefinedRole.USER_GROUP_ADMIN.getId());
+        assertEquals(1, r.getId());
+    }
+
+    @Test
+    public void createDeleteRole () {
+        String roleName = "vc editor";
+        List<PrivilegeType> privileges = new ArrayList<PrivilegeType>();
+        privileges.add(PrivilegeType.READ);
+        privileges.add(PrivilegeType.WRITE);
+        roleDao.createRole(roleName, privileges);
+        Role r = roleDao.retrieveRoleByName(roleName);
+        assertEquals(roleName, r.getName());
+        assertEquals(2, r.getPrivileges().size());
+        roleDao.deleteRole(r.getId());
+    }
+
+    @Test
+    public void updateRole () {
+        Role role = roleDao.retrieveRoleByName("USER_GROUP_MEMBER");
+        roleDao.editRoleName(role.getId(), "USER_GROUP_MEMBER role");
+        role = roleDao.retrieveRoleById(role.getId());
+        assertEquals(role.getName(), "USER_GROUP_MEMBER role");
+        roleDao.editRoleName(role.getId(), "USER_GROUP_MEMBER");
+        role = roleDao.retrieveRoleById(role.getId());
+        assertEquals(role.getName(), "USER_GROUP_MEMBER");
+    }
+
+    @Test
+    public void addDeletePrivilegeOfExistingRole () {
+        Role role = roleDao.retrieveRoleByName("USER_GROUP_MEMBER");
+        List<Privilege> privileges = role.getPrivileges();
+        assertEquals(1, role.getPrivileges().size());
+        assertEquals(privileges.get(0).getName(), PrivilegeType.DELETE);
+        // add privilege
+        List<PrivilegeType> privilegeTypes = new ArrayList<PrivilegeType>();
+        privilegeTypes.add(PrivilegeType.READ);
+        privilegeDao.addPrivilegesToRole(role, privilegeTypes);
+        role = roleDao.retrieveRoleByName("USER_GROUP_MEMBER");
+        assertEquals(2, role.getPrivileges().size());
+        // delete privilege
+        privilegeDao.deletePrivilegeFromRole(role.getId(), PrivilegeType.READ);
+        role = roleDao.retrieveRoleByName("USER_GROUP_MEMBER");
+        assertEquals(1, role.getPrivileges().size());
+        assertEquals(privileges.get(0).getName(), PrivilegeType.DELETE);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/dao/UserGroupDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/UserGroupDaoTest.java
new file mode 100644
index 0000000..1ee65bf
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/UserGroupDaoTest.java
@@ -0,0 +1,145 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.config.FullConfiguration;
+import de.ids_mannheim.korap.constant.GroupMemberStatus;
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.constant.QueryAccessStatus;
+import de.ids_mannheim.korap.constant.QueryType;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.constant.UserGroupStatus;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.entity.Role;
+import de.ids_mannheim.korap.entity.UserGroup;
+import de.ids_mannheim.korap.entity.UserGroupMember;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.user.User.CorpusAccess;
+import edu.emory.mathcs.backport.java.util.Collections;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class UserGroupDaoTest {
+
+    @Autowired
+    private UserGroupDao userGroupDao;
+
+    @Autowired
+    private QueryDao virtualCorpusDao;
+
+    @Autowired
+    private RoleDao roleDao;
+
+    @Autowired
+    private FullConfiguration config;
+
+    @Test
+    public void createDeleteNewUserGroup () throws KustvaktException {
+        String groupName = "test group";
+        String createdBy = "test class";
+        // create group
+        int groupId = userGroupDao.createGroup(groupName, null, createdBy,
+                UserGroupStatus.ACTIVE);
+        // retrieve group
+        UserGroup group = userGroupDao.retrieveGroupById(groupId, true);
+        assertEquals(groupName, group.getName());
+        assertEquals(createdBy, group.getCreatedBy());
+        assertEquals(UserGroupStatus.ACTIVE, group.getStatus());
+        assertNull(group.getDeletedBy());
+        // group member
+        List<UserGroupMember> members = group.getMembers();
+        assertEquals(1, members.size());
+        UserGroupMember m = members.get(0);
+        assertEquals(GroupMemberStatus.ACTIVE, m.getStatus());
+        assertEquals(createdBy, m.getCreatedBy());
+        assertEquals(createdBy, m.getUserId());
+        // member roles
+        Set<Role> roles = roleDao.retrieveRoleByGroupMemberId(m.getId());
+        assertEquals(2, roles.size());
+        ArrayList<Role> roleList = new ArrayList<>(2);
+        roleList.addAll(roles);
+        Collections.sort(roleList);
+        assertEquals(PredefinedRole.USER_GROUP_ADMIN.getId(),
+                roleList.get(0).getId());
+        assertEquals(PredefinedRole.VC_ACCESS_ADMIN.getId(),
+                roleList.get(1).getId());
+        // retrieve VC by group
+        List<QueryDO> vc = virtualCorpusDao.retrieveQueryByGroup(groupId);
+        assertEquals(0, vc.size());
+        // soft delete group
+        userGroupDao.deleteGroup(groupId, createdBy,
+                config.isSoftDeleteGroup());
+        group = userGroupDao.retrieveGroupById(groupId);
+        assertEquals(UserGroupStatus.DELETED, group.getStatus());
+        // hard delete
+        userGroupDao.deleteGroup(groupId, createdBy, false);
+        KustvaktException exception = assertThrows(KustvaktException.class,
+                () -> {
+                    userGroupDao.retrieveGroupById(groupId);
+                });
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                exception.getStatusCode().intValue());
+    }
+
+    @Test
+    public void retrieveGroupWithMembers () throws KustvaktException {
+        // dory group
+        List<UserGroupMember> members = userGroupDao.retrieveGroupById(2, true)
+                .getMembers();
+        assertEquals(4, members.size());
+        UserGroupMember m = members.get(1);
+        Set<Role> roles = m.getRoles();
+        assertEquals(2, roles.size());
+        List<Role> sortedRoles = new ArrayList<>(roles);
+        Collections.sort(sortedRoles);
+        assertEquals(PredefinedRole.USER_GROUP_MEMBER.name(),
+                sortedRoles.get(0).getName());
+        assertEquals(PredefinedRole.VC_ACCESS_MEMBER.name(),
+                sortedRoles.get(1).getName());
+    }
+
+    @Test
+    public void retrieveGroupByUserId () throws KustvaktException {
+        List<UserGroup> group = userGroupDao.retrieveGroupByUserId("dory");
+        assertEquals(2, group.size());
+        group = userGroupDao.retrieveGroupByUserId("pearl");
+        assertEquals(0, group.size());
+    }
+
+    @Test
+    public void addVCToGroup () throws KustvaktException {
+        // dory group
+        int groupId = 2;
+        UserGroup group = userGroupDao.retrieveGroupById(groupId);
+        String createdBy = "dory";
+        String name = "dory new vc";
+        int id = virtualCorpusDao.createQuery(name, ResourceType.PROJECT,
+                QueryType.VIRTUAL_CORPUS, CorpusAccess.PUB, "corpusSigle=WPD15",
+                "", "", "", false, createdBy, null, null);
+        QueryDO virtualCorpus = virtualCorpusDao.retrieveQueryById(id);
+        userGroupDao.addQueryToGroup(virtualCorpus, createdBy,
+                QueryAccessStatus.ACTIVE, group);
+        List<QueryDO> vc = virtualCorpusDao.retrieveQueryByGroup(groupId);
+        assertEquals(2, vc.size());
+        assertEquals(name, vc.get(1).getName());
+        // delete vc from group
+        userGroupDao.deleteQueryFromGroup(virtualCorpus.getId(), groupId);
+        vc = virtualCorpusDao.retrieveQueryByGroup(groupId);
+        assertEquals(1, vc.size());
+        // delete vc
+        virtualCorpusDao.deleteQuery(virtualCorpus);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/dao/UserGroupMemberDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/UserGroupMemberDaoTest.java
new file mode 100644
index 0000000..e3fdc87
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/UserGroupMemberDaoTest.java
@@ -0,0 +1,52 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.entity.Role;
+import de.ids_mannheim.korap.entity.UserGroupMember;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class UserGroupMemberDaoTest {
+
+    @Autowired
+    private UserGroupMemberDao dao;
+
+    @Autowired
+    private RoleDao roleDao;
+
+    @Test
+    public void testRetrieveMemberByRole () throws KustvaktException {
+        // dory group
+        List<UserGroupMember> vcaAdmins = dao.retrieveMemberByRole(2,
+                PredefinedRole.VC_ACCESS_ADMIN.getId());
+        // System.out.println(vcaAdmins);
+        assertEquals(1, vcaAdmins.size());
+        assertEquals(vcaAdmins.get(0).getUserId(), "dory");
+    }
+
+    @Test
+    public void testAddSameMemberRole () throws KustvaktException {
+        UserGroupMember member = dao.retrieveMemberById("dory", 1);
+        Set<Role> roles = member.getRoles();
+        Role adminRole = roleDao
+                .retrieveRoleById(PredefinedRole.USER_GROUP_ADMIN.getId());
+        roles.add(adminRole);
+        member.setRoles(roles);
+        dao.updateMember(member);
+        member = dao.retrieveMemberById("dory", 1);
+        member.getRoles();
+        assertEquals(2, roles.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusAccessDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusAccessDaoTest.java
new file mode 100644
index 0000000..72f1e0f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusAccessDaoTest.java
@@ -0,0 +1,34 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.QueryAccessStatus;
+import de.ids_mannheim.korap.entity.QueryAccess;
+import de.ids_mannheim.korap.entity.UserGroup;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class VirtualCorpusAccessDaoTest {
+
+    @Autowired
+    private QueryAccessDao dao;
+
+    @Test
+    public void getAccessByVC () throws KustvaktException {
+        List<QueryAccess> vcaList = dao.retrieveActiveAccessByQuery(2);
+        QueryAccess access = vcaList.get(0);
+        assertEquals(QueryAccessStatus.ACTIVE, access.getStatus());
+        assertEquals(access.getCreatedBy(), "dory");
+        UserGroup group = access.getUserGroup();
+        assertEquals(2, group.getId());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java
new file mode 100644
index 0000000..cfd226b
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java
@@ -0,0 +1,152 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Iterator;
+import java.util.List;
+
+import jakarta.persistence.PersistenceException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.constant.QueryType;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.user.User;
+
+public class VirtualCorpusDaoTest extends SpringJerseyTest {
+
+    @Autowired
+    private QueryDao dao;
+
+    @Test
+    public void testListVCByType () throws KustvaktException {
+        List<QueryDO> vcList = dao.retrieveQueryByType(ResourceType.PUBLISHED,
+                null, QueryType.VIRTUAL_CORPUS);
+        assertEquals(1, vcList.size());
+        QueryDO vc = vcList.get(0);
+        assertEquals(4, vc.getId());
+        assertEquals(vc.getName(), "published-vc");
+        assertEquals(vc.getCreatedBy(), "marlin");
+    }
+
+    @Test
+    public void testSystemVC () throws KustvaktException {
+        // insert vc
+        int id = dao.createQuery("system-vc", ResourceType.SYSTEM,
+                QueryType.VIRTUAL_CORPUS, User.CorpusAccess.FREE,
+                "corpusSigle=GOE", "definition", "description", "experimental",
+                false, "test class", null, null);
+        // select vc
+        List<QueryDO> vcList = dao.retrieveQueryByType(ResourceType.SYSTEM,
+                null, QueryType.VIRTUAL_CORPUS);
+        assertEquals(2, vcList.size());
+        QueryDO vc = dao.retrieveQueryById(id);
+        // delete vc
+        dao.deleteQuery(vc);
+        // check if vc has been deleted
+        KustvaktException exception = assertThrows(KustvaktException.class,
+                () -> {
+                    dao.retrieveQueryById(id);
+                });
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                exception.getStatusCode().intValue());
+    }
+
+    @Test
+    public void testNonUniqueVC () throws KustvaktException {
+
+        PersistenceException exception = assertThrows(
+                PersistenceException.class, () -> {
+                    dao.createQuery("system-vc", ResourceType.SYSTEM,
+                            QueryType.VIRTUAL_CORPUS, User.CorpusAccess.FREE,
+                            "corpusSigle=GOE", "definition", "description",
+                            "experimental", false, "system", null, null);
+                });
+
+        assertEquals(exception.getMessage(),
+                "Converting `org.hibernate.exception.GenericJDBCException` "
+                        + "to JPA `PersistenceException` : could not execute statement");
+    }
+
+    @Test
+    public void retrieveSystemVC () throws KustvaktException {
+        List<QueryDO> vc = dao.retrieveQueryByType(ResourceType.SYSTEM, null,
+                QueryType.VIRTUAL_CORPUS);
+        assertEquals(1, vc.size());
+    }
+
+    /**
+     * retrieve private and group VC
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void retrieveVCByUserDory () throws KustvaktException {
+        List<QueryDO> virtualCorpora = dao.retrieveQueryByUser("dory",
+                QueryType.VIRTUAL_CORPUS);
+        // System.out.println(virtualCorpora);
+        assertEquals(4, virtualCorpora.size());
+        // ordered by id
+        Iterator<QueryDO> i = virtualCorpora.iterator();
+        assertEquals(i.next().getName(), "dory-vc");
+        assertEquals(i.next().getName(), "group-vc");
+        assertEquals(i.next().getName(), "system-vc");
+        assertEquals(i.next().getName(), "published-vc");
+    }
+
+    /**
+     * retrieves group VC and
+     * excludes hidden published VC (user has never used it)
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void retrieveVCByUserNemo () throws KustvaktException {
+        List<QueryDO> virtualCorpora = dao.retrieveQueryByUser("nemo",
+                QueryType.VIRTUAL_CORPUS);
+        assertEquals(3, virtualCorpora.size());
+        Iterator<QueryDO> i = virtualCorpora.iterator();
+        assertEquals(i.next().getName(), "group-vc");
+        assertEquals(i.next().getName(), "system-vc");
+        assertEquals(i.next().getName(), "nemo-vc");
+    }
+
+    /**
+     * retrieves published VC by the owner and
+     * excludes group vc when a user is a pending member
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void retrieveVCByUserMarlin () throws KustvaktException {
+        List<QueryDO> virtualCorpora = dao.retrieveQueryByUser("marlin",
+                QueryType.VIRTUAL_CORPUS);
+        assertEquals(3, virtualCorpora.size());
+        Iterator<QueryDO> i = virtualCorpora.iterator();
+        assertEquals(i.next().getName(), "system-vc");
+        assertEquals(i.next().getName(), "published-vc");
+        assertEquals(i.next().getName(), "marlin-vc");
+    }
+
+    /**
+     * retrieves published VC from an auto-generated hidden group and
+     * excludes group vc when a user is a deleted member
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void retrieveVCByUserPearl () throws KustvaktException {
+        List<QueryDO> virtualCorpora = dao.retrieveQueryByUser("pearl",
+                QueryType.VIRTUAL_CORPUS);
+        assertEquals(2, virtualCorpora.size());
+        Iterator<QueryDO> i = virtualCorpora.iterator();
+        assertEquals(i.next().getName(), "system-vc");
+        assertEquals(i.next().getName(), "published-vc");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/BCryptTest.java b/src/test/java/de/ids_mannheim/korap/misc/BCryptTest.java
new file mode 100644
index 0000000..ce1abb3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/BCryptTest.java
@@ -0,0 +1,19 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.mindrot.jbcrypt.BCrypt;
+
+public class BCryptTest {
+
+    @Test
+    public void testSalt () {
+        String salt = BCrypt.gensalt(8);
+        // System.out.println(salt);
+        String plain = "secret";
+        String password = BCrypt.hashpw(plain, salt);
+        // System.out.println(password);
+        assertTrue(BCrypt.checkpw(plain, password));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/CollectionQueryBuilderTest.java b/src/test/java/de/ids_mannheim/korap/misc/CollectionQueryBuilderTest.java
new file mode 100644
index 0000000..0935f6c
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/CollectionQueryBuilderTest.java
@@ -0,0 +1,328 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.KoralCollectionQueryBuilder;
+
+/**
+ * @author hanl
+ * @date 12/08/2015
+ */
+public class CollectionQueryBuilderTest {
+
+    @Test
+    public void testsimpleAdd () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("corpusSigle=WPD");
+        JsonNode node = JsonUtils.readTree(b.toJSON());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "corpusSigle");
+    }
+
+    @Test
+    public void testSimpleConjunction () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("corpusSigle=WPD & textClass=freizeit");
+        JsonNode node = JsonUtils.readTree(b.toJSON());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "corpusSigle");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "textClass");
+    }
+
+    @Test
+    public void testSimpleDisjunction () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("corpusSigle=WPD | textClass=freizeit");
+        JsonNode node = JsonUtils.readTree(b.toJSON());
+        assertNotNull(node);
+        assert node.at("/collection/operation").asText().equals("operation:or");
+        assert node.at("/collection/operands/0/key").asText()
+                .equals("corpusSigle");
+        assert node.at("/collection/operands/1/key").asText()
+                .equals("textClass");
+    }
+
+    @Test
+    public void testComplexSubQuery () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("(corpusSigle=WPD) | (textClass=freizeit & corpusSigle=BRZ13)");
+        JsonNode node = JsonUtils.readTree(b.toJSON());
+        assertNotNull(node);
+        assert node.at("/collection/operation").asText().equals("operation:or");
+        assert node.at("/collection/operands/0/key").asText()
+                .equals("corpusSigle");
+        assert node.at("/collection/operands/1/@type").asText()
+                .equals("koral:docGroup");
+    }
+
+    @Test
+    public void testAddResourceQueryAfter () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("(textClass=politik & title=\"random title\") | textClass=wissenschaft");
+        KoralCollectionQueryBuilder c = new KoralCollectionQueryBuilder();
+        c.setBaseQuery(b.toJSON());
+        c.with("corpusSigle=WPD");
+        JsonNode node = JsonUtils.readTree(c.toJSON());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:docGroup");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(2, node.at("/collection/operands/0/operands").size());
+        assertEquals(2,
+                node.at("/collection/operands/0/operands/0/operands").size());
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/operands/0/operation").asText(),
+                "operation:or");
+        assertEquals(
+                node.at("/collection/operands/0/operands/0/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "WPD");
+    }
+
+    @Test
+    public void testAddComplexResourceQueryAfter () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("(title=\"random title\") | (textClass=wissenschaft)");
+        KoralCollectionQueryBuilder c = new KoralCollectionQueryBuilder();
+        c.setBaseQuery(b.toJSON());
+        c.with("(corpusSigle=BRZ13 | corpusSigle=AZPS)");
+        JsonNode node = JsonUtils.readTree(c.toJSON());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:docGroup");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:docGroup");
+        assertEquals(
+                node.at("/collection/operands/1/operands/0/value").asText(),
+                "BRZ13");
+        assertEquals(
+                node.at("/collection/operands/1/operands/1/value").asText(),
+                "AZPS");
+        assertEquals(
+                node.at("/collection/operands/0/operands/0/value").asText(),
+                "random title");
+        assertEquals(
+                node.at("/collection/operands/0/operands/1/value").asText(),
+                "wissenschaft");
+    }
+
+    @Test
+    public void testBuildQuery () throws KustvaktException {
+        String coll = "corpusSigle=WPD";
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        check.setCollection(coll);
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.setBaseQuery(check.toJSON());
+        b.with("textClass=freizeit");
+        JsonNode res = (JsonNode) b.rebaseCollection();
+        assertNotNull(res);
+        assertEquals(res.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(res.at("/collection/operation").asText(), "operation:and");
+        assertEquals(res.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(res.at("/collection/operands/1/value").asText(),
+                "freizeit");
+        assertEquals(res.at("/collection/operands/1/key").asText(),
+                "textClass");
+        assertEquals(res.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(res.at("/collection/operands/0/value").asText(), "WPD");
+        assertEquals(res.at("/collection/operands/0/key").asText(),
+                "corpusSigle");
+        // check also that query is still there
+        assertEquals(res.at("/query/@type").asText(), "koral:token");
+        assertEquals(res.at("/query/wrap/@type").asText(), "koral:term");
+        assertEquals(res.at("/query/wrap/key").asText(), "Haus");
+        assertEquals(res.at("/query/wrap/layer").asText(), "lemma");
+    }
+
+    @Test
+    public void testBaseQueryBuild () throws KustvaktException {
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.with("(corpusSigle=ADF) | (textClass=freizeit & corpusSigle=WPD)");
+        KoralCollectionQueryBuilder c = new KoralCollectionQueryBuilder();
+        c.setBaseQuery(b.toJSON());
+        c.with("corpusSigle=BRZ13");
+        JsonNode base = (JsonNode) c.rebaseCollection();
+        assertNotNull(base);
+        assertEquals(base.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(base.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(base.at("/collection/operands/1/value").asText(), "BRZ13");
+        assertEquals(base.at("/collection/operands/0/@type").asText(),
+                "koral:docGroup");
+        assertEquals(base.at("/collection/operands/0/operands").size(), 2);
+    }
+
+    @Test
+    public void testNodeMergeWithBase () throws KustvaktException {
+        String coll = "corpusSigle=WPD";
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        check.setCollection(coll);
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.setBaseQuery(check.toJSON());
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        test.with("textClass=wissenschaft | textClass=politik");
+        JsonNode node = (JsonNode) test.rebaseCollection();
+        node = b.mergeWith(node);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(2, node.at("/collection/operands").size());
+    }
+
+    @Test
+    public void testNodeMergeWithoutBase () throws KustvaktException {
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.setBaseQuery(check.toJSON());
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        test.with("corpusSigle=WPD");
+        // String json = test.toJSON();
+        // System.out.println(json);
+        // JsonNode node = (JsonNode) test.rebaseCollection(null);
+        // node = b.mergeWith(node);
+        // assertNotNull(node);
+        // assertEquals("koral:doc", node.at("/collection/@type").asText());
+        // assertEquals("corpusSigle", node.at("/collection/key").asText());
+    }
+
+    @Test
+    public void testNodeMergeWithoutBaseWrongOperator ()
+            throws KustvaktException {
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        KoralCollectionQueryBuilder b = new KoralCollectionQueryBuilder();
+        b.setBaseQuery(check.toJSON());
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        // operator is not supposed to be here!
+        test.and().with("corpusSigle=WPD");
+        // String json = test.toJSON();
+        // System.out.println(json);
+        // JsonNode node = (JsonNode) test.rebaseCollection(null);
+        // node = b.mergeWith(node);
+        // assertNotNull(node);
+        // assertEquals("koral:doc", node.at("/collection/@type").asText());
+        // assertEquals("corpusSigle", node.at("/collection/key").asText());
+    }
+
+    @Test
+    public void testStoredCollectionBaseQueryBuild () {}
+
+    @Test
+    public void testAddOROperator () throws KustvaktException {
+        String coll = "corpusSigle=WPD";
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        check.setCollection(coll);
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        test.setBaseQuery(check.toJSON());
+        test.or().with("textClass=wissenschaft | textClass=politik");
+        JsonNode node = (JsonNode) test.rebaseCollection();
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(), "operation:or");
+        assertEquals(2, node.at("/collection/operands/1/operands").size());
+    }
+
+    @Test
+    public void testAddANDOperator () throws KustvaktException {
+        String coll = "corpusSigle=WPD";
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        check.setCollection(coll);
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        test.setBaseQuery(check.toJSON());
+        test.and().with("textClass=wissenschaft | textClass=politik");
+        JsonNode node = (JsonNode) test.rebaseCollection();
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(2, node.at("/collection/operands/1/operands").size());
+    }
+
+    @Test
+    public void testAddDefaultOperator () throws KustvaktException {
+        String coll = "corpusSigle=WPD";
+        String query = "[base=Haus]";
+        QuerySerializer check = new QuerySerializer();
+        check.setQuery(query, "poliqarp");
+        check.setCollection(coll);
+        KoralCollectionQueryBuilder test = new KoralCollectionQueryBuilder();
+        test.setBaseQuery(check.toJSON());
+        test.with("textClass=wissenschaft | textClass=politik");
+        JsonNode node = (JsonNode) test.rebaseCollection();
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(2, node.at("/collection/operands/1/operands").size());
+    }
+
+    @Test
+    public void testBaseCollectionNull () throws KustvaktException {
+        // base is missing collection segment
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[base=Haus]", "poliqarp");
+        KoralCollectionQueryBuilder total = new KoralCollectionQueryBuilder();
+        total.setBaseQuery(s.toJSON());
+        KoralCollectionQueryBuilder builder = new KoralCollectionQueryBuilder();
+        builder.with("textClass=politik & corpusSigle=WPD");
+        JsonNode node = total.and()
+                .mergeWith((JsonNode) builder.rebaseCollection());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "corpusSigle");
+    }
+
+    @Test
+    public void testMergeCollectionNull () throws KustvaktException {
+        // merge json is missing collection segment
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[base=Haus]", "poliqarp");
+        s.setCollection("textClass=wissenschaft");
+        KoralCollectionQueryBuilder total = new KoralCollectionQueryBuilder();
+        total.setBaseQuery(s.toJSON());
+        KoralCollectionQueryBuilder builder = new KoralCollectionQueryBuilder();
+        JsonNode node = total.and()
+                .mergeWith((JsonNode) builder.rebaseCollection());
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "textClass");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/ConfigTest.java b/src/test/java/de/ids_mannheim/korap/misc/ConfigTest.java
new file mode 100644
index 0000000..f683230
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/ConfigTest.java
@@ -0,0 +1,55 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.config.ConfigLoader;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.utils.ServiceInfo;
+import de.ids_mannheim.korap.utils.TimeUtils;
+
+/**
+ * @author hanl
+ * @date 02/09/2015
+ */
+public class ConfigTest extends SpringJerseyTest {
+
+    @Autowired
+    KustvaktConfiguration config;
+
+    @Test
+    public void testConfigLoader () {
+        InputStream stream = ConfigLoader.loadConfigStream("kustvakt.conf");
+        assertNotNull(stream);
+    }
+
+    @Test
+    public void testPropertyLoader () throws IOException {
+        Properties p = ConfigLoader.loadProperties("kustvakt.conf");
+        assertNotNull(p);
+    }
+
+    @Test
+    public void testServiceInfo () {
+        String version = ServiceInfo.getInfo().getVersion();
+        String name = ServiceInfo.getInfo().getName();
+        assertNotEquals("UNKNOWN", version, "wrong version");
+        assertNotEquals("UNKNOWN", name, "wrong name");
+    }
+
+    @Test
+    public void testProperties () {
+        assertEquals("opennlp", config.getDefault_orthography(),
+                "token layer does not match");
+        assertEquals(TimeUtils.convertTimeToSeconds("1D"),
+                config.getLongTokenTTL(), "token expiration does not match");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/KoralNodeTest.java b/src/test/java/de/ids_mannheim/korap/misc/KoralNodeTest.java
new file mode 100644
index 0000000..eb9051b
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/KoralNodeTest.java
@@ -0,0 +1,48 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import de.ids_mannheim.korap.rewrite.KoralNode;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author hanl
+ * @date 21/10/2015
+ */
+// todo: complete tests
+public class KoralNodeTest {
+
+    // todo: 21.10.15 --> e.g. injection does not tell you if an entire node was injected, or just a value!
+    @Test
+    public void addToNode () {
+        ObjectNode node = JsonUtils.createObjectNode();
+        KoralNode knode = KoralNode.wrapNode(node);
+        knode.put("value_1", "setting_1");
+        assertEquals(knode.rawNode().toString(), "{\"value_1\":\"setting_1\"}");
+    }
+
+    @Test
+    public void removeFromNode () {
+        ObjectNode node = JsonUtils.createObjectNode();
+        node.put("value_1", "setting_1");
+        KoralNode knode = KoralNode.wrapNode(node);
+        knode.remove("value_1", null);
+        assertEquals(knode.rawNode().toString(), "{}");
+    }
+
+    @Test
+    public void replaceObject () {
+        ObjectNode node = JsonUtils.createObjectNode();
+        node.put("value_1", "setting_1");
+        KoralNode knode = KoralNode.wrapNode(node);
+        knode.replace("value_1", "settings_2", null);
+        assertEquals(knode.rawNode().toString(),
+                "{\"value_1\":\"settings_2\"}");
+    }
+
+    // todo: 21.10.15 --> if a node is injected, that node must contain a "rewrites" reference?!
+    @Test
+    public void addNodeToKoral () {}
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/LocalQueryTest.java b/src/test/java/de/ids_mannheim/korap/misc/LocalQueryTest.java
new file mode 100644
index 0000000..9591a0e
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/LocalQueryTest.java
@@ -0,0 +1,71 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.IOException;
+
+import jakarta.annotation.PostConstruct;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.KrillCollection;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.CollectionQueryProcessor;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.KoralCollectionQueryBuilder;
+import de.ids_mannheim.korap.web.SearchKrill;
+
+/**
+ * @author hanl
+ * @date 14/01/2016
+ */
+public class LocalQueryTest extends SpringJerseyTest {
+
+    private static String index;
+
+    @Autowired
+    KustvaktConfiguration config;
+
+    @PostConstruct
+    public void setup () throws Exception {
+        index = config.getIndexDir();
+    }
+
+    @Test
+    public void testQuery () throws KustvaktException {
+        String qstring = "creationDate since 1786 & creationDate until 1788";
+        // qstring = "creationDate since 1765 & creationDate until 1768";
+        // qstring = "textType = Aphorismus";
+        // qstring = "title ~ \"Werther\"";
+        SearchKrill krill = new SearchKrill(index);
+        KoralCollectionQueryBuilder coll = new KoralCollectionQueryBuilder();
+        coll.with(qstring);
+        String stats = krill.getStatistics(coll.toJSON());
+        assert stats != null && !stats.isEmpty() && !stats.equals("null");
+    }
+
+    @Test
+    public void testCollQuery () throws IOException, KustvaktException {
+        String qstring = "creationDate since 1800 & creationDate until 1820";
+        CollectionQueryProcessor processor = new CollectionQueryProcessor();
+        processor.process(qstring);
+        String s = JsonUtils.toJSON(processor.getRequestMap());
+        KrillCollection c = new KrillCollection(s);
+        c.setIndex(new SearchKrill(index).getIndex());
+        long docs = c.numberOf("documents");
+        assert docs > 0 && docs < 15;
+    }
+
+    @Test
+    public void testCollQuery2 () throws IOException {
+        String query = "{\"@context\":\"http://korap.ids-mannheim.de/ns/koral/0.3/context.jsonld\",\"errors\":[],\"warnings\":[],\"messages\":[],\"collection\":{\"@type\":\"koral:docGroup\",\"operation\":\"operation:and\",\"operands\":[{\"@type\":\"koral:doc\",\"key\":\"creationDate\",\"type\":\"type:date\",\"value\":\"1786\",\"match\":\"match:geq\"},{\"@type\":\"koral:doc\",\"key\":\"creationDate\",\"type\":\"type:date\",\"value\":\"1788\",\"match\":\"match:leq\"}]},\"query\":{},\"meta\":{}}";
+        KrillCollection c = new KrillCollection(query);
+        c.setIndex(new SearchKrill(index).getIndex());
+        long sent = c.numberOf("base/sentences");
+        long docs = c.numberOf("documents");
+        assertNotNull(sent);
+        assertNotNull(docs);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/MapUtilsTest.java b/src/test/java/de/ids_mannheim/korap/misc/MapUtilsTest.java
new file mode 100644
index 0000000..4d59790
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/MapUtilsTest.java
@@ -0,0 +1,38 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import de.ids_mannheim.korap.web.utils.MapUtils;
+import edu.emory.mathcs.backport.java.util.Arrays;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+
+public class MapUtilsTest {
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testConvertToMap () {
+        MultivaluedMap<String, String> mm = new MultivaluedHashMap<String, String>();
+        mm.put("k1", Arrays.asList(new String[] { "a", "b", "c" }));
+        mm.put("k2", Arrays.asList(new String[] { "d", "e", "f" }));
+        Map<String, String> map = MapUtils.toMap(mm);
+        assertEquals(map.get("k1"), "a b c");
+        assertEquals(map.get("k2"), "d e f");
+    }
+
+    @Test
+    public void testConvertNullMap () {
+        Map<String, String> map = MapUtils.toMap(null);
+        assertEquals(0, map.size());
+    }
+
+    @Test
+    public void testConvertEmptyMap () {
+        MultivaluedMap<String, String> mm = new MultivaluedHashMap<String, String>();
+        Map<String, String> map = MapUtils.toMap(mm);
+        assertEquals(0, map.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/MetaQueryBuilderTest.java b/src/test/java/de/ids_mannheim/korap/misc/MetaQueryBuilderTest.java
new file mode 100644
index 0000000..7eda84d
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/MetaQueryBuilderTest.java
@@ -0,0 +1,26 @@
+package de.ids_mannheim.korap.misc;
+
+import de.ids_mannheim.korap.config.QueryBuilderUtil;
+import de.ids_mannheim.korap.query.serialize.MetaQueryBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Created by hanl on 17.04.16.
+ */
+public class MetaQueryBuilderTest {
+
+    @Test
+    public void testSpanContext () {
+        MetaQueryBuilder m = QueryBuilderUtil.defaultMetaBuilder(0, 1, 5,
+                "sentence", false);
+        Map<?, ?> map = m.raw();
+        assertEquals(map.get("context"), "sentence");
+        assertEquals(1, map.get("startPage"));
+        assertEquals(0, map.get("startIndex"));
+        assertEquals(false, map.get("cutOff"));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/RegexTest.java b/src/test/java/de/ids_mannheim/korap/misc/RegexTest.java
new file mode 100644
index 0000000..634530b
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/RegexTest.java
@@ -0,0 +1,20 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.regex.Matcher;
+
+import org.junit.jupiter.api.Test;
+import de.ids_mannheim.korap.annotation.AnnotationParser;
+
+public class RegexTest {
+
+    @Test
+    public void testQuote () {
+        String s = "ah[\"-\"]";
+        Matcher m = AnnotationParser.quotePattern.matcher(s);
+        if (m.find()) {
+            assertEquals(m.group(1), "-");
+        }
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/ScopesTest.java b/src/test/java/de/ids_mannheim/korap/misc/ScopesTest.java
new file mode 100644
index 0000000..ad92c36
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/ScopesTest.java
@@ -0,0 +1,16 @@
+package de.ids_mannheim.korap.misc;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author hanl
+ * @date 20/01/2016
+ */
+public class ScopesTest {
+
+    @Test
+    public void testScopes () {}
+
+    @Test
+    public void testOpenIDScopes () {}
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/SerializationTest.java b/src/test/java/de/ids_mannheim/korap/misc/SerializationTest.java
new file mode 100644
index 0000000..dd4ca5b
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/SerializationTest.java
@@ -0,0 +1,23 @@
+package de.ids_mannheim.korap.misc;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author hanl
+ * @date 21/01/2016
+ */
+@Disabled
+public class SerializationTest {
+
+    @Test
+    public void testSettingsObject () {
+        // String t = "poliqarp_test";
+        // UserSettings s = new UserSettings();
+        // Map map = s.toObjectMap();
+        // map.put(Attributes.QUERY_LANGUAGE, t);
+        // s.updateObjectSettings(map);
+        // 
+        // assert s.getQueryLanguage().equals(t);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/ServiceSuite.java b/src/test/java/de/ids_mannheim/korap/misc/ServiceSuite.java
new file mode 100644
index 0000000..ac2c8a1
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/ServiceSuite.java
@@ -0,0 +1,7 @@
+package de.ids_mannheim.korap.misc;
+
+/**
+ * @author hanl
+ * @date 29/02/2016
+ */
+public class ServiceSuite {}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/StringUtilsTest.java b/src/test/java/de/ids_mannheim/korap/misc/StringUtilsTest.java
new file mode 100644
index 0000000..f7dfb3a
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/StringUtilsTest.java
@@ -0,0 +1,38 @@
+package de.ids_mannheim.korap.misc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.commons.codec.binary.Base64;
+import org.junit.jupiter.api.Test;
+import de.ids_mannheim.korap.authentication.http.AuthorizationData;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.authentication.http.TransferEncoding;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.StringUtils;
+
+/**
+ * Created by hanl on 29.05.16.
+ */
+public class StringUtilsTest {
+
+    @Test
+    public void testTextIToDoc () {
+        String textSigle = "WPD_AAA.02439";
+        String docSigle = "WPD_AAA";
+        assertEquals(docSigle, StringUtils.getDocSigle(textSigle));
+        assertEquals(docSigle, StringUtils.getDocSigle(docSigle));
+    }
+
+    @Test
+    public void testBasicHttpSplit () throws KustvaktException {
+        String s2 = new String(Base64.encodeBase64("test:testPass".getBytes()));
+        String[] f2 = TransferEncoding.decodeBase64(s2);
+        assertEquals(f2[0], "test");
+        assertEquals(f2[1], "testPass");
+        HttpAuthorizationHandler handler = new HttpAuthorizationHandler();
+        String s1 = "basic "
+                + new String(Base64.encodeBase64("test:testPass".getBytes()));
+        AuthorizationData f1 = handler.parseAuthorizationHeaderValue(s1);
+        assertEquals(s2, f1.getToken());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/misc/TestNullableSqls.java b/src/test/java/de/ids_mannheim/korap/misc/TestNullableSqls.java
new file mode 100644
index 0000000..8fcb1d0
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/misc/TestNullableSqls.java
@@ -0,0 +1,7 @@
+package de.ids_mannheim.korap.misc;
+
+/**
+ * @author hanl
+ * @date 30/01/2016
+ */
+public class TestNullableSqls {}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/CollectionRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/CollectionRewriteTest.java
new file mode 100644
index 0000000..e205ce3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/CollectionRewriteTest.java
@@ -0,0 +1,277 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.config.TestVariables;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.user.User;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author hanl
+ * @date 03/09/2015
+ */
+public class CollectionRewriteTest extends SpringJerseyTest {
+
+    @Autowired
+    public KustvaktConfiguration config;
+
+    @Test
+    public void testCollectionNodeRemoveCorpusIdNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection("textClass=politik & corpusSigle=WPD");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(1, node.at("/collection/operands").size());
+    }
+
+    @Test
+    public void testCollectionNodeDeletionNotification () {}
+
+    @Test
+    public void testCollectionNodeRemoveAllCorpusIdNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection("corpusSigle=BRZ13 & corpusSigle=WPD");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(0, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testCollectionNodeRemoveGroupedCorpusIdNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(corpusSigle=BRZ13 & textClass=Wissenschaft) & corpusSigle=WPD");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:docGroup");
+        assertEquals(node.at("/collection/operands/0/operands/0/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    // fixme: will probably fail when one doc groups are being refactored
+    @Test
+    public void testCollectionCleanEmptyDocGroupNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        handler.add(CollectionCleanRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(corpusSigle=BRZ13 & corpusSigle=WPD) & textClass=Wissenschaft & textClass=Sport");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testCollectionCleanMoveOneDocFromGroupUpNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        handler.add(CollectionCleanRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection("(corpusSigle=BRZ13 & textClass=wissenschaft)");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "textClass");
+        assertEquals(node.at("/collection/value").asText(), "wissenschaft");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testCollectionCleanEmptyGroupAndMoveOneFromGroupUpNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        handler.add(CollectionCleanRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(corpusSigle=BRZ13 & corpusSigle=WPD) & textClass=Wissenschaft");
+        String result = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(result,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "textClass");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testCollectionRemoveAndMoveOneFromGroupUpNoErrors ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        handler.add(CollectionCleanRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(docID=random & textClass=Wissenschaft) & corpusSigle=WPD");
+        String org = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(org,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testPublicCollectionRewriteEmptyAdd ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        String org = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(org,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/key").asText(), "availability");
+        assertEquals(node.at("/collection/value").asText(), "CC-BY.*");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        // todo:
+    }
+
+    @Test
+    public void testPublicCollectionRewriteNonEmptyAdd ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection("(docSigle=WPD_AAA & textClass=wissenschaft)");
+        String org = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(org,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/operands/0/key").asText(),
+                "docSigle");
+        assertEquals(node.at("/collection/operands/1/operands/1/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+    }
+
+    @Test
+    public void testRemoveCorpusFromDifferentGroups ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(corpusSigle=BRZ14 & textClass=wissenschaft) | (corpusSigle=AZPR | textClass=freizeit)");
+        String org = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(org,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:docGroup");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:docGroup");
+        assertEquals(1, node.at("/collection/operands/0/operands").size());
+        assertEquals(1, node.at("/collection/operands/1/operands").size());
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testRemoveOneCorpusAndMoveDocFromTwoGroups ()
+            throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        handler.add(CollectionConstraint.class);
+        // todo: use this collection query also to test clean up filter! after reduction of corpusSigle (wiss | freizeit)!
+        handler.add(CollectionCleanRewrite.class);
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(TestVariables.SIMPLE_ADD_QUERY, "poliqarp");
+        s.setCollection(
+                "(corpusSigle=BRZ14 & textClass=wissenschaft) | (corpusSigle=AZPR | textClass=freizeit)");
+        String org = s.toJSON();
+        JsonNode node = JsonUtils.readTree(handler.processQuery(org,
+                User.UserFactory.getUser("test_user")));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "wissenschaft");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/operands/1/value").asText(),
+                "freizeit");
+        assertEquals(node.at("/collection/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/FoundryRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/FoundryRewriteTest.java
new file mode 100644
index 0000000..51e7fc5
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/FoundryRewriteTest.java
@@ -0,0 +1,189 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.user.KorAPUser;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+/**
+ * @author hanl, margaretha
+ * @date 18/06/2015
+ */
+// MH todo: check position and information of rewrites!
+public class FoundryRewriteTest extends SpringJerseyTest {
+
+    // private static String simple_add_query = "[pos=ADJA]";
+    // private static String simple_rewrite_query = "[base=Haus]";
+    // private static String complex_rewrite_query = "<c=INFC>";
+    // private static String complex_rewrite_query2 = "[orth=laufe/i & base!=Lauf]";
+    // private static String complex_rewrite_query3 = "[(base=laufen | base=gehen) & tt/pos=VVFIN]";
+    @Autowired
+    public KustvaktConfiguration config;
+
+    @Autowired
+    public RewriteHandler handler;
+
+    @Autowired
+    private LayerMapper m;
+
+    @Test
+    public void testSearchRewriteFoundryWithUserSetting ()
+            throws KustvaktException {
+        // create user setting
+        String json = "{\"pos-foundry\":\"opennlp\"}";
+        String username = "foundryRewriteTest";
+        String pathUsername = "~" + username;
+        Response response = target().path(API_VERSION).path(pathUsername)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        // search
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[pos=ADJA]").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/query/wrap/foundry").asText(), "opennlp");
+        assertEquals(node.at("/query/wrap/rewrites/0/scope").asText(),
+                "foundry");
+    }
+
+    @Test
+    public void testRewritePosFoundryWithUserSetting ()
+            throws KustvaktException {
+        // EM: see
+        // full/src/main/resources/db/insert/V3.6__insert_default_settings.sql
+        String username = "bubbles";
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[pos=ADJA]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(),
+                new KorAPUser(username));
+        JsonNode node = JsonUtils.readTree(result);
+        assertEquals(node.at("/query/wrap/foundry").asText(), "corenlp");
+        assertEquals(node.at("/query/wrap/rewrites/0/scope").asText(),
+                "foundry");
+    }
+
+    @Test
+    public void testRewriteLemmaFoundryWithUserSetting ()
+            throws KustvaktException {
+        String username = "bubbles";
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[base=Haus]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(),
+                new KorAPUser(username));
+        JsonNode node = JsonUtils.readTree(result);
+        // EM: only for testing, in fact, opennlp lemma does not
+        // exist!
+        assertEquals(node.at("/query/wrap/foundry").asText(), "opennlp");
+        assertEquals(node.at("/query/wrap/rewrites/0/scope").asText(),
+                "foundry");
+    }
+
+    @Test
+    public void testDefaultLayerMapperThrowsNoException () {
+        assertEquals(config.getDefault_lemma(), m.findFoundry("lemma"));
+        assertEquals(config.getDefault_pos(), m.findFoundry("pos"));
+        assertEquals(config.getDefault_orthography(), m.findFoundry("surface"));
+        assertEquals(config.getDefault_dep(), m.findFoundry("d"));
+        assertEquals(config.getDefault_const(), m.findFoundry("c"));
+    }
+
+    @Test
+    public void testDefaultFoundryInjectLemmaThrowsNoError ()
+            throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[base=Haus]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser("test"));
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertFalse(node.at("/query/wrap/foundry").isMissingNode());
+        assertEquals(config.getDefault_lemma(),
+                node.at("/query/wrap/foundry").asText());
+        assertEquals(node.at("/query/wrap/layer").asText(), "lemma");
+        assertFalse(node.at("/query/wrap/rewrites").isMissingNode());
+        assertEquals(node.at("/query/wrap/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testDefaultFoundryInjectPOSNoErrors ()
+            throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[pos=ADJA]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser("test"));
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertFalse(node.at("/query/wrap/foundry").isMissingNode());
+        assertEquals(config.getDefault_pos(),
+                node.at("/query/wrap/foundry").asText());
+        assertEquals(node.at("/query/wrap/layer").asText(), "pos");
+        assertFalse(node.at("/query/wrap/rewrites").isMissingNode());
+        assertEquals(node.at("/query/wrap/rewrites/0/@type").asText(),
+                "koral:rewrite");
+    }
+
+    @Test
+    public void testFoundryInjectJoinedQueryNoErrors ()
+            throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=laufe/i & base!=Lauf]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser("test"));
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/@type").asText(), "koral:termGroup");
+        assertFalse(node.at("/query/wrap/operands/0/foundry").isMissingNode());
+        assertFalse(node.at("/query/wrap/operands/0/rewrites").isMissingNode());
+        assertFalse(node.at("/query/wrap/operands/1/foundry").isMissingNode());
+        assertFalse(node.at("/query/wrap/operands/1/rewrites").isMissingNode());
+    }
+
+    @Test
+    public void testFoundryInjectGroupedQueryNoErrors ()
+            throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[(base=laufen | tt/pos=VVFIN)]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser("test"));
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/@type").asText(), "koral:termGroup");
+        assertFalse(node.at("/query/wrap/operands/0/foundry").isMissingNode());
+        assertFalse(node.at("/query/wrap/operands/0/rewrites").isMissingNode());
+        assertFalse(node.at("/query/wrap/operands/1/foundry").isMissingNode());
+        assertTrue(node.at("/query/wrap/operands/1/rewrites").isMissingNode());
+    }
+
+    @Test
+    public void testFoundryBaseRewrite () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=laufen]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser("test"));
+        JsonNode node = JsonUtils.readTree(result);
+        assertEquals(node.at("/query/wrap/@type").asText(), "koral:term");
+        assertFalse(node.at("/query/wrap/foundry").isMissingNode());
+        assertFalse(node.at("/query/wrap/rewrites").isMissingNode());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/IdRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/IdRewriteTest.java
new file mode 100644
index 0000000..ef33982
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/IdRewriteTest.java
@@ -0,0 +1,53 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.rewrite.IdWriter;
+import de.ids_mannheim.korap.rewrite.RewriteHandler;
+import de.ids_mannheim.korap.user.KorAPUser;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author hanl
+ * @date 21/10/2015
+ */
+public class IdRewriteTest extends SpringJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    @Test
+    public void insertTokenId () throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        assertTrue(handler.add(IdWriter.class));
+        String query = "[surface=Wort]";
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery(query, "poliqarp");
+        String value = handler.processQuery(s.toJSON(), new KorAPUser());
+        JsonNode result = JsonUtils.readTree(value);
+        assertNotNull(result);
+        assertTrue(result.path("query").has("idn"));
+    }
+
+    @Test
+    public void testIdWriterTest () throws KustvaktException {
+        RewriteHandler handler = new RewriteHandler(config);
+        assertTrue(handler.add(IdWriter.class));
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[base=Haus]", "poliqarp");
+        String result = handler.processQuery(s.toJSON(), new KorAPUser());
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertFalse(node.at("/query/wrap").isMissingNode());
+        assertFalse(node.at("/query/idn").isMissingNode());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/QueryRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/QueryRewriteTest.java
new file mode 100644
index 0000000..524dad5
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/QueryRewriteTest.java
@@ -0,0 +1,59 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * @author diewald
+ */
+public class QueryRewriteTest extends SpringJerseyTest {
+
+    @Test
+    public void testRewriteRefNotFound () throws KustvaktException, Exception {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "{q}").queryParam("ql", "poliqarp")
+                .resolveTemplate("q", "[orth=der]{#examplequery} Baum")
+                .request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Query system/examplequery is not found.");
+    }
+
+    @Test
+    public void testRewriteSystemQuery () throws KustvaktException, Exception {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "{q}").queryParam("ql", "poliqarp")
+                .resolveTemplate("q", "[orth=der]{#system-q} Baum").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        // System.out.println(ent);
+        JsonNode node = JsonUtils.readTree(ent);
+    }
+
+    @Test
+    public void testRewriteRefRewrite () throws KustvaktException, Exception {
+        // Added in the database migration sql for tests
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "{q}").queryParam("ql", "poliqarp")
+                .resolveTemplate("q", "[orth=der]{#dory/dory-q} Baum").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/query/operands/1/@type").asText(),
+                "koral:token");
+        assertEquals(node.at("/query/operands/1/rewrites/0/scope").asText(),
+                "@type(koral:queryRef)");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/ResultRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/ResultRewriteTest.java
new file mode 100644
index 0000000..77767a0
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/ResultRewriteTest.java
@@ -0,0 +1,31 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.config.TestVariables;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.rewrite.CollectionRewrite;
+import de.ids_mannheim.korap.rewrite.RewriteHandler;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author hanl
+ * @date 12/11/2015
+ */
+public class ResultRewriteTest extends SpringJerseyTest {
+
+    @Autowired
+    public RewriteHandler ha;
+
+    @Test
+    public void testPostRewriteNothingToDo () throws KustvaktException {
+        assertEquals(true, ha.add(CollectionRewrite.class),
+                "Handler could not be added to rewrite handler instance!");
+        String v = ha.processResult(TestVariables.RESULT, null);
+        assertEquals(JsonUtils.readTree(TestVariables.RESULT),
+                JsonUtils.readTree(v), "results do not match");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/RewriteHandlerTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/RewriteHandlerTest.java
new file mode 100644
index 0000000..35df9d3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/RewriteHandlerTest.java
@@ -0,0 +1,156 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * EM: to do: Fix tests
+ * New DB does not save users.
+ *
+ * @author hanl
+ * @date 21/10/2015
+ */
+// @Deprecated
+// @Test
+// public void testRewriteNoBeanInject () throws KustvaktException {
+// RewriteHandler handler = new RewriteHandler(config);
+// QuerySerializer s = new QuerySerializer();
+// s.setQuery("[(base=laufen | base=gehen) & tt/pos=VVFIN]", "poliqarp");
+// assertTrue(handler.add(FoundryInject.class));
+// String res = handler.processQuery(s.toJSON(), null);
+// assertNotNull(res);
+// }
+// 
+// @Deprecated
+// @Test
+// public void testRewriteBeanInject () throws KustvaktException {
+// RewriteHandler handler = new RewriteHandler(config);
+// QuerySerializer s = new QuerySerializer();
+// s.setQuery("[base=laufen | tt/pos=VVFIN]", "poliqarp");
+// assertTrue(handler.add(FoundryInject.class));
+// String res = handler.processQuery(s.toJSON(), null);
+// JsonNode node = JsonUtils.readTree(res);
+// assertNotNull(node);
+// assertEquals("tt", node.at("/query/wrap/operands/0/foundry")
+// .asText());
+// assertEquals("tt", node.at("/query/wrap/operands/1/foundry")
+// .asText());
+// }
+// EM: Fix me usersetting
+// @Test
+// @Ignore
+// public void testRewriteUserSpecific () throws KustvaktException {
+// RewriteHandler handler = new RewriteHandler(config);
+// QuerySerializer s = new QuerySerializer();
+// s.setQuery("[base=laufen|tt/pos=VFIN]", "poliqarp");
+// assertTrue(handler.add(FoundryInject.class));
+// String res = handler.processQuery(s.toJSON(), helper().getUser());
+// JsonNode node = JsonUtils.readTree(res);
+// assertNotNull(node);
+// assertEquals("tt_test",
+// node.at("/query/wrap/operands/0/foundry").asText());
+// assertNotEquals("tt_test",
+// node.at("/query/wrap/operands/1/foundry").asText());
+// }
+// EM: Fix me usersetting
+// @Override
+// public void initMethod () throws KustvaktException {
+// helper().setupAccount();
+// UserDataDbIface settingsdao = BeansFactory.getTypeFactory()
+// .getTypeInterfaceBean(
+// helper().getContext().getUserDataProviders(),
+// UserSettings.class);
+// assertNotNull(settingsdao);
+// UserSettings s = (UserSettings) settingsdao.get(helper().getUser());
+// s.setField(Attributes.DEFAULT_LEMMA_FOUNDRY, "tt_test");
+// settingsdao.update(s);
+// }
+public class RewriteHandlerTest extends SpringJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    @Test
+    public void testRewriteTaskAdd () {
+        RewriteHandler handler = new RewriteHandler(config);
+        assertTrue(handler.add(FoundryInject.class));
+        assertTrue(handler.add(CollectionCleanRewrite.class));
+        assertTrue(handler.add(IdWriter.class));
+    }
+
+    // throws exception cause of missing configuration
+    @Test
+    public void testRewriteConfigThrowsException () {
+        assertThrows(RuntimeException.class, () -> {
+            RewriteHandler handler = new RewriteHandler();
+            QuerySerializer s = new QuerySerializer();
+            s.setQuery("[(base=laufen | base=gehen) & tt/pos=VVFIN]",
+                    "poliqarp");
+            assertTrue(handler.add(FoundryInject.class));
+            handler.processQuery(s.toJSON(), null);
+        });
+    }
+    // @Deprecated
+    // @Test
+    // public void testRewriteNoBeanInject () throws KustvaktException {
+    // RewriteHandler handler = new RewriteHandler(config);
+    // QuerySerializer s = new QuerySerializer();
+    // s.setQuery("[(base=laufen | base=gehen) & tt/pos=VVFIN]", "poliqarp");
+    // assertTrue(handler.add(FoundryInject.class));
+    // String res = handler.processQuery(s.toJSON(), null);
+    // assertNotNull(res);
+    // }
+    // 
+    // @Deprecated
+    // @Test
+    // public void testRewriteBeanInject () throws KustvaktException {
+    // RewriteHandler handler = new RewriteHandler(config);
+    // QuerySerializer s = new QuerySerializer();
+    // s.setQuery("[base=laufen | tt/pos=VVFIN]", "poliqarp");
+    // assertTrue(handler.add(FoundryInject.class));
+    // String res = handler.processQuery(s.toJSON(), null);
+    // JsonNode node = JsonUtils.readTree(res);
+    // assertNotNull(node);
+    // assertEquals("tt", node.at("/query/wrap/operands/0/foundry")
+    // .asText());
+    // assertEquals("tt", node.at("/query/wrap/operands/1/foundry")
+    // .asText());
+    // }
+    // EM: Fix me usersetting
+    // @Test
+    // @Ignore
+    // public void testRewriteUserSpecific () throws KustvaktException {
+    // RewriteHandler handler = new RewriteHandler(config);
+    // QuerySerializer s = new QuerySerializer();
+    // s.setQuery("[base=laufen|tt/pos=VFIN]", "poliqarp");
+    // assertTrue(handler.add(FoundryInject.class));
+    // String res = handler.processQuery(s.toJSON(), helper().getUser());
+    // JsonNode node = JsonUtils.readTree(res);
+    // assertNotNull(node);
+    // assertEquals("tt_test",
+    // node.at("/query/wrap/operands/0/foundry").asText());
+    // assertNotEquals("tt_test",
+    // node.at("/query/wrap/operands/1/foundry").asText());
+    // }
+    // EM: Fix me usersetting
+    // @Override
+    // public void initMethod () throws KustvaktException {
+    // helper().setupAccount();
+    // UserDataDbIface settingsdao = BeansFactory.getTypeFactory()
+    // .getTypeInterfaceBean(
+    // helper().getContext().getUserDataProviders(),
+    // UserSettings.class);
+    // assertNotNull(settingsdao);
+    // UserSettings s = (UserSettings) settingsdao.get(helper().getUser());
+    // s.setField(Attributes.DEFAULT_LEMMA_FOUNDRY, "tt_test");
+    // settingsdao.update(s);
+    // }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewriteTest.java
new file mode 100644
index 0000000..f0ea66c
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewriteTest.java
@@ -0,0 +1,133 @@
+package de.ids_mannheim.korap.rewrite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.cache.VirtualCorpusCache;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.NamedVCLoader;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.dao.QueryDao;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.util.QueryException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * @author margaretha
+ */
+public class VirtualCorpusRewriteTest extends SpringJerseyTest {
+
+    @Autowired
+    private NamedVCLoader vcLoader;
+
+    @Autowired
+    private QueryDao dao;
+
+    @Test
+    public void testRefCachedVC ()
+            throws KustvaktException, IOException, QueryException {
+        vcLoader.loadVCToCache("named-vc1", "/vc/named-vc1.jsonld");
+        assertTrue(VirtualCorpusCache.contains("named-vc1"));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo named-vc1").request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        assertTrue(node.at("/operands/1/rewrites").isMissingNode());
+        testRefCachedVCWithUsername();
+        QueryDO vc = dao.retrieveQueryByName("named-vc1", "system");
+        dao.deleteQuery(vc);
+        vc = dao.retrieveQueryByName("named-vc1", "system");
+        assertNull(vc);
+        VirtualCorpusCache.delete("named-vc1");
+        assertFalse(VirtualCorpusCache.contains("named-vc1"));
+    }
+
+    private void testRefCachedVCWithUsername ()
+            throws KustvaktException, IOException, QueryException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"system/named-vc1\"").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        node = node.at("/operands/1/rewrites");
+        assertEquals(2, node.size());
+        assertEquals(node.at("/0/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/1/operation").asText(), "operation:insertion");
+    }
+
+    @Test
+    public void testRewriteFreeAndSystemVCRef ()
+            throws KustvaktException, Exception {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"system-vc\"").request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/operands/0/@type").asText(), "koral:doc");
+        assertEquals(node.at("/operands/1/@type").asText(), "koral:doc");
+        assertEquals(node.at("/operands/1/value").asText(), "GOE");
+        assertEquals(node.at("/operands/1/key").asText(), "corpusSigle");
+        node = node.at("/operands/1/rewrites");
+        assertEquals(3, node.size());
+        assertEquals(node.at("/0/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/1/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/2/operation").asText(), "operation:insertion");
+    }
+
+    @Test
+    public void testRewritePubAndSystemVCRef () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"system/system-vc\"").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("user", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/operands/0/@type").asText(), "koral:docGroup");
+        node = node.at("/operands/1/rewrites");
+        assertEquals(3, node.size());
+        assertEquals(node.at("/0/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/1/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/2/operation").asText(), "operation:insertion");
+    }
+
+    @Test
+    public void testRewriteWithDoryVCRef ()
+            throws KustvaktException, IOException, QueryException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Fisch").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"dory/dory-vc\"").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        node = node.at("/operands/1/rewrites");
+        assertEquals(3, node.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/scenario/ICCTest.java b/src/test/java/de/ids_mannheim/korap/scenario/ICCTest.java
new file mode 100644
index 0000000..fcc655f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/scenario/ICCTest.java
@@ -0,0 +1,190 @@
+package de.ids_mannheim.korap.scenario;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.context.ContextConfiguration;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+/**
+ * <p>Test scenario for ICC (International Comparable Corpus)
+ * instance</p>
+ * <p>
+ * The instance requires user authentication and access to data is
+ * restricted to only logged-in users.
+ * <p>
+ * This class uses <em>test-config-icc.xml</em> spring XML config
+ * defining the location of a specific kustvakt configuration file for
+ * this instance:<em>kustvakt-icc.conf</em>.
+ *
+ * <p>
+ * To run a Kustvakt jar with ICC setup, the following files are
+ * needed:
+ * </p>
+ * <ul>
+ * <li>a Spring configuration file</li>
+ * <li>a Kustvakt configuration file that must be placed at the jar
+ * folder</li>
+ * <li>a JDBC properties file that must be placed at the jar
+ * folder</li>
+ * </ul>
+ * <p>
+ * Example:
+ *
+ * <p>
+ * <code>
+ * java -jar Kustvakt-full-0.69.3.jar --spring-config
+ * test-config-icc.xml
+ * </code>
+ * </p>
+ *
+ * <h1>Spring configuration file</h1>
+ * <p>
+ * For ICC, collectionRewrite in the Spring XML configuration must
+ * be disabled. This has been done in <em>test-config-icc.xml</em>.
+ * </p>
+ *
+ * <p>For testing, the ICC configuration uses HTTP Basic
+ * Authentication and doesn't use LDAP.</p>
+ *
+ * <p>For production, Basic Authentication must be
+ * disabled/commented.</p>
+ *
+ * <pre><code>
+ * &lt;bean id="basic_auth"
+ * class="de.ids_mannheim.korap.authentication.BasicAuthentication"/&gt;
+ *
+ * &lt;util:list id="kustvakt_authproviders"
+ * value-type="de.ids_mannheim.korap.interfaces.AuthenticationIface"&gt;
+ * &lt;!-- &lt;ref bean="basic_auth" /&gt; --&gt;
+ * </code>
+ * </pre>
+ *
+ * <p>For production, the init-method of Initializator should be
+ * changed to init.</p>
+ *
+ * <pre>
+ * <code>
+ * &lt;bean id="initializator"
+ * class="de.ids_mannheim.de.init.Initializator"
+ * init-method="init"&gt;&lt;/bean&gt;
+ * </code>
+ * </pre>
+ *
+ * <h1>Kustvakt configuration file</h1>
+ *
+ * <p>
+ * The configuration file: <em>kustvakt-icc.conf</em> includes the
+ * following setup:
+ * </p>
+ *
+ * <ul>
+ * <li>
+ * <em>krill.indexDir</em> should indicate the location of the index.
+ * It is set to the wiki-index for the test.
+ * </li>
+ *
+ * <p>
+ * <code>krill.indexDir=../wiki-index</code>
+ * </p>
+ *
+ * <li>
+ * <em>availability.regex</em>
+ * properties should be removed or commented since the data doesn't
+ * contain availability and access to data is not determined by this
+ * field.
+ * </li>
+ *
+ * <li>
+ * Resource filter class names for the search and match info services
+ * should be defined by <em>search.resource.filters property</em>. For
+ * example, to restricts access with only authentication filter:</li>
+ *
+ * <p>
+ * <code>search.resource.filters=AuthenticationFilter </code>
+ * </p>
+ *
+ * <li><em>oauth2.password.authentication</em> indicating the
+ * authentication method to match usernames and password.
+ * <code>TEST</code> is a dummy authentication that doesn't do any
+ * matching. For production, it must be changed to
+ * <code>LDAP</code>.</li>
+ *
+ * <p><code>oauth2.password.authentication=LDAP</code></p>
+ *
+ * </ul>
+ *
+ * @author elma
+ * @see /src/main/resources/properties/jdbc.properties
+ */
+@ContextConfiguration("classpath:test-config-icc.xml")
+public class ICCTest extends SpringJerseyTest {
+
+    public final static String API_VERSION = "v1.0";
+
+    public String basicAuth;
+
+    public ICCTest () throws KustvaktException {
+        basicAuth = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("user", "password");
+    }
+
+    @Test
+    public void searchWithoutLogin () throws KustvaktException {
+        Response r = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .request().get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), r.getStatus());
+        String entity = r.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void searchWithLogin () throws KustvaktException {
+        Response r = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .request().header(Attributes.AUTHORIZATION, basicAuth).get();
+        assertEquals(Status.OK.getStatusCode(), r.getStatus());
+        String entity = r.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/matches").size() > 0);
+    }
+
+    @Test
+    public void matchInfoWithoutLogin () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("WDD17").path("982").path("72848").path("p2815-2816")
+                .queryParam("foundry", "*").request().get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void matchInfoWithLogin () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("WDD17").path("982").path("72848").path("p2815-2816")
+                .queryParam("foundry", "*").request()
+                .header(Attributes.AUTHORIZATION, basicAuth).get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/hasSnippet").asBoolean());
+        assertNotNull(node.at("/matchID").asText());
+        assertNotNull(node.at("/snippet").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/server/EmbeddedLdapServerTest.java b/src/test/java/de/ids_mannheim/korap/server/EmbeddedLdapServerTest.java
new file mode 100644
index 0000000..98b35cf
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/server/EmbeddedLdapServerTest.java
@@ -0,0 +1,107 @@
+package de.ids_mannheim.korap.server;
+
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.util.Base64;
+import de.ids_mannheim.korap.authentication.LdapAuth3;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.net.UnknownHostException;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+
+import static de.ids_mannheim.korap.authentication.LdapAuth3.LDAP_AUTH_ROK;
+import static de.ids_mannheim.korap.authentication.LdapAuth3.LDAP_AUTH_RUNKNOWN;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class EmbeddedLdapServerTest {
+
+    public static final String TEST_EMBEDDED_LDAP_CONF = "src/test/resources/test-embedded-ldap.conf";
+
+    @AfterAll
+    static void shutdownEmbeddedLdapServer () {
+        EmbeddedLdapServer.stop();
+    }
+
+    @Test
+    public void embeddedServerStartsAutomaticallyAndUsersCanLogin ()
+            throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("user", "password", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void usersWithClearPasswordCanLogin () throws LDAPException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("user1", "password1", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void usersWithSHA1PasswordCanLogin ()
+            throws LDAPException, NoSuchAlgorithmException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("user3", "password3", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void usersWithSHA256PasswordCanLogin () throws LDAPException,
+            NoSuchAlgorithmException, InvalidKeySpecException {
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("user4", "password4", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void asteriskPasswordsFail () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("user1", "*", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithPreencodedPBKDF2Password () throws LDAPException,
+            NoSuchAlgorithmException, InvalidKeySpecException {
+        byte[] salt = new byte[32];
+        KeySpec spec = new PBEKeySpec("password5".toCharArray(), salt, 65536,
+                256);
+        SecretKeyFactory f = SecretKeyFactory
+                .getInstance("PBKDF2withHmacSHA256");
+        byte[] hash = f.generateSecret(spec).getEncoded();
+        final String pbkdf2sha256Password = "{PBKDF2-SHA256}"
+                + Base64.encode(hash);
+        // System.out.println(pbkdf2sha256Password);
+        assertEquals(LDAP_AUTH_ROK, LdapAuth3.login("user5",
+                pbkdf2sha256Password, TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void loginWithUnencodedPBKDF2PasswordFails () throws LDAPException,
+            NoSuchAlgorithmException, InvalidKeySpecException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("user5", "password5", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void unauthorizedUsersAreNotAllowed () throws LDAPException {
+        assertEquals(LDAP_AUTH_RUNKNOWN,
+                LdapAuth3.login("yuser", "password", TEST_EMBEDDED_LDAP_CONF));
+    }
+
+    @Test
+    public void gettingMailForUser () throws LDAPException,
+            UnknownHostException, GeneralSecurityException {
+        EmbeddedLdapServer.startIfNotRunning(TEST_EMBEDDED_LDAP_CONF);
+        assertEquals(LdapAuth3.getEmail("user2", TEST_EMBEDDED_LDAP_CONF),
+                "user2@example.com");
+    }
+
+    @Test
+    public void gettingMailForNAUTHUserIsNull () throws LDAPException,
+            UnknownHostException, GeneralSecurityException {
+        EmbeddedLdapServer.startIfNotRunning(TEST_EMBEDDED_LDAP_CONF);
+        assertEquals(null,
+                LdapAuth3.getEmail("user1000", TEST_EMBEDDED_LDAP_CONF));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/service/VirtualCorpusServiceTest.java b/src/test/java/de/ids_mannheim/korap/service/VirtualCorpusServiceTest.java
new file mode 100644
index 0000000..8799ad7
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/service/VirtualCorpusServiceTest.java
@@ -0,0 +1,125 @@
+package de.ids_mannheim.korap.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.QueryType;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.constant.UserGroupStatus;
+import de.ids_mannheim.korap.dto.QueryAccessDto;
+import de.ids_mannheim.korap.dto.QueryDto;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.entity.UserGroup;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.web.input.QueryJson;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class VirtualCorpusServiceTest {
+
+    @Autowired
+    private QueryService vcService;
+
+    @Autowired
+    private UserGroupService groupService;
+
+    @Test
+    public void testCreateNonUniqueVC () throws KustvaktException {
+        // EM: message differs depending on the database used
+        // for testing. The message below is from sqlite.
+        // thrown.expectMessage("A UNIQUE constraint failed "
+        // + "(UNIQUE constraint failed: virtual_corpus.name, "
+        // + "virtual_corpus.created_by)");
+        QueryJson vc = new QueryJson();
+        vc.setCorpusQuery("corpusSigle=GOE");
+        vc.setType(ResourceType.PRIVATE);
+        vc.setQueryType(QueryType.VIRTUAL_CORPUS);
+        assertThrows(KustvaktException.class,
+                () -> vcService.storeQuery(vc, "dory-vc", "dory", "dory"));
+    }
+
+    @Test
+    public void createDeletePublishVC () throws KustvaktException {
+        String vcName = "new-published-vc";
+        QueryJson vc = new QueryJson();
+        vc.setCorpusQuery("corpusSigle=GOE");
+        vc.setType(ResourceType.PUBLISHED);
+        vc.setQueryType(QueryType.VIRTUAL_CORPUS);
+        String username = "VirtualCorpusServiceTest";
+        vcService.storeQuery(vc, vcName, username, username);
+        List<QueryAccessDto> accesses = vcService
+                .listQueryAccessByUsername("admin");
+        int size = accesses.size();
+        QueryAccessDto dto = accesses.get(accesses.size() - 1);
+        assertEquals(vcName, dto.getQueryName());
+        assertEquals(dto.getCreatedBy(), "system");
+        assertTrue(dto.getUserGroupName().startsWith("auto"));
+        // check hidden group
+        int groupId = dto.getUserGroupId();
+        UserGroup group = groupService.retrieveUserGroupById(groupId);
+        assertEquals(UserGroupStatus.HIDDEN, group.getStatus());
+        // delete vc
+        vcService.deleteQueryByName(username, vcName, username,
+                QueryType.VIRTUAL_CORPUS);
+        // check hidden access
+        accesses = vcService.listQueryAccessByUsername("admin");
+        assertEquals(size - 1, accesses.size());
+        // check hidden group
+        KustvaktException e = assertThrows(KustvaktException.class,
+                () -> groupService.retrieveUserGroupById(groupId));
+        assertEquals("Group with id " + groupId + " is not found",
+                e.getMessage());
+    }
+
+    @Test
+    public void testEditPublishVC () throws KustvaktException {
+        String username = "dory";
+        int vcId = 2;
+        String vcName = "group-vc";
+        QueryDO existingVC = vcService.searchQueryByName(username, vcName,
+                username, QueryType.VIRTUAL_CORPUS);
+        QueryJson vcJson = new QueryJson();
+        vcJson.setType(ResourceType.PUBLISHED);
+        vcService.editQuery(existingVC, vcJson, vcName, username);
+        // check VC
+        QueryDto vcDto = vcService.searchQueryById("dory", vcId);
+        assertEquals(vcName, vcDto.getName());
+        assertEquals(ResourceType.PUBLISHED.displayName(), vcDto.getType());
+        // check access
+        List<QueryAccessDto> accesses = vcService
+                .listQueryAccessByUsername("admin");
+        int size = accesses.size();
+        QueryAccessDto dto = accesses.get(accesses.size() - 1);
+        assertEquals(vcName, dto.getQueryName());
+        assertEquals(dto.getCreatedBy(), "system");
+        assertTrue(dto.getUserGroupName().startsWith("auto"));
+        // check auto hidden group
+        int groupId = dto.getUserGroupId();
+        UserGroup group = groupService.retrieveUserGroupById(groupId);
+        assertEquals(UserGroupStatus.HIDDEN, group.getStatus());
+        // 2nd edit (withdraw from publication)
+        vcJson = new QueryJson();
+        vcJson.setType(ResourceType.PROJECT);
+        vcService.editQuery(existingVC, vcJson, vcName, username);
+        // check VC
+        vcDto = vcService.searchQueryById("dory", vcId);
+        assertEquals(vcDto.getName(), "group-vc");
+        assertEquals(ResourceType.PROJECT.displayName(), vcDto.getType());
+        // check access
+        accesses = vcService.listQueryAccessByUsername("admin");
+        assertEquals(size - 1, accesses.size());
+        KustvaktException e = assertThrows(KustvaktException.class,
+                () -> groupService.retrieveUserGroupById(groupId));
+        assertEquals("Group with id " + groupId + " is not found",
+                e.getMessage());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/user/DataFactoryTest.java b/src/test/java/de/ids_mannheim/korap/user/DataFactoryTest.java
new file mode 100644
index 0000000..5584fb7
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/user/DataFactoryTest.java
@@ -0,0 +1,156 @@
+package de.ids_mannheim.korap.user;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * Taken from UserdataTest
+ *
+ * @author hanl
+ * @date 27/01/2016
+ */
+public class DataFactoryTest {
+
+    @Test
+    public void testDataFactoryAdd () throws KustvaktException {
+        String data = "{}";
+        Object node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        data = "[]";
+        node = JsonUtils.readTree(data);
+        factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+    }
+
+    @Test
+    public void testDataFactoryGet () throws KustvaktException {
+        String data = "{}";
+        Object node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        Object value = factory.getValue(node, "field_1");
+        assertEquals(value, "value_1");
+        value = factory.getValue(node, "field_2");
+        assertEquals(20, value);
+        value = factory.getValue(node, "field_3");
+        assertEquals(true, value);
+        data = "[]";
+        node = JsonUtils.readTree(data);
+        assertTrue(factory.addValue(node, "", "value_2"));
+        assertTrue(factory.addValue(node, "", 10));
+        assertTrue(factory.addValue(node, "", false));
+        value = factory.getValue(node, "/0");
+        assertEquals(value, "value_2");
+        value = factory.getValue(node, "/1");
+        assertEquals(10, value);
+        value = factory.getValue(node, "/2");
+        assertEquals(false, value);
+    }
+
+    @Test
+    public void testDataFactoryMerge () throws KustvaktException {
+        String data = "{}";
+        Object node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        data = "{}";
+        Object node2 = JsonUtils.readTree(data);
+        assertTrue(factory.addValue(node2, "field_1", "value_new"));
+        assertTrue(factory.addValue(node2, "field_2", "value_next"));
+        assertTrue(factory.addValue(node2, "field_4", "value_2"));
+        assertTrue(factory.addValue(node2, "field_7", "value_3"));
+        JsonNode node_new = (JsonNode) factory.merge(node, node2);
+        assertEquals(node_new.path("field_1").asText(), "value_new");
+        assertEquals(node_new.path("field_2").asText(), "value_next");
+        assertEquals(true, node_new.path("field_3").asBoolean());
+        assertEquals(node_new.path("field_4").asText(), "value_2");
+        assertEquals(node_new.path("field_7").asText(), "value_3");
+    }
+
+    @Test
+    public void testDataFactoryKeys () throws KustvaktException {
+        String data = "{}";
+        Object node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        assertEquals(3, factory.size(node));
+        assertEquals(3, factory.keys(node).size());
+    }
+
+    @Test
+    @Disabled
+    public void testDataFactoryRemove () throws KustvaktException {
+        String data = "{}";
+        Object node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        Object value = factory.getValue(node, "field_1");
+        assertEquals(value, "value_1");
+        value = factory.getValue(node, "field_2");
+        assertEquals(20, value);
+        value = factory.getValue(node, "field_3");
+        assertEquals(true, value);
+        assertTrue(factory.removeValue(node, "field_1"));
+        assertTrue(factory.removeValue(node, "field_2"));
+        assertTrue(factory.removeValue(node, "field_3"));
+        assertNotNull(node);
+        assertEquals(node.toString(), "{}");
+        data = "[]";
+        node = JsonUtils.readTree(data);
+        assertTrue(factory.addValue(node, "", "value_2"));
+        assertTrue(factory.addValue(node, "", 10));
+        assertTrue(factory.addValue(node, "", false));
+        value = factory.getValue(node, "/0");
+        assertEquals(value, "value_2");
+        value = factory.getValue(node, "/1");
+        assertEquals(10, value);
+        value = factory.getValue(node, "/2");
+        assertEquals(false, value);
+        // fixme: cannot be removed
+        assertTrue(factory.removeValue(node, "0"));
+        assertTrue(factory.removeValue(node, "1"));
+        assertTrue(factory.removeValue(node, "2"));
+        assertNotNull(node);
+        assertEquals(node.toString(), "[]");
+    }
+
+    @Test
+    public void testDataFactoryEmbeddedProperty () throws KustvaktException {
+        String data = "{}";
+        JsonNode node = JsonUtils.readTree(data);
+        DataFactory factory = DataFactory.getFactory();
+        assertTrue(factory.addValue(node, "field_1", "value_1"));
+        assertTrue(factory.addValue(node, "field_2", 20));
+        assertTrue(factory.addValue(node, "field_3", true));
+        ArrayNode array = JsonUtils.createArrayNode();
+        array.add(10);
+        array.add("v1");
+        array.add("v2");
+        factory.addValue(node, "field_3", array);
+        assertNotNull(node);
+        assertEquals(10, node.at("/field_3/0").asInt());
+        assertEquals(node.at("/field_3/1").asText(), "v1");
+        assertEquals(node.at("/field_3/2").asText(), "v2");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/user/UserdataTest.java b/src/test/java/de/ids_mannheim/korap/user/UserdataTest.java
new file mode 100644
index 0000000..0e99a7f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/user/UserdataTest.java
@@ -0,0 +1,171 @@
+package de.ids_mannheim.korap.user;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.validator.ApacheValidator;
+import edu.emory.mathcs.backport.java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * @author hanl, margaretha
+ * @date 27/01/2016
+ */
+public class UserdataTest {
+
+    // EM: added
+    @Test
+    public void testReadEmptyMap () throws KustvaktException {
+        Userdata userData = new UserSettingProcessor();
+        userData.read(new HashMap<>(), false);
+        String jsonSettings = userData.serialize();
+        assertEquals(jsonSettings, "{}");
+    }
+
+    @Test
+    public void testReadNullMap () throws KustvaktException {
+        Userdata userData = new UserSettingProcessor();
+        userData.read(null, false);
+        String jsonSettings = userData.serialize();
+        assertEquals(jsonSettings, "{}");
+    }
+
+    // EM: based on MH code, supposedly to validate entries like email
+    // and date. See ApacheValidator
+    // 
+    // It has inconsistent behaviors:
+    // throws exceptions when there are invalid entries in a list,
+    // otherwise skips invalid entries and returns a valid map
+    // Moreover, Userdata.validate(ValidatorIface) does not return a
+    // valid map.
+    // 
+    // At the moment, validation is not needed for default settings.
+    @Test
+    public void testValidateMap () throws IOException, KustvaktException {
+        Map<String, Object> map = new HashMap<>();
+        map.put("k1", Arrays.asList(new String[] { "a", "b", "c" }));
+        map.put("k2", Arrays.asList(new Integer[] { 1, 2, 3 }));
+        Userdata data = new UserSettingProcessor();
+        data.read(map, false);
+        data.validate(new ApacheValidator());
+    }
+
+    // EM: below are tests from MH
+    @Test
+    public void testDataValidation () {
+        Userdata data = new UserDetails(1);
+        data.setField(Attributes.COUNTRY, "Germany");
+        String[] req = data.requiredFields();
+        String[] r = data.findMissingFields();
+        assertNotEquals(0, r.length);
+        assertEquals(req.length, r.length);
+        assertFalse(data.isValid());
+    }
+
+    @Test
+    public void testSettingsValidation () {
+        Userdata data = new UserSettingProcessor();
+        data.setField(Attributes.FILE_FORMAT_FOR_EXPORT, "export");
+        String[] req = data.requiredFields();
+        String[] r = data.findMissingFields();
+        assertEquals(0, r.length);
+        assertEquals(req.length, r.length);
+        assertTrue(data.isValid());
+    }
+
+    @Test
+    public void testUserdataRequiredFields () throws KustvaktException {
+        UserDetails details = new UserDetails(-1);
+        Map<String, Object> m = new HashMap<>();
+        m.put(Attributes.FIRSTNAME, "first");
+        m.put(Attributes.LASTNAME, "last");
+        m.put(Attributes.ADDRESS, "address");
+        m.put(Attributes.EMAIL, "email");
+        details.setData(JsonUtils.toJSON(m));
+        details.setData(JsonUtils.toJSON(m));
+        String[] missing = details.findMissingFields();
+        assertEquals(0, missing.length);
+    }
+
+    @Test
+    public void testUserdataDefaultFields () throws KustvaktException {
+        UserSettingProcessor settings = new UserSettingProcessor();
+        Map<String, Object> m = new HashMap<>();
+        m.put(Attributes.DEFAULT_FOUNDRY_RELATION, "rel_1");
+        m.put(Attributes.DEFAULT_FOUNDRY_CONSTITUENT, "const_1");
+        m.put(Attributes.DEFAULT_FOUNDRY_POS, "pos_1");
+        m.put(Attributes.DEFAULT_FOUNDRY_LEMMA, "lemma_1");
+        m.put(Attributes.PAGE_LENGTH, 10);
+        m.put(Attributes.QUERY_LANGUAGE, "poliqarp");
+        m.put(Attributes.METADATA_QUERY_EXPERT_MODUS, false);
+        settings.read(m, true);
+        assertNotEquals(m.size(), settings.size());
+        assertEquals(settings.defaultFields().length, settings.size());
+        assertEquals(settings.get(Attributes.DEFAULT_FOUNDRY_RELATION),
+                "rel_1");
+        assertEquals(settings.get(Attributes.DEFAULT_FOUNDRY_POS), "pos_1");
+        assertEquals(settings.get(Attributes.DEFAULT_FOUNDRY_LEMMA), "lemma_1");
+        assertEquals(settings.get(Attributes.DEFAULT_FOUNDRY_CONSTITUENT),
+                "const_1");
+        assertEquals(10, settings.get(Attributes.PAGE_LENGTH));
+    }
+
+    @Test
+    public void testUserDataRequiredFieldsException () {
+        assertThrows(KustvaktException.class, () -> {
+            UserDetails details = new UserDetails(-1);
+            Map<String, Object> m = new HashMap<>();
+            m.put(Attributes.FIRSTNAME, "first");
+            m.put(Attributes.LASTNAME, "last");
+            m.put(Attributes.ADDRESS, "address");
+            details.setData(JsonUtils.toJSON(m));
+            String[] missing = details.findMissingFields();
+            assertEquals(1, missing.length);
+            assertEquals(missing[0], "email");
+            details.checkRequired();
+        });
+    }
+
+    @Test
+    public void testUserDataPointerFunction () throws KustvaktException {
+        UserDetails details = new UserDetails(-1);
+        Map<String, Object> m = new HashMap<>();
+        m.put(Attributes.FIRSTNAME, "first");
+        m.put(Attributes.LASTNAME, "last");
+        m.put(Attributes.ADDRESS, "address");
+        m.put(Attributes.EMAIL, "email");
+        details.setData(JsonUtils.toJSON(m));
+        ArrayNode array = JsonUtils.createArrayNode();
+        array.add(100);
+        array.add("message");
+        details.setField("errors", array);
+        assertEquals(100, details.get("/errors/0"));
+        assertEquals(details.get("/errors/1"), "message");
+    }
+
+    @Test
+    public void testUserDataUpdate () {
+        UserDetails details = new UserDetails(-1);
+        details.setField(Attributes.FIRSTNAME, "first");
+        details.setField(Attributes.LASTNAME, "last");
+        details.setField(Attributes.ADDRESS, "address");
+        details.setField(Attributes.EMAIL, "email");
+        UserDetails details2 = new UserDetails(-1);
+        details2.setField(Attributes.COUNTRY, "Germany");
+        details.update(details2);
+        assertEquals(details.get(Attributes.FIRSTNAME), "first");
+        assertEquals(details.get(Attributes.COUNTRY), "Germany");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/ApiVersionTest.java b/src/test/java/de/ids_mannheim/korap/web/ApiVersionTest.java
new file mode 100644
index 0000000..9e138fa
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/ApiVersionTest.java
@@ -0,0 +1,39 @@
+package de.ids_mannheim.korap.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.net.URI;
+
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * @author margaretha
+ */
+public class ApiVersionTest extends SpringJerseyTest {
+
+    @Test
+    public void testSearchWithoutVersion () throws KustvaktException {
+        Response response = target().path("api").path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .request().accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(HttpStatus.PERMANENT_REDIRECT_308, response.getStatus());
+        URI location = response.getLocation();
+        assertEquals("/api/" + API_VERSION + "/search", location.getPath());
+    }
+
+    @Test
+    public void testSearchWrongVersion () throws KustvaktException {
+        Response response = target().path("api").path("v0.2").path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .request().accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(HttpStatus.PERMANENT_REDIRECT_308, response.getStatus());
+        URI location = response.getLocation();
+        assertEquals("/api/" + API_VERSION + "/search", location.getPath());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/InitialSuperClientTest.java b/src/test/java/de/ids_mannheim/korap/web/InitialSuperClientTest.java
new file mode 100644
index 0000000..e8c8eef
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/InitialSuperClientTest.java
@@ -0,0 +1,69 @@
+package de.ids_mannheim.korap.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.config.FullConfiguration;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.dao.OAuth2ClientDao;
+import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
+import de.ids_mannheim.korap.oauth2.service.OAuth2InitClientService;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.controller.OAuth2TestBase;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class InitialSuperClientTest extends OAuth2TestBase {
+
+    @Autowired
+    private FullConfiguration config;
+
+    @Autowired
+    private OAuth2ClientDao clientDao;
+
+    private String path = KustvaktConfiguration.DATA_FOLDER + "/"
+            + OAuth2InitClientService.TEST_OUTPUT_FILENAME;
+
+    @Test
+    public void testCreatingInitialSuperClient ()
+            throws IOException, KustvaktException {
+        assertTrue(config.createInitialSuperClient());
+        File f = new File(path);
+        assertTrue(f.exists());
+        JsonNode node = JsonUtils.readFile(path, JsonNode.class);
+        String superClientId = node.at("/client_id").asText();
+        String superClientSecret = node.at("/client_secret").asText();
+        OAuth2Client superClient = clientDao.retrieveClientById(superClientId);
+        assertTrue(superClient.isSuper());
+        testLogin(superClientId, superClientSecret);
+        removeSuperClientFile();
+    }
+
+    private void testLogin (String superClientId, String superClientSecret)
+            throws KustvaktException {
+        Response response = requestTokenWithPassword(superClientId,
+                superClientSecret, "username", "password");
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertTrue(!node.at("/access_token").isMissingNode());
+        assertTrue(!node.at("/refresh_token").isMissingNode());
+        assertTrue(!node.at("/expires_in").isMissingNode());
+        assertEquals(node.at("/scope").asText(), "all");
+        assertEquals(node.at("/token_type").asText(), "Bearer");
+    }
+
+    private void removeSuperClientFile () {
+        File f = new File(path);
+        if (f.exists()) {
+            f.delete();
+        }
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/JettyServerTest.java b/src/test/java/de/ids_mannheim/korap/web/JettyServerTest.java
new file mode 100644
index 0000000..4730f2c
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/JettyServerTest.java
@@ -0,0 +1,49 @@
+package de.ids_mannheim.korap.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.ServerSocket;
+import java.net.URL;
+
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.ShutdownHandler;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author margaretha
+ */
+public class JettyServerTest {
+
+    static int selectedPort = 0;
+
+    @BeforeAll
+    static void testServerStarts () throws Exception {
+
+        for (int port = 1000; port <= 2000; port++) {
+            try (ServerSocket ignored = new ServerSocket(port)) {
+                selectedPort = port;
+                break;
+            }
+            catch (IOException ignored) {
+                // Port is already in use, try the next one
+            }
+        }
+
+        Server server = new Server(selectedPort);
+        ShutdownHandler shutdownHandler = new ShutdownHandler("secret");
+        server.setHandler(shutdownHandler);
+        server.start();
+    }
+
+    @Test
+    public void testShutdown () throws IOException {
+        URL url = new URL(
+                "http://localhost:" + selectedPort + "/shutdown?token=secret");
+        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+        connection.setRequestMethod("POST");
+        assertEquals(200, connection.getResponseCode());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/SearchKrillTest.java b/src/test/java/de/ids_mannheim/korap/web/SearchKrillTest.java
new file mode 100644
index 0000000..2034592
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/SearchKrillTest.java
@@ -0,0 +1,83 @@
+package de.ids_mannheim.korap.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import jakarta.annotation.PostConstruct;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.KrillIndex;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.MetaQueryBuilder;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * Created by hanl on 02.06.16.
+ * <p>
+ * Updated by margaretha
+ */
+public class SearchKrillTest extends SpringJerseyTest {
+
+    @Autowired
+    KustvaktConfiguration config;
+
+    SearchKrill krill = null;
+
+    @PostConstruct
+    private void createKrill () {
+        krill = new SearchKrill(config.getIndexDir());
+        assertNotNull(krill);
+    }
+
+    @Test
+    public void testIndex () throws KustvaktException {
+        KrillIndex index = krill.getIndex();
+        assertNotNull(index);
+    }
+
+    @Test
+    public void testDocSize () {
+        assertNotEquals(0, krill.getIndex().numberOf("documents"));
+    }
+
+    @Test
+    public void testMatchInfo () throws KustvaktException {
+        String matchinfo = krill.getMatch("WPD/AAA.00002/p169-197",
+                config.getFreeLicensePattern());
+        JsonNode node = JsonUtils.readTree(matchinfo);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Invalid match identifier");
+    }
+
+    @Test
+    public void testSearch () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=der]", "poliqarp");
+        String result = krill.search(s.toJSON());
+        JsonNode node = JsonUtils.readTree(result);
+        assertNotNull(node);
+        assertNotEquals(0, node.at("/matches").size());
+    }
+
+    @Test
+    public void testTimeOut () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=der]", "poliqarp");
+        // s.setQuery("node ->malt/d[func=/.*/] node", "annis");
+        MetaQueryBuilder meta = new MetaQueryBuilder();
+        meta.addEntry("timeout", 1);
+        s.setMeta(meta);
+        String query = s.toJSON();
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(1, node.at("/meta/timeout").asInt());
+        String result = krill.search(query);
+        node = JsonUtils.readTree(result);
+        assertEquals(true, node.at("/meta/timeExceeded").asBoolean());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/AdminLoadVCTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/AdminLoadVCTest.java
new file mode 100644
index 0000000..2c359a0
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/AdminLoadVCTest.java
@@ -0,0 +1,39 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import com.google.common.net.HttpHeaders;
+
+import de.ids_mannheim.korap.cache.VirtualCorpusCache;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class AdminLoadVCTest extends SpringJerseyTest {
+
+    @Test
+    public void testLoadCacheVC ()
+            throws KustvaktException, InterruptedException {
+        assertFalse(VirtualCorpusCache.contains("named-vc1"));
+        Form f = new Form();
+        f.param("token", "secret");
+        Response response = target().path(API_VERSION).path("admin").path("vc")
+                .path("load-cache").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        Thread.sleep(100);
+        assertTrue(VirtualCorpusCache.contains("named-vc1"));
+        VirtualCorpusCache.reset();
+        assertFalse(VirtualCorpusCache.contains("named-vc1"));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/AnnotationControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/AnnotationControllerTest.java
new file mode 100644
index 0000000..e6737bb
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/AnnotationControllerTest.java
@@ -0,0 +1,82 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Iterator;
+import java.util.Map.Entry;
+
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+
+public class AnnotationControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testAnnotationLayers () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("annotation")
+                .path("layers").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode n = JsonUtils.readTree(entity);
+        assertEquals(31, n.size());
+        n = n.get(0);
+        assertEquals(1, n.get("id").asInt());
+        // assertEquals("opennlp/p", n.get("code").asText());
+        // assertEquals("p", n.get("layer").asText());
+        // assertEquals("opennlp", n.get("foundry").asText());
+        // assertNotNull(n.get("description"));
+    }
+
+    @Test
+    public void testAnnotationFoundry () throws KustvaktException {
+        String json = "{\"codes\":[\"opennlp/*\"], \"language\":\"en\"}";
+        Response response = target().path(API_VERSION).path("annotation")
+                .path("description").request().post(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        JsonNode n = JsonUtils.readTree(entity);
+        n = n.get(0);
+        assertEquals(n.get("code").asText(), "opennlp");
+        assertEquals(n.get("description").asText(), "OpenNLP");
+        assertEquals(1, n.get("layers").size());
+        n = n.get("layers").get(0);
+        assertEquals(n.get("code").asText(), "p");
+        assertEquals(n.get("description").asText(), "Part-of-Speech");
+        assertEquals(52, n.get("keys").size());
+        n = n.get("keys").get(0);
+        assertEquals(n.get("code").asText(), "ADJA");
+        assertEquals(n.get("description").asText(), "Attributive Adjective");
+        assertTrue(n.get("values") == null);
+    }
+
+    @Test
+    public void testAnnotationValues () throws KustvaktException {
+        String json = "{\"codes\":[\"mate/m\"], \"language\":\"en\"}";
+        Response response = target().path(API_VERSION).path("annotation")
+                .path("description").request().post(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        JsonNode n = JsonUtils.readTree(entity);
+        n = n.get(0);
+        assertEquals(n.get("code").asText(), "mate");
+        assertEquals(n.get("description").asText(), "Mate");
+        assertEquals(1, n.get("layers").size());
+        n = n.get("layers").get(0);
+        assertEquals(n.get("code").asText(), "m");
+        assertEquals(n.get("description").asText(), "Morphology");
+        assertEquals(8, n.get("keys").size());
+        n = n.get("keys").get(1);
+        assertEquals(n.get("code").asText(), "case");
+        assertEquals(n.get("description").asText(), "Case");
+        assertEquals(5, n.get("values").size());
+        n = n.get("values");
+        Iterator<Entry<String, JsonNode>> fields = n.fields();
+        Entry<String, JsonNode> e = fields.next();
+        assertEquals(e.getKey(), "*");
+        assertEquals(e.getValue().asText(), "Undefined");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/AvailabilityTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/AvailabilityTest.java
new file mode 100644
index 0000000..42d7a68
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/AvailabilityTest.java
@@ -0,0 +1,379 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class AvailabilityTest extends SpringJerseyTest {
+
+    private void checkAndFree (String json) throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(json);
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+    }
+
+    private void checkAndPublic (String json) throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(json);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(
+                node.at("/collection/operands/0/operands/0/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/collection/operands/0/operands/0/type").asText(),
+                "type:regex");
+        assertEquals(node.at("/collection/operands/0/operands/0/key").asText(),
+                "availability");
+        assertEquals(
+                node.at("/collection/operands/0/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(
+                node.at("/collection/operands/0/operands/1/operands/0/match")
+                        .asText(),
+                "match:eq");
+        assertEquals(
+                node.at("/collection/operands/0/operands/1/operands/0/value")
+                        .asText(),
+                "ACA.*");
+        assertEquals(
+                node.at("/collection/operands/0/operands/1/operands/1/match")
+                        .asText(),
+                "match:eq");
+        assertEquals(
+                node.at("/collection/operands/0/operands/1/operands/1/value")
+                        .asText(),
+                "QAO-NC");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(PUB)");
+    }
+
+    private void checkAndPublicWithACA (String json) throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(json);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(PUB)");
+        assertEquals(node.at("/collection/operands/1/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/collection/operands/1/type").asText(),
+                "type:regex");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "ACA.*");
+        node = node.at("/collection/operands/0");
+        assertEquals(node.at("/operands/0/match").asText(), "match:eq");
+        assertEquals(node.at("/operands/0/type").asText(), "type:regex");
+        assertEquals(node.at("/operands/0/key").asText(), "availability");
+        assertEquals(node.at("/operands/0/value").asText(), "CC-BY.*");
+        assertEquals(node.at("/operands/1/operands/0/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/operands/1/operands/0/type").asText(),
+                "type:regex");
+        assertEquals(node.at("/operands/1/operands/0/key").asText(),
+                "availability");
+        assertEquals(node.at("/operands/1/operands/0/value").asText(), "ACA.*");
+    }
+
+    private void checkAndAllWithACA (String json) throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(json);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertEquals(node.at("/collection/operands/1/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/collection/operands/1/type").asText(),
+                "type:regex");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "ACA.*");
+        node = node.at("/collection/operands/0");
+        assertEquals(node.at("/operands/0/match").asText(), "match:eq");
+        assertEquals(node.at("/operands/0/type").asText(), "type:regex");
+        assertEquals(node.at("/operands/0/key").asText(), "availability");
+        assertEquals(node.at("/operands/0/value").asText(), "CC-BY.*");
+        assertEquals(
+                node.at("/operands/1/operands/1/operands/0/match").asText(),
+                "match:eq");
+        assertEquals(
+                node.at("/operands/1/operands/1/operands/0/value").asText(),
+                "QAO-NC");
+        assertEquals(
+                node.at("/operands/1/operands/1/operands/1/match").asText(),
+                "match:eq");
+        assertEquals(
+                node.at("/operands/1/operands/1/operands/1/value").asText(),
+                "QAO.*");
+    }
+
+    private Response searchQuery (String collectionQuery) {
+        return target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("cq", collectionQuery).request().get();
+    }
+
+    private Response searchQueryWithIP (String collectionQuery, String ip)
+            throws ProcessingException, KustvaktException {
+        return target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("cq", collectionQuery).request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, ip).get();
+    }
+
+    @Test
+    public void testAvailabilityFreeAuthorized () throws KustvaktException {
+        Response response = searchQuery("availability = CC-BY-SA");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityRegexFreeAuthorized ()
+            throws KustvaktException {
+        Response response = searchQuery("availability = /.*BY.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityFreeUnauthorized () throws KustvaktException {
+        Response response = searchQuery("availability = ACA-NC");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityRegexFreeUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQuery("availability = /ACA.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityRegexNoRewrite () throws KustvaktException {
+        Response response = searchQuery(
+                "availability = /CC-BY.*/ & availability = /ACA.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String json = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(json);
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(node.at("/collection/operands/0/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/collection/operands/0/type").asText(),
+                "type:regex");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/match").asText(),
+                "match:eq");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "ACA.*");
+    }
+
+    @Test
+    public void testAvailabilityRegexFreeUnauthorized3 ()
+            throws KustvaktException {
+        Response response = searchQuery("availability = /.*NC.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // System.out.println(response.readEntity(String.class));
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationAvailabilityFreeUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQuery("availability != /CC-BY.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationAvailabilityFreeUnauthorized2 ()
+            throws KustvaktException {
+        Response response = searchQuery("availability != /.*BY.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationAvailabilityWithOperationOrUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQuery(
+                "availability = /CC-BY.*/ | availability != /CC-BY.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testComplexNegationAvailabilityFreeUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQuery(
+                "textClass=politik & availability != /CC-BY.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testComplexAvailabilityFreeUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQuery(
+                "textClass=politik & availability=ACA-NC");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testComplexAvailabilityFreeUnauthorized3 ()
+            throws KustvaktException {
+        Response response = searchQuery(
+                "textClass=politik & availability=/.*NC.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityPublicAuthorized () throws KustvaktException {
+        Response response = searchQueryWithIP("availability=ACA-NC",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityPublicUnauthorized () throws KustvaktException {
+        Response response = searchQueryWithIP("availability=QAO-NC-LOC:ids",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityRegexPublicAuthorized ()
+            throws KustvaktException {
+        Response response = searchQueryWithIP("availability= /ACA.*/",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublicWithACA(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationAvailabilityPublicUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQueryWithIP("availability != ACA-NC",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationAvailabilityRegexPublicUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQueryWithIP("availability != /ACA.*/",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testComplexAvailabilityPublicUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQueryWithIP(
+                "textClass=politik & availability=QAO-NC-LOC:ids",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testNegationComplexAvailabilityPublicUnauthorized ()
+            throws KustvaktException {
+        Response response = searchQueryWithIP(
+                "textClass=politik & availability!=QAO-NC-LOC:ids",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndPublic(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityRegexAllAuthorized () throws KustvaktException {
+        Response response = searchQueryWithIP("availability= /ACA.*/",
+                "10.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndAllWithACA(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testAvailabilityOr () throws KustvaktException {
+        Response response = searchQuery(
+                "availability=/CC-BY.*/ | availability=/ACA.*/");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testRedundancyOrPub () throws KustvaktException {
+        Response response = searchQueryWithIP(
+                "availability=/CC-BY.*/ | availability=/ACA.*/ | availability=/QAO-NC/",
+                "149.27.0.32");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String json = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(json);
+        assertTrue(node.at("/collection/rewrites").isMissingNode());
+        assertEquals(node.at("/collection/operation").asText(), "operation:or");
+    }
+
+    @Test
+    public void testAvailabilityOrCorpusSigle () throws KustvaktException {
+        Response response = searchQuery(
+                "availability=/CC-BY.*/ | corpusSigle=GOE");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testOrWithoutAvailability () throws KustvaktException {
+        Response response = searchQuery("corpusSigle=GOE | textClass=politik");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+
+    @Test
+    public void testWithoutAvailability () throws KustvaktException {
+        Response response = searchQuery("corpusSigle=GOE");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        checkAndFree(response.readEntity(String.class));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/FreeResourceControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/FreeResourceControllerTest.java
new file mode 100644
index 0000000..cf74436
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/FreeResourceControllerTest.java
@@ -0,0 +1,31 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.context.ContextConfiguration;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+@ContextConfiguration("classpath:test-resource-config.xml")
+public class FreeResourceControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testResource () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("resource")
+                .request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode n = JsonUtils.readTree(entity).get(0);
+        assertEquals(n.at("/resourceId").asText(), "WPD17");
+        assertEquals(n.at("/titles/de").asText(),
+                "Deutsche Wikipedia Artikel 2017");
+        assertEquals(n.at("/titles/en").asText(),
+                "German Wikipedia Articles 2017");
+        assertEquals(1, n.at("/languages").size());
+        assertEquals(6, n.at("/layers").size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/IndexControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/IndexControllerTest.java
new file mode 100644
index 0000000..3b9dfbc
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/IndexControllerTest.java
@@ -0,0 +1,76 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.SearchKrill;
+
+/**
+ * @author margaretha
+ */
+public class IndexControllerTest extends SpringJerseyTest {
+
+    @Autowired
+    private SearchKrill searchKrill;
+
+    @Test
+    public void testCloseIndex () throws IOException, KustvaktException,
+            URISyntaxException, InterruptedException {
+        URI uri = IndexControllerTest.class.getClassLoader()
+                .getResource("vc/named-vc1.jsonld").toURI();
+        Path toLink = Paths.get(uri);
+        Path symLink = Paths.get("vc/named-vc1.jsonld");
+        Path vcPath = Paths.get("vc");
+        if (!Files.exists(vcPath)) {
+            Files.createDirectory(vcPath);
+        }
+        if (Files.exists(symLink, LinkOption.NOFOLLOW_LINKS)) {
+            Files.delete(symLink);
+        }
+        Files.createSymbolicLink(symLink, toLink);
+        searchKrill.getStatistics(null);
+        assertEquals(true, searchKrill.getIndex().isReaderOpen());
+        Form form = new Form();
+        form.param("token", "secret");
+        Response response = target().path(API_VERSION).path("index")
+                .path("close").request().post(Entity.form(form));
+        assertEquals(HttpStatus.SC_OK, response.getStatus());
+        assertEquals(false, searchKrill.getIndex().isReaderOpen());
+        // Cleaning database and cache
+        Thread.sleep(200);
+        response = target().path(API_VERSION).path("vc").path("~system")
+                .path("named-vc1").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .delete();
+        response = target().path(API_VERSION).path("vc").path("~system")
+                .path("named-vc1").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/InfoControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/InfoControllerTest.java
new file mode 100644
index 0000000..ab60ba3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/InfoControllerTest.java
@@ -0,0 +1,45 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.ServiceInfo;
+import de.ids_mannheim.korap.web.SearchKrill;
+
+public class InfoControllerTest extends SpringJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    @Autowired
+    private SearchKrill krill;
+
+    @Test
+    public void testInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("info").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(config.getCurrentVersion(),
+                node.at("/latest_api_version").asText());
+        assertEquals(config.getSupportedVersions().size(),
+                node.at("/supported_api_versions").size());
+        assertEquals(ServiceInfo.getInfo().getVersion(),
+                node.at("/kustvakt_version").asText());
+        assertEquals(krill.getIndex().getVersion(),
+                node.at("/krill_version").asText());
+        QuerySerializer s = new QuerySerializer();
+        assertEquals(s.getVersion(), node.at("/koral_version").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/MatchInfoControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/MatchInfoControllerTest.java
new file mode 100644
index 0000000..ec282c9
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/MatchInfoControllerTest.java
@@ -0,0 +1,113 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class MatchInfoControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testGetMatchInfoPublicCorpus () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGA").path("01784").path("p36-100")
+                .path("matchInfo").queryParam("foundry", "*").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+        assertEquals(node.at("/author").asText(),
+                "Goethe, Johann Wolfgang von");
+        assertTrue(node.at("/snippet").asText()
+                .startsWith("<span class=\"context-left\"></span>"
+                        + "<span class=\"match\">"));
+    }
+
+    @Test
+    public void testGetMatchInfoNotAllowed () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGI").path("04846").path("p36875-36876")
+                .path("matchInfo").queryParam("foundry", "*").request().get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Retrieving resource with ID "
+                        + "match-GOE/AGI/04846-p36875-36876 is not allowed.",
+                node.at("/errors/0/1").asText());
+        assertTrue(node.at("/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testGetMatchInfoWithAuthentication () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGI").path("04846").path("p36875-36876")
+                .path("matchInfo").queryParam("foundry", "*").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "172.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGI/04846");
+        assertEquals(node.at("/title").asText(),
+                "Zweiter römischer Aufenthalt");
+        assertEquals(node.at("/subTitle").asText(),
+                "vom Juni 1787 bis April 1788");
+        assertEquals(node.at("/author").asText(),
+                "Goethe, Johann Wolfgang von");
+        assertTrue(node.at("/snippet").asText()
+                .startsWith("<span class=\"context-left\"></span>"
+                        + "<span class=\"match\">"));
+        assertEquals(node.at("/availability").asText(), "QAO-NC-LOC:ids");
+    }
+
+    @Test
+    public void testAvailabilityAll () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGD").path("00000").path("p75-76").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "10.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testAvailabilityAllUnauthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGD").path("00000").path("p75-76").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "170.27.0.32").get();
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Retrieving resource with ID "
+                        + "match-GOE/AGD/00000-p75-76 is not allowed.",
+                node.at("/errors/0/1").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/MetadataControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/MetadataControllerTest.java
new file mode 100644
index 0000000..dcc63d7
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/MetadataControllerTest.java
@@ -0,0 +1,117 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class MetadataControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testRetrieveMetadataWithField () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGA").path("01784")
+                .queryParam("fields", "author").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/document/fields/0/key").asText(), "author");
+    }
+
+    @Test
+    public void testRetrieveMetadataWithMultipleFields ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGA").path("01784")
+                .queryParam("fields", "author,title").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/document/fields/0/key").asText(), "author");
+        assertEquals(node.at("/document/fields/1/key").asText(), "title");
+    }
+
+    @Test
+    public void testFreeMetadata () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGA").path("01784").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(!node.at("/document").isMissingNode());
+    }
+
+    // EM: currently all metadata are allowed
+    @Test
+    @Disabled
+    public void testMetadataUnauthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGI").path("04846").request().get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Retrieving resource with ID "
+                        + "GOE/AGI/04846 is not allowed.",
+                node.at("/errors/0/1").asText());
+    }
+
+    @Test
+    public void testMetadataWithAuthentication () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGI").path("04846").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "172.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testMetadataAvailabilityAll () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGI").path("00000").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "10.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    // EM: currently all metadata are allowed
+    @Test
+    @Disabled
+    public void testMetadataAvailabilityAllUnauthorized ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").path("AGD").path("00000").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "170.27.0.32").get();
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Retrieving resource with ID "
+                        + "GOE/AGD/00000 is not allowed.",
+                node.at("/errors/0/1").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/MultipleCorpusQueryTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/MultipleCorpusQueryTest.java
new file mode 100644
index 0000000..775afd8
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/MultipleCorpusQueryTest.java
@@ -0,0 +1,75 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class MultipleCorpusQueryTest extends SpringJerseyTest {
+
+    @Test
+    public void testSearchGet () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "das").queryParam("ql", "poliqarp")
+                .queryParam("cq", "pubPlace=München")
+                .queryParam("cq", "textSigle=\"GOE/AGA/01784\"").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        node = node.at("/collection/operands/1");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/operation").asText(), "operation:and");
+        assertEquals(2, node.at("/operands").size());
+        assertEquals(node.at("/operands/0/@type").asText(), "koral:doc");
+        assertEquals(node.at("/operands/0/match").asText(), "match:eq");
+        assertEquals(node.at("/operands/0/key").asText(), "pubPlace");
+        assertEquals(node.at("/operands/0/value").asText(), "München");
+        assertEquals(node.at("/operands/1/key").asText(), "textSigle");
+        assertEquals(node.at("/operands/1/value").asText(), "GOE/AGA/01784");
+    }
+
+    @Test
+    public void testStatisticsWithMultipleCq ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung")
+                .queryParam("cq", "corpusSigle=GOE").request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testStatisticsWithMultipleCorpusQuery ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "textType=Autobiographie")
+                .queryParam("corpusQuery", "corpusSigle=GOE").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(9, node.at("/documents").asInt());
+        assertEquals(527662, node.at("/tokens").asInt());
+        assertEquals(19387, node.at("/sentences").asInt());
+        assertEquals(514, node.at("/paragraphs").asInt());
+        assertEquals(StatusCodes.DEPRECATED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "Parameter corpusQuery is deprecated in favor of cq.");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
new file mode 100644
index 0000000..6b5627d
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
@@ -0,0 +1,231 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import com.nimbusds.oauth2.sdk.GrantType;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.OAuth2Scope;
+import de.ids_mannheim.korap.constant.TokenType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class OAuth2AccessTokenTest extends OAuth2TestBase {
+
+    private String userAuthHeader;
+
+    private String clientAuthHeader;
+
+    public OAuth2AccessTokenTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+        clientAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(confidentialClientId,
+                        clientSecret);
+    }
+
+    @Test
+    public void testScopeWithSuperClient () throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(superClientId,
+                clientSecret);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(node.at("/scope").asText(), "all");
+        String accessToken = node.at("/access_token").asText();
+        // test list user group
+        response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(2, node.size());
+    }
+
+    @Test
+    public void testCustomScope () throws KustvaktException {
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", OAuth2Scope.VC_INFO.toString(), "",
+                userAuthHeader);
+        String code = parseAuthorizationCode(response);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String token = node.at("/access_token").asText();
+        assertTrue(node.at("/scope").asText()
+                .contains(OAuth2Scope.VC_INFO.toString()));
+        // test list vc using the token
+        response = target().path(API_VERSION).path("vc").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + token).get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(4, node.size());
+    }
+
+    @Test
+    public void testDefaultScope () throws KustvaktException, IOException {
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken = node.at("/access_token").asText();
+        testScopeNotAuthorized(accessToken);
+        testScopeNotAuthorize2(accessToken);
+        testSearchWithOAuth2Token(accessToken);
+    }
+
+    private void testScopeNotAuthorized (String accessToken)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Scope vc_info is not authorized");
+    }
+
+    private void testScopeNotAuthorize2 (String accessToken)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Scope vc_access_info is not authorized");
+    }
+
+    @Test
+    public void testSearchWithUnknownToken ()
+            throws KustvaktException, IOException {
+        Response response = searchWithAccessToken(
+                "ljsa8tKNRSczJhk20öhq92zG8z350");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is invalid");
+    }
+
+    @Test
+    public void testRevokeAccessTokenConfidentialClient ()
+            throws KustvaktException {
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        JsonNode node = requestTokenWithAuthorizationCodeAndHeader(
+                confidentialClientId, code, clientAuthHeader);
+        String accessToken = node.at("/access_token").asText();
+        Form form = new Form();
+        form.param("token", accessToken);
+        form.param("client_id", confidentialClientId);
+        form.param("client_secret", "secret");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("revoke").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testSearchWithRevokedAccessToken(accessToken);
+    }
+
+    @Test
+    public void testRevokeAccessTokenPublicClientViaSuperClient ()
+            throws KustvaktException {
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, "", code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken = node.at("/access_token").asText();
+        testRevokeTokenViaSuperClient(accessToken, userAuthHeader);
+        testSearchWithRevokedAccessToken(accessToken);
+    }
+
+    @Test
+    public void testAccessTokenAfterRequestRefreshToken ()
+            throws KustvaktException, IOException {
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        JsonNode node = requestTokenWithAuthorizationCodeAndHeader(
+                confidentialClientId, code, clientAuthHeader);
+        String accessToken = node.at("/access_token").asText();
+        String refreshToken = node.at("/refresh_token").asText();
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", confidentialClientId);
+        form.param("client_secret", "secret");
+        form.param("refresh_token", refreshToken);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertTrue(!refreshToken.equals(node.at("/refresh_token").asText()));
+        testSearchWithRevokedAccessToken(accessToken);
+    }
+
+    @Test
+    public void testRequestAuthorizationWithBearerTokenUnauthorized ()
+            throws KustvaktException {
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        JsonNode node = requestTokenWithAuthorizationCodeAndHeader(
+                confidentialClientId, code, clientAuthHeader);
+        String userAuthToken = node.at("/access_token").asText();
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", "search", "",
+                "Bearer " + userAuthToken);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Scope authorize is not authorized");
+    }
+
+    @Test
+    public void testRequestAuthorizationWithBearerToken ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(superClientId,
+                clientSecret);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        String userAuthToken = node.at("/access_token").asText();
+        assertNotNull(userAuthToken);
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        String code = requestAuthorizationCode(superClientId,
+                "Bearer " + userAuthToken);
+        assertNotNull(code);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java
new file mode 100644
index 0000000..fabc267
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java
@@ -0,0 +1,188 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.dao.AccessTokenDao;
+import de.ids_mannheim.korap.oauth2.dao.RefreshTokenDao;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+@Order(Integer.MAX_VALUE) // make sure this runs as last test as it removes tokens
+public class OAuth2AdminControllerTest extends OAuth2TestBase {
+
+    private String username = "OAuth2AdminControllerTest";
+
+    private String adminAuthHeader;
+
+    private String userAuthHeader;
+
+    @Autowired
+    private RefreshTokenDao refreshDao;
+
+    @Autowired
+    private AccessTokenDao accessDao;
+
+    public OAuth2AdminControllerTest () throws KustvaktException {
+        adminAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("admin", "password");
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+    }
+
+    private Response updateClientPrivilege (String username, Form form)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("admin")
+                .path("oauth2").path("client").path("privilege").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        return response;
+    }
+
+    private Response updateClientPrivilegeWithAdminToken (String clientId)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("client_id", clientId);
+        form.param("super", Boolean.toString(false));
+        // adminToken
+        form.param("token", "secret");
+        Response response = target().path(API_VERSION).path("admin")
+                .path("oauth2").path("client").path("privilege").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        return response;
+    }
+
+    private void testUpdateClientPriviledgeUnauthorized (Form form)
+            throws ProcessingException, KustvaktException {
+        Response response = updateClientPrivilege(username, form);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    @Order(1)
+    public void testCleanExpiredTokensUsingAdminToken ()
+            throws KustvaktException {
+        createExpiredAccessToken();
+
+        int refreshTokensBefore = refreshDao.retrieveInvalidRefreshTokens()
+                .size();
+
+        assertTrue(refreshTokensBefore > 0);
+        int accessTokensBefore = accessDao.retrieveInvalidAccessTokens().size();
+        assertTrue(accessTokensBefore > 0);
+        Form form = new Form();
+        form.param("token", "secret");
+        target().path(API_VERSION).path("admin").path("oauth2").path("token")
+                .path("clean").request().post(Entity.form(form));
+        assertEquals(0, refreshDao.retrieveInvalidRefreshTokens().size());
+        assertEquals(0, accessDao.retrieveInvalidAccessTokens().size());
+    }
+
+    @Test
+    @Order(2)
+    public void testCleanRevokedTokens () throws KustvaktException {
+
+        int accessTokensBefore = accessDao.retrieveInvalidAccessTokens().size();
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, clientSecret, code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        String accessToken = node.at("/access_token").asText();
+        testRevokeToken(accessToken, publicClientId, null, ACCESS_TOKEN_TYPE);
+        int accessTokensAfter = accessDao.retrieveInvalidAccessTokens().size();
+        assertEquals(accessTokensAfter, accessTokensBefore + 1);
+        target().path(API_VERSION).path("admin").path("oauth2").path("token")
+                .path("clean").request()
+                .header(Attributes.AUTHORIZATION, adminAuthHeader).post(null);
+        assertEquals(0, accessDao.retrieveInvalidAccessTokens().size());
+    }
+
+    @Test
+    public void testUpdateClientPrivilege () throws KustvaktException {
+        // register a client
+        Response response = registerConfidentialClient(username);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        String clientSecret = node.at("/client_secret").asText();
+        // request an access token
+        String clientAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(clientId, clientSecret);
+        String code = requestAuthorizationCode(clientId, userAuthHeader);
+        node = requestTokenWithAuthorizationCodeAndHeader(clientId, code,
+                clientAuthHeader);
+        String accessToken = node.at("/access_token").asText();
+        // update client priviledge to super client
+        Form form = new Form();
+        form.param("client_id", clientId);
+        form.param("super", Boolean.toString(true));
+        testUpdateClientPriviledgeUnauthorized(form);
+        response = updateClientPrivilege("admin", form);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testAccessTokenAfterUpgradingClient(clientId, accessToken);
+        // degrade a super client to a common client
+        updateClientPrivilegeWithAdminToken(clientId);
+        testAccessTokenAfterDegradingSuperClient(clientId, accessToken);
+        deregisterClient(username, clientId);
+    }
+
+    // old access tokens retain their scopes
+    private void testAccessTokenAfterUpgradingClient (String clientId,
+            String accessToken) throws KustvaktException {
+        JsonNode node = retrieveClientInfo(clientId, "admin");
+        assertTrue(node.at("/super").asBoolean());
+        // list vc
+        Response response = target().path(API_VERSION).path("vc").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Scope vc_info is not authorized");
+        // search
+        response = searchWithAccessToken(accessToken);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    private void testAccessTokenAfterDegradingSuperClient (String clientId,
+            String accessToken) throws KustvaktException {
+        JsonNode node = retrieveClientInfo(clientId, username);
+        assertTrue(node.at("/isSuper").isMissingNode());
+        Response response = searchWithAccessToken(accessToken);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is invalid");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationPostTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationPostTest.java
new file mode 100644
index 0000000..8372acc
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationPostTest.java
@@ -0,0 +1,87 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.net.URI;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.glassfish.jersey.uri.UriComponent;
+import org.junit.jupiter.api.Test;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.TokenType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class OAuth2AuthorizationPostTest extends OAuth2TestBase {
+
+    public String userAuthHeader;
+
+    public OAuth2AuthorizationPostTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+    }
+
+    private Response requestAuthorizationCode (Form form, String authHeader)
+            throws KustvaktException {
+        return target().path(API_VERSION).path("oauth2").path("authorize")
+                .request().header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    @Test
+    public void testAuthorizeConfidentialClient () throws KustvaktException {
+        Form form = new Form();
+        form.param("response_type", "code");
+        form.param("client_id", confidentialClientId);
+        form.param("state", "thisIsMyState");
+        form.param("scope", "search");
+        Response response = requestAuthorizationCode(form, userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        assertNotNull(params.getFirst("code"));
+        assertEquals(params.getFirst("state"), "thisIsMyState");
+    }
+
+    @Test
+    public void testRequestTokenAuthorizationConfidential ()
+            throws KustvaktException {
+        Form authForm = new Form();
+        authForm.param("response_type", "code");
+        authForm.param("client_id", confidentialClientId);
+        authForm.param("scope", "search");
+        Response response = requestAuthorizationCode(authForm, userAuthHeader);
+        URI redirectUri = response.getLocation();
+
+        MultivaluedMap<String, String> params = UriComponent
+                .decodeQuery(redirectUri, true);
+        String code = params.get("code").get(0);
+
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationTest.java
new file mode 100644
index 0000000..27903e7
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AuthorizationTest.java
@@ -0,0 +1,293 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+public class OAuth2AuthorizationTest extends OAuth2TestBase {
+
+    private String userAuthHeader;
+
+    public OAuth2AuthorizationTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+    }
+
+    @Test
+    public void testAuthorizeUnauthenticated () throws KustvaktException {
+
+        Response response = requestAuthorizationCode("code", publicClientId, "",
+                "search match_info", "", "");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: guest",
+                node.at("/errors/0/1").asText());
+    }
+
+    @Test
+    public void testAuthorizeConfidentialClient () throws KustvaktException {
+        // with registered redirect URI
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", "match_info search client_info",
+                state, userAuthHeader);
+
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+        MultivaluedMap<String, String> params = getQueryParamsFromURI(
+                redirectUri);
+        assertNotNull(params.getFirst("code"));
+        assertEquals(state, params.getFirst("state"));
+    }
+
+    @Test
+    public void testAuthorizePublicClient () throws KustvaktException {
+        // with registered redirect URI
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        assertNotNull(code);
+    }
+
+    @Test
+    public void testAuthorizeWithRedirectUri () throws KustvaktException {
+        Response response = requestAuthorizationCode("code", publicClientId2,
+                "https://public.com/redirect", "search match_info", "",
+                userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        URI redirectUri = response.getLocation();
+        assertEquals("https", redirectUri.getScheme());
+        assertEquals("public.com", redirectUri.getHost());
+        assertEquals("/redirect", redirectUri.getPath());
+
+        assertTrue(redirectUri.getQuery().startsWith("code="));
+    }
+
+    @Test
+    public void testAuthorizeWithoutScope () throws KustvaktException {
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", "", "", userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        URI redirectUri = response.getLocation();
+        assertEquals(redirectUri.getScheme(), "https");
+        assertEquals(redirectUri.getHost(), "third.party.com");
+        assertEquals(redirectUri.getPath(), "/confidential/redirect");
+
+        String[] queryParts = redirectUri.getQuery().split("&");
+        assertEquals("error_description=scope+is+required", queryParts[1]);
+        assertEquals("error=invalid_scope", queryParts[0]);
+    }
+
+    @Test
+    public void testAuthorizeMissingClientId () throws KustvaktException {
+        Response response = requestAuthorizationCode("code", "", "", "search",
+                "", userAuthHeader);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals("Missing parameter: client_id",
+                node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testAuthorizeMissingRedirectUri () throws KustvaktException {
+        Response response = requestAuthorizationCode("code", publicClientId2,
+                "", "search", state, userAuthHeader);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals("Missing parameter: redirect URI",
+                node.at("/error_description").asText());
+        assertEquals(state, node.at("/state").asText());
+    }
+
+    @Test
+    public void testAuthorizeMissingResponseType () throws KustvaktException {
+        Response response = requestAuthorizationCode("", confidentialClientId,
+                "", "search", "", userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        assertEquals(
+                "https://third.party.com/confidential/redirect?"
+                        + "error=invalid_request_uri&"
+                        + "error_description=Missing+response_type+parameter",
+                response.getLocation().toString());
+    }
+
+    @Test
+    public void testAuthorizeMissingResponseTypeWithoutClientId ()
+            throws KustvaktException {
+        Response response = requestAuthorizationCode("", "", "", "search", "",
+                userAuthHeader);
+
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals("Missing parameter: client_id",
+                node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testAuthorizeInvalidClientId () throws KustvaktException {
+        Response response = requestAuthorizationCode("code",
+                "unknown-client-id", "", "search", "", userAuthHeader);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_CLIENT, node.at("/error").asText());
+        assertEquals("Unknown client: unknown-client-id",
+                node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testAuthorizeDifferentRedirectUri () throws KustvaktException {
+        String redirectUri = "https://different.uri/redirect";
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, redirectUri, "", state, userAuthHeader);
+
+        testInvalidRedirectUri(response.readEntity(String.class),
+                response.getHeaderString("Content-Type"), true,
+                response.getStatus());
+    }
+
+    @Test
+    public void testAuthorizeWithRedirectUriLocalhost ()
+            throws KustvaktException {
+        Response response = requestAuthorizationCode("code", publicClientId2,
+                "http://localhost:1410", "search", state, userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        URI redirectUri = response.getLocation();
+        MultivaluedMap<String, String> params = getQueryParamsFromURI(
+                redirectUri);
+        assertNotNull(params.getFirst("code"));
+        assertEquals(state, params.getFirst("state"));
+    }
+
+    @Test
+    public void testAuthorizeWithRedirectUriFragment ()
+            throws KustvaktException {
+        Response response = requestAuthorizationCode("code", publicClientId2,
+                "http://public.com/index.html#redirect", "search", state,
+                userAuthHeader);
+        testInvalidRedirectUri(response.readEntity(String.class),
+                response.getHeaderString("Content-Type"), true,
+                response.getStatus());
+    }
+
+    @Test
+    public void testAuthorizeInvalidRedirectUri () throws KustvaktException {
+        // host not allowed by Apache URI Validator
+        String redirectUri = "https://public.uri/redirect";
+        Response response = requestAuthorizationCode("code", publicClientId2,
+                redirectUri, "", state, userAuthHeader);
+        testInvalidRedirectUri(response.readEntity(String.class),
+                response.getHeaderString("Content-Type"), true,
+                response.getStatus());
+    }
+
+    @Test
+    public void testAuthorizeInvalidResponseType () throws KustvaktException {
+        // without redirect URI in the request
+        Response response = requestAuthorizationCode("string",
+                confidentialClientId, "", "search", state, userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        assertEquals("https://third.party.com/confidential/redirect?"
+                + "error=unsupported_response_type"
+                + "&error_description=Unsupported+response+type.+Only+code+is+supported."
+                + "&state=thisIsMyState", response.getLocation().toString());
+
+        // with redirect URI, and no registered redirect URI
+        response = requestAuthorizationCode("string", publicClientId2,
+                "https://public.client.com/redirect", "search", state,
+                userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        assertEquals("https://public.client.com/redirect?"
+                + "error=unsupported_response_type"
+                + "&error_description=Unsupported+response+type.+Only+code+is+supported."
+                + "&state=thisIsMyState", response.getLocation().toString());
+
+        // with different redirect URI
+        String redirectUri = "https://different.uri/redirect";
+        response = requestAuthorizationCode("string", confidentialClientId,
+                redirectUri, "", state, userAuthHeader);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals("Invalid redirect URI",
+                node.at("/error_description").asText());
+        assertEquals(state, node.at("/state").asText());
+
+        // without redirect URI in the request and no registered
+        // redirect URI
+        response = requestAuthorizationCode("string", publicClientId2, "", "",
+                state, userAuthHeader);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals("Missing parameter: redirect URI",
+                node.at("/error_description").asText());
+        assertEquals(state, node.at("/state").asText());
+    }
+
+    @Test
+    public void testAuthorizeInvalidScope () throws KustvaktException {
+        String scope = "read_address";
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", scope, state, userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        assertEquals(
+                "https://third.party.com/confidential/redirect?"
+                        + "error=invalid_scope&error_description=Invalid+"
+                        + "scope&state=thisIsMyState",
+                response.getLocation().toString());
+    }
+
+    @Test
+    public void testAuthorizeUnsupportedTokenResponseType ()
+            throws KustvaktException {
+        Response response = requestAuthorizationCode("token",
+                confidentialClientId, "", "search", state, userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        assertEquals("https://third.party.com/confidential/redirect?"
+                + "error=unsupported_response_type"
+                + "&error_description=Unsupported+response+type.+Only+code+is+supported."
+                + "&state=thisIsMyState", response.getLocation().toString());
+    }
+
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
new file mode 100644
index 0000000..5f24cd6
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
@@ -0,0 +1,678 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.http.entity.ContentType;
+import org.glassfish.jersey.server.ContainerRequest;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+/**
+ * @author margaretha
+ */
+public class OAuth2ClientControllerTest extends OAuth2TestBase {
+
+    private String username = "OAuth2ClientControllerTest";
+
+    private String userAuthHeader;
+
+    public OAuth2ClientControllerTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+    }
+
+    private OAuth2ClientJson createOAuth2ClientJson (String name,
+            OAuth2ClientType type, String description) {
+        OAuth2ClientJson client = new OAuth2ClientJson();
+        if (name != null) {
+            client.setName(name);
+        }
+        client.setType(type);
+        if (description != null) {
+            client.setDescription(description);
+        }
+        return client;
+    }
+
+    @Test
+    public void testRetrieveClientInfo () throws KustvaktException {
+        // public client
+        JsonNode clientInfo = retrieveClientInfo(publicClientId, "system");
+        assertEquals(publicClientId, clientInfo.at("/client_id").asText());
+        assertEquals(clientInfo.at("/client_name").asText(),
+                "public client plugin with redirect uri");
+        assertNotNull(clientInfo.at("/client_description"));
+        assertNotNull(clientInfo.at("/client_url"));
+        assertEquals(clientInfo.at("/client_type").asText(), "PUBLIC");
+        assertEquals(clientInfo.at("/registered_by").asText(), "system");
+        // confidential client
+        clientInfo = retrieveClientInfo(confidentialClientId, "system");
+        assertEquals(confidentialClientId,
+                clientInfo.at("/client_id").asText());
+        assertEquals(clientInfo.at("/client_name").asText(),
+                "non super confidential client");
+        assertNotNull(clientInfo.at("/client_url"));
+        assertNotNull(clientInfo.at("/redirect_uri"));
+        assertEquals(false, clientInfo.at("/super").asBoolean());
+        assertEquals(clientInfo.at("/client_type").asText(), "CONFIDENTIAL");
+        // super client
+        clientInfo = retrieveClientInfo(superClientId, "system");
+        assertEquals(superClientId, clientInfo.at("/client_id").asText());
+        assertEquals(clientInfo.at("/client_name").asText(),
+                "super confidential client");
+        assertNotNull(clientInfo.at("/client_url"));
+        assertNotNull(clientInfo.at("/redirect_uri"));
+        assertEquals(clientInfo.at("/client_type").asText(), "CONFIDENTIAL");
+        assertTrue(clientInfo.at("/super").asBoolean());
+    }
+
+    @Test
+    public void testRegisterConfidentialClient () throws KustvaktException {
+        Response response = registerConfidentialClient(username);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        String clientId = node.at("/client_id").asText();
+        String clientSecret = node.at("/client_secret").asText();
+        assertNotNull(clientId);
+        assertNotNull(clientSecret);
+        assertFalse(clientId.contains("a"));
+        testListConfidentialClient(username, clientId);
+        testConfidentialClientInfo(clientId, username);
+        testResetConfidentialClientSecret(clientId, clientSecret);
+        deregisterClient(username, clientId);
+    }
+
+    @Test
+    public void testRegisterClientNameTooShort ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson("R",
+                OAuth2ClientType.PUBLIC, null);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "client_name must contain at least 3 characters");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientEmptyName ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson("",
+                OAuth2ClientType.PUBLIC, null);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "client_name must contain at least 3 characters");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientMissingName ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(null,
+                OAuth2ClientType.PUBLIC, null);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "client_name is null");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientMissingDescription ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson("R client",
+                OAuth2ClientType.PUBLIC, null);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "client_description is null");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientMissingType ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson("R client", null,
+                null);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "client_type is null");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientInvalidRedirectURI ()
+            throws ProcessingException, KustvaktException {
+        // invalid hostname
+        String redirectUri = "https://test.public.client/redirect";
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2PublicClient", OAuth2ClientType.PUBLIC,
+                "A public test client.");
+        clientJson.setRedirectURI(redirectUri);
+        Response response = registerClient(username, clientJson);
+        testInvalidRedirectUri(response.readEntity(String.class),
+                response.getHeaderString("Content-Type"), false,
+                response.getStatus());
+        // localhost is not allowed
+        // redirectUri = "http://localhost:1410";
+        // clientJson.setRedirectURI(redirectUri);
+        // response = registerClient(username, clientJson);
+        // testInvalidRedirectUri(response.readEntity(String.class), false,
+        // response.getStatus());
+        // fragment is not allowed
+        redirectUri = "https://public.client.com/redirect.html#bar";
+        clientJson.setRedirectURI(redirectUri);
+        response = registerClient(username, clientJson);
+        testInvalidRedirectUri(response.readEntity(String.class),
+                response.getHeaderString("Content-Type"), false,
+                response.getStatus());
+    }
+
+    @Test
+    public void testRegisterPublicClientWithRefreshTokenExpiry ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2PublicClient", OAuth2ClientType.PUBLIC,
+                "A public test client.");
+        clientJson.setRefreshTokenExpiry(31535000);
+        Response response = registerClient(username, clientJson);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterConfidentialClientWithRefreshTokenExpiry ()
+            throws ProcessingException, KustvaktException {
+        int expiry = 31535000;
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2 Confidential Client", OAuth2ClientType.CONFIDENTIAL,
+                "A confidential client.");
+        clientJson.setRefreshTokenExpiry(expiry);
+        Response response = registerClient(username, clientJson);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        JsonNode clientInfo = retrieveClientInfo(clientId, username);
+        assertEquals(expiry, clientInfo.at("/refresh_token_expiry").asInt());
+        deregisterClient(username, clientId);
+    }
+
+    @Test
+    public void testRegisterConfidentialClientWithInvalidRefreshTokenExpiry ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2 Confidential Client", OAuth2ClientType.CONFIDENTIAL,
+                "A confidential client.");
+        clientJson.setRefreshTokenExpiry(31537000);
+        Response response = registerClient(username, clientJson);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(node.at("/error_description").asText(),
+                "Maximum refresh token expiry is 31536000 seconds (1 year)");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientInvalidURL ()
+            throws ProcessingException, KustvaktException {
+        // invalid hostname
+        String url = "https://test.public.client";
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2PublicClient", OAuth2ClientType.PUBLIC,
+                "A public test client.");
+        clientJson.setUrl(url);
+        Response response = registerClient(username, clientJson);
+        testInvalidUrl(response.readEntity(String.class), response.getStatus());
+        // localhost is not allowed
+        url = "http://localhost:1410";
+        clientJson.setRedirectURI(url);
+        response = registerClient(username, clientJson);
+        testInvalidUrl(response.readEntity(String.class), response.getStatus());
+    }
+
+    private void testInvalidUrl (String entity, int status)
+            throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(), "Invalid URL");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), status);
+    }
+
+    @Test
+    public void testRegisterPublicClient ()
+            throws ProcessingException, KustvaktException {
+        String redirectUri = "https://public.client.com/redirect";
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2PublicClient", OAuth2ClientType.PUBLIC,
+                "A public test client.");
+        // http and fragment are allowed
+        clientJson.setUrl("http://public.client.com/index.html#bar");
+        clientJson.setRedirectURI(redirectUri);
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        String clientId = node.at("/client_id").asText();
+        assertNotNull(clientId);
+        assertTrue(node.at("/client_secret").isMissingNode());
+        testRegisterClientUnauthorizedScope(clientId);
+        testResetPublicClientSecret(clientId);
+        testAccessTokenAfterDeregistration(clientId, null, "");
+    }
+
+    private void testRegisterClientUnauthorizedScope (String clientId)
+            throws ProcessingException, KustvaktException {
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+        String code = requestAuthorizationCode(clientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(node.at("/scope").asText(), "match_info search");
+        String accessToken = node.at("/access_token").asText();
+        OAuth2ClientJson clientJson = createOAuth2ClientJson("R client",
+                OAuth2ClientType.PUBLIC, null);
+        response = target().path(API_VERSION).path("oauth2").path("client")
+                .path("register").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .post(Entity.json(clientJson));
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Scope register_client is not authorized");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testRegisterClientUsingPlainJson ()
+            throws ProcessingException, KustvaktException, IOException {
+        InputStream is = getClass().getClassLoader()
+                .getResourceAsStream("json/oauth2_public_client.json");
+        String json = IOUtils.toString(is, Charset.defaultCharset());
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("register").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(username,
+                                        "password"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .post(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        String clientId = node.at("/client_id").asText();
+        assertNotNull(clientId);
+        assertTrue(node.at("/client_secret").isMissingNode());
+        testResetPublicClientSecret(clientId);
+        testAccessTokenAfterDeregistration(clientId, null, "");
+    }
+
+    @Test
+    public void testRegisterDesktopApp ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2DesktopClient", OAuth2ClientType.PUBLIC,
+                "This is a desktop test client.");
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        String clientId = node.at("/client_id").asText();
+        assertNotNull(clientId);
+        assertTrue(node.at("/client_secret").isMissingNode());
+        testDeregisterPublicClientMissingUserAuthentication(clientId);
+        testDeregisterPublicClientMissingId();
+        testDeregisterPublicClient(clientId, username);
+    }
+
+    @Test
+    public void testRegisterMultipleDesktopApps ()
+            throws ProcessingException, KustvaktException {
+        // First client
+        OAuth2ClientJson clientJson = createOAuth2ClientJson(
+                "OAuth2DesktopClient1", OAuth2ClientType.PUBLIC,
+                "A desktop test client.");
+        Response response = registerClient(username, clientJson);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        String clientId1 = node.at("/client_id").asText();
+        assertNotNull(clientId1);
+        assertTrue(node.at("/client_secret").isMissingNode());
+        // Second client
+        clientJson = createOAuth2ClientJson("OAuth2DesktopClient2",
+                OAuth2ClientType.PUBLIC, "Another desktop test client.");
+        response = registerClient(username, clientJson);
+        entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(entity);
+        String clientId2 = node.at("/client_id").asText();
+        assertNotNull(clientId2);
+        assertTrue(node.at("/client_secret").isMissingNode());
+        testResetPublicClientSecret(clientId1);
+        testAccessTokenAfterDeregistration(clientId1, null,
+                "https://OAuth2DesktopClient1.com");
+        testResetPublicClientSecret(clientId2);
+        testAccessTokenAfterDeregistration(clientId2, null,
+                "https://OAuth2DesktopClient2.com");
+    }
+
+    private void testAccessTokenAfterDeregistration (String clientId,
+            String clientSecret, String redirectUri) throws KustvaktException {
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+        String code = requestAuthorizationCode(clientId, redirectUri,
+                userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code, redirectUri);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken = node.at("/access_token").asText();
+        response = searchWithAccessToken(accessToken);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        code = requestAuthorizationCode(clientId, redirectUri, userAuthHeader);
+        testDeregisterPublicClient(clientId, username);
+        response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code, redirectUri);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(OAuth2Error.INVALID_CLIENT.toString(),
+                node.at("/error").asText());
+        response = searchWithAccessToken(accessToken);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is invalid");
+    }
+
+    private void testDeregisterPublicClientMissingUserAuthentication (
+            String clientId) throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId).request()
+                .delete();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    private void testDeregisterPublicClientMissingId ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.METHOD_NOT_ALLOWED.getStatusCode(),
+                response.getStatus());
+    }
+
+    private void testDeregisterPublicClient (String clientId, String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    private void testResetPublicClientSecret (String clientId)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("client_id", clientId);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("reset").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Operation is not allowed for public clients");
+    }
+
+    private String testResetConfidentialClientSecret (String clientId,
+            String clientSecret) throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("reset").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(clientId, node.at("/client_id").asText());
+        String newClientSecret = node.at("/client_secret").asText();
+        assertTrue(!clientSecret.equals(newClientSecret));
+        return newClientSecret;
+    }
+
+    private void requestAuthorizedClientList (String userAuthHeader)
+            throws KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("authorized_only", "true");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("list").request()
+                .header(Attributes.AUTHORIZATION, userAuthHeader)
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.size());
+        assertEquals(confidentialClientId, node.at("/0/client_id").asText());
+        assertEquals(publicClientId, node.at("/1/client_id").asText());
+        assertEquals(node.at("/0/client_name").asText(),
+                "non super confidential client");
+        assertEquals(node.at("/0/client_type").asText(), "CONFIDENTIAL");
+        assertFalse(node.at("/0/client_url").isMissingNode());
+        assertFalse(node.at("/0/client_description").isMissingNode());
+    }
+
+    @Test
+    public void testListPublicClient () throws KustvaktException {
+        String clientName = "OAuth2DoryClient";
+        OAuth2ClientJson json = createOAuth2ClientJson(clientName,
+                OAuth2ClientType.PUBLIC, "Dory's client.");
+        registerClient("dory", json);
+        JsonNode node = listUserRegisteredClients("dory");
+        assertEquals(1, node.size());
+        assertEquals(clientName, node.at("/0/client_name").asText());
+        assertEquals(OAuth2ClientType.PUBLIC.name(),
+                node.at("/0/client_type").asText());
+        assertTrue(node.at("/0/permitted").asBoolean());
+        assertFalse(node.at("/0/registration_date").isMissingNode());
+        assertTrue(node.at("/refresh_token_expiry").isMissingNode());
+        String clientId = node.at("/0/client_id").asText();
+        testDeregisterPublicClient(clientId, "dory");
+    }
+
+    private void testListConfidentialClient (String username, String clientId)
+            throws ProcessingException, KustvaktException {
+        JsonNode node = listUserRegisteredClients(username);
+        assertEquals(1, node.size());
+        assertEquals(clientId, node.at("/0/client_id").asText());
+        assertEquals(node.at("/0/client_name").asText(), "OAuth2ClientTest");
+        assertEquals(OAuth2ClientType.CONFIDENTIAL.name(),
+                node.at("/0/client_type").asText());
+        assertNotNull(node.at("/0/client_description"));
+        assertEquals(clientURL, node.at("/0/client_url").asText());
+        assertEquals(clientRedirectUri,
+                node.at("/0/client_redirect_uri").asText());
+        assertNotNull(node.at("/0/registration_date"));
+        assertEquals(defaultRefreshTokenExpiry,
+                node.at("/0/refresh_token_expiry").asInt());
+        assertTrue(node.at("/0/permitted").asBoolean());
+        assertTrue(node.at("/0/source").isMissingNode());
+    }
+
+    @Test
+    public void testListUserClients () throws KustvaktException {
+        String username = "pearl";
+        String password = "pwd";
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, password);
+        // super client
+        Response response = requestTokenWithPassword(superClientId,
+                clientSecret, username, password);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // client 1
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(publicClientId, "",
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken = node.at("/access_token").asText();
+        // client 2
+        code = requestAuthorizationCode(confidentialClientId, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        String refreshToken = node.at("/refresh_token").asText();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        requestAuthorizedClientList(userAuthHeader);
+        testListAuthorizedClientWithMultipleRefreshTokens(userAuthHeader);
+        testListAuthorizedClientWithMultipleAccessTokens(userAuthHeader);
+        testListWithClientsFromAnotherUser(userAuthHeader);
+        // revoke client 1
+        testRevokeAllTokenViaSuperClient(publicClientId, userAuthHeader,
+                accessToken);
+        // revoke client 2
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        accessToken = node.at("/access_token").asText();
+        refreshToken = node.at("/refresh_token").asText();
+        testRevokeAllTokenViaSuperClient(confidentialClientId, userAuthHeader,
+                accessToken);
+        testRequestTokenWithRevokedRefreshToken(confidentialClientId,
+                clientSecret, refreshToken);
+    }
+
+    private void testListAuthorizedClientWithMultipleRefreshTokens (
+            String userAuthHeader) throws KustvaktException {
+        // client 2
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        requestAuthorizedClientList(userAuthHeader);
+    }
+
+    private void testListAuthorizedClientWithMultipleAccessTokens (
+            String userAuthHeader) throws KustvaktException {
+        // client 1
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, "", code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        requestAuthorizedClientList(userAuthHeader);
+    }
+
+    private void testListWithClientsFromAnotherUser (String userAuthHeader)
+            throws KustvaktException {
+        String aaaAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("aaa", "pwd");
+        // client 1
+        String code = requestAuthorizationCode(publicClientId, aaaAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, "", code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken1 = node.at("/access_token").asText();
+        // client 2
+        code = requestAuthorizationCode(confidentialClientId, aaaAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken2 = node.at("/access_token").asText();
+        String refreshToken = node.at("/refresh_token").asText();
+        requestAuthorizedClientList(aaaAuthHeader);
+        requestAuthorizedClientList(userAuthHeader);
+        testRevokeAllTokenViaSuperClient(publicClientId, aaaAuthHeader,
+                accessToken1);
+        testRevokeAllTokenViaSuperClient(confidentialClientId, aaaAuthHeader,
+                accessToken2);
+        testRequestTokenWithRevokedRefreshToken(confidentialClientId,
+                clientSecret, refreshToken);
+    }
+
+    private void testRevokeAllTokenViaSuperClient (String clientId,
+            String userAuthHeader, String accessToken)
+            throws KustvaktException {
+        // check token before revoking
+        Response response = searchWithAccessToken(accessToken);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertTrue(node.at("/matches").size() > 0);
+        Form form = getSuperClientForm();
+        form.param("client_id", clientId);
+        response = target().path(API_VERSION).path("oauth2").path("revoke")
+                .path("super").path("all").request()
+                .header(Attributes.AUTHORIZATION, userAuthHeader)
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals(response.readEntity(String.class), "SUCCESS");
+        response = searchWithAccessToken(accessToken);
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is invalid");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java
new file mode 100644
index 0000000..ab2eb27
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java
@@ -0,0 +1,730 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.ZonedDateTime;
+import java.util.Set;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import com.nimbusds.oauth2.sdk.GrantType;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.FullConfiguration;
+import de.ids_mannheim.korap.constant.TokenType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
+import de.ids_mannheim.korap.oauth2.entity.AccessScope;
+import de.ids_mannheim.korap.oauth2.entity.RefreshToken;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+/**
+ * @author margaretha
+ */
+public class OAuth2ControllerTest extends OAuth2TestBase {
+
+    @Autowired
+    public FullConfiguration config;
+
+    public String userAuthHeader;
+
+    public OAuth2ControllerTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("dory", "password");
+    }
+
+    @Test
+    public void testRequestTokenAuthorizationPublic ()
+            throws KustvaktException {
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, clientSecret, code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        String accessToken = node.at("/access_token").asText();
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertEquals(31536000, node.at("/expires_in").asInt());
+        testRevokeToken(accessToken, publicClientId, null, ACCESS_TOKEN_TYPE);
+        assertTrue(node.at("/refresh_token").isMissingNode());
+    }
+
+    @Test
+    public void testRequestTokenAuthorizationConfidential ()
+            throws KustvaktException {
+        String scope = "search";
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, "", scope, state, userAuthHeader);
+        MultivaluedMap<String, String> params = getQueryParamsFromURI(
+                response.getLocation());
+        String code = params.get("code").get(0);
+
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        testRequestTokenWithUsedAuthorization(code);
+        String refreshToken = node.at("/refresh_token").asText();
+        testRefreshTokenExpiry(refreshToken);
+        testRequestRefreshTokenInvalidScope(confidentialClientId, refreshToken);
+        testRequestRefreshTokenInvalidClient(refreshToken);
+        testRequestRefreshTokenInvalidRefreshToken(confidentialClientId);
+        testRequestRefreshToken(confidentialClientId, clientSecret,
+                refreshToken);
+    }
+
+    private void testRequestTokenWithUsedAuthorization (String code)
+            throws KustvaktException {
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Invalid authorization");
+    }
+
+    @Test
+    public void testRequestTokenInvalidAuthorizationCode ()
+            throws KustvaktException {
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, "blahblah");
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+    }
+
+    @Test
+    public void testRequestTokenAuthorizationReplyAttack ()
+            throws KustvaktException {
+        String redirect_uri = "https://third.party.com/confidential/redirect";
+        String scope = "search";
+        Response response = requestAuthorizationCode("code",
+                confidentialClientId, redirect_uri, scope, state,
+                userAuthHeader);
+        String code = parseAuthorizationCode(response);
+        testRequestTokenAuthorizationInvalidClient(code);
+        testRequestTokenAuthorizationMissingRedirectUri(code);
+        testRequestTokenAuthorizationInvalidRedirectUri(code);
+        testRequestTokenAuthorizationRevoked(code, redirect_uri);
+    }
+
+    private void testRequestTokenAuthorizationInvalidClient (String code)
+            throws KustvaktException {
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, "wrong_secret", code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_CLIENT, node.at("/error").asText());
+    }
+
+    private void testRequestTokenAuthorizationMissingRedirectUri (String code)
+            throws KustvaktException {
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, "secret", code);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Missing redirect URI");
+    }
+
+    private void testRequestTokenAuthorizationInvalidRedirectUri (String code)
+            throws KustvaktException {
+        Form tokenForm = new Form();
+        tokenForm.param("grant_type", "authorization_code");
+        tokenForm.param("client_id", confidentialClientId);
+        tokenForm.param("client_secret", "secret");
+        tokenForm.param("code", code);
+        tokenForm.param("redirect_uri", "https://blahblah.com");
+        Response response = requestToken(tokenForm);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT, node.at("/error").asText());
+    }
+
+    private void testRequestTokenAuthorizationRevoked (String code, String uri)
+            throws KustvaktException {
+        Form tokenForm = new Form();
+        tokenForm.param("grant_type", "authorization_code");
+        tokenForm.param("client_id", confidentialClientId);
+        tokenForm.param("client_secret", "secret");
+        tokenForm.param("code", code);
+        tokenForm.param("redirect_uri", uri);
+        Response response = requestToken(tokenForm);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Invalid authorization");
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantConfidentialSuper ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(superClientId,
+                clientSecret);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        assertEquals(node.at("/scope").asText(), "all");
+        String refresh = node.at("/refresh_token").asText();
+        RefreshToken refreshToken = refreshTokenDao
+                .retrieveRefreshToken(refresh);
+        Set<AccessScope> scopes = refreshToken.getScopes();
+        assertEquals(1, scopes.size());
+        assertEquals(scopes.toString(), "[all]");
+        testRefreshTokenExpiry(refresh);
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantWithScope ()
+            throws KustvaktException {
+        String scope = "match_info search";
+        Form form = new Form();
+        form.param("grant_type", "password");
+        form.param("client_id", superClientId);
+        form.param("client_secret", clientSecret);
+        form.param("username", "dory");
+        form.param("password", "pwd");
+        form.param("scope", scope);
+        Response response = requestToken(form);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        assertEquals(scope, node.at("/scope").asText());
+        String refreshToken = node.at("/refresh_token").asText();
+        testRequestRefreshTokenWithUnauthorizedScope(superClientId,
+                clientSecret, refreshToken, "all");
+        testRequestRefreshTokenWithScope(superClientId, clientSecret,
+                refreshToken, "search");
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantConfidentialNonSuper ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(confidentialClientId,
+                clientSecret);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.UNAUTHORIZED_CLIENT,
+                node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Password grant is not allowed for third party clients");
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantPublic ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(publicClientId, "");
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.UNAUTHORIZED_CLIENT,
+                node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Password grant is not allowed for third party clients");
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantAuthorizationHeader ()
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "password");
+        form.param("client_id", superClientId);
+        form.param("username", "dory");
+        form.param("password", "password");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.AUTHORIZATION,
+                        "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+    }
+
+    /**
+     * In case, client_id is specified both in Authorization header
+     * and request body, client_id in the request body is ignored.
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void testRequestTokenPasswordGrantDifferentClientIds ()
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "password");
+        form.param("client_id", "9aHsGW6QflV13ixNpez");
+        form.param("username", "dory");
+        form.param("password", "password");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.AUTHORIZATION,
+                        "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantMissingClientSecret ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(confidentialClientId,
+                "");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertNotNull(node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantEmptyClientSecret ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(confidentialClientId,
+                "");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertEquals(node.at("/error_description").asText(),
+                "Missing parameter: client_secret");
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantMissingClientId ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword(null, clientSecret);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertNotNull(node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testRequestTokenPasswordGrantEmptyClientId ()
+            throws KustvaktException {
+        Response response = requestTokenWithDoryPassword("", clientSecret);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertNotNull(node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testRequestTokenClientCredentialsGrant ()
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "client_credentials");
+        form.param("client_id", confidentialClientId);
+        form.param("client_secret", "secret");
+        Response response = requestToken(form);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        // length?
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+    }
+
+    /**
+     * Client credentials grant is only allowed for confidential
+     * clients.
+     */
+    @Test
+    public void testRequestTokenClientCredentialsGrantPublic ()
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "client_credentials");
+        form.param("client_id", publicClientId);
+        form.param("client_secret", "");
+        Response response = requestToken(form);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertNotNull(node.at("/error_description").asText());
+    }
+
+    @Test
+    public void testRequestTokenClientCredentialsGrantReducedScope ()
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "client_credentials");
+        form.param("client_id", confidentialClientId);
+        form.param("client_secret", "secret");
+        form.param("scope", "preferred_username client_info");
+        Response response = requestToken(form);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        // length?
+        assertNotNull(node.at("/access_token").asText());
+        assertNotNull(node.at("/refresh_token").asText());
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        assertEquals(node.at("/scope").asText(), "client_info");
+    }
+
+    @Test
+    public void testRequestTokenMissingGrantType () throws KustvaktException {
+        Form form = new Form();
+        Response response = requestToken(form);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+    }
+
+    @Test
+    public void testRequestTokenUnsupportedGrant () throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "blahblah");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.get("error_description").asText());
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.get("error").asText());
+    }
+
+    private void testRequestRefreshTokenInvalidScope (String clientId,
+            String refreshToken) throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", refreshToken);
+        form.param("scope", "search serialize_query");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_SCOPE, node.at("/error").asText());
+    }
+
+    private void testRequestRefreshToken (String clientId, String clientSecret,
+            String refreshToken) throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", refreshToken);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        String newRefreshToken = node.at("/refresh_token").asText();
+        assertNotNull(newRefreshToken);
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        assertTrue(!newRefreshToken.equals(refreshToken));
+        testRequestTokenWithRevokedRefreshToken(clientId, clientSecret,
+                refreshToken);
+        testRevokeToken(newRefreshToken, clientId, clientSecret,
+                REFRESH_TOKEN_TYPE);
+        testRequestTokenWithRevokedRefreshToken(clientId, clientSecret,
+                newRefreshToken);
+    }
+
+    private void testRequestRefreshTokenWithUnauthorizedScope (String clientId,
+            String clientSecret, String refreshToken, String scope)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", refreshToken);
+        form.param("scope", scope);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_SCOPE, node.at("/error").asText());
+    }
+
+    private void testRequestRefreshTokenWithScope (String clientId,
+            String clientSecret, String refreshToken, String scope)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", refreshToken);
+        form.param("scope", scope);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node.at("/access_token").asText());
+        String newRefreshToken = node.at("/refresh_token").asText();
+        assertNotNull(newRefreshToken);
+        assertEquals(TokenType.BEARER.displayName(),
+                node.at("/token_type").asText());
+        assertNotNull(node.at("/expires_in").asText());
+        assertTrue(!newRefreshToken.equals(refreshToken));
+        assertEquals(scope, node.at("/scope").asText());
+    }
+
+    private void testRequestRefreshTokenInvalidClient (String refreshToken)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", "iBr3LsTCxOj7D2o0A5m");
+        form.param("refresh_token", refreshToken);
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_CLIENT, node.at("/error").asText());
+    }
+
+    private void testRequestRefreshTokenInvalidRefreshToken (String clientId)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", "Lia8s8w8tJeZSBlaQDrYV8ion3l");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT, node.at("/error").asText());
+    }
+
+    private JsonNode requestTokenList (String userAuthHeader, String tokenType,
+            String clientId) throws KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", superClientId);
+        form.param("super_client_secret", clientSecret);
+        form.param("token_type", tokenType);
+        if (clientId != null && !clientId.isEmpty()) {
+            form.param("client_id", clientId);
+        }
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").path("list").request()
+                .header(Attributes.AUTHORIZATION, userAuthHeader)
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    private JsonNode requestTokenList (String userAuthHeader, String tokenType)
+            throws KustvaktException {
+        return requestTokenList(userAuthHeader, tokenType, null);
+    }
+
+    @Test
+    public void testListRefreshTokenConfidentialClient ()
+            throws KustvaktException {
+        String username = "gurgle";
+        String password = "pwd";
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, password);
+        // super client
+        Response response = requestTokenWithPassword(superClientId,
+                clientSecret, username, password);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String refreshToken1 = node.at("/refresh_token").asText();
+        // client 1
+        String code = requestAuthorizationCode(confidentialClientId,
+                userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // client 2
+        code = requestAuthorizationCode(confidentialClientId2,
+                clientRedirectUri, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId2, clientSecret, code, clientRedirectUri);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // list
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(2, node.size());
+        assertEquals(confidentialClientId, node.at("/0/client_id").asText());
+        assertEquals(confidentialClientId2, node.at("/1/client_id").asText());
+        // client 1
+        code = requestAuthorizationCode(confidentialClientId, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // another user
+        String darlaAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("darla", "pwd");
+        // test listing clients
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+        // client 1
+        code = requestAuthorizationCode(confidentialClientId, darlaAuthHeader);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        String refreshToken5 = node.at("/refresh_token").asText();
+        // list all refresh tokens
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(3, node.size());
+        // list refresh tokens from client 1
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE,
+                confidentialClientId);
+        assertEquals(2, node.size());
+        testRevokeToken(refreshToken1, superClientId, clientSecret,
+                REFRESH_TOKEN_TYPE);
+        testRevokeToken(node.at("/0/token").asText(), confidentialClientId,
+                clientSecret, REFRESH_TOKEN_TYPE);
+        testRevokeToken(node.at("/1/token").asText(), confidentialClientId2,
+                clientSecret, REFRESH_TOKEN_TYPE);
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(1, node.size());
+        testRevokeTokenViaSuperClient(node.at("/0/token").asText(),
+                userAuthHeader);
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+        // try revoking a token belonging to another user
+        // should not return any errors
+        testRevokeTokenViaSuperClient(refreshToken5, userAuthHeader);
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(1, node.size());
+        testRevokeTokenViaSuperClient(refreshToken5, darlaAuthHeader);
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+    }
+
+    @Test
+    public void testListTokenPublicClient () throws KustvaktException {
+        String username = "nemo";
+        String password = "pwd";
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, password);
+        // access token 1
+        String code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(
+                publicClientId, "", code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken1 = node.at("/access_token").asText();
+        // access token 2
+        code = requestAuthorizationCode(publicClientId, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(publicClientId, "",
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken2 = node.at("/access_token").asText();
+        // list access tokens
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        assertEquals(2, node.size());
+        // list refresh tokens
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+        testRevokeTokenViaSuperClient(accessToken1, userAuthHeader);
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        // System.out.println(node);
+        assertEquals(1, node.size());
+        assertEquals(accessToken2, node.at("/0/token").asText());
+        assertTrue(node.at("/0/scope").size() > 0);
+        assertNotNull(node.at("/0/created_date").asText());
+        assertNotNull(node.at("/0/expires_in").asLong());
+        assertNotNull(node.at("/0/user_authentication_time").asText());
+        assertEquals(publicClientId, node.at("/0/client_id").asText());
+        assertNotNull(node.at("/0/client_name").asText());
+        assertNotNull(node.at("/0/client_description").asText());
+        assertNotNull(node.at("/0/client_url").asText());
+        testRevokeTokenViaSuperClient(accessToken2, userAuthHeader);
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        assertEquals(0, node.size());
+    }
+
+    private void testRefreshTokenExpiry (String refreshToken)
+            throws KustvaktException {
+        RefreshToken token = refreshTokenDao.retrieveRefreshToken(refreshToken);
+        ZonedDateTime expiry = token.getCreatedDate()
+                .plusSeconds(config.getRefreshTokenLongExpiry());
+        assertTrue(expiry.equals(token.getExpiryDate()));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java
new file mode 100644
index 0000000..90597e2
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java
@@ -0,0 +1,527 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.entity.InstalledPlugin;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
+import de.ids_mannheim.korap.oauth2.dao.InstalledPluginDao;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.TimeUtils;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+
+public class OAuth2PluginTest extends OAuth2TestBase {
+
+    private String username = "plugin-user";
+
+    @Autowired
+    private InstalledPluginDao pluginDao;
+
+    @Test
+    public void testRegisterPlugin ()
+            throws ProcessingException, KustvaktException {
+        JsonNode source = JsonUtils.readTree("{ \"plugin\" : \"source\"}");
+        int refreshTokenExpiry = TimeUtils.convertTimeToSeconds("90D");
+        String clientName = "Plugin";
+        OAuth2ClientJson json = new OAuth2ClientJson();
+        json.setName(clientName);
+        json.setType(OAuth2ClientType.CONFIDENTIAL);
+        json.setDescription("This is a plugin test client.");
+        json.setSource(source);
+        json.setRefreshTokenExpiry(refreshTokenExpiry);
+        Response response = registerClient(username, json);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        String clientSecret = node.at("/client_secret").asText();
+        assertNotNull(clientId);
+        assertNotNull(clientSecret);
+        testInstallPluginNotPermitted(clientId);
+        testRetrievePluginInfo(clientId, refreshTokenExpiry);
+        node = listPlugins(false);
+        assertEquals(3, node.size());
+        // permitted only
+        node = listPlugins(true);
+        assertEquals(2, node.size());
+        testListUserRegisteredPlugins(username, clientId, clientName,
+                refreshTokenExpiry);
+        deregisterClient(username, clientId);
+    }
+
+    @Test
+    public void testRegisterPublicPlugin () throws KustvaktException {
+        JsonNode source = JsonUtils.readTree("{ \"plugin\" : \"source\"}");
+        String clientName = "Public Plugin";
+        OAuth2ClientJson json = new OAuth2ClientJson();
+        json.setName(clientName);
+        json.setType(OAuth2ClientType.PUBLIC);
+        json.setDescription("This is a public plugin.");
+        json.setSource(source);
+        Response response = registerClient(username, json);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertFalse(node.at("/error_description").isMissingNode());
+        // assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // String clientId = node.at("/client_id").asText();
+        // assertTrue(node.at("/client_secret").isMissingNode());
+        // 
+        // deregisterClient(username, clientId);
+    }
+
+    private void testRetrievePluginInfo (String clientId,
+            int refreshTokenExpiry)
+            throws ProcessingException, KustvaktException {
+        JsonNode clientInfo = retrieveClientInfo(clientId, username);
+        assertEquals(clientId, clientInfo.at("/client_id").asText());
+        assertEquals(clientInfo.at("/client_name").asText(), "Plugin");
+        assertEquals(OAuth2ClientType.CONFIDENTIAL.name(),
+                clientInfo.at("/client_type").asText());
+        assertNotNull(clientInfo.at("/client_description").asText());
+        assertNotNull(clientInfo.at("/source").asText());
+        assertFalse(clientInfo.at("/permitted").asBoolean());
+        assertEquals(username, clientInfo.at("/registered_by").asText());
+        assertNotNull(clientInfo.at("/registration_date"));
+        assertEquals(refreshTokenExpiry,
+                clientInfo.at("/refresh_token_expiry").asInt());
+    }
+
+    private void testListUserRegisteredPlugins (String username,
+            String clientId, String clientName, int refreshTokenExpiry)
+            throws ProcessingException, KustvaktException {
+        JsonNode node = listUserRegisteredClients(username);
+        assertEquals(1, node.size());
+        assertEquals(clientId, node.at("/0/client_id").asText());
+        assertEquals(clientName, node.at("/0/client_name").asText());
+        assertEquals(OAuth2ClientType.CONFIDENTIAL.name(),
+                node.at("/0/client_type").asText());
+        assertFalse(node.at("/0/permitted").asBoolean());
+        assertFalse(node.at("/0/registration_date").isMissingNode());
+        assertFalse(node.at("/0/source").isMissingNode());
+        assertEquals(refreshTokenExpiry,
+                node.at("/0/refresh_token_expiry").asInt());
+    }
+
+    @Test
+    public void testListPluginsUnauthorizedPublic ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", publicClientId);
+        testListPluginsClientUnauthorized(form);
+    }
+
+    @Test
+    public void testListPluginsUnauthorizedConfidential ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", confidentialClientId2);
+        form.param("super_client_secret", clientSecret);
+        testListPluginsClientUnauthorized(form);
+    }
+
+    @Test
+    public void testListPluginsMissingClientSecret ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", confidentialClientId);
+        Response response = target().path(API_VERSION).path("plugins").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(OAuth2Error.INVALID_REQUEST, node.at("/error").asText());
+        assertFalse(node.at("/error_description").isMissingNode());
+    }
+
+    private void testListPluginsClientUnauthorized (Form form)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("plugins").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(OAuth2Error.UNAUTHORIZED_CLIENT,
+                node.at("/error").asText());
+        assertFalse(node.at("/error_description").isMissingNode());
+    }
+
+    @Test
+    public void testListPluginsUserUnauthorized ()
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        Response response = target().path(API_VERSION).path("plugins").request()
+                .header(Attributes.AUTHORIZATION, "Bearer blahblah")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testListPluginsConcurrent () throws InterruptedException {
+        ExecutorService executorService = Executors.newFixedThreadPool(3);
+        List<Future<Void>> futures = new ArrayList<>();
+        for (int i = 0; i < 3; i++) {
+            futures.add(executorService
+                    .submit(new PluginListCallable("Thread " + (i + 1))));
+        }
+        executorService.shutdown();
+        executorService.awaitTermination(2, TimeUnit.SECONDS);
+        for (Future<Void> future : futures) {
+            try {
+                // This will re-throw any exceptions
+                future.get();
+                // that occurred in threads
+            }
+            catch (ExecutionException e) {
+                fail("Test failed: " + e.getCause().getMessage());
+            }
+        }
+    }
+
+    class PluginListCallable implements Callable<Void> {
+
+        private final String name;
+
+        public PluginListCallable (String name) {
+            this.name = name;
+        }
+
+        @Override
+        public Void call () {
+            Form form = getSuperClientForm();
+            try {
+                Response response = target().path(API_VERSION).path("plugins")
+                        .request()
+                        .header(Attributes.AUTHORIZATION,
+                                HttpAuthorizationHandler
+                                        .createBasicAuthorizationHeaderValue(
+                                                username, "pass"))
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .post(Entity.form(form));
+                assertEquals(Status.OK.getStatusCode(), response.getStatus());
+                String entity = response.readEntity(String.class);
+                JsonNode node = JsonUtils.readTree(entity);
+                assertEquals(2, node.size());
+            }
+            catch (KustvaktException e) {
+                e.printStackTrace();
+                throw new RuntimeException(name, e);
+            }
+            return null;
+        }
+    }
+
+    @Test
+    public void testListAllPlugins ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = listPlugins(false);
+        assertEquals(2, node.size());
+        assertFalse(node.at("/0/client_id").isMissingNode());
+        assertFalse(node.at("/0/client_name").isMissingNode());
+        assertFalse(node.at("/0/client_description").isMissingNode());
+        assertFalse(node.at("/0/client_type").isMissingNode());
+        assertFalse(node.at("/0/permitted").isMissingNode());
+        assertFalse(node.at("/0/registration_date").isMissingNode());
+        assertFalse(node.at("/0/source").isMissingNode());
+        assertFalse(node.at("/0/refresh_token_expiry").isMissingNode());
+        // assertTrue(node.at("/1/refresh_token_expiry").isMissingNode());
+    }
+
+    private JsonNode listPlugins (boolean permitted_only)
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        if (permitted_only) {
+            form.param("permitted_only", Boolean.toString(permitted_only));
+        }
+        Response response = target().path(API_VERSION).path("plugins").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    private void testInstallConfidentialPlugin (String superClientId,
+            String clientId, String username)
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("client_id", clientId);
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(clientId, node.at("/client_id").asText());
+        assertEquals(superClientId, node.at("/super_client_id").asText());
+        assertFalse(node.at("/name").isMissingNode());
+        assertFalse(node.at("/description").isMissingNode());
+        assertFalse(node.at("/url").isMissingNode());
+        assertFalse(node.at("/installed_date").isMissingNode());
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testRetrieveInstalledPlugin(superClientId, clientId, username);
+    }
+
+    @Test
+    public void testInstallPublicPlugin ()
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("client_id", publicClientId2);
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(publicClientId2, node.at("/client_id").asText());
+        assertEquals(superClientId, node.at("/super_client_id").asText());
+        assertFalse(node.at("/name").isMissingNode());
+        assertFalse(node.at("/description").isMissingNode());
+        assertFalse(node.at("/url").isMissingNode());
+        assertFalse(node.at("/installed_date").isMissingNode());
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testInstallPluginRedundant(form);
+        testRetrieveInstalledPlugin(superClientId, publicClientId2, username);
+        response = uninstallPlugin(publicClientId2, username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertTrue(node.isEmpty());
+    }
+
+    private void testInstallPluginRedundant (Form form)
+            throws ProcessingException, KustvaktException {
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PLUGIN_HAS_BEEN_INSTALLED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    private void testInstallPluginNotPermitted (String clientId)
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("client_id", clientId);
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PLUGIN_NOT_PERMITTED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testInstallPluginMissingClientId ()
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testInstallPluginInvalidClientId ()
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("client_id", "unknown");
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "Unknown client: unknown");
+        assertEquals(node.at("/error").asText(), "invalid_client");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testInstallPluginMissingSuperClientSecret ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", superClientId);
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "Missing parameter: super_client_secret");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testInstallPluginMissingSuperClientId ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error_description").asText(),
+                "Missing parameter: super_client_id");
+        assertEquals(node.at("/error").asText(), "invalid_request");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testInstallPluginUnauthorizedClient ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", confidentialClientId);
+        form.param("super_client_secret", clientSecret);
+        Response response = installPlugin(form);
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/error").asText(), "unauthorized_client");
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+    }
+
+    private Response installPlugin (Form form)
+            throws ProcessingException, KustvaktException {
+        return target().path(API_VERSION).path("plugins").path("install")
+                .request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    private Response uninstallPlugin (String clientId, String username)
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        form.param("client_id", clientId);
+        return target().path(API_VERSION).path("plugins").path("uninstall")
+                .request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    private void testRetrieveInstalledPlugin (String superClientId,
+            String clientId, String installedBy) throws KustvaktException {
+        InstalledPlugin plugin = pluginDao
+                .retrieveInstalledPlugin(superClientId, clientId, installedBy);
+        assertEquals(clientId, plugin.getClient().getId());
+        assertEquals(superClientId, plugin.getSuperClient().getId());
+        assertEquals(installedBy, plugin.getInstalledBy());
+        assertTrue(plugin.getId() > 0);
+        assertTrue(plugin.getInstalledDate() != null);
+    }
+
+    @Test
+    public void testListUserInstalledPlugins ()
+            throws ProcessingException, KustvaktException, IOException {
+        testInstallConfidentialPlugin(superClientId, confidentialClientId,
+                username);
+        JsonNode node = testRequestAccessToken(confidentialClientId);
+        String accessToken = node.at("/access_token").asText();
+        String refreshToken = node.at("/refresh_token").asText();
+        testSearchWithOAuth2Token(accessToken);
+        testInstallConfidentialPlugin(superClientId, confidentialClientId2,
+                username);
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertEquals(2, node.size());
+        Response response = uninstallPlugin(confidentialClientId, username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertEquals(1, node.size());
+        testRequestTokenWithRevokedRefreshToken(confidentialClientId,
+                clientSecret, refreshToken);
+        testSearchWithRevokedAccessToken(accessToken);
+        response = uninstallPlugin(confidentialClientId2, username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertEquals(0, node.size());
+        testReinstallUninstalledPlugin();
+        testUninstallNotInstalledPlugin();
+    }
+
+    private void testReinstallUninstalledPlugin ()
+            throws ProcessingException, KustvaktException {
+        testInstallConfidentialPlugin(superClientId, confidentialClientId2,
+                username);
+        JsonNode node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertEquals(1, node.size());
+        Response response = uninstallPlugin(confidentialClientId2, username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertEquals(0, node.size());
+    }
+
+    private JsonNode testRequestAccessToken (String clientId)
+            throws KustvaktException {
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, "password");
+        String code = requestAuthorizationCode(clientId, userAuthHeader);
+        Response response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        return node;
+    }
+
+    private void testUninstallNotInstalledPlugin ()
+            throws ProcessingException, KustvaktException {
+        Response response = uninstallPlugin(confidentialClientId2, username);
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+
+    private JsonNode retrieveUserInstalledPlugin (Form form)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("plugins")
+                .path("installed").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2RClientTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2RClientTest.java
new file mode 100644
index 0000000..d0eab61
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2RClientTest.java
@@ -0,0 +1,83 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.IOException;
+import java.net.URI;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+
+public class OAuth2RClientTest extends OAuth2TestBase {
+
+    private String username = "OAuth2ClientControllerTest";
+
+    private String userAuthHeader;
+
+    public OAuth2RClientTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue("R-user", "password");
+    }
+
+    public OAuth2ClientJson createOAuth2RClient () {
+        OAuth2ClientJson client = new OAuth2ClientJson();
+        client.setName("R client");
+        client.setType(OAuth2ClientType.PUBLIC);
+        client.setDescription("An R client with httr web server.");
+        client.setRedirectURI("http://localhost:1410");
+        return client;
+    }
+
+    @Test
+    public void testRClientWithLocalhost ()
+            throws ProcessingException, KustvaktException, IOException {
+        // Register client
+        OAuth2ClientJson clientJson = createOAuth2RClient();
+        Response response = registerClient(username, clientJson);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        // send authorization
+        String code = testAuthorize(clientId);
+        // send token request
+        response = requestTokenWithAuthorizationCodeAndForm(clientId, null,
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        // testing
+        String accessToken = node.at("/access_token").asText();
+        testSearchWithOAuth2Token(accessToken);
+        // cleaning up
+        deregisterClient(username, clientId);
+        testSearchWithRevokedAccessToken(accessToken);
+    }
+
+    private String testAuthorize (String clientId) throws KustvaktException {
+        Response response = requestAuthorizationCode("code", clientId, "",
+                "search", "", userAuthHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+        assertEquals(redirectUri.getScheme(), "http");
+        assertEquals(redirectUri.getHost(), "localhost");
+        assertEquals(1410, redirectUri.getPort());
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        String code = params.getFirst("code");
+        assertNotNull(code);
+        return code;
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
new file mode 100644
index 0000000..b27fd0f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
@@ -0,0 +1,487 @@
+package de.ids_mannheim.korap.web.controller;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.http.entity.ContentType;
+import org.glassfish.jersey.client.ClientConfig;
+import org.glassfish.jersey.client.ClientProperties;
+import org.glassfish.jersey.uri.UriComponent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import com.nimbusds.oauth2.sdk.GrantType;
+import com.nimbusds.oauth2.sdk.OAuth2Error;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.constant.OAuth2Scope;
+import de.ids_mannheim.korap.encryption.RandomCodeGenerator;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.oauth2.dao.AccessTokenDao;
+import de.ids_mannheim.korap.oauth2.dao.OAuth2ClientDao;
+import de.ids_mannheim.korap.oauth2.dao.RefreshTokenDao;
+import de.ids_mannheim.korap.oauth2.entity.AccessScope;
+import de.ids_mannheim.korap.oauth2.entity.AccessToken;
+import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.TimeUtils;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.Invocation.Builder;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Provides common methods and variables for OAuth2 tests,
+ * and does not run any test.
+ * 
+ * @author margaretha
+ *
+ */
+public abstract class OAuth2TestBase extends SpringJerseyTest {
+
+    @Autowired
+    private AccessTokenDao tokenDao;
+    @Autowired
+    private OAuth2ClientDao clientDao;
+    @Autowired
+    private RandomCodeGenerator codeGenerator;
+    @Autowired
+    protected RefreshTokenDao refreshTokenDao;
+
+    protected String publicClientId = "8bIDtZnH6NvRkW2Fq";
+    // without registered redirect URI
+    protected String publicClientId2 = "nW5qM63Rb2a7KdT9L";
+    protected String confidentialClientId = "9aHsGW6QflV13ixNpez";
+    protected String confidentialClientId2 = "52atrL0ajex_3_5imd9Mgw";
+    protected String superClientId = "fCBbQkAyYzI4NzUxMg";
+    protected String clientSecret = "secret";
+    protected String state = "thisIsMyState";
+
+    public static String ACCESS_TOKEN_TYPE = "access_token";
+    public static String REFRESH_TOKEN_TYPE = "refresh_token";
+
+    protected int defaultRefreshTokenExpiry = TimeUtils
+            .convertTimeToSeconds("365D");
+
+    protected String clientURL = "http://example.client.com";
+    protected String clientRedirectUri = "https://example.client.com/redirect";
+
+    protected MultivaluedMap<String, String> getQueryParamsFromURI (URI uri) {
+        return UriComponent.decodeQuery(uri, true);
+    };
+
+    protected Form getSuperClientForm () {
+        Form form = new Form();
+        form.param("super_client_id", superClientId);
+        form.param("super_client_secret", clientSecret);
+        return form;
+    }
+
+    protected String parseAuthorizationCode (Response response) {
+
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+
+        URI redirectUri = response.getLocation();
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        return params.getFirst("code");
+    }
+
+    protected Response requestAuthorizationCode (String responseType,
+            String clientId, String redirectUri, String scope, String state,
+            String authHeader) throws KustvaktException {
+
+        ClientConfig clientConfig = new ClientConfig();
+        clientConfig.property(ClientProperties.FOLLOW_REDIRECTS, false);
+        Client client = ClientBuilder.newClient(clientConfig);
+
+        WebTarget request = client.target(getBaseUri()).path(API_VERSION)
+                .path("oauth2").path("authorize");
+
+        if (!responseType.isEmpty()) {
+            request = request.queryParam("response_type", responseType);
+        }
+        if (!clientId.isEmpty()) {
+            request = request.queryParam("client_id", clientId);
+        }
+        if (!redirectUri.isEmpty()) {
+            request = request.queryParam("redirect_uri", redirectUri);
+        }
+        if (!scope.isEmpty()) {
+            request = request.queryParam("scope", scope);
+        }
+        if (!state.isEmpty()) {
+            request = request.queryParam("state", state);
+        }
+
+        Builder builder = request.request().header(Attributes.AUTHORIZATION,
+                authHeader);
+
+        return builder.get();
+    }
+
+    protected String requestAuthorizationCode (String clientId,
+            String authHeader) throws KustvaktException {
+
+        Response response = requestAuthorizationCode("code", clientId, "",
+                "search match_info", "", authHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        return params.getFirst("code");
+    }
+
+    protected String requestAuthorizationCode (String clientId,
+            String redirect_uri, String authHeader) throws KustvaktException {
+        Response response = requestAuthorizationCode("code", clientId,
+                redirect_uri, "search", "", authHeader);
+        assertEquals(Status.TEMPORARY_REDIRECT.getStatusCode(),
+                response.getStatus());
+        URI redirectUri = response.getLocation();
+
+        MultiValueMap<String, String> params = UriComponentsBuilder
+                .fromUri(redirectUri).build().getQueryParams();
+        return params.getFirst("code");
+    }
+
+    protected Response requestToken (Form form) throws KustvaktException {
+        return target().path(API_VERSION).path("oauth2").path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    // client credentials as form params
+    protected Response requestTokenWithAuthorizationCodeAndForm (
+            String clientId, String clientSecret, String code)
+            throws KustvaktException {
+
+        Form form = new Form();
+        form.param("grant_type", "authorization_code");
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("code", code);
+
+        return target().path(API_VERSION).path("oauth2").path("token").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    protected Response requestTokenWithAuthorizationCodeAndForm (
+            String clientId, String clientSecret, String code,
+            String redirectUri) throws KustvaktException {
+
+        Form form = new Form();
+        form.param("grant_type", "authorization_code");
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("code", code);
+        if (redirectUri != null) {
+            form.param("redirect_uri", redirectUri);
+        }
+
+        return target().path(API_VERSION).path("oauth2").path("token").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+    }
+
+    // client credentials in authorization header
+    protected JsonNode requestTokenWithAuthorizationCodeAndHeader (
+            String clientId, String code, String authHeader)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "authorization_code");
+        form.param("client_id", clientId);
+        form.param("code", code);
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected Response requestTokenWithDoryPassword (String clientId,
+            String clientSecret) throws KustvaktException {
+        return requestTokenWithPassword(clientId, clientSecret, "dory",
+                "password");
+    }
+
+    protected Response requestTokenWithPassword (String clientId,
+            String clientSecret, String username, String password)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", "password");
+        form.param("client_id", clientId);
+        if (clientSecret != null && !clientSecret.isEmpty()) {
+            form.param("client_secret", clientSecret);
+        }
+        form.param("username", username);
+        form.param("password", password);
+
+        return requestToken(form);
+    }
+
+    protected void testRequestTokenWithRevokedRefreshToken (String clientId,
+            String clientSecret, String refreshToken) throws KustvaktException {
+        Form form = new Form();
+        form.param("grant_type", GrantType.REFRESH_TOKEN.toString());
+        form.param("client_id", clientId);
+        form.param("client_secret", clientSecret);
+        form.param("refresh_token", refreshToken);
+        if (clientSecret != null) {
+            form.param("client_secret", clientSecret);
+        }
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_GRANT.getCode(),
+                node.at("/error").asText());
+        assertEquals("Refresh token has been revoked",
+                node.at("/error_description").asText());
+    }
+
+    protected Response registerClient (String username, OAuth2ClientJson json)
+            throws ProcessingException, KustvaktException {
+        return target().path(API_VERSION).path("oauth2").path("client")
+                .path("register").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(username,
+                                        "password"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .post(Entity.json(json));
+    }
+
+    protected Response registerConfidentialClient (String username)
+            throws KustvaktException {
+
+        OAuth2ClientJson json = new OAuth2ClientJson();
+        json.setName("OAuth2ClientTest");
+        json.setType(OAuth2ClientType.CONFIDENTIAL);
+        json.setUrl(clientURL);
+        json.setRedirectURI(clientRedirectUri);
+        json.setDescription("This is a confidential test client.");
+
+        return registerClient(username, json);
+    }
+
+    protected void testConfidentialClientInfo (String clientId, String username)
+            throws ProcessingException, KustvaktException {
+        JsonNode clientInfo = retrieveClientInfo(clientId, username);
+        assertEquals(clientId, clientInfo.at("/client_id").asText());
+        assertEquals("OAuth2ClientTest",
+                clientInfo.at("/client_name").asText());
+        assertEquals(OAuth2ClientType.CONFIDENTIAL.name(),
+                clientInfo.at("/client_type").asText());
+        assertEquals(username, clientInfo.at("/registered_by").asText());
+        assertEquals(clientURL, clientInfo.at("/client_url").asText());
+        assertEquals(clientRedirectUri,
+                clientInfo.at("/client_redirect_uri").asText());
+        // 31536000 seconds
+        assertEquals(defaultRefreshTokenExpiry,
+                clientInfo.at("/refresh_token_expiry").asInt());
+        assertNotNull(clientInfo.at("/description"));
+        assertNotNull(clientInfo.at("/registration_date"));
+        assertTrue(clientInfo.at("/permitted").asBoolean());
+        assertTrue(clientInfo.at("/source").isMissingNode());
+
+    }
+
+    protected void deregisterClient (String username, String clientId)
+            throws ProcessingException, KustvaktException {
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    protected JsonNode retrieveClientInfo (String clientId, String username)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("super_client_id", superClientId);
+        form.param("super_client_secret", clientSecret);
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path(clientId).request()
+                //                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                //                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected Response searchWithAccessToken (String accessToken) {
+        return target().path(API_VERSION).path("search")
+                .queryParam("q", "Wasser").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+    }
+
+    protected void testSearchWithOAuth2Token (String accessToken)
+            throws KustvaktException, IOException {
+        Response response = searchWithAccessToken(accessToken);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertEquals(25, node.at("/matches").size());
+    }
+
+    protected void testSearchWithRevokedAccessToken (String accessToken)
+            throws KustvaktException {
+        Response response = searchWithAccessToken(accessToken);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Access token is invalid",
+                node.at("/errors/0/1").asText());
+    }
+
+    protected void testRevokeTokenViaSuperClient (String token,
+            String userAuthHeader) {
+        Form form = new Form();
+        form.param("token", token);
+        form.param("super_client_id", superClientId);
+        form.param("super_client_secret", clientSecret);
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("revoke").path("super").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .header(Attributes.AUTHORIZATION, userAuthHeader)
+                .post(Entity.form(form));
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals("SUCCESS", response.readEntity(String.class));
+    }
+
+    protected void testRevokeToken (String token, String clientId,
+            String clientSecret, String tokenType) {
+        Form form = new Form();
+        form.param("token_type", tokenType);
+        form.param("token", token);
+        form.param("client_id", clientId);
+        if (clientSecret != null) {
+            form.param("client_secret", clientSecret);
+        }
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("revoke").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals("SUCCESS", response.readEntity(String.class));
+    }
+
+    protected JsonNode listUserRegisteredClients (String username)
+            throws ProcessingException, KustvaktException {
+        Form form = getSuperClientForm();
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("client").path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pwd"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected void testInvalidRedirectUri (String entity, String contentType,
+            boolean includeState, int status) throws KustvaktException {
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(OAuth2Error.INVALID_REQUEST.getCode(),
+                node.at("/error").asText());
+        assertEquals("Invalid redirect URI",
+                node.at("/error_description").asText());
+        if (includeState) {
+            assertEquals(state, node.at("/state").asText());
+        }
+
+        assertEquals("application/json;charset=utf-8", contentType);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), status);
+    }
+
+    protected String createExpiredAccessToken () throws KustvaktException {
+        String authToken = codeGenerator.createRandomCode();
+
+        // create new access token
+        OAuth2Client client = clientDao.retrieveClientById(publicClientId);
+
+        ZonedDateTime now = ZonedDateTime
+                .now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE));
+        Set<AccessScope> scopes = new HashSet<>();
+        scopes.add(new AccessScope(OAuth2Scope.EDIT_VC));
+
+        AccessToken accessToken = new AccessToken();
+        accessToken.setCreatedDate(now.minusSeconds(5));
+        accessToken.setExpiryDate(now.minusSeconds(3));
+        accessToken.setToken(authToken);
+        accessToken.setScopes(scopes);
+        accessToken.setUserId("marlin");
+        accessToken.setClient(client);
+        accessToken.setUserAuthenticationTime(now.minusSeconds(5));
+        tokenDao.storeAccessToken(accessToken);
+        return authToken;
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceControllerTest.java
new file mode 100644
index 0000000..57dc847
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceControllerTest.java
@@ -0,0 +1,334 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.client.Entity;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.user.User.CorpusAccess;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class QueryReferenceControllerTest extends SpringJerseyTest {
+
+    private String testUser = "qRefControllerTest";
+
+    private String adminUser = "admin";
+
+    private String system = "system";
+
+    private void testRetrieveQueryByName (String qName, String query,
+            String queryCreator, String username, ResourceType resourceType,
+            CorpusAccess access) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + queryCreator).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(qName, node.at("/name").asText());
+        assertEquals(resourceType.displayName(), node.at("/type").asText());
+        assertEquals(queryCreator, node.at("/createdBy").asText());
+        assertEquals(query, node.at("/query").asText());
+        assertEquals(node.at("/queryLanguage").asText(), "poliqarp");
+        assertEquals(access.name(), node.at("/requiredAccess").asText());
+    }
+
+    private void testUpdateQuery (String qName, String qCreator,
+            String username, ResourceType type)
+            throws ProcessingException, KustvaktException {
+        String json = "{\"query\": \"Sonne\""
+                + ",\"queryLanguage\": \"poliqarp\"}";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + qCreator).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "Sonne", qCreator, username, type,
+                CorpusAccess.PUB);
+    }
+
+    @Test
+    public void testCreatePrivateQuery () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\"" + ",\"query\": \"der\"}";
+        String qName = "new_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "der", testUser, testUser,
+                ResourceType.PRIVATE, CorpusAccess.PUB);
+        testUpdateQuery(qName, testUser, testUser, ResourceType.PRIVATE);
+        testDeleteQueryByName(qName, testUser, testUser);
+    }
+
+    @Test
+    public void testCreatePublishQuery () throws KustvaktException {
+        String json = "{\"type\": \"PUBLISHED\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\"" + ",\"query\": \"Regen\"}";
+        String qName = "publish_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "Regen", testUser, testUser,
+                ResourceType.PUBLISHED, CorpusAccess.PUB);
+        testDeleteQueryByName(qName, testUser, testUser);
+        // check if hidden group has been created
+    }
+
+    @Test
+    public void testCreateUserQueryByAdmin () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\""
+                + ",\"query\": \"Sommer\"}";
+        String qName = "marlin-query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~marlin").path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(adminUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "Sommer", "marlin", adminUser,
+                ResourceType.PRIVATE, CorpusAccess.PUB);
+        testUpdateQuery(qName, "marlin", adminUser, ResourceType.PRIVATE);
+        testDeleteQueryByName(qName, "marlin", adminUser);
+    }
+
+    @Test
+    public void testCreateSystemQuery () throws KustvaktException {
+        String json = "{\"type\": \"SYSTEM\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\""
+                + ",\"query\": \"Sommer\"}";
+        String qName = "system-query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~system").path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(adminUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "Sommer", system, adminUser,
+                ResourceType.SYSTEM, CorpusAccess.PUB);
+        testUpdateQuery(qName, system, adminUser, ResourceType.SYSTEM);
+        testDeleteSystemQueryUnauthorized(qName);
+        testDeleteQueryByName(qName, system, adminUser);
+    }
+
+    @Test
+    public void testCreateSystemQueryUnauthorized () throws KustvaktException {
+        String json = "{\"type\": \"SYSTEM\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\""
+                + ",\"query\": \"Sommer\"}";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path("system-query").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: " + testUser,
+                node.at("/errors/0/1").asText());
+    }
+
+    @Test
+    public void testCreateQueryMissingQueryType () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryLanguage\": \"poliqarp\"" + ",\"query\": \"Sohn\"}";
+        String qName = "new_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveQueryByName(qName, "Sohn", testUser, testUser,
+                ResourceType.PRIVATE, CorpusAccess.PUB);
+        testDeleteQueryByName(qName, testUser, testUser);
+    }
+
+    @Test
+    public void testCreateQueryMissingQueryLanguage ()
+            throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\"" + ",\"queryType\": \"QUERY\""
+                + ",\"query\": \"Sohn\"}";
+        String qName = "new_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "queryLanguage is null");
+        assertEquals(node.at("/errors/0/2").asText(), "queryLanguage");
+    }
+
+    @Test
+    public void testCreateQueryMissingQuery () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\"" + ",\"queryType\": \"QUERY\""
+                + ",\"queryLanguage\": \"poliqarp\"}";
+        String qName = "new_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "query is null");
+        assertEquals(node.at("/errors/0/2").asText(), "query");
+    }
+
+    @Test
+    public void testCreateQueryMissingResourceType () throws KustvaktException {
+        String json = "{\"query\": \"Wind\""
+                + ",\"queryLanguage\": \"poliqarp\"}";
+        String qName = "new_query";
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + testUser).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "type is null");
+        assertEquals(node.at("/errors/0/2").asText(), "type");
+    }
+
+    private void testDeleteQueryByName (String qName, String qCreator,
+            String username) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .path("~" + qCreator).path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testDeleteQueryUnauthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .path("~dory").path("dory-q").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .delete();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: " + testUser,
+                node.at("/errors/0/1").asText());
+    }
+
+    private void testDeleteSystemQueryUnauthorized (String qName)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .path("~system").path(qName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .delete();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: " + testUser,
+                node.at("/errors/0/1").asText());
+    }
+
+    @Test
+    public void testDeleteNonExistingQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .path("~dory").path("non-existing-query").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .delete();
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Query dory/non-existing-query is not found.");
+        assertEquals(node.at("/errors/0/2").asText(),
+                "dory/non-existing-query");
+    }
+
+    @Test
+    public void testListAvailableQueryForDory ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testListAvailableQuery("dory");
+        assertEquals(2, node.size());
+    }
+
+    @Test
+    public void testListAvailableQueryForPearl ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testListAvailableQuery("pearl");
+        assertEquals(1, node.size());
+        assertEquals(node.at("/0/name").asText(), "system-q");
+        assertEquals(ResourceType.SYSTEM.displayName(),
+                node.at("/0/type").asText());
+        assertEquals(node.at("/0/description").asText(), "\"system\" query");
+        assertEquals(node.at("/0/query").asText(), "[]");
+        assertEquals(CorpusAccess.FREE.name(),
+                node.at("/0/requiredAccess").asText());
+        // assertEquals("koral:token", node.at("/0/koralQuery/@type").asText());
+    }
+
+    private JsonNode testListAvailableQuery (String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("query").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceSearchTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceSearchTest.java
new file mode 100644
index 0000000..27f3567
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/QueryReferenceSearchTest.java
@@ -0,0 +1,37 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/*@Test
+    public void testSearchWithVCRefEqual () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"dory/dory-q\"")
+                .get();
+
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches").size() > 0);
+    }
+*/
+public class QueryReferenceSearchTest {
+    /*@Test
+    public void testSearchWithVCRefEqual () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"dory/dory-q\"")
+                .get();
+    
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches").size() > 0);
+    }
+    */
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/QuerySerializationControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/QuerySerializationControllerTest.java
new file mode 100644
index 0000000..5c70f57
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/QuerySerializationControllerTest.java
@@ -0,0 +1,228 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Iterator;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+// EM: The API is disabled
+@Disabled
+public class QuerySerializationControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testQuerySerializationFilteredPublic ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/WPD13/query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").queryParam("context", "base/s:s")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/key").asText(), "corpusSigle");
+        assertEquals(node.at("/collection/value").asText(), "WPD13");
+    }
+
+    @Test
+    public void testQuerySerializationUnexistingResource ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/ZUW19/query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").queryParam("context", "base/s:s")
+                .request().method("GET");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(101, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[Cannot found public Corpus with ids: [ZUW19]]");
+    }
+
+    @Test
+    public void testQuerySerializationWithNonPublicCorpus ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/BRZ10/query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").queryParam("context", "base/s:s")
+                .request().method("GET");
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(101, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[Cannot found public Corpus with ids: [BRZ10]]");
+    }
+
+    @Test
+    public void testQuerySerializationWithAuthentication ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/BRZ10/query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "corpusSigle");
+        assertEquals(node.at("/collection/value").asText(), "BRZ10");
+    }
+
+    @Test
+    public void testQuerySerializationWithNewCollection ()
+            throws KustvaktException {
+        // Add Virtual Collection
+        Response response = target().path(API_VERSION).path("virtualcollection")
+                .queryParam("filter", "false")
+                .queryParam("query",
+                        "creationDate since 1775 & corpusSigle=GOE")
+                .queryParam("name", "Weimarer Werke")
+                .queryParam("description", "Goethe-Werke in Weimar (seit 1775)")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .post(Entity.json(""));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.isObject());
+        assertEquals(node.path("name").asText(), "Weimarer Werke");
+        // Get virtual collections
+        response = target().path(API_VERSION).path("collection").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        ent = response.readEntity(String.class);
+        node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        Iterator<JsonNode> it = node.elements();
+        String id = null;
+        while (it.hasNext()) {
+            JsonNode next = (JsonNode) it.next();
+            if ("Weimarer Werke".equals(next.path("name").asText()))
+                id = next.path("id").asText();
+        }
+        assertNotNull(id);
+        assertFalse(id.isEmpty());
+        // query serialization service
+        response = target().path(API_VERSION).path("collection").path(id)
+                .path("query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").queryParam("context", "base/s:s")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        ent = response.readEntity(String.class);
+        node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        // System.out.println("NODE " + ent);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "creationDate");
+        assertEquals(node.at("/collection/operands/0/value").asText(), "1775");
+        assertEquals(node.at("/collection/operands/0/type").asText(),
+                "type:date");
+        assertEquals(node.at("/collection/operands/0/match").asText(),
+                "match:geq");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "corpusSigle");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "GOE");
+        assertEquals(node.at("/collection/operands/1/match").asText(),
+                "match:eq");
+    }
+
+    @Test
+    public void testQuerySerializationOfVirtualCollection ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("collection/GOE-VC/query").queryParam("q", "[orth=der]")
+                .queryParam("ql", "poliqarp").queryParam("context", "base/s:s")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/operands/0/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "corpusSigle");
+        assertEquals(node.at("/collection/operands/0/value").asText(), "GOE");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "creationDate");
+        assertEquals(node.at("/collection/operands/1/value").asText(),
+                "1810-01-01");
+    }
+
+    @Test
+    public void testMetaQuerySerialization () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .queryParam("context", "sentence").queryParam("count", "20")
+                .queryParam("page", "5").queryParam("cutoff", "true")
+                .queryParam("q", "[pos=ADJA]").queryParam("ql", "poliqarp")
+                .request().method("GET");
+        assertEquals(response.getStatus(), Status.OK.getStatusCode());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/meta/context").asText(), "sentence");
+        assertEquals(20, node.at("/meta/count").asInt());
+        assertEquals(5, node.at("/meta/startPage").asInt());
+        assertEquals(true, node.at("/meta/cutOff").asBoolean());
+        assertEquals(node.at("/query/wrap/@type").asText(), "koral:term");
+        assertEquals(node.at("/query/wrap/layer").asText(), "pos");
+        assertEquals(node.at("/query/wrap/match").asText(), "match:eq");
+        assertEquals(node.at("/query/wrap/key").asText(), "ADJA");
+    }
+
+    @Test
+    public void testMetaQuerySerializationWithOffset ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .queryParam("context", "sentence").queryParam("count", "20")
+                .queryParam("page", "5").queryParam("offset", "2")
+                .queryParam("cutoff", "true").queryParam("q", "[pos=ADJA]")
+                .queryParam("ql", "poliqarp").request().method("GET");
+        assertEquals(response.getStatus(), Status.OK.getStatusCode());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/meta/context").asText(), "sentence");
+        assertEquals(20, node.at("/meta/count").asInt());
+        assertEquals(2, node.at("/meta/startIndex").asInt());
+        assertEquals(true, node.at("/meta/cutOff").asBoolean());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/ResourceInfoControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/ResourceInfoControllerTest.java
new file mode 100644
index 0000000..d2d4fed
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/ResourceInfoControllerTest.java
@@ -0,0 +1,176 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author hanl, margaretha
+ * @lastUpdate 19/04/2017
+ *             EM: FIX ME: Database restructure
+ */
+@Disabled
+public class ResourceInfoControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testGetPublicVirtualCollectionInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("collection")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testGetVirtualCollectionInfoWithAuthentication ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("collection")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertNotNull(node);
+        assertTrue(node.isArray());
+        assertEquals(3, node.size());
+    }
+
+    @Test
+    public void testGetVirtualCollectionInfoById () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("collection")
+                .path("GOE-VC").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.size());
+        assertEquals(node.path("name").asText(), "Goethe Virtual Collection");
+        assertEquals(node.path("description").asText(),
+                "Goethe works from 1810");
+    }
+
+    @Test
+    public void testGetVirtualCollectionInfoByIdUnauthorized ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("collection")
+                .path("WPD15-VC").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.size());
+        assertEquals(101, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[Cannot found public VirtualCollection with ids: [WPD15-VC]]");
+    }
+
+    @Test
+    public void testGetPublicCorporaInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.isArray());
+        assertEquals(2, node.size());
+    }
+
+    @Test
+    public void testGetCorpusInfoById () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("WPD13").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        // System.out.println(ent);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.isObject());
+        assertEquals(node.path("id").asText(), "WPD13");
+    }
+
+    @Test
+    public void testGetCorpusInfoById2 () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("GOE").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.isObject());
+        assertEquals(node.path("id").asText(), "GOE");
+    }
+
+    @Test
+    public void testGetPublicFoundriesInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("foundry").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.isArray());
+        assertEquals(10, node.size());
+    }
+
+    @Test
+    public void testGetFoundryInfoById () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("foundry")
+                .path("tt").request().get();
+        String ent = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.size());
+    }
+
+    @Test
+    public void testGetUnexistingCorpusInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("ZUW19").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.size());
+        assertEquals(101, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[Cannot found public Corpus with ids: [ZUW19]]");
+    }
+
+    // EM: queries for an unauthorized corpus get the same responses /
+    // treatment as
+    // asking for an unexisting corpus info. Does it need a specific
+    // exception instead?
+    @Test
+    public void testGetUnauthorizedCorpusInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("corpus")
+                .path("BRZ10").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.size());
+        assertEquals(101, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[Cannot found public Corpus with ids: [BRZ10]]");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/SearchControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/SearchControllerTest.java
new file mode 100644
index 0000000..9e932fc
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/SearchControllerTest.java
@@ -0,0 +1,452 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * @author hanl, margaretha
+ * @lastUpdate 18/03/2019
+ */
+public class SearchControllerTest extends SpringJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    private JsonNode requestSearchWithFields (String fields)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("fields", fields).queryParam("context", "sentence")
+                .queryParam("count", "13").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        return node;
+    }
+
+    private String createJsonQuery () {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=der]", "poliqarp");
+        s.setCollection("corpusSigle=GOE");
+        s.setQuery("Wasser", "poliqarp");
+        return s.toJSON();
+    }
+
+    @Test
+    public void testApiWelcomeMessage () {
+        Response response = target().path(API_VERSION).path("").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        String message = response.readEntity(String.class);
+        assertEquals(message, config.getApiWelcomeMessage());
+    }
+
+    @Test
+    public void testSearchShowTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("show-tokens", true).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(3, node.at("/matches/0/tokens").size());
+        assertFalse(node.at("/matches/0/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testSearchDisableSnippet () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("show-snippet", false)
+                .queryParam("show-tokens", true).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+        assertEquals(3, node.at("/matches/0/tokens").size());
+    }
+
+    @Test
+    public void testSearchWithField () throws KustvaktException {
+        JsonNode node = requestSearchWithFields("author");
+        assertNotEquals(0, node.at("/matches").size());
+        assertEquals(node.at("/meta/fields").toString(), "[\"author\"]");
+        assertTrue(node.at("/matches/0/tokens").isMissingNode());
+    }
+
+    @Test
+    public void testSearchWithMultipleFields () throws KustvaktException {
+        JsonNode node = requestSearchWithFields("author, title");
+        assertNotEquals(0, node.at("/matches").size());
+        assertEquals(node.at("/meta/fields").toString(),
+                "[\"author\",\"title\"]");
+    }
+
+    @Test
+    public void testSearchQueryPublicCorpora () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .request().accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "availability");
+        assertEquals(node.at("/collection/value").asText(), "CC-BY.*");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchQueryFailure () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der").queryParam("ql", "poliqarp")
+                .queryParam("cq", "corpusSigle=WPD | corpusSigle=GOE")
+                .queryParam("count", "13").request()
+                .accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(302, node.at("/errors/0/0").asInt());
+        assertEquals(302, node.at("/errors/1/0").asInt());
+        assertTrue(node.at("/errors/2").isMissingNode());
+        assertFalse(node.at("/collection").isMissingNode());
+        assertEquals(13, node.at("/meta/count").asInt());
+    }
+
+    @Test
+    public void testSearchQueryWithMeta () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=Bachelor]").queryParam("ql", "poliqarp")
+                .queryParam("cutoff", "true").queryParam("count", "5")
+                .queryParam("page", "1").queryParam("context", "40-t,30-t")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertTrue(node.at("/meta/cutOff").asBoolean());
+        assertEquals(5, node.at("/meta/count").asInt());
+        assertEquals(0, node.at("/meta/startIndex").asInt());
+        assertEquals(node.at("/meta/context/left/0").asText(), "token");
+        assertEquals(40, node.at("/meta/context/left/1").asInt());
+        assertEquals(30, node.at("/meta/context/right/1").asInt());
+        assertEquals(-1, node.at("/meta/totalResults").asInt());
+        for (String path : new String[] { "/meta/count", "/meta/startIndex",
+                "/meta/context/left/1", "/meta/context/right/1",
+                "/meta/totalResults", "/meta/itemsPerPage" }) {
+            assertTrue(node.at(path).isNumber(), path + " should be a number");
+        }
+    }
+
+    @Test
+    public void testSearchQueryFreeExtern () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request().header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "availability");
+        assertEquals(node.at("/collection/value").asText(), "CC-BY.*");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchQueryFreeIntern () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request().header(HttpHeaders.X_FORWARDED_FOR, "172.27.0.32")
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "availability");
+        assertEquals(node.at("/collection/value").asText(), "CC-BY.*");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchQueryExternAuthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        // System.out.println(entity);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(
+                node.at("/collection/operands/1/operands/0/value").asText(),
+                "ACA.*");
+        assertEquals(
+                node.at("/collection/operands/1/operands/1/value").asText(),
+                "QAO-NC");
+        assertEquals(node.at("/collection/operation").asText(), "operation:or");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(PUB)");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchQueryInternAuthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "172.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        // System.out.println(node);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(
+                node.at("/collection/operands/1/operands/0/value").asText(),
+                "ACA.*");
+        assertEquals(
+                node.at("/collection/operands/1/operands/1/operands/0/value")
+                        .asText(),
+                "QAO-NC");
+        assertEquals(
+                node.at("/collection/operands/1/operands/1/operands/1/value")
+                        .asText(),
+                "QAO.*");
+        assertEquals(node.at("/collection/operation").asText(), "operation:or");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchWithCorpusQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "corpusTitle=gingko").request()
+                .accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(2, node.at("/collection/operands").size());
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/value").asText(),
+                "gingko");
+        assertEquals(node.at("/collection/operands/1/match").asText(),
+                "match:eq");
+        assertTrue(node.at("/collection/operands/1/type").isMissingNode());
+    }
+
+    @Test
+    public void testSearchQueryWithCollectionQueryAuthorizedWithoutIP ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "textClass=politik & corpusSigle=BRZ10")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertNotNull(node);
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        // EM: double AND operations
+        assertEquals(node.at("/collection/operands/0/key").asText(),
+                "availability");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/operands/0/key").asText(),
+                "textClass");
+        assertEquals(node.at("/collection/operands/1/operands/1/key").asText(),
+                "corpusSigle");
+    }
+
+    @Test
+    @Disabled
+    public void testSearchQueryAuthorizedWithoutIP () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "ACA.*");
+        assertEquals(node.at("/collection/operation").asText(), "operation:or");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(PUB)");
+    }
+
+    @Test
+    public void testSearchWithInvalidPage () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "0").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "page must start from 1");
+    }
+
+    @Test
+    public void testSearchSentenceMeta () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("context", "sentence").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/meta/context").asText(), "base/s:s");
+        assertNotEquals("/meta/version", "${project.version}");
+    }
+
+    // EM: The API is disabled
+    @Disabled
+    @Test
+    public void testSearchSimpleCQL () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("(der) or (das)", "CQL");
+        Response response = target().path(API_VERSION).path("search").request()
+                .post(Entity.json(s.toJSON()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        // assertEquals(17027, node.at("/meta/totalResults").asInt());
+    }
+
+    // EM: The API is disabled
+    @Test
+    @Disabled
+    public void testSearchRawQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search").request()
+                .post(Entity.json(createJsonQuery()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+    }
+
+    // EM: The API is disabled
+    @Test
+    @Disabled
+    public void testSearchPostAll () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "10.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .post(Entity.json(createJsonQuery()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+    }
+
+    // EM: The API is disabled
+    @Test
+    @Disabled
+    public void testSearchPostPublic () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue("kustvakt",
+                                        "kustvakt2015"))
+                .post(Entity.json(createJsonQuery()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertNotEquals(0, node.path("matches").size());
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(PUB)");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/SearchNetworkEndpointTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/SearchNetworkEndpointTest.java
new file mode 100644
index 0000000..ef42760
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/SearchNetworkEndpointTest.java
@@ -0,0 +1,113 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class SearchNetworkEndpointTest extends SpringJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    private ClientAndServer mockServer;
+
+    private MockServerClient mockClient;
+
+    private int port = 6081;
+
+    private String searchResult;
+
+    private String endpointURL = "http://localhost:" + port + "/searchEndpoint";
+
+    public SearchNetworkEndpointTest () throws IOException {
+        searchResult = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "network-output/search-result.jsonld"),
+                StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void startMockServer () {
+        mockServer = startClientAndServer(port);
+        mockClient = new MockServerClient("localhost", mockServer.getPort());
+    }
+
+    @AfterEach
+    public void stopMockServer () {
+        mockServer.stop();
+    }
+
+    @Test
+    public void testSearchNetwork ()
+            throws IOException, KustvaktException, URISyntaxException {
+        config.setNetworkEndpointURL(endpointURL);
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/searchEndpoint")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(searchResult).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/matches").size());
+    }
+
+    @Test
+    public void testSearchWithUnknownURL ()
+            throws IOException, KustvaktException {
+        config.setNetworkEndpointURL("http://localhost:1040/search");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.SEARCH_NETWORK_ENDPOINT_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testSearchWithUnknownHost () throws KustvaktException {
+        config.setNetworkEndpointURL("http://search.com");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.SEARCH_NETWORK_ENDPOINT_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/SearchPipeTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/SearchPipeTest.java
new file mode 100644
index 0000000..03a019e
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/SearchPipeTest.java
@@ -0,0 +1,315 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class SearchPipeTest extends SpringJerseyTest {
+
+    private ClientAndServer mockServer;
+
+    private MockServerClient mockClient;
+
+    private int port = 6071;
+
+    private String pipeJson, pipeWithParamJson;
+
+    private String glemmUri = "http://localhost:" + port + "/glemm";
+
+    public SearchPipeTest () throws URISyntaxException, IOException {
+        pipeJson = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "pipe-output/test-pipes.jsonld"),
+                StandardCharsets.UTF_8);
+        pipeWithParamJson = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "pipe-output/with-param.jsonld"),
+                StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void startMockServer () {
+        mockServer = startClientAndServer(port);
+        mockClient = new MockServerClient("localhost", mockServer.getPort());
+    }
+
+    @AfterEach
+    public void stopMockServer () {
+        mockServer.stop();
+    }
+
+    @Test
+    public void testMockServer () throws IOException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/test")
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody("{test}").withStatusCode(200));
+        URL url = new URL("http://localhost:" + port + "/test");
+        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+        connection.setRequestMethod("POST");
+        connection.setRequestProperty("Content-Type",
+                "application/json; charset=UTF-8");
+        connection.setRequestProperty("Accept", "application/json");
+        connection.setDoOutput(true);
+        String json = "{\"name\" : \"dory\"}";
+        try (OutputStream os = connection.getOutputStream()) {
+            byte[] input = json.getBytes("utf-8");
+            os.write(input, 0, input.length);
+        }
+        assertEquals(200, connection.getResponseCode());
+        BufferedReader br = new BufferedReader(
+                new InputStreamReader(connection.getInputStream(), "utf-8"));
+        assertEquals(br.readLine(), "{test}");
+    }
+
+    @Test
+    public void testSearchWithPipes ()
+            throws IOException, KustvaktException, URISyntaxException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+        assertEquals(1, node.at("/collection/rewrites").size());
+        assertEquals(node.at("/collection/rewrites/0/operation").asText(),
+                "operation:insertion");
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(FREE)");
+        node = node.at("/query/wrap/rewrites");
+        assertEquals(2, node.size());
+        assertEquals(node.at("/0/src").asText(), "Glemm");
+        assertEquals(node.at("/0/operation").asText(), "operation:override");
+        assertEquals(node.at("/0/scope").asText(), "key");
+        assertEquals(node.at("/1/src").asText(), "Kustvakt");
+        assertEquals(node.at("/1/operation").asText(), "operation:injection");
+        assertEquals(node.at("/1/scope").asText(), "foundry");
+    }
+
+    @Test
+    public void testSearchWithUrlEncodedPipes ()
+            throws IOException, KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        glemmUri = URLEncoder.encode(glemmUri, "utf-8");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri).request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+    }
+
+    @Test
+    public void testSearchWithMultiplePipes () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withQueryStringParameter("param").withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeWithParamJson).withStatusCode(200));
+        String glemmUri2 = glemmUri + "?param=blah";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri + "," + glemmUri2).request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(3, node.at("/query/wrap/key").size());
+    }
+
+    @Test
+    public void testSearchWithUnknownURL ()
+            throws IOException, KustvaktException {
+        String url = target().getUri().toString() + API_VERSION
+                + "/test/tralala";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", url).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(), "404 Not Found");
+    }
+
+    @Test
+    public void testSearchWithUnknownHost () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", "http://glemm").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(), "glemm");
+    }
+
+    @Test
+    public void testSearchUnsupportedMediaType () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/non-json-pipe"))
+                .respond(response().withStatusCode(415));
+        String pipeUri = "http://localhost:" + port + "/non-json-pipe";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(),
+                "415 Unsupported Media Type");
+    }
+
+    @Test
+    public void testSearchWithMultiplePipeWarnings () throws KustvaktException {
+        String url = target().getUri().toString() + API_VERSION
+                + "/test/tralala";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", url + "," + "http://glemm").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/warnings").size());
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(url, node.at("/warnings/0/2").asText());
+        assertEquals(node.at("/warnings/0/3").asText(), "404 Not Found");
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/1/0").asInt());
+        assertEquals(node.at("/warnings/1/2").asText(), "http://glemm");
+        assertEquals(node.at("/warnings/1/3").asText(), "glemm");
+    }
+
+    @Test
+    public void testSearchWithInvalidJsonResponse () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/invalid-response")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response().withBody("{blah:}").withStatusCode(200)
+                        .withHeaders(new Header("Content-Type",
+                                "application/json; charset=utf-8")));
+        String pipeUri = "http://localhost:" + port + "/invalid-response";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testSearchWithPlainTextResponse () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/plain-text")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response().withBody("blah").withStatusCode(200));
+        String pipeUri = "http://localhost:" + port + "/plain-text";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testSearchWithMultipleAndUnknownPipes ()
+            throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", "http://unknown" + "," + glemmUri)
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+        assertTrue(node.at("/warnings").isMissingNode());
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri + ",http://unknown").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/SearchPublicMetadataTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/SearchPublicMetadataTest.java
new file mode 100644
index 0000000..aeec05e
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/SearchPublicMetadataTest.java
@@ -0,0 +1,150 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.query.serialize.MetaQueryBuilder;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class SearchPublicMetadataTest extends SpringJerseyTest {
+
+    @Test
+    public void testSearchPublicMetadata () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataExtern () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("access-rewrite-disabled", "true").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithCustomFields ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author,title")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+        assertEquals(node.at("/matches/0/author").asText(),
+                "Goethe, Johann Wolfgang von");
+        assertEquals(node.at("/matches/0/title").asText(),
+                "Italienische Reise");
+        // assertEquals(3, node.at("/matches/0").size());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithNonPublicField ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author,title,snippet")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NON_PUBLIC_FIELD_IGNORED,
+                node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "The requested non public fields are ignored");
+        assertEquals(node.at("/warnings/0/2").asText(), "snippet");
+    }
+
+    // EM: The API is disabled
+    @Disabled
+    @Test
+    public void testSearchPostPublicMetadata () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=der]", "poliqarp");
+        s.setCollection("corpusSigle=GOE");
+        s.setQuery("Wasser", "poliqarp");
+        MetaQueryBuilder meta = new MetaQueryBuilder();
+        meta.addEntry("snippets", "true");
+        s.setMeta(meta);
+        Response response = target().path(API_VERSION).path("search").request()
+                .post(Entity.json(s.toJSON()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/collection/rewrites/0/scope").asText(),
+                "availability(ALL)");
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithSystemVC ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo system-vc")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        node = node.at("/collection/operands/1");
+        assertEquals(node.at("/@type").asText(), "koral:doc");
+        assertEquals(node.at("/value").asText(), "GOE");
+        assertEquals(node.at("/match").asText(), "match:eq");
+        assertEquals(node.at("/key").asText(), "corpusSigle");
+        assertEquals(node.at("/rewrites/0/operation").asText(),
+                "operation:deletion");
+        assertEquals(node.at("/rewrites/0/scope").asText(),
+                "@type(koral:docGroupRef)");
+        assertEquals(node.at("/rewrites/1/operation").asText(),
+                "operation:deletion");
+        assertEquals(node.at("/rewrites/1/scope").asText(), "ref(system-vc)");
+        assertEquals(node.at("/rewrites/2/operation").asText(),
+                "operation:insertion");
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithPrivateVC ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"dory/dory-vc\"")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(), "guest");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/SearchTokenSnippetTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/SearchTokenSnippetTest.java
new file mode 100644
index 0000000..e115f90
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/SearchTokenSnippetTest.java
@@ -0,0 +1,69 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class SearchTokenSnippetTest extends SpringJerseyTest {
+
+    @Test
+    public void testSearchWithTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("show-tokens", "true")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches/0/hasSnippet").asBoolean());
+        assertTrue(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/tokens/left").size() > 0);
+        assertTrue(node.at("/matches/0/tokens/right").size() > 0);
+        assertEquals(1, node.at("/matches/0/tokens/match").size());
+    }
+
+    @Test
+    public void testSearchWithoutTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("show-tokens", "false")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches/0/hasSnippet").asBoolean());
+        assertFalse(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/tokens").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("access-rewrite-disabled", "true")
+                .queryParam("show-tokens", "true")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertFalse(node.at("/matches/0/hasSnippet").asBoolean());
+        assertFalse(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+        assertTrue(node.at("/matches/0/tokens").isMissingNode());
+        assertEquals(StatusCodes.NOT_ALLOWED, node.at("/warnings/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/StatisticsControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/StatisticsControllerTest.java
new file mode 100644
index 0000000..ecdad64
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/StatisticsControllerTest.java
@@ -0,0 +1,216 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha, diewald
+ */
+public class StatisticsControllerTest extends SpringJerseyTest {
+
+    @Test
+    public void testGetStatisticsNoResource ()
+            throws IOException, KustvaktException {
+        String corpusQuery = "corpusSigle=WPD15";
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", corpusQuery).request().get();
+        assert Status.OK.getStatusCode() == response.getStatus();
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.get("documents").asInt(), 0);
+        assertEquals(node.get("tokens").asInt(), 0);
+    }
+
+    @Test
+    public void testStatisticsWithCq () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung & corpusSigle=GOE")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testStatisticsWithCqAndCorpusQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung & corpusSigle=GOE")
+                .queryParam("corpusQuery",
+                        "textType=Autobiographie & corpusSigle=GOE")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testGetStatisticsWithcorpusQuery1 ()
+            throws IOException, KustvaktException {
+        String corpusQuery = "corpusSigle=GOE";
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", corpusQuery).request().get();
+        assert Status.OK.getStatusCode() == response.getStatus();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.get("documents").asInt(), 11);
+        assertEquals(node.get("tokens").asInt(), 665842);
+        assertEquals(StatusCodes.DEPRECATED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "Parameter corpusQuery is deprecated in favor of cq.");
+    }
+
+    @Test
+    public void testGetStatisticsWithcorpusQuery2 ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "creationDate since 1810").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assert Status.OK.getStatusCode() == response.getStatus();
+        assertEquals(node.get("documents").asInt(), 7);
+        assertEquals(node.get("tokens").asInt(), 279402);
+        assertEquals(node.get("sentences").asInt(), 11047);
+        assertEquals(node.get("paragraphs").asInt(), 489);
+    }
+
+    @Test
+    public void testGetStatisticsWithWrongcorpusQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "creationDate geq 1810").request()
+                .get();
+        assert Status.BAD_REQUEST.getStatusCode() == response.getStatus();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/errors/0/0").asInt(), 302);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Could not parse query >>> (creationDate geq 1810) <<<.");
+        assertEquals(node.at("/errors/0/2").asText(),
+                "(creationDate geq 1810)");
+    }
+
+    @Test
+    public void testGetStatisticsWithWrongcorpusQuery2 ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "creationDate >= 1810").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/errors/0/0").asInt(), 305);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Operator >= is not acceptable.");
+        assertEquals(node.at("/errors/0/2").asText(), ">=");
+    }
+
+    @Test
+    public void testGetStatisticsWithoutcorpusQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(11, node.at("/documents").asInt());
+        assertEquals(665842, node.at("/tokens").asInt());
+        assertEquals(25074, node.at("/sentences").asInt());
+        assertEquals(772, node.at("/paragraphs").asInt());
+    }
+
+    @Test
+    public void testGetStatisticsWithKoralQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{ \"collection\" : {\"@type\": "
+                        + "\"koral:doc\", \"key\": \"availability\", \"match\": "
+                        + "\"match:eq\", \"type\": \"type:regex\", \"value\": "
+                        + "\"CC-BY.*\"} }"));
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(72770, node.at("/tokens").asInt());
+        assertEquals(2985, node.at("/sentences").asInt());
+        assertEquals(128, node.at("/paragraphs").asInt());
+    }
+
+    @Test
+    public void testGetStatisticsWithEmptyCollection ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{}"));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/errors/0/0").asInt(),
+                de.ids_mannheim.korap.util.StatusCodes.MISSING_COLLECTION);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Collection is not found");
+    }
+
+    @Test
+    public void testGetStatisticsWithIncorrectJson ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{ \"collection\" : }"));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Failed deserializing json object: { \"collection\" : }");
+    }
+
+    @Test
+    public void testGetStatisticsWithoutKoralQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request().post(Entity.json(""));
+        String ent = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(11, node.at("/documents").asInt());
+        assertEquals(665842, node.at("/tokens").asInt());
+        assertEquals(25074, node.at("/sentences").asInt());
+        assertEquals(772, node.at("/paragraphs").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/TokenExpiryTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/TokenExpiryTest.java
new file mode 100644
index 0000000..1d130b6
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/TokenExpiryTest.java
@@ -0,0 +1,104 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.HttpStatus;
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * Before running this test:
+ * set oauth2.access.token.expiry = 2S
+ * oauth2.authorization.code.expiry = 1S
+ *
+ * @author margaretha
+ */
+public class TokenExpiryTest extends SpringJerseyTest {
+
+    @Disabled
+    @Test
+    public void requestToken ()
+            throws KustvaktException, InterruptedException, IOException {
+        Form form = new Form();
+        form.param("grant_type", "password");
+        form.param("client_id", "fCBbQkAyYzI4NzUxMg");
+        form.param("client_secret", "secret");
+        form.param("username", "dory");
+        form.param("password", "password");
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("token").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        String token = node.at("/access_token").asText();
+        Thread.sleep(1000);
+        testRequestAuthorizationCodeAuthenticationTooOld(token);
+        Thread.sleep(1500);
+        testSearchWithExpiredToken(token);
+    }
+
+    // not possible to store expired token in the test database,
+    // because sqlite needs a trigger after INSERT to
+    // oauth_access_token to store created_date. Before INSERT trigger
+    // does not work.
+    private void testSearchWithExpiredToken (String token)
+            throws KustvaktException, IOException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Wasser").queryParam("ql", "poliqarp")
+                .request().header(Attributes.AUTHORIZATION, "Bearer " + token)
+                .get();
+        String ent = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.EXPIRED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is expired");
+    }
+
+    // cannot be tested dynamically
+    private void testRequestAuthorizationCodeAuthenticationTooOld (String token)
+            throws KustvaktException {
+        Form form = new Form();
+        form.param("response_type", "code");
+        form.param("client_id", "fCBbQkAyYzI4NzUxMg");
+        form.param("redirect_uri",
+                "https://korap.ids-mannheim.de/confidential/redirect");
+        form.param("scope", "search");
+        form.param("max_age", "1");
+
+        Response response = target().path(API_VERSION).path("oauth2")
+                .path("authorize").request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + token)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(form));
+
+        assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.USER_REAUTHENTICATION_REQUIRED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "User reauthentication is required because the authentication "
+                        + "time is too old according to max_age",
+                node.at("/errors/0/1").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/UserControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/UserControllerTest.java
new file mode 100644
index 0000000..27c8512
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/UserControllerTest.java
@@ -0,0 +1,79 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.net.URI;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
+
+public class UserControllerTest extends OAuth2TestBase {
+
+    private String username = "User\"ControllerTest";
+
+    private String userAuthHeader;
+
+    public UserControllerTest () throws KustvaktException {
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, "password");
+    }
+
+    private OAuth2ClientJson createOAuth2Client () {
+        OAuth2ClientJson client = new OAuth2ClientJson();
+        client.setName("OWID client");
+        client.setType(OAuth2ClientType.PUBLIC);
+        client.setDescription("OWID web-based client");
+        client.setRedirectURI("https://www.owid.de");
+        return client;
+    }
+
+    private String registerClient ()
+            throws ProcessingException, KustvaktException {
+        OAuth2ClientJson clientJson = createOAuth2Client();
+        Response response = registerClient(username, clientJson);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        return clientId;
+    }
+
+    private String requestOAuth2AccessToken (String clientId)
+            throws KustvaktException {
+        Response response = requestAuthorizationCode("code", clientId, "",
+                "user_info", "", userAuthHeader);
+        String code = parseAuthorizationCode(response);
+        response = requestTokenWithAuthorizationCodeAndForm(clientId, null,
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        String accessToken = node.at("/access_token").asText();
+        return accessToken;
+    }
+
+    @Test
+    public void getUsername () throws ProcessingException, KustvaktException {
+        String clientId = registerClient();
+        String accessToken = requestOAuth2AccessToken(clientId);
+        Response response = target().path(API_VERSION).path("user").path("info")
+                .request()
+                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(username, node.at("/username").asText());
+        deregisterClient(username, clientId);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerAdminTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerAdminTest.java
new file mode 100644
index 0000000..aa6a4c6
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerAdminTest.java
@@ -0,0 +1,356 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.client.Entity;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.constant.GroupMemberStatus;
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.service.UserGroupService;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha
+ */
+public class UserGroupControllerAdminTest extends SpringJerseyTest {
+
+    private String sysAdminUser = "admin";
+
+    private String testUser = "group-admin";
+
+    private JsonNode listGroup (String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    @Test
+    public void testListUserGroupsUsingAdminToken () throws KustvaktException {
+        Form f = new Form();
+        f.param("username", "dory");
+        f.param("token", "secret");
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(3, node.size());
+    }
+
+    /**
+     * Cannot use admin token
+     * see
+     * {@link UserGroupService#retrieveUserGroupByStatus(String,
+     * String, de.ids_mannheim.korap.constant.UserGroupStatus)}
+     *
+     * @throws KustvaktException
+     */
+    // @Test
+    // public void testListUserGroupsWithAdminToken () throws KustvaktException {
+    // Response response = target().path(API_VERSION).path("group")
+    // .path("list").path("system-admin")
+    // .queryParam("username", "dory")
+    // .queryParam("token", "secret")
+    // .request()
+    // .get();
+    // 
+    // assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    // String entity = response.readEntity(String.class);
+    // JsonNode node = JsonUtils.readTree(entity);
+    // assertEquals(3, node.size());
+    // }
+    @Test
+    public void testListUserGroupsUnauthorized () throws KustvaktException {
+        Form f = new Form();
+        f.param("username", "dory");
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testListUserGroupsWithStatus () throws KustvaktException {
+        Form f = new Form();
+        f.param("username", "dory");
+        f.param("status", "ACTIVE");
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").queryParam("username", "dory")
+                .queryParam("status", "ACTIVE").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.size());
+    }
+
+    // same as list user-groups of the admin
+    @Test
+    public void testListWithoutUsername ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        assertEquals(entity, "[]");
+    }
+
+    @Test
+    public void testListByStatusAll ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(null);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        boolean containsHiddenStatus = false;
+        for (int i = 0; i < node.size(); i++) {
+            if (node.get(i).at("/status").asText().equals("HIDDEN")) {
+                containsHiddenStatus = true;
+            }
+        }
+        assertEquals(true, containsHiddenStatus);
+    }
+
+    @Test
+    public void testListByStatusHidden ()
+            throws ProcessingException, KustvaktException {
+        Form f = new Form();
+        f.param("status", "HIDDEN");
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").queryParam("status", "HIDDEN")
+                .request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(1, node.size());
+        assertEquals(3, node.at("/0/id").asInt());
+    }
+
+    @Test
+    public void testUserGroupAdmin ()
+            throws ProcessingException, KustvaktException {
+        String groupName = "admin-test-group";
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(testUser,
+                                        "password"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .put(Entity.form(new Form()));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        // list user group
+        JsonNode node = listGroup(testUser);
+        assertEquals(1, node.size());
+        node = node.get(0);
+        assertEquals(groupName, node.get("name").asText());
+        testInviteMember(groupName);
+        testMemberRole("marlin", groupName);
+        testDeleteMember(groupName);
+        testDeleteGroup(groupName);
+    }
+
+    private void testMemberRole (String memberUsername, String groupName)
+            throws ProcessingException, KustvaktException {
+        // accept invitation
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        memberUsername, "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testAddMemberRoles(groupName, memberUsername);
+        testDeleteMemberRoles(groupName, memberUsername);
+    }
+
+    private void testAddMemberRoles (String groupName, String memberUsername)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", memberUsername);
+        // USER_GROUP_ADMIN
+        form.param("roleId", "1");
+        // USER_GROUP_MEMBER
+        form.param("roleId", "2");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("role").path("add").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "password"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = retrieveGroup(groupName).at("/members");
+        JsonNode member;
+        for (int i = 0; i < node.size(); i++) {
+            member = node.get(i);
+            if (member.at("/userId").asText().equals(memberUsername)) {
+                assertEquals(3, member.at("/roles").size());
+                assertEquals(PredefinedRole.USER_GROUP_ADMIN.name(),
+                        member.at("/roles/0").asText());
+                break;
+            }
+        }
+    }
+
+    private void testDeleteMemberRoles (String groupName, String memberUsername)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", memberUsername);
+        // USER_GROUP_ADMIN
+        form.param("roleId", "1");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("role").path("delete").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "password"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = retrieveGroup(groupName).at("/members");
+        JsonNode member;
+        for (int i = 0; i < node.size(); i++) {
+            member = node.get(i);
+            if (member.at("/userId").asText().equals(memberUsername)) {
+                assertEquals(2, member.at("/roles").size());
+                break;
+            }
+        }
+    }
+
+    private JsonNode retrieveGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").post(null);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    private void testDeleteGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        // delete group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check group
+        JsonNode node = listGroup(testUser);
+        assertEquals(0, node.size());
+    }
+
+    private void testDeleteMember (String groupName)
+            throws ProcessingException, KustvaktException {
+        // delete marlin from group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("~marlin").request()
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check group member
+        JsonNode node = listGroup(testUser);
+        node = node.get(0);
+        assertEquals(3, node.get("members").size());
+        assertEquals(node.at("/members/1/userId").asText(), "nemo");
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                node.at("/members/1/status").asText());
+    }
+
+    private void testInviteMember (String groupName)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("members", "marlin,nemo,darla");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        HttpAuthorizationHandler
+                                .createBasicAuthorizationHeaderValue(
+                                        sysAdminUser, "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // list group
+        JsonNode node = listGroup(testUser);
+        node = node.get(0);
+        assertEquals(4, node.get("members").size());
+        assertEquals(node.at("/members/3/userId").asText(), "darla");
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                node.at("/members/1/status").asText());
+        assertEquals(0, node.at("/members/1/roles").size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java
new file mode 100644
index 0000000..5495c85
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java
@@ -0,0 +1,853 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Set;
+
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.client.Entity;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.constant.GroupMemberStatus;
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.dao.UserGroupMemberDao;
+import de.ids_mannheim.korap.entity.Role;
+import de.ids_mannheim.korap.entity.UserGroupMember;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha
+ */
+public class UserGroupControllerTest extends SpringJerseyTest {
+
+    @Autowired
+    private UserGroupMemberDao memberDao;
+
+    private String username = "UserGroupControllerTest";
+
+    private String admin = "admin";
+
+    private JsonNode retrieveUserGroups (String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        return JsonUtils.readTree(entity);
+    }
+
+    private void deleteGroupByName (String groupName) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    // dory is a group admin in dory-group
+    @Test
+    public void testListDoryGroups () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        JsonNode group = node.get(1);
+        assertEquals(2, group.at("/id").asInt());
+        assertEquals(group.at("/name").asText(), "dory-group");
+        assertEquals(group.at("/owner").asText(), "dory");
+        assertEquals(3, group.at("/members").size());
+    }
+
+    // nemo is a group member in dory-group
+    @Test
+    public void testListNemoGroups () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("nemo", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/0/id").asInt());
+        assertEquals(node.at("/0/name").asText(), "dory-group");
+        assertEquals(node.at("/0/owner").asText(), "dory");
+        // group members are not allowed to see other members
+        assertEquals(0, node.at("/0/members").size());
+    }
+
+    // marlin has 2 groups
+    @Test
+    public void testListMarlinGroups () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.size());
+    }
+
+    @Test
+    public void testListGroupGuest () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: guest");
+    }
+
+    @Test
+    public void testCreateGroupEmptyDescription ()
+            throws ProcessingException, KustvaktException {
+        String groupName = "empty_group";
+        Response response = testCreateUserGroup(groupName, "");
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        deleteGroupByName(groupName);
+    }
+
+    @Test
+    public void testCreateGroupMissingDescription ()
+            throws ProcessingException, KustvaktException {
+        String groupName = "missing-desc-group";
+        Response response = testCreateGroupWithoutDescription(groupName);
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        deleteGroupByName(groupName);
+    }
+
+    private Response testCreateUserGroup (String groupName, String description)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("description", description);
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .put(Entity.form(form));
+        return response;
+    }
+
+    private Response testCreateGroupWithoutDescription (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .put(Entity.form(new Form()));
+        return response;
+    }
+
+    @Test
+    public void testCreateGroupInvalidName ()
+            throws ProcessingException, KustvaktException {
+        String groupName = "invalid-group-name$";
+        Response response = testCreateGroupWithoutDescription(groupName);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        // assertEquals("User-group name must only contains letters, numbers, "
+        // + "underscores, hypens and spaces", node.at("/errors/0/1").asText());
+        assertEquals(node.at("/errors/0/2").asText(), "invalid-group-name$");
+    }
+
+    @Test
+    public void testCreateGroupNameTooShort ()
+            throws ProcessingException, KustvaktException {
+        String groupName = "a";
+        Response response = testCreateGroupWithoutDescription(groupName);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "groupName must contain at least 3 characters");
+        assertEquals(node.at("/errors/0/2").asText(), "groupName");
+    }
+
+    @Test
+    public void testUserGroup () throws ProcessingException, KustvaktException {
+        String groupName = "new-user-group";
+        String description = "This is new-user-group.";
+        Response response = testCreateUserGroup(groupName, description);
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        // same name
+        response = testCreateGroupWithoutDescription(groupName);
+        assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
+        // list user group
+        JsonNode node = retrieveUserGroups(username);
+        assertEquals(1, node.size());
+        node = node.get(0);
+        assertEquals(node.get("name").asText(), "new-user-group");
+        assertEquals(description, node.get("description").asText());
+        assertEquals(username, node.get("owner").asText());
+        assertEquals(1, node.get("members").size());
+        assertEquals(username, node.at("/members/0/userId").asText());
+        assertEquals(GroupMemberStatus.ACTIVE.name(),
+                node.at("/members/0/status").asText());
+        assertEquals(PredefinedRole.VC_ACCESS_ADMIN.name(),
+                node.at("/members/0/roles/1").asText());
+        assertEquals(PredefinedRole.USER_GROUP_ADMIN.name(),
+                node.at("/members/0/roles/0").asText());
+        testUpdateUserGroup(groupName);
+        testInviteMember(groupName);
+        testDeleteMemberUnauthorized(groupName);
+        testDeleteMember(groupName);
+        testDeleteGroup(groupName);
+        testSubscribeToDeletedGroup(groupName);
+        testUnsubscribeToDeletedGroup(groupName);
+    }
+
+    private void testUpdateUserGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        String description = "Description is updated.";
+        Response response = testCreateUserGroup(groupName, description);
+        assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
+        JsonNode node = retrieveUserGroups(username);
+        assertEquals(1, node.size());
+        assertEquals(description, node.get(0).get("description").asText());
+    }
+
+    private void testDeleteMember (String groupName)
+            throws ProcessingException, KustvaktException {
+        // delete darla from group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("~darla").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        // check group member
+        response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        node = node.get(0);
+        assertEquals(1, node.get("members").size());
+    }
+
+    private void testDeleteMemberUnauthorized (String groupName)
+            throws ProcessingException, KustvaktException {
+        // nemo is a group member
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("~darla").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("nemo", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: nemo");
+    }
+
+    // EM: same as cancel invitation
+    private void testDeletePendingMember ()
+            throws ProcessingException, KustvaktException {
+        // dory delete pearl
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("~pearl").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check member
+        JsonNode node = retrieveUserGroups("pearl");
+        assertEquals(0, node.size());
+    }
+
+    @Test
+    public void testDeleteDeletedMember ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("~pearl").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_DELETED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "pearl has already been deleted from the group dory-group");
+        assertEquals(node.at("/errors/0/2").asText(), "[pearl, dory-group]");
+    }
+
+    private void testDeleteGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        // delete group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        Form f = new Form();
+        f.param("username", username);
+        f.param("status", "DELETED");
+        // EM: this is so complicated because the group retrieval are not allowed
+        // for delete groups
+        // check group
+        response = target().path(API_VERSION).path("admin").path("group")
+                .path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        for (int j = 0; j < node.size(); j++) {
+            JsonNode group = node.get(j);
+            // check group members
+            for (int i = 0; i < group.at("/0/members").size(); i++) {
+                assertEquals(GroupMemberStatus.DELETED.name(),
+                        group.at("/0/members/" + i + "/status").asText());
+            }
+        }
+    }
+
+    @Test
+    public void testDeleteGroupUnauthorized ()
+            throws ProcessingException, KustvaktException {
+        // dory is a group admin in marlin-group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: dory");
+    }
+
+    @Test
+    public void testDeleteDeletedGroup ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@deleted-group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_DELETED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group deleted-group has been deleted.");
+        assertEquals(node.at("/errors/0/2").asText(), "deleted-group");
+    }
+
+    @Test
+    public void testDeleteGroupOwner ()
+            throws ProcessingException, KustvaktException {
+        // delete marlin from marlin-group
+        // dory is a group admin in marlin-group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").path("~marlin").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").delete();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.NOT_ALLOWED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Operation 'delete group owner'is not allowed.");
+    }
+
+    private void testInviteMember (String groupName)
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("members", "darla");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // list group
+        response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        node = node.get(0);
+        assertEquals(2, node.get("members").size());
+        assertEquals(node.at("/members/1/userId").asText(), "darla");
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                node.at("/members/1/status").asText());
+        assertEquals(0, node.at("/members/1/roles").size());
+    }
+
+    private void testInviteDeletedMember ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("members", "marlin");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check member
+        JsonNode node = retrieveUserGroups("marlin");
+        assertEquals(2, node.size());
+        JsonNode group = node.get(1);
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                group.at("/userMemberStatus").asText());
+    }
+
+    @Test
+    public void testInviteDeletedMember2 ()
+            throws ProcessingException, KustvaktException {
+        // pearl has status deleted in dory-group
+        Form form = new Form();
+        form.param("members", "pearl");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check member
+        JsonNode node = retrieveUserGroups("pearl");
+        assertEquals(1, node.size());
+        JsonNode group = node.get(0);
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                group.at("/userMemberStatus").asText());
+        testDeletePendingMember();
+    }
+
+    @Test
+    public void testInvitePendingMember ()
+            throws ProcessingException, KustvaktException {
+        // marlin has status PENDING in dory-group
+        Form form = new Form();
+        form.param("members", "marlin");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(form));
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_EXISTS,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Username marlin with status PENDING exists in the user-group "
+                        + "dory-group",
+                node.at("/errors/0/1").asText());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[marlin, PENDING, dory-group]");
+    }
+
+    @Test
+    public void testInviteActiveMember ()
+            throws ProcessingException, KustvaktException {
+        // nemo has status active in dory-group
+        Form form = new Form();
+        form.param("members", "nemo");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_MEMBER_EXISTS,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Username nemo with status ACTIVE exists in the user-group "
+                        + "dory-group",
+                node.at("/errors/0/1").asText());
+        assertEquals(node.at("/errors/0/2").asText(),
+                "[nemo, ACTIVE, dory-group]");
+    }
+
+    @Test
+    public void testInviteMemberToDeletedGroup ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("members", "nemo");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@deleted-group").path("invite").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_DELETED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group deleted-group has been deleted.");
+        assertEquals(node.at("/errors/0/2").asText(), "deleted-group");
+    }
+
+    // marlin has GroupMemberStatus.PENDING in dory-group
+    @Test
+    public void testSubscribePendingMember () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // retrieve marlin group
+        JsonNode node = retrieveUserGroups("marlin");
+        // System.out.println(node);
+        assertEquals(2, node.size());
+        JsonNode group = node.get(1);
+        assertEquals(2, group.at("/id").asInt());
+        assertEquals(group.at("/name").asText(), "dory-group");
+        assertEquals(group.at("/owner").asText(), "dory");
+        // group members are not allowed to see other members
+        assertEquals(0, group.at("/members").size());
+        assertEquals(GroupMemberStatus.ACTIVE.name(),
+                group.at("/userMemberStatus").asText());
+        assertEquals(PredefinedRole.VC_ACCESS_MEMBER.name(),
+                group.at("/userRoles/1").asText());
+        assertEquals(PredefinedRole.USER_GROUP_MEMBER.name(),
+                group.at("/userRoles/0").asText());
+        // unsubscribe marlin from dory-group
+        testUnsubscribeActiveMember("dory-group");
+        checkGroupMemberRole("dory-group", "marlin");
+        // invite marlin to dory-group to set back the
+        // GroupMemberStatus.PENDING
+        testInviteDeletedMember();
+    }
+
+    // pearl has GroupMemberStatus.DELETED in dory-group
+    @Test
+    public void testSubscribeDeletedMember () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .post(Entity.form(new Form()));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_DELETED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "pearl has already been deleted from the group dory-group");
+    }
+
+    @Test
+    public void testSubscribeMissingGroupName () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("bruce", "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testSubscribeNonExistentMember () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("bruce", "pass"))
+                .post(Entity.form(new Form()));
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_NOT_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "bruce is not found in the group");
+    }
+
+    @Test
+    public void testSubscribeToNonExistentGroup () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@non-existent").path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .post(Entity.form(new Form()));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group non-existent is not found");
+    }
+
+    private void testSubscribeToDeletedGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("subscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("nemo", "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_DELETED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group new-user-group has been deleted.");
+    }
+
+    private void testUnsubscribeActiveMember (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = retrieveUserGroups("marlin");
+        assertEquals(1, node.size());
+    }
+
+    private void checkGroupMemberRole (String groupName,
+            String deletedMemberName) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .post(null);
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity).at("/members");
+        JsonNode member;
+        for (int i = 0; i < node.size(); i++) {
+            member = node.get(i);
+            if (deletedMemberName.equals(member.at("/userId").asText())) {
+                assertEquals(0, node.at("/roles").size());
+                break;
+            }
+        }
+    }
+
+    @Test
+    public void testUnsubscribeDeletedMember ()
+            throws ProcessingException, KustvaktException {
+        // pearl unsubscribes from dory-group
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .delete();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_DELETED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "pearl has already been deleted from the group dory-group");
+        assertEquals(node.at("/errors/0/2").asText(), "[pearl, dory-group]");
+    }
+
+    @Test
+    public void testUnsubscribePendingMember ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = retrieveUserGroups("marlin");
+        assertEquals(2, node.size());
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserGroups("marlin");
+        assertEquals(1, node.size());
+        // invite marlin to dory-group to set back the
+        // GroupMemberStatus.PENDING
+        testInviteDeletedMember();
+    }
+
+    @Test
+    public void testUnsubscribeMissingGroupName () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .delete();
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testUnsubscribeNonExistentMember () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@dory-group").path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("bruce", "pass"))
+                .delete();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_MEMBER_NOT_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "bruce is not found in the group");
+    }
+
+    @Test
+    public void testUnsubscribeToNonExistentGroup () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@tralala-group").path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .delete();
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group tralala-group is not found");
+    }
+
+    private void testUnsubscribeToDeletedGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("unsubscribe").request()
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("nemo", "pass"))
+                .delete();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.GROUP_DELETED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Group new-user-group has been deleted.");
+    }
+
+    @Test
+    public void testAddSameMemberRole ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", "dory");
+        form.param("roleId", "1");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").path("role").path("add").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        UserGroupMember member = memberDao.retrieveMemberById("dory", 1);
+        Set<Role> roles = member.getRoles();
+        assertEquals(2, roles.size());
+    }
+
+    @Test
+    public void testDeleteAddMemberRole ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", "dory");
+        form.param("roleId", "1");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").path("role").path("delete").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        UserGroupMember member = memberDao.retrieveMemberById("dory", 1);
+        Set<Role> roles = member.getRoles();
+        assertEquals(1, roles.size());
+        testAddSameMemberRole();
+    }
+
+    @Test
+    public void testEditMemberRoleEmpty ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", "dory");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").path("role").path("edit").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        UserGroupMember member = memberDao.retrieveMemberById("dory", 1);
+        Set<Role> roles = member.getRoles();
+        assertEquals(0, roles.size());
+        testEditMemberRole();
+    }
+
+    private void testEditMemberRole ()
+            throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("memberUsername", "dory");
+        form.param("roleId", "1");
+        form.param("roleId", "3");
+        Response response = target().path(API_VERSION).path("group")
+                .path("@marlin-group").path("role").path("edit").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        UserGroupMember member = memberDao.retrieveMemberById("dory", 1);
+        Set<Role> roles = member.getRoles();
+        assertEquals(2, roles.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/UserSettingControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/UserSettingControllerTest.java
new file mode 100644
index 0000000..176e31b
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/UserSettingControllerTest.java
@@ -0,0 +1,241 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.ws.rs.client.Entity;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha
+ */
+public class UserSettingControllerTest extends SpringJerseyTest {
+
+    private String username = "UserSetting_Test";
+
+    private String username2 = "UserSetting.Test2";
+
+    public Response sendPutRequest (String username, Map<String, Object> map)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .put(Entity.json(map));
+        return response;
+    }
+
+    @Test
+    public void testCreateSettingWithJson () throws KustvaktException {
+        String json = "{\"pos-foundry\":\"opennlp\",\"metadata\":[\"author\", \"title\","
+                + "\"textSigle\", \"availability\"],\"resultPerPage\":25}";
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        int numOfResult = 25;
+        String metadata = "[\"author\",\"title\",\"textSigle\","
+                + "\"availability\"]";
+        testRetrieveSettings(username, "opennlp", numOfResult, metadata, true);
+        testDeleteKeyNotExist(username);
+        testDeleteKey(username, numOfResult, metadata, true);
+        testDeleteSetting(username);
+    }
+
+    @Test
+    public void testCreateSettingWithMap () throws KustvaktException {
+        Map<String, Object> map = new HashMap<>();
+        map.put("pos-foundry", "opennlp");
+        map.put("resultPerPage", 25);
+        map.put("metadata", "author title textSigle availability");
+        Response response = sendPutRequest(username2, map);
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        testRetrieveSettings(username2, "opennlp", 25,
+                "author title textSigle availability", false);
+        testUpdateSetting(username2);
+        testputRequestInvalidKey();
+    }
+
+    @Test
+    public void testputRequestInvalidKey () throws KustvaktException {
+        Map<String, Object> map = new HashMap<>();
+        map.put("key/", "invalidKey");
+        Response response = sendPutRequest(username2, map);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(), "key/");
+    }
+
+    @Test
+    public void testPutDifferentUsername () throws KustvaktException {
+        String json = "{\"pos-foundry\":\"opennlp\",\"metadata\":\"author title "
+                + "textSigle availability\",\"resultPerPage\":25}";
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username2, "pass"))
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testGetDifferentUsername () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username2, "pass"))
+                .get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testGetSettingNotExist () throws KustvaktException {
+        String username = "tralala";
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(
+                "No default setting for username: " + username + " is found",
+                node.at("/errors/0/1").asText());
+        assertEquals(username, node.at("/errors/0/2").asText());
+    }
+
+    @Test
+    public void testDeleteSettingNotExist () throws KustvaktException {
+        String username = "tralala";
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testDeleteKeyDifferentUsername () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").path("pos-foundry").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username2, "pass"))
+                .delete();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+    }
+
+    private void testDeleteSetting (String username) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(username, node.at("/errors/0/2").asText());
+    }
+
+    // EM: deleting a non-existing key does not throw an error,
+    // because
+    // the purpose of the request has been achieved.
+    private void testDeleteKeyNotExist (String username)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").path("lemma-foundry").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    private void testDeleteKey (String username, int numOfResult,
+            String metadata, boolean isMetadataArray) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").path("pos-foundry").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testRetrieveSettings(username, null, numOfResult, metadata,
+                isMetadataArray);
+    }
+
+    private void testUpdateSetting (String username) throws KustvaktException {
+        Map<String, Object> map = new HashMap<>();
+        map.put("pos-foundry", "malt");
+        map.put("resultPerPage", 15);
+        map.put("metadata", "author title");
+        Response response = sendPutRequest(username, map);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        testRetrieveSettings(username, "malt", 15, "author title", false);
+    }
+
+    private void testRetrieveSettings (String username, String posFoundry,
+            int numOfResult, String metadata, boolean isMetadataArray)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("~" + username)
+                .path("setting").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        if (posFoundry == null) {
+            assertTrue(node.at("/pos-foundry").isMissingNode());
+        }
+        else {
+            assertEquals(posFoundry, node.at("/pos-foundry").asText());
+        }
+        assertEquals(numOfResult, node.at("/resultPerPage").asInt());
+        if (isMetadataArray) {
+            assertEquals(metadata, node.at("/metadata").toString());
+        }
+        else {
+            assertEquals(metadata, node.at("/metadata").asText());
+        }
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VCReferenceTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VCReferenceTest.java
new file mode 100644
index 0000000..346b71d
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VCReferenceTest.java
@@ -0,0 +1,215 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.cache.VirtualCorpusCache;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.NamedVCLoader;
+import de.ids_mannheim.korap.config.SpringJerseyTest;
+import de.ids_mannheim.korap.dao.QueryDao;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.util.QueryException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VCReferenceTest extends SpringJerseyTest {
+
+    @Autowired
+    private NamedVCLoader vcLoader;
+
+    @Autowired
+    private QueryDao dao;
+
+    /**
+     * VC data exists, but it has not been cached, so it is not found
+     * in the DB.
+     *
+     * @throws KustvaktException
+     */
+    @Test
+    public void testRefVcNotPrecached () throws KustvaktException {
+        JsonNode node = testSearchWithRef_VC1();
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Virtual corpus system/named-vc1 is not found.");
+        assertEquals(node.at("/errors/0/2").asText(), "system/named-vc1");
+    }
+
+    @Test
+    public void testRefVcPrecached ()
+            throws KustvaktException, IOException, QueryException {
+        int numOfMatches = testSearchWithoutRef_VC1();
+        vcLoader.loadVCToCache("named-vc1", "/vc/named-vc1.jsonld");
+        assertTrue(VirtualCorpusCache.contains("named-vc1"));
+        JsonNode node = testSearchWithRef_VC1();
+        assertEquals(numOfMatches, node.at("/matches").size());
+        testStatisticsWithRef();
+        numOfMatches = testSearchWithoutRef_VC2();
+        vcLoader.loadVCToCache("named-vc2", "/vc/named-vc2.jsonld");
+        assertTrue(VirtualCorpusCache.contains("named-vc2"));
+        node = testSearchWithRef_VC2();
+        assertEquals(numOfMatches, node.at("/matches").size());
+        VirtualCorpusCache.delete("named-vc2");
+        assertFalse(VirtualCorpusCache.contains("named-vc2"));
+        QueryDO vc = dao.retrieveQueryByName("named-vc1", "system");
+        dao.deleteQuery(vc);
+        vc = dao.retrieveQueryByName("named-vc1", "system");
+        assertNull(vc);
+        vc = dao.retrieveQueryByName("named-vc2", "system");
+        dao.deleteQuery(vc);
+        vc = dao.retrieveQueryByName("named-vc2", "system");
+        assertNull(vc);
+    }
+
+    private int testSearchWithoutRef_VC1 () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq",
+                        "textSigle=\"GOE/AGF/00000\" | textSigle=\"GOE/AGA/01784\"")
+                .request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        int size = node.at("/matches").size();
+        assertTrue(size > 0);
+        return size;
+    }
+
+    private int testSearchWithoutRef_VC2 () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq",
+                        "textSigle!=\"GOE/AGI/04846\" & textSigle!=\"GOE/AGA/01784\"")
+                .request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        int size = node.at("/matches").size();
+        assertTrue(size > 0);
+        return size;
+    }
+
+    private JsonNode testSearchWithRef_VC1 () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"system/named-vc1\"").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        return JsonUtils.readTree(ent);
+    }
+
+    private JsonNode testSearchWithRef_VC2 () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo named-vc2").request().get();
+        String ent = response.readEntity(String.class);
+        return JsonUtils.readTree(ent);
+    }
+
+    @Test
+    public void testStatisticsWithRef () throws KustvaktException {
+        String corpusQuery = "availability = /CC-BY.*/ & referTo named-vc1";
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", corpusQuery).request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(2, node.at("/documents").asInt());
+        VirtualCorpusCache.delete("named-vc1");
+        assertFalse(VirtualCorpusCache.contains("named-vc1"));
+    }
+
+    @Test
+    public void testRefVcNotExist () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"username/vc1\"").request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(), "username/vc1");
+    }
+
+    @Test
+    public void testRefNotAuthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"dory/dory-vc\"").request().get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/2").asText(), "guest");
+    }
+
+    @Test
+    public void testSearchWithRefPublishedVcGuest () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"marlin/published-vc\"").request()
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches").size() > 0);
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "CC-BY.*");
+        assertEquals(node.at("/collection/operands/1/@type").asText(),
+                "koral:doc");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "GOE");
+        assertEquals(node.at("/collection/operands/1/key").asText(),
+                "corpusSigle");
+        node = node.at("/collection/operands/1/rewrites");
+        assertEquals(3, node.size());
+        assertEquals(node.at("/0/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/0/scope").asText(), "@type(koral:docGroupRef)");
+        assertEquals(node.at("/1/operation").asText(), "operation:deletion");
+        assertEquals(node.at("/1/scope").asText(), "ref(marlin/published-vc)");
+        assertEquals(node.at("/2/operation").asText(), "operation:insertion");
+    }
+
+    @Test
+    public void testSearchWithRefPublishedVc () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "referTo \"marlin/published-vc\"").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("squirt", "pass"))
+                .get();
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches").size() > 0);
+        Form f = new Form();
+        f.param("status", "HIDDEN");
+        // check dory in the hidden group of the vc
+        response = target().path(API_VERSION).path("admin").path("group")
+                .path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(3, node.at("/0/id").asInt());
+        String members = node.at("/0/members").toString();
+        assertTrue(members.contains("\"userId\":\"squirt\""));
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusAccessTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusAccessTest.java
new file mode 100644
index 0000000..a1594fc
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusAccessTest.java
@@ -0,0 +1,165 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusAccessTest extends VirtualCorpusTestBase {
+
+    private String testUser = "VirtualCorpusAccessTest";
+
+    @Test
+    public void testlistAccessByNonVCAAdmin () throws KustvaktException {
+        JsonNode node = listAccessByGroup("nemo", "dory-group");
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: nemo");
+    }
+
+    // @Test
+    // public void testlistAccessMissingId () throws KustvaktException
+    // {
+    // Response response =
+    // target().path(API_VERSION).path("vc")
+    // .path("access")
+    // .request().header(Attributes.AUTHORIZATION,
+    // HttpAuthorizationHandler
+    // .createBasicAuthorizationHeaderValue(
+    // testUser, "pass"))
+    // .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+    // .get();
+    // String entity = response.readEntity(String.class);
+    // JsonNode node = JsonUtils.readTree(entity);
+    // assertEquals(Status.BAD_REQUEST.getStatusCode(),
+    // response.getStatus());
+    // assertEquals(StatusCodes.MISSING_PARAMETER,
+    // node.at("/errors/0/0").asInt());
+    // assertEquals("vcId", node.at("/errors/0/1").asText());
+    // }
+    @Test
+    public void testlistAccessByGroup () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .queryParam("groupName", "dory-group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(1, node.at("/0/accessId").asInt());
+        assertEquals(2, node.at("/0/queryId").asInt());
+        assertEquals(node.at("/0/queryName").asText(), "group-vc");
+        assertEquals(2, node.at("/0/userGroupId").asInt());
+        assertEquals(node.at("/0/userGroupName").asText(), "dory-group");
+    }
+
+    @Test
+    public void testDeleteSharedVC () throws KustvaktException {
+        String json = "{\"type\": \"PROJECT\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        String vcName = "new_project_vc";
+        String username = "dory";
+        String authHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, "pass");
+        createVC(authHeader, username, vcName, json);
+        String groupName = "dory-group";
+        testShareVCByCreator(username, vcName, groupName);
+        JsonNode node = listAccessByGroup(username, groupName);
+        assertEquals(2, node.size());
+        // delete project VC
+        deleteVC(vcName, username, username);
+        node = listAccessByGroup(username, groupName);
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testCreateDeleteAccess ()
+            throws ProcessingException, KustvaktException {
+        String vcName = "marlin-vc";
+        String groupName = "marlin-group";
+        // check the vc type
+        JsonNode node = retrieveVCInfo("marlin", "marlin", vcName);
+        assertEquals(vcName, node.at("/name").asText());
+        assertEquals(node.at("/type").asText(), "private");
+        // share vc to group
+        Response response = testShareVCByCreator("marlin", vcName, groupName);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // check the vc type
+        node = retrieveVCInfo("marlin", "marlin", vcName);
+        assertEquals(node.at("/type").asText(), "project");
+        // list vc access by marlin
+        node = listAccessByGroup("marlin", groupName);
+        assertEquals(2, node.size());
+        // get access id
+        node = node.get(1);
+        assertEquals(5, node.at("/queryId").asInt());
+        assertEquals(vcName, node.at("/queryName").asText());
+        assertEquals(1, node.at("/userGroupId").asInt());
+        assertEquals(groupName, node.at("/userGroupName").asText());
+        String accessId = node.at("/accessId").asText();
+        testShareVC_nonUniqueAccess("marlin", vcName, groupName);
+        // delete unauthorized
+        response = testDeleteAccess(testUser, accessId);
+        testResponseUnauthorized(response, testUser);
+        // delete access by vc-admin
+        // dory is a vc-admin in marlin group
+        response = testDeleteAccess("dory", accessId);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // list vc access by dory
+        node = listAccessByGroup("dory", groupName);
+        assertEquals(1, node.size());
+        // edit VC back to private
+        String json = "{\"type\": \"" + ResourceType.PRIVATE + "\"}";
+        editVC("marlin", "marlin", vcName, json);
+        node = retrieveVCInfo("marlin", "marlin", vcName);
+        assertEquals(ResourceType.PRIVATE.displayName(),
+                node.at("/type").asText());
+    }
+
+    private void testShareVC_nonUniqueAccess (String vcCreator, String vcName,
+            String groupName) throws ProcessingException, KustvaktException {
+        Response response = testShareVCByCreator(vcCreator, vcName, groupName);
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatus());
+        assertEquals(StatusCodes.DB_INSERT_FAILED,
+                node.at("/errors/0/0").asInt());
+        // EM: message differs depending on the database used
+        // for testing. The message below is from sqlite.
+        // assertTrue(node.at("/errors/0/1").asText()
+        // .startsWith("[SQLITE_CONSTRAINT_UNIQUE]"));
+    }
+
+    private Response testDeleteAccess (String username, String accessId)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .path(accessId).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+        return response;
+    }
+
+    @Test
+    public void testDeleteNonExistingAccess ()
+            throws ProcessingException, KustvaktException {
+        Response response = testDeleteAccess("dory", "100");
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusClientTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusClientTest.java
new file mode 100644
index 0000000..a3b7e12
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusClientTest.java
@@ -0,0 +1,76 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusClientTest extends VirtualCorpusTestBase {
+
+    private String username = "VirtualCorpusClientTest";
+
+    @Test
+    public void testVC_withClient () throws KustvaktException {
+        // create client
+        Response response = registerConfidentialClient(username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        String clientId = node.at("/client_id").asText();
+        String clientSecret = node.at("/client_secret").asText();
+        // obtain authorization
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, "password");
+        response = requestAuthorizationCode("code", clientId, clientRedirectUri,
+                "create_vc vc_info delete_vc edit_vc", "myState",
+                userAuthHeader);
+        String code = parseAuthorizationCode(response);
+        response = requestTokenWithAuthorizationCodeAndForm(clientId,
+                clientSecret, code, clientRedirectUri);
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        String accessToken = node.at("/access_token").asText();
+        String accessTokenHeader = "Bearer " + accessToken;
+        // create VC 1
+        String vcName = "vc-client1";
+        String vcJson = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        createVC(accessTokenHeader, username, vcName, vcJson);
+        // create VC 2
+        vcName = "vc-client2";
+        vcJson = "{\"type\": \"PRIVATE\"" + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate until 1820\"}";
+        createVC(accessTokenHeader, username, vcName, vcJson);
+        // edit VC
+        String description = "vc created from client";
+        vcJson = "{\"description\": \"" + description + "\"}";
+        editVC(username, username, vcName, vcJson);
+        // retrieve vc info
+        node = retrieveVCInfo(username, username, vcName);
+        assertEquals(description, node.at("/description").asText());
+        // list vc
+        node = listVCWithAuthHeader(accessTokenHeader);
+        assertEquals(3, node.size());
+        // delete vc
+        deleteVC(vcName, username, username);
+        // list vc
+        node = listVCWithAuthHeader(accessTokenHeader);
+        assertEquals(2, node.size());
+        // delete client
+        deregisterClient(username, clientId);
+        testSearchWithRevokedAccessToken(accessToken);
+        // obtain authorization from another client
+        response = requestTokenWithPassword(superClientId, this.clientSecret,
+                username, "pass");
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        accessToken = node.at("/access_token").asText();
+        // checking vc should still be available after client deregistration
+        node = listVCWithAuthHeader("Bearer " + accessToken);
+        assertEquals(2, node.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerAdminTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerAdminTest.java
new file mode 100644
index 0000000..90f9d4a
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerAdminTest.java
@@ -0,0 +1,223 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha
+ */
+public class VirtualCorpusControllerAdminTest extends VirtualCorpusTestBase {
+
+    private String admin = "admin";
+
+    private String testUser = "VirtualCorpusControllerAdminTest";
+
+    private JsonNode testAdminListVC (String username)
+            throws ProcessingException, KustvaktException {
+        Form f = new Form();
+        f.param("createdBy", username);
+        Response response = target().path(API_VERSION).path("admin").path("vc")
+                .path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    private JsonNode testAdminListVC_UsingAdminToken (String username,
+            ResourceType type) throws ProcessingException, KustvaktException {
+        Form f = new Form();
+        f.param("createdBy", username);
+        f.param("type", type.toString());
+        f.param("token", "secret");
+        Response response = target().path(API_VERSION).path("admin").path("vc")
+                .path("list").request()
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    @Test
+    public void testCreateSystemVC () throws KustvaktException {
+        String json = "{\"type\": \"SYSTEM\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~system").path("new-system-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        JsonNode node = testAdminListVC_UsingAdminToken("system",
+                ResourceType.SYSTEM);
+        assertEquals(2, node.size());
+        testDeleteSystemVC(admin, "new-system-vc");
+    }
+
+    private void testDeleteSystemVC (String vcCreator, String vcName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~system").path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = testAdminListVC_UsingAdminToken("system",
+                ResourceType.SYSTEM);
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testCreatePrivateVC ()
+            throws ProcessingException, KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        String vcName = "new-vc";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        JsonNode node = testAdminListVC(testUser);
+        assertEquals(1, node.size());
+        testEditPrivateVC(testUser, vcName);
+        testDeletePrivateVC(testUser, vcName);
+    }
+
+    private void testEditPrivateVC (String vcCreator, String vcName)
+            throws ProcessingException, KustvaktException {
+        String json = "{\"description\": \"edited vc\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + vcCreator).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
+        JsonNode node = testAdminListVC(testUser);
+        assertEquals(node.at("/0/description").asText(), "edited vc");
+    }
+
+    private void testDeletePrivateVC (String vcCreator, String vcName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + vcCreator).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = testAdminListVC(vcCreator);
+        assertEquals(0, node.size());
+    }
+
+    // @Deprecated
+    // private String testlistAccessByVC (String groupName) throws KustvaktException {
+    // Response response = target().path(API_VERSION).path("vc")
+    // .path("access")
+    // .queryParam("groupName", groupName)
+    // .request()
+    // .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+    // .createBasicAuthorizationHeaderValue(admin, "pass"))
+    // .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+    // .get();
+    // 
+    // String entity = response.readEntity(String.class);
+    // JsonNode node = JsonUtils.readTree(entity);
+    // assertEquals(1, node.size());
+    // node = node.get(0);
+    // 
+    // assertEquals(admin, node.at("/createdBy").asText());
+    // assertEquals(5, node.at("/vcId").asInt());
+    // assertEquals("marlin-vc", node.at("/vcName").asText());
+    // assertEquals(1, node.at("/userGroupId").asInt());
+    // assertEquals("marlin group", node.at("/userGroupName").asText());
+    // 
+    // return node.at("/accessId").asText();
+    // }
+    private JsonNode testlistAccessByGroup (String groupName)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .queryParam("groupName", groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.size());
+        return node.get(node.size() - 1);
+    }
+
+    @Test
+    public void testVCSharing () throws ProcessingException, KustvaktException {
+        String vcCreator = "marlin";
+        String vcName = "marlin-vc";
+        String groupName = "marlin-group";
+        JsonNode node2 = testAdminListVC_UsingAdminToken(vcCreator,
+                ResourceType.PROJECT);
+        assertEquals(0, node2.size());
+        testCreateVCAccess(vcCreator, vcName, groupName);
+        JsonNode node = testlistAccessByGroup(groupName);
+        String accessId = node.at("/accessId").asText();
+        testDeleteVCAccess(accessId);
+        node2 = testAdminListVC_UsingAdminToken(vcCreator,
+                ResourceType.PROJECT);
+        assertEquals(1, node2.size());
+        String json = "{\"type\": \"" + ResourceType.PRIVATE + "\"}";
+        editVC(admin, vcCreator, vcName, json);
+        node = retrieveVCInfo(admin, vcCreator, vcName);
+        assertEquals(ResourceType.PRIVATE.displayName(),
+                node.at("/type").asText());
+        node2 = testAdminListVC_UsingAdminToken(vcCreator,
+                ResourceType.PROJECT);
+        assertEquals(0, node2.size());
+    }
+
+    private void testCreateVCAccess (String vcCreator, String vcName,
+            String groupName) throws ProcessingException, KustvaktException {
+        Response response;
+        // share VC
+        response = target().path(API_VERSION).path("vc").path("~" + vcCreator)
+                .path(vcName).path("share").path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    private void testDeleteVCAccess (String accessId)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .path(accessId).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .delete();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerTest.java
new file mode 100644
index 0000000..a9ff786
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusControllerTest.java
@@ -0,0 +1,479 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.AuthenticationScheme;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+/**
+ * @author margaretha
+ */
+public class VirtualCorpusControllerTest extends VirtualCorpusTestBase {
+
+    private String testUser = "vcControllerTest";
+
+    private String authHeader;
+
+    public VirtualCorpusControllerTest () throws KustvaktException {
+        authHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(testUser, "pass");
+    }
+
+    @Test
+    public void testCreatePrivateVC () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        createVC(authHeader, testUser, "new_vc", json);
+        // list user VC
+        JsonNode node = listVC(testUser);
+        assertEquals(2, node.size());
+        assertEquals(node.get(1).get("name").asText(), "new_vc");
+        // delete new VC
+        deleteVC("new_vc", testUser, testUser);
+        // list VC
+        node = listVC(testUser);
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testCreatePublishedVC () throws KustvaktException {
+        String json = "{\"type\": \"PUBLISHED\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        String vcName = "new-published-vc";
+        createVC(authHeader, testUser, vcName, json);
+        // test list owner vc
+        JsonNode node = retrieveVCInfo(testUser, testUser, vcName);
+        assertEquals(vcName, node.get("name").asText());
+        // EM: check hidden access
+        node = listAccessByGroup("admin", "");
+        node = node.get(node.size() - 1);
+        assertEquals(node.at("/createdBy").asText(), "system");
+        assertEquals(vcName, node.at("/queryName").asText());
+        assertTrue(node.at("/userGroupName").asText().startsWith("auto"));
+        assertEquals(vcName, node.at("/queryName").asText());
+        String groupName = node.at("/userGroupName").asText();
+        // EM: check if hidden group has been created
+        node = testCheckHiddenGroup(groupName);
+        assertEquals(node.at("/status").asText(), "HIDDEN");
+        // EM: delete vc
+        deleteVC(vcName, testUser, testUser);
+        // EM: check if the hidden groups are deleted as well
+        node = testCheckHiddenGroup(groupName);
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Group " + groupName + " is not found",
+                node.at("/errors/0/1").asText());
+    }
+
+    private JsonNode testCheckHiddenGroup (String groupName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").post(null);
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    @Test
+    public void testCreateVCWithInvalidToken ()
+            throws IOException, KustvaktException {
+        String json = "{\"type\": \"PRIVATE\","
+                + "\"corpusQuery\": \"corpusSigle=GOE\"}";
+        InputStream is = getClass().getClassLoader()
+                .getResourceAsStream("test-invalid-signature.token");
+        String authToken;
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(is))) {
+            authToken = reader.readLine();
+        }
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION,
+                        AuthenticationScheme.BEARER.displayName() + " "
+                                + authToken)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is invalid");
+        checkWWWAuthenticateHeader(response);
+    }
+
+    @Test
+    public void testCreateVCWithExpiredToken ()
+            throws IOException, KustvaktException {
+        String json = "{\"type\": \"PRIVATE\","
+                + "\"corpusQuery\": \"corpusSigle=GOE\"}";
+
+        //String authToken = "fia0123ikBWn931470H8s5gRqx7Moc4p";
+        String authToken = createExpiredAccessToken();
+
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~marlin").path("new_vc").request()
+                .header(Attributes.AUTHORIZATION,
+                        AuthenticationScheme.BEARER.displayName() + " "
+                                + authToken)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.EXPIRED, node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Access token is expired");
+        checkWWWAuthenticateHeader(response);
+    }
+
+    @Test
+    public void testCreateSystemVC () throws KustvaktException {
+        String json = "{\"type\": \"SYSTEM\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"pubDate since 1820\"}";
+        String vcName = "new_system_vc";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~system").path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        JsonNode node = listSystemVC("pearl");
+        assertEquals(2, node.size());
+        assertEquals(ResourceType.SYSTEM.displayName(),
+                node.at("/0/type").asText());
+        assertEquals(ResourceType.SYSTEM.displayName(),
+                node.at("/1/type").asText());
+        deleteVC(vcName, "system", "admin");
+        node = listSystemVC("pearl");
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testCreateSystemVC_unauthorized () throws KustvaktException {
+        String json = "{\"type\": \"SYSTEM\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        testResponseUnauthorized(response, testUser);
+    }
+
+    @Test
+    public void testCreateVC_invalidName () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new $vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testCreateVC_nameTooShort () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("ne").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "queryName must contain at least 3 characters");
+    }
+
+    @Test
+    public void testCreateVC_unauthorized () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\","
+                + "\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: guest");
+        checkWWWAuthenticateHeader(response);
+    }
+
+    @Test
+    public void testCreateVC_withoutCorpusQuery () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\"" + "}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "corpusQuery is null");
+        assertEquals(node.at("/errors/0/2").asText(), "corpusQuery");
+    }
+
+    @Test
+    public void testCreateVC_withoutEntity () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(""));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "request entity is null");
+        assertEquals(node.at("/errors/0/2").asText(), "request entity");
+    }
+
+    @Test
+    public void testCreateVC_withoutType () throws KustvaktException {
+        String json = "{\"corpusQuery\": " + "\"creationDate since 1820\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\"" + "}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "type is null");
+        assertEquals(node.at("/errors/0/2").asText(), "type");
+    }
+
+    @Test
+    public void testCreateVC_withWrongType () throws KustvaktException {
+        String json = "{\"type\": \"PRIVAT\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"creationDate since 1820\"}";
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertTrue(node.at("/errors/0/1").asText().startsWith(
+                "Cannot deserialize value of type `de.ids_mannheim.korap.constant."
+                        + "ResourceType` from String \"PRIVAT\""));
+    }
+
+    @Test
+    public void testMaxNumberOfVC () throws KustvaktException {
+        String json = "{\"type\": \"PRIVATE\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        for (int i = 1; i < 6; i++) {
+            createVC(authHeader, testUser, "new_vc_" + i, json);
+        }
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + testUser).path("new_vc_6").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NOT_ALLOWED, node.at("/errors/0/0").asInt());
+        assertEquals(
+                "Cannot create virtual corpus. The maximum number of "
+                        + "virtual corpus has been reached.",
+                node.at("/errors/0/1").asText());
+        // list user VC
+        node = listVC(testUser);
+        // including 1 system-vc
+        assertEquals(6, node.size());
+        // delete new VC
+        for (int i = 1; i < 6; i++) {
+            deleteVC("new_vc_" + i, testUser, testUser);
+        }
+        // list VC
+        node = listVC(testUser);
+        // system-vc
+        assertEquals(1, node.size());
+    }
+
+    @Test
+    public void testDeleteVC_unauthorized () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("dory-vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader).delete();
+        testResponseUnauthorized(response, testUser);
+    }
+
+    @Test
+    public void testEditVC () throws KustvaktException {
+        // 1st edit
+        String json = "{\"description\": \"edited vc\"}";
+        editVC("dory", "dory", "dory-vc", json);
+        // check VC
+        JsonNode node = retrieveVCInfo("dory", "dory", "dory-vc");
+        assertEquals(node.at("/description").asText(), "edited vc");
+        // 2nd edit
+        json = "{\"description\": \"test vc\"}";
+        editVC("dory", "dory", "dory-vc", json);
+        // check VC
+        node = retrieveVCInfo("dory", "dory", "dory-vc");
+        assertEquals(node.at("/description").asText(), "test vc");
+    }
+
+    @Test
+    public void testEditVCName () throws KustvaktException {
+        String json = "{\"name\": \"new-name\"}";
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("dory-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testEditCorpusQuery ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testRetrieveKoralQuery("dory", "dory-vc");
+        assertEquals(node.at("/collection/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/collection/operation").asText(),
+                "operation:and");
+        assertEquals(2, node.at("/collection/operands").size());
+        String json = "{\"corpusQuery\": \"corpusSigle=WPD17\"}";
+        editVC("dory", "dory", "dory-vc", json);
+        node = testRetrieveKoralQuery("dory", "dory-vc");
+        assertEquals(node.at("/collection/@type").asText(), "koral:doc");
+        assertEquals(node.at("/collection/key").asText(), "corpusSigle");
+        assertEquals(node.at("/collection/value").asText(), "WPD17");
+    }
+
+    private JsonNode testRetrieveKoralQuery (String username, String vcName)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("koralQuery").path("~" + username).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    @Test
+    public void testEditVC_notOwner () throws KustvaktException {
+        String json = "{\"description\": \"edited vc\"}";
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("dory-vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(json));
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: " + testUser,
+                node.at("/errors/0/1").asText());
+        checkWWWAuthenticateHeader(response);
+    }
+
+    @Test
+    public void testPublishProjectVC () throws KustvaktException {
+        String vcName = "group-vc";
+        // check the vc type
+        JsonNode node = retrieveVCInfo("dory", "dory", vcName);
+        assertEquals(ResourceType.PROJECT.displayName(),
+                node.get("type").asText());
+        // edit vc
+        String json = "{\"type\": \"PUBLISHED\"}";
+        editVC("dory", "dory", vcName, json);
+        // check VC
+        node = testListOwnerVC("dory");
+        JsonNode n = node.get(1);
+        assertEquals(ResourceType.PUBLISHED.displayName(),
+                n.get("type").asText());
+        // check hidden VC access
+        node = listAccessByGroup("admin", "");
+        assertEquals(4, node.size());
+        node = node.get(node.size() - 1);
+        assertEquals(vcName, node.at("/queryName").asText());
+        assertEquals(node.at("/createdBy").asText(), "system");
+        assertTrue(node.at("/userGroupName").asText().startsWith("auto"));
+        // edit 2nd
+        json = "{\"type\": \"PROJECT\"}";
+        editVC("dory", "dory", vcName, json);
+        node = testListOwnerVC("dory");
+        assertEquals(ResourceType.PROJECT.displayName(),
+                node.get(1).get("type").asText());
+        // check VC access
+        node = listAccessByGroup("admin", "");
+        assertEquals(3, node.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusFieldTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusFieldTest.java
new file mode 100644
index 0000000..27c527d
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusFieldTest.java
@@ -0,0 +1,142 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+
+import org.apache.http.entity.ContentType;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.cache.VirtualCorpusCache;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.NamedVCLoader;
+import de.ids_mannheim.korap.dao.QueryDao;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.util.QueryException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusFieldTest extends VirtualCorpusTestBase {
+
+    @Autowired
+    private NamedVCLoader vcLoader;
+
+    @Autowired
+    private QueryDao dao;
+
+    private JsonNode testRetrieveField (String username, String vcName,
+            String field) throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("field")
+                .path("~" + username).path(vcName)
+                .queryParam("fieldName", field).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    private void testRetrieveProhibitedField (String username, String vcName,
+            String field) throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("field")
+                .path("~" + username).path(vcName)
+                .queryParam("fieldName", field).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NOT_ALLOWED, node.at("/errors/0/0").asInt());
+    }
+
+    private void deleteVcFromDB (String vcName) throws KustvaktException {
+        QueryDO vc = dao.retrieveQueryByName(vcName, "system");
+        dao.deleteQuery(vc);
+        vc = dao.retrieveQueryByName(vcName, "system");
+        assertEquals(null, vc);
+    }
+
+    @Test
+    public void testRetrieveFieldsNamedVC1 ()
+            throws IOException, QueryException, KustvaktException {
+        vcLoader.loadVCToCache("named-vc1", "/vc/named-vc1.jsonld");
+        JsonNode n = testRetrieveField("system", "named-vc1", "textSigle");
+        assertEquals(n.at("/@context").asText(),
+                "http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld");
+        assertEquals(n.at("/corpus/key").asText(), "textSigle");
+        assertEquals(2, n.at("/corpus/value").size());
+        n = testRetrieveField("system", "named-vc1", "author");
+        assertEquals(2, n.at("/corpus/value").size());
+        assertEquals(n.at("/corpus/value/0").asText(),
+                "Goethe, Johann Wolfgang von");
+        testRetrieveUnknownTokens();
+        testRetrieveProhibitedField("system", "named-vc1", "tokens");
+        testRetrieveProhibitedField("system", "named-vc1", "base");
+        VirtualCorpusCache.delete("named-vc1");
+        deleteVcFromDB("named-vc1");
+    }
+
+    private void testRetrieveUnknownTokens ()
+            throws ProcessingException, KustvaktException {
+        JsonNode n = testRetrieveField("system", "named-vc1", "unknown");
+        assertEquals(n.at("/corpus/key").asText(), "unknown");
+        assertEquals(0, n.at("/corpus/value").size());
+    }
+
+    @Test
+    public void testRetrieveTextSigleNamedVC2 ()
+            throws IOException, QueryException, KustvaktException {
+        vcLoader.loadVCToCache("named-vc2", "/vc/named-vc2.jsonld");
+        JsonNode n = testRetrieveField("system", "named-vc2", "textSigle");
+        assertEquals(2, n.at("/corpus/value").size());
+        VirtualCorpusCache.delete("named-vc2");
+        deleteVcFromDB("named-vc2");
+    }
+
+    @Test
+    public void testRetrieveTextSigleNamedVC3 ()
+            throws IOException, QueryException, KustvaktException {
+        vcLoader.loadVCToCache("named-vc3", "/vc/named-vc3.jsonld");
+        JsonNode n = testRetrieveField("system", "named-vc3", "textSigle");
+        n = n.at("/corpus/value");
+        assertEquals(1, n.size());
+        assertEquals(n.get(0).asText(), "GOE/AGI/00000");
+        VirtualCorpusCache.delete("named-vc3");
+        deleteVcFromDB("named-vc3");
+    }
+
+    @Test
+    public void testRetrieveFieldUnauthorized ()
+            throws KustvaktException, IOException, QueryException {
+        vcLoader.loadVCToCache("named-vc3", "/vc/named-vc3.jsonld");
+        Response response = target().path(API_VERSION).path("vc").path("field")
+                .path("~system").path("named-vc3")
+                .queryParam("fieldName", "textSigle").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .get();
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: dory");
+        VirtualCorpusCache.delete("named-vc3");
+        deleteVcFromDB("named-vc3");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusInfoTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusInfoTest.java
new file mode 100644
index 0000000..4d20c0f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusInfoTest.java
@@ -0,0 +1,157 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusInfoTest extends VirtualCorpusTestBase {
+
+    private String admin = "admin";
+
+    private String testUser = "VirtualCorpusInfoTest";
+
+    @Test
+    public void testRetrieveSystemVC ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = retrieveVCInfo(testUser, "system", "system-vc");
+        assertEquals(node.at("/name").asText(), "system-vc");
+        assertEquals(ResourceType.SYSTEM.displayName(),
+                node.at("/type").asText());
+        // assertEquals("koral:doc", node.at("/koralQuery/collection/@type").asText());
+        assertTrue(node.at("/query").isMissingNode());
+        assertTrue(node.at("/queryLanguage").isMissingNode());
+    }
+
+    @Test
+    public void testRetrieveSystemVCGuest ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~system").path("system-vc").request().get();
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(node.at("/name").asText(), "system-vc");
+        assertEquals(ResourceType.SYSTEM.displayName(),
+                node.at("/type").asText());
+    }
+
+    @Test
+    public void testRetrieveOwnerPrivateVC ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = retrieveVCInfo("dory", "dory", "dory-vc");
+        assertEquals(node.at("/name").asText(), "dory-vc");
+        assertEquals(ResourceType.PRIVATE.displayName(),
+                node.at("/type").asText());
+    }
+
+    @Test
+    public void testRetrievePrivateVCUnauthorized ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("dory-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .get();
+        testResponseUnauthorized(response, testUser);
+    }
+
+    @Test
+    public void testRetrieveProjectVC ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = retrieveVCInfo("nemo", "dory", "group-vc");
+        assertEquals(node.at("/name").asText(), "group-vc");
+        assertEquals(ResourceType.PROJECT.displayName(),
+                node.at("/type").asText());
+    }
+
+    @Test
+    public void testRetrieveProjectVCUnauthorized ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("group-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(testUser, "pass"))
+                .get();
+        testResponseUnauthorized(response, testUser);
+    }
+
+    @Test
+    public void testRetrieveProjectVCbyNonActiveMember ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("group-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("marlin", "pass"))
+                .get();
+        testResponseUnauthorized(response, "marlin");
+    }
+
+    @Test
+    public void testRetrievePublishedVC ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = retrieveVCInfo("gill", "marlin", "published-vc");
+        assertEquals(node.at("/name").asText(), "published-vc");
+        assertEquals(ResourceType.PUBLISHED.displayName(),
+                node.at("/type").asText());
+        Form f = new Form();
+        f.param("status", "HIDDEN");
+        // check gill in the hidden group of the vc
+        Response response = target().path(API_VERSION).path("admin")
+                .path("group").path("list").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        MediaType.APPLICATION_FORM_URLENCODED)
+                .post(Entity.form(f));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(3, node.at("/0/id").asInt());
+        String members = node.at("/0/members").toString();
+        assertTrue(members.contains("\"userId\":\"gill\""));
+    }
+
+    @Test
+    public void testAdminRetrievePrivateVC ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("dory-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(1, node.at("/id").asInt());
+        assertEquals(node.at("/name").asText(), "dory-vc");
+    }
+
+    @Test
+    public void testAdminRetrieveProjectVC ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .path("group-vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(admin, "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(node.at("/name").asText(), "group-vc");
+        assertEquals(ResourceType.PROJECT.displayName(),
+                node.at("/type").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusListTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusListTest.java
new file mode 100644
index 0000000..f1e1d17
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusListTest.java
@@ -0,0 +1,93 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusListTest extends VirtualCorpusTestBase {
+
+    @Test
+    public void testListVCNemo ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testListOwnerVC("nemo");
+        assertEquals(1, node.size());
+        node = listSystemVC("nemo");
+        assertEquals(1, node.size());
+        node = listVC("nemo");
+        assertEquals(3, node.size());
+    }
+
+    @Test
+    public void testListVCPearl ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testListOwnerVC("pearl");
+        assertEquals(0, node.size());
+        node = listVC("pearl");
+        assertEquals(2, node.size());
+    }
+
+    @Test
+    public void testListVCDory ()
+            throws ProcessingException, KustvaktException {
+        JsonNode node = testListOwnerVC("dory");
+        assertEquals(2, node.size());
+        node = listVC("dory");
+        assertEquals(4, node.size());
+    }
+
+    @Test
+    public void testListAvailableVCGuest ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").request()
+                .get();
+        testResponseUnauthorized(response, "guest");
+    }
+
+    @Disabled
+    @Deprecated
+    @Test
+    public void testListAvailableVCByOtherUser ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("~dory")
+                .request().header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Unauthorized operation for user: pearl");
+        checkWWWAuthenticateHeader(response);
+    }
+
+    @Disabled
+    @Deprecated
+    @Test
+    public void testListUserVC ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .queryParam("username", "dory").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("admin", "pass"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(4, node.size());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusSharingTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusSharingTest.java
new file mode 100644
index 0000000..d7de5a3
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusSharingTest.java
@@ -0,0 +1,202 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.constant.GroupMemberStatus;
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class VirtualCorpusSharingTest extends VirtualCorpusTestBase {
+
+    private String testUser = "VirtualCorpusSharingTest";
+
+    @Test
+    public void testShareUnknownVC ()
+            throws ProcessingException, KustvaktException {
+        Response response = testShareVCByCreator("marlin", "non-existing-vc",
+                "marlin group");
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testShareUnknownGroup ()
+            throws ProcessingException, KustvaktException {
+        Response response = testShareVCByCreator("marlin", "marlin-vc",
+                "non-existing-group");
+        JsonNode node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testShareVC_notOwner ()
+            throws ProcessingException, KustvaktException {
+        // dory is VCA in marlin group
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~marlin").path("marlin-vc").path("share")
+                .path("@marlin group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("dory", "pass"))
+                .post(Entity.form(new Form()));
+        testResponseUnauthorized(response, "dory");
+    }
+
+    @Test
+    public void testShareVC_byMember ()
+            throws ProcessingException, KustvaktException {
+        // nemo is not VCA in marlin group
+        Response response = target().path(API_VERSION).path("vc").path("~nemo")
+                .path("nemo-vc").path("share").path("@marlin-group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("nemo", "pass"))
+                .post(Entity.form(new Form()));
+        testResponseUnauthorized(response, "nemo");
+    }
+
+    @Test
+    public void testCreateShareProjectVC () throws KustvaktException {
+        String json = "{\"type\": \"PROJECT\""
+                + ",\"queryType\": \"VIRTUAL_CORPUS\""
+                + ",\"corpusQuery\": \"corpusSigle=GOE\"}";
+        String vcName = "new_project_vc";
+        String authHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(testUser, "pass");
+        createVC(authHeader, testUser, vcName, json);
+        // retrieve vc info
+        JsonNode vcInfo = retrieveVCInfo(testUser, testUser, vcName);
+        assertEquals(vcName, vcInfo.get("name").asText());
+        // list user VC
+        JsonNode node = listVC(testUser);
+        assertEquals(2, node.size());
+        assertEquals(vcName, node.get(1).get("name").asText());
+        // search by non member
+        Response response = searchWithVCRef("dory", testUser, vcName);
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        // create user group
+        String groupName = "owidGroup";
+        String memberName = "darla";
+        response = createUserGroup(testUser, groupName, "Owid users");
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+        listUserGroup(testUser, groupName);
+        testInviteMember(testUser, groupName, "darla");
+        subscribeToGroup(memberName, groupName);
+        checkMemberInGroup(memberName, testUser, groupName);
+        // share vc to group
+        testShareVCByCreator(testUser, vcName, groupName);
+        node = listAccessByGroup(testUser, groupName);
+        // search by member
+        response = searchWithVCRef(memberName, testUser, vcName);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertTrue(node.at("/matches").size() > 0);
+        // delete project VC
+        deleteVC(vcName, testUser, testUser);
+        // list VC
+        node = listVC(testUser);
+        assertEquals(1, node.size());
+        // search by member
+        response = searchWithVCRef(memberName, testUser, vcName);
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.readEntity(String.class));
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+    }
+
+    private Response createUserGroup (String username, String groupName,
+            String description) throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("description", description);
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .put(Entity.form(form));
+        return response;
+    }
+
+    private JsonNode listUserGroup (String username, String groupName)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    private void testInviteMember (String username, String groupName,
+            String memberName) throws ProcessingException, KustvaktException {
+        Form form = new Form();
+        form.param("members", memberName);
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("invite").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .post(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        // list group
+        JsonNode node = listUserGroup(username, groupName);
+        node = node.get(0);
+        assertEquals(2, node.get("members").size());
+        assertEquals(memberName, node.at("/members/1/userId").asText());
+        assertEquals(GroupMemberStatus.PENDING.name(),
+                node.at("/members/1/status").asText());
+        assertEquals(0, node.at("/members/1/roles").size());
+    }
+
+    private void subscribeToGroup (String username, String groupName)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("group")
+                .path("@" + groupName).path("subscribe").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .post(Entity.form(new Form()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    private void checkMemberInGroup (String memberName, String testUser,
+            String groupName) throws KustvaktException {
+        JsonNode node = listUserGroup(testUser, groupName).get(0);
+        assertEquals(2, node.get("members").size());
+        assertEquals(memberName, node.at("/members/1/userId").asText());
+        assertEquals(GroupMemberStatus.ACTIVE.name(),
+                node.at("/members/1/status").asText());
+        assertEquals(PredefinedRole.VC_ACCESS_MEMBER.name(),
+                node.at("/members/1/roles/1").asText());
+        assertEquals(PredefinedRole.USER_GROUP_MEMBER.name(),
+                node.at("/members/1/roles/0").asText());
+    }
+
+    private Response searchWithVCRef (String username, String vcCreator,
+            String vcName) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("cq",
+                        "referTo \"" + vcCreator + "/" + vcName + "\"")
+                .request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        return response;
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusTestBase.java b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusTestBase.java
new file mode 100644
index 0000000..04538e9
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/VirtualCorpusTestBase.java
@@ -0,0 +1,180 @@
+package de.ids_mannheim.korap.web.controller;
+
+import static org.hamcrest.CoreMatchers.hasItem;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.apache.http.entity.ContentType;
+import org.glassfish.jersey.server.ContainerRequest;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public abstract class VirtualCorpusTestBase extends OAuth2TestBase {
+
+    protected JsonNode retrieveVCInfo (String username, String vcCreator,
+            String vcName) throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + vcCreator).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        return JsonUtils.readTree(entity);
+    }
+
+    protected void createVC (String authHeader, String username, String vcName,
+            String vcJson) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + username).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, authHeader)
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(vcJson));
+
+        assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
+    }
+
+    protected void editVC (String username, String vcCreator, String vcName,
+            String vcJson) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + vcCreator).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON)
+                .put(Entity.json(vcJson));
+
+        assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
+    }
+
+    protected JsonNode listVC (String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        // System.out.println(entity);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected JsonNode listVCWithAuthHeader (String authHeader)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").request()
+                .header(Attributes.AUTHORIZATION, authHeader).get();
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected JsonNode testListOwnerVC (String username)
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .queryParam("filter-by", "own").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+
+    protected JsonNode listSystemVC (String username) throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .queryParam("filter-by", "system").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("pearl", "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    protected Response testShareVCByCreator (String vcCreator, String vcName,
+            String groupName) throws ProcessingException, KustvaktException {
+
+        return target().path(API_VERSION).path("vc").path("~" + vcCreator)
+                .path(vcName).path("share").path("@" + groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(vcCreator, "pass"))
+                .post(Entity.form(new Form()));
+    }
+
+    protected JsonNode listAccessByGroup (String username, String groupName)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc").path("access")
+                .queryParam("groupName", groupName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        return node;
+    }
+
+    protected void deleteVC (String vcName, String vcCreator, String username)
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("vc")
+                .path("~" + vcCreator).path(vcName).request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .delete();
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+    }
+
+    protected void testResponseUnauthorized (Response response, String username)
+            throws KustvaktException {
+        assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+
+        assertEquals(StatusCodes.AUTHORIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("Unauthorized operation for user: " + username,
+                node.at("/errors/0/1").asText());
+
+        checkWWWAuthenticateHeader(response);
+    }
+
+    protected void checkWWWAuthenticateHeader (Response response) {
+        Set<Entry<String, List<Object>>> headers = response.getHeaders()
+                .entrySet();
+
+        for (Entry<String, List<Object>> header : headers) {
+            if (header.getKey().equals(ContainerRequest.WWW_AUTHENTICATE)) {
+                assertThat(header.getValue(),
+                        not(hasItem("Api realm=\"Kustvakt\"")));
+                assertThat(header.getValue(),
+                        hasItem("Bearer realm=\"Kustvakt\""));
+                assertThat(header.getValue(),
+                        hasItem("Basic realm=\"Kustvakt\""));
+            }
+        }
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/InfoControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/InfoControllerTest.java
new file mode 100644
index 0000000..27054fd
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/InfoControllerTest.java
@@ -0,0 +1,45 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.utils.ServiceInfo;
+import de.ids_mannheim.korap.web.SearchKrill;
+
+public class InfoControllerTest extends LiteJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    @Autowired
+    private SearchKrill krill;
+
+    @Test
+    public void testInfo () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("info").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(config.getCurrentVersion(),
+                node.at("/latest_api_version").asText());
+        assertEquals(config.getSupportedVersions().size(),
+                node.at("/supported_api_versions").size());
+        assertEquals(ServiceInfo.getInfo().getVersion(),
+                node.at("/kustvakt_version").asText());
+        assertEquals(krill.getIndex().getVersion(),
+                node.at("/krill_version").asText());
+        assertEquals(ServiceInfo.getInfo().getKoralVersion(),
+                node.at("/koral_version").asText());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/LiteMultipleCorpusQueryTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/LiteMultipleCorpusQueryTest.java
new file mode 100644
index 0000000..5d93355
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/LiteMultipleCorpusQueryTest.java
@@ -0,0 +1,76 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class LiteMultipleCorpusQueryTest extends LiteJerseyTest {
+
+    @Test
+    public void testSearchGet () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "das").queryParam("ql", "poliqarp")
+                .queryParam("cq", "pubPlace=München")
+                .queryParam("cq", "textSigle=\"GOE/AGA/01784\"").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        node = node.at("/collection");
+        assertEquals(node.at("/@type").asText(), "koral:docGroup");
+        assertEquals(node.at("/operation").asText(), "operation:and");
+        assertEquals(2, node.at("/operands").size());
+        assertEquals(node.at("/operands/0/@type").asText(), "koral:doc");
+        assertEquals(node.at("/operands/0/match").asText(), "match:eq");
+        assertEquals(node.at("/operands/0/key").asText(), "pubPlace");
+        assertEquals(node.at("/operands/0/value").asText(), "München");
+        assertEquals(node.at("/operands/1/key").asText(), "textSigle");
+        assertEquals(node.at("/operands/1/value").asText(), "GOE/AGA/01784");
+    }
+
+    @Test
+    public void testStatisticsWithMultipleCq ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung")
+                .queryParam("cq", "corpusSigle=GOE").request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testStatisticsWithMultipleCorpusQuery ()
+            throws ProcessingException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "textType=Autobiographie")
+                .queryParam("corpusQuery", "corpusSigle=GOE").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(9, node.at("/documents").asInt());
+        assertEquals(527662, node.at("/tokens").asInt());
+        assertEquals(19387, node.at("/sentences").asInt());
+        assertEquals(514, node.at("/paragraphs").asInt());
+        assertEquals(StatusCodes.DEPRECATED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "Parameter corpusQuery is deprecated in favor of cq.");
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchControllerTest.java
new file mode 100644
index 0000000..610646c0
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchControllerTest.java
@@ -0,0 +1,495 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Iterator;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.net.HttpHeaders;
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.query.serialize.QuerySerializer;
+import de.ids_mannheim.korap.utils.JsonUtils;
+import de.ids_mannheim.korap.web.SearchKrill;
+
+public class LiteSearchControllerTest extends LiteJerseyTest {
+
+    @Autowired
+    private SearchKrill searchKrill;
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    // EM: The API is disabled
+    @Disabled
+    @Test
+    public void testGetJSONQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertEquals(node.at("/query/wrap/foundry").asText(), "opennlp");
+        assertEquals(node.at("/meta/context").asText(), "sentence");
+        assertEquals(node.at("/meta/count").asText(), "13");
+    }
+
+    // EM: The API is disabled
+    @Disabled
+    @Test
+    public void testbuildAndPostQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("cq", "corpusSigle=WPD | corpusSigle=GOE").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        response = target().path(API_VERSION).path("search").request()
+                .post(Entity.json(query));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String matches = response.readEntity(String.class);
+        JsonNode match_node = JsonUtils.readTree(matches);
+        assertNotEquals(0, match_node.path("matches").size());
+    }
+
+    @Test
+    public void testApiWelcomeMessage () {
+        Response response = target().path(API_VERSION).path("").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String message = response.readEntity(String.class);
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        assertEquals(message, config.getApiWelcomeMessage());
+    }
+
+    @Test
+    public void testQueryGet () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertEquals(node.at("/meta/context").asText(), "base/s:s");
+        assertEquals(node.at("/meta/count").asText(), "13");
+        assertNotEquals(0, node.at("/matches").size());
+    }
+
+    @Test
+    public void testQueryFailure () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das").queryParam("ql", "poliqarp")
+                .queryParam("cq", "corpusSigle=WPD | corpusSigle=GOE")
+                .queryParam("count", "13").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(302, node.at("/errors/0/0").asInt());
+        assertEquals(302, node.at("/errors/1/0").asInt());
+        assertTrue(node.at("/errors/2").isMissingNode());
+        assertFalse(node.at("/collection").isMissingNode());
+        assertEquals(13, node.at("/meta/count").asInt());
+    }
+
+    @Test
+    public void testFoundryRewrite () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertEquals(node.at("/query/wrap/foundry").asText(), "opennlp");
+    }
+
+    // EM: The API is disabled
+    @Test
+    @Disabled
+    public void testQueryPost () throws KustvaktException {
+        QuerySerializer s = new QuerySerializer();
+        s.setQuery("[orth=das]", "poliqarp");
+        Response response = target().path(API_VERSION).path("search").request()
+                .post(Entity.json(s.toJSON()));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertNotEquals(0, node.at("/matches").size());
+    }
+
+    @Test
+    public void testParameterField () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author,docSigle")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertNotEquals(0, node.at("/matches").size());
+        assertEquals(node.at("/meta/fields").toString(),
+                "[\"author\",\"docSigle\"]");
+    }
+
+    @Test
+    public void testMatchInfoGetWithoutSpans () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/GOE/AGA/01784/p36-46(5)37-45(2)38-42/matchInfo")
+                .queryParam("foundry", "*").queryParam("spans", "false")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(node.at("/matchID").asText(),
+                "match-GOE/AGA/01784-p36-46(5)37-45(2)38-42");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+    }
+
+    @Test
+    public void testMatchInfoGetWithoutHighlights () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/GOE/AGA/01784/p36-46(5)37-45(2)38-42/matchInfo")
+                .queryParam("foundry", "xy").queryParam("spans", "false")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/snippet").asText(),
+                "<span class=\"context-left\"></span><span class=\"match\">der alte freie Weg nach Mainz war gesperrt, ich mußte über die Schiffbrücke bei Rüsselsheim; in Ginsheim ward <mark>gefüttert; der Ort ist sehr zerschossen; dann über die Schiffbrücke</mark> auf die Nonnenaue, wo viele Bäume niedergehauen lagen, sofort auf dem zweiten Teil der Schiffbrücke über den größern Arm des Rheins.</span><span class=\"context-right\"></span>");
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(node.at("/matchID").asText(),
+                "match-GOE/AGA/01784-p36-46(5)37-45(2)38-42");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+    }
+
+    @Test
+    public void testMatchInfoWithoutExtension () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/GOE/AGA/01784/p36-46(5)37-45(2)38-42")
+                .queryParam("foundry", "-").queryParam("spans", "false")
+                .queryParam("expand", "false").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(node.at("/matchID").asText(),
+                "match-GOE/AGA/01784-p36-46(5)37-45(2)38-42");
+        assertEquals(node.at("/snippet").asText(),
+                "<span class=\"context-left\"><span class=\"more\"></span></span><span class=\"match\"><mark>gefüttert; der Ort ist sehr zerschossen; dann über die Schiffbrücke</mark></span><span class=\"context-right\"><span class=\"more\"></span></span>");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+    }
+
+    @Test
+    public void testMatchInfoGetWithHighlights () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/GOE/AGA/01784/p36-46(5)37-45(2)38-42/matchInfo")
+                .queryParam("foundry", "xy").queryParam("spans", "false")
+                .queryParam("hls", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(
+                "<span class=\"context-left\"></span><span class=\"match\">"
+                        + "der alte freie Weg nach Mainz war gesperrt, ich mußte über die "
+                        + "Schiffbrücke bei Rüsselsheim; in Ginsheim ward <mark>gefüttert; "
+                        + "<mark class=\"class-5 level-0\">der <mark class=\"class-2 level-1\">"
+                        + "Ort ist sehr zerschossen; dann</mark> über die Schiffbrücke</mark></mark> "
+                        + "auf die Nonnenaue, wo viele Bäume niedergehauen lagen, sofort auf dem "
+                        + "zweiten Teil der Schiffbrücke über den größern Arm des Rheins.</span>"
+                        + "<span class=\"context-right\"></span>",
+                node.at("/snippet").asText());
+        assertEquals(node.at("/matchID").asText(),
+                "match-GOE/AGA/01784-p36-46(5)37-45(2)38-42");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+    }
+
+    @Test
+    public void testMatchInfoGet2 () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("corpus/GOE/AGA/01784/p36-46/matchInfo")
+                .queryParam("foundry", "*").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertNotNull(node);
+        assertEquals(node.at("/textSigle").asText(), "GOE/AGA/01784");
+        assertEquals(node.at("/title").asText(), "Belagerung von Mainz");
+    }
+
+    // EM: The API is disabled
+    @Disabled
+    @Test
+    public void testCollectionQueryParameter () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("query")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author, docSigle")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .queryParam("cq", "textClass=Politik & corpus=WPD").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "Politik");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "WPD");
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author, docSigle")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .queryParam("cq", "textClass=Politik & corpus=WPD").request()
+                .get();
+        // String version =
+        // LucenePackage.get().getImplementationVersion();;
+        // System.out.println("VERSION "+ version);
+        // System.out.println("RESPONSE "+ response);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        query = response.readEntity(String.class);
+        node = JsonUtils.readTree(query);
+        assertNotNull(node);
+        assertEquals(node.at("/query/wrap/layer").asText(), "orth");
+        assertEquals(node.at("/collection/operands/0/value").asText(),
+                "Politik");
+        assertEquals(node.at("/collection/operands/1/value").asText(), "WPD");
+    }
+
+    @Test
+    public void testTokenRetrieval () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("/corpus/GOE/AGA/01784/p104-105/").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String resp = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(resp);
+        assertTrue(node.at("/hasSnippet").asBoolean());
+        assertFalse(node.at("/hasTokens").asBoolean());
+        assertTrue(node.at("/tokens").isMissingNode());
+        assertEquals(
+                "<span class=\"context-left\"><span class=\"more\"></span></span>"
+                        + "<span class=\"match\"><mark>die</mark></span>"
+                        + "<span class=\"context-right\"><span class=\"more\"></span></span>",
+                node.at("/snippet").asText());
+        // Tokens
+        response = target().path(API_VERSION)
+                .path("/corpus/GOE/AGA/01784/p104-105")
+                .queryParam("show-snippet", "false")
+                .queryParam("show-tokens", "true").queryParam("expand", "false")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        resp = response.readEntity(String.class);
+        node = JsonUtils.readTree(resp);
+        assertFalse(node.at("/hasSnippet").asBoolean());
+        assertTrue(node.at("/hasTokens").asBoolean());
+        assertTrue(node.at("/snippet").isMissingNode());
+        assertEquals(node.at("/tokens/match/0").asText(), "die");
+        assertTrue(node.at("/tokens/match/1").isMissingNode());
+    }
+
+    @Test
+    public void testMetaFields () throws KustvaktException {
+        Response response = target().path(API_VERSION)
+                .path("/corpus/GOE/AGA/01784").request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String resp = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(resp);
+        // System.err.println(node.toString());
+        Iterator<JsonNode> fieldIter = node.at("/document/fields").elements();
+        int checkC = 0;
+        while (fieldIter.hasNext()) {
+            JsonNode field = (JsonNode) fieldIter.next();
+            String key = field.at("/key").asText();
+            assertEquals(field.at("/@type").asText(), "koral:field");
+            switch (key) {
+                case "textSigle":
+                    assertEquals(field.at("/type").asText(), "type:string");
+                    assertEquals(field.at("/value").asText(), "GOE/AGA/01784");
+                    checkC++;
+                    break;
+                case "author":
+                    assertEquals(field.at("/type").asText(), "type:text");
+                    assertEquals(field.at("/value").asText(),
+                            "Goethe, Johann Wolfgang von");
+                    checkC++;
+                    break;
+                case "docSigle":
+                    assertEquals(field.at("/type").asText(), "type:string");
+                    assertEquals(field.at("/value").asText(), "GOE/AGA");
+                    checkC++;
+                    break;
+                case "docTitle":
+                    assertEquals(field.at("/type").asText(), "type:text");
+                    assertEquals(field.at("/value").asText(),
+                            "Goethe: Autobiographische Schriften II, (1817-1825, 1832)");
+                    checkC++;
+                    break;
+                case "pubDate":
+                    assertEquals(field.at("/type").asText(), "type:date");
+                    assertEquals(1982, field.at("/value").asInt());
+                    checkC++;
+                    break;
+            };
+        };
+        assertEquals(5, checkC);
+    }
+
+    @Test
+    public void testSearchWithoutVersion () throws KustvaktException {
+        Response response = target().path("api").path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .request().accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(HttpStatus.PERMANENT_REDIRECT_308, response.getStatus());
+        URI location = response.getLocation();
+        assertEquals(location.getPath(), "/api/v1.0/search");
+    }
+
+    @Test
+    public void testSearchWrongVersion () throws KustvaktException {
+        Response response = target().path("api").path("v0.2").path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .request().accept(MediaType.APPLICATION_JSON).get();
+        assertEquals(HttpStatus.PERMANENT_REDIRECT_308, response.getStatus());
+        URI location = response.getLocation();
+        assertEquals(location.getPath(), "/api/v1.0/search");
+    }
+
+    @Test
+    public void testSearchWithIP () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Wasser").queryParam("ql", "poliqarp")
+                .request().header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/collection").isMissingNode());
+    }
+
+    @Test
+    public void testSearchWithAuthorizationHeader () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Wasser").queryParam("ql", "poliqarp")
+                .request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue("test", "pwd"))
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32").get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/collection").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadata () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithCustomFields ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author,title")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+        assertEquals(node.at("/matches/0/author").asText(),
+                "Goethe, Johann Wolfgang von");
+        assertEquals(node.at("/matches/0/title").asText(),
+                "Italienische Reise");
+        // assertEquals(3, node.at("/matches/0").size());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithNonPublicField ()
+            throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "Sonne").queryParam("ql", "poliqarp")
+                .queryParam("fields", "author,title,snippet")
+                .queryParam("access-rewrite-disabled", "true").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.NON_PUBLIC_FIELD_IGNORED,
+                node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "The requested non public fields are ignored");
+        assertEquals(node.at("/warnings/0/2").asText(), "snippet");
+    }
+
+    @Test
+    public void testSearchWithInvalidPage () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=die]").queryParam("ql", "poliqarp")
+                .queryParam("page", "0").request().get();
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(), "page must start from 1");
+    }
+
+    @Test
+    public void testCloseIndex () throws IOException, KustvaktException {
+        searchKrill.getStatistics(null);
+        assertEquals(true, searchKrill.getIndex().isReaderOpen());
+        Form form = new Form();
+        form.param("token", "secret");
+        Response response = target().path(API_VERSION).path("index")
+                .path("close").request().post(Entity.form(form));
+        assertEquals(HttpStatus.OK_200, response.getStatus());
+        assertEquals(false, searchKrill.getIndex().isReaderOpen());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchPipeTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchPipeTest.java
new file mode 100644
index 0000000..704ae5f
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchPipeTest.java
@@ -0,0 +1,310 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class LiteSearchPipeTest extends LiteJerseyTest {
+
+    private ClientAndServer mockServer;
+
+    private MockServerClient mockClient;
+
+    private int port = 6070;
+
+    private String pipeJson, pipeWithParamJson;
+
+    private String glemmUri = "http://localhost:" + port + "/glemm";
+
+    public LiteSearchPipeTest () throws IOException {
+        pipeJson = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "pipe-output/test-pipes.jsonld"),
+                StandardCharsets.UTF_8);
+        pipeWithParamJson = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "pipe-output/with-param.jsonld"),
+                StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void startMockServer () {
+        mockServer = startClientAndServer(port);
+        mockClient = new MockServerClient("localhost", mockServer.getPort());
+    }
+
+    @AfterEach
+    public void stopMockServer () {
+        mockServer.stop();
+    }
+
+    @Test
+    public void testMockServer () throws IOException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/test")
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody("{test}").withStatusCode(200));
+        URL url = new URL("http://localhost:" + port + "/test");
+        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+        connection.setRequestMethod("POST");
+        connection.setRequestProperty("Content-Type",
+                "application/json; charset=UTF-8");
+        connection.setRequestProperty("Accept", "application/json");
+        connection.setDoOutput(true);
+        String json = "{\"name\" : \"dory\"}";
+        try (OutputStream os = connection.getOutputStream()) {
+            byte[] input = json.getBytes("utf-8");
+            os.write(input, 0, input.length);
+        }
+        assertEquals(200, connection.getResponseCode());
+        BufferedReader br = new BufferedReader(
+                new InputStreamReader(connection.getInputStream(), "utf-8"));
+        assertEquals(br.readLine(), "{test}");
+    }
+
+    @Test
+    public void testSearchWithPipes ()
+            throws IOException, KustvaktException, URISyntaxException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+        node = node.at("/query/wrap/rewrites");
+        assertEquals(2, node.size());
+        assertEquals(node.at("/0/src").asText(), "Glemm");
+        assertEquals(node.at("/0/operation").asText(), "operation:override");
+        assertEquals(node.at("/0/scope").asText(), "key");
+        assertEquals(node.at("/1/src").asText(), "Kustvakt");
+        assertEquals(node.at("/1/operation").asText(), "operation:injection");
+        assertEquals(node.at("/1/scope").asText(), "foundry");
+    }
+
+    @Test
+    public void testSearchWithUrlEncodedPipes ()
+            throws IOException, KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        glemmUri = URLEncoder.encode(glemmUri, "utf-8");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri).request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+    }
+
+    @Test
+    public void testSearchWithMultiplePipes () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withQueryStringParameter("param").withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeWithParamJson).withStatusCode(200));
+        String glemmUri2 = glemmUri + "?param=blah";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri + "," + glemmUri2).request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(3, node.at("/query/wrap/key").size());
+    }
+
+    @Test
+    public void testSearchWithUnknownURL ()
+            throws IOException, KustvaktException {
+        String url = target().getUri().toString() + API_VERSION
+                + "/test/tralala";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", url).request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(), "404 Not Found");
+    }
+
+    @Test
+    public void testSearchWithUnknownHost () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", "http://glemm").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(), "glemm");
+    }
+
+    @Test
+    public void testSearchUnsupportedMediaType () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/non-json-pipe"))
+                .respond(response().withStatusCode(415));
+        String pipeUri = "http://localhost:" + port + "/non-json-pipe";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/3").asText(),
+                "415 Unsupported Media Type");
+    }
+
+    @Test
+    public void testSearchWithMultiplePipeWarnings () throws KustvaktException {
+        String url = target().getUri().toString() + API_VERSION
+                + "/test/tralala";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", url + "," + "http://glemm").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/warnings").size());
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+        assertEquals(url, node.at("/warnings/0/2").asText());
+        assertEquals(node.at("/warnings/0/3").asText(), "404 Not Found");
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/1/0").asInt());
+        assertEquals(node.at("/warnings/1/2").asText(), "http://glemm");
+        assertEquals(node.at("/warnings/1/3").asText(), "glemm");
+    }
+
+    @Test
+    public void testSearchWithInvalidJsonResponse () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/invalid-response")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response().withBody("{blah:}").withStatusCode(200)
+                        .withHeaders(new Header("Content-Type",
+                                "application/json; charset=utf-8")));
+        String pipeUri = "http://localhost:" + port + "/invalid-response";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testSearchWithPlainTextResponse () throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/plain-text")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response().withBody("blah").withStatusCode(200));
+        String pipeUri = "http://localhost:" + port + "/plain-text";
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", pipeUri).request().get();
+        String entity = response.readEntity(String.class);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+    }
+
+    @Test
+    public void testSearchWithMultipleAndUnknownPipes ()
+            throws KustvaktException {
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/glemm")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(pipeJson).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", "http://unknown" + "," + glemmUri)
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/query/wrap/key").size());
+        assertTrue(node.at("/warnings").isMissingNode());
+        response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("pipes", glemmUri + ",http://unknown").request()
+                .get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        entity = response.readEntity(String.class);
+        node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.PIPE_FAILED, node.at("/warnings/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchTokenSnippetTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchTokenSnippetTest.java
new file mode 100644
index 0000000..fb78508
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/LiteSearchTokenSnippetTest.java
@@ -0,0 +1,69 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class LiteSearchTokenSnippetTest extends LiteJerseyTest {
+
+    @Test
+    public void testSearchWithTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("show-tokens", "true")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches/0/hasSnippet").asBoolean());
+        assertTrue(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/tokens/left").size() > 0);
+        assertTrue(node.at("/matches/0/tokens/right").size() > 0);
+        assertEquals(1, node.at("/matches/0/tokens/match").size());
+    }
+
+    @Test
+    public void testSearchWithoutTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("show-tokens", "false")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertTrue(node.at("/matches/0/hasSnippet").asBoolean());
+        assertFalse(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/tokens").isMissingNode());
+    }
+
+    @Test
+    public void testSearchPublicMetadataWithTokens () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=das]").queryParam("ql", "poliqarp")
+                .queryParam("access-rewrite-disabled", "true")
+                .queryParam("show-tokens", "true")
+                .queryParam("context", "sentence").queryParam("count", "13")
+                .request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertFalse(node.at("/matches/0/hasSnippet").asBoolean());
+        assertFalse(node.at("/matches/0/hasTokens").asBoolean());
+        assertTrue(node.at("/matches/0/snippet").isMissingNode());
+        assertTrue(node.at("/matches/0/tokens").isMissingNode());
+        assertEquals(StatusCodes.NOT_ALLOWED, node.at("/warnings/0/0").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/LiteStatisticControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/LiteStatisticControllerTest.java
new file mode 100644
index 0000000..4314ef0
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/LiteStatisticControllerTest.java
@@ -0,0 +1,163 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class LiteStatisticControllerTest extends LiteJerseyTest {
+
+    @Test
+    public void testStatisticsWithCq () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung & corpusSigle=GOE")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testStatisticsWithCqAndCorpusQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("cq", "textType=Abhandlung & corpusSigle=GOE")
+                .queryParam("corpusQuery",
+                        "textType=Autobiographie & corpusSigle=GOE")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(138180, node.at("/tokens").asInt());
+        assertEquals(5687, node.at("/sentences").asInt());
+        assertEquals(258, node.at("/paragraphs").asInt());
+        assertTrue(node.at("/warnings").isMissingNode());
+    }
+
+    @Test
+    public void testStatisticsWithCorpusQuery () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery",
+                        "textType=Autobiographie & corpusSigle=GOE")
+                .request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(9, node.at("/documents").asInt());
+        assertEquals(527662, node.at("/tokens").asInt());
+        assertEquals(19387, node.at("/sentences").asInt());
+        assertEquals(514, node.at("/paragraphs").asInt());
+        assertEquals(StatusCodes.DEPRECATED, node.at("/warnings/0/0").asInt());
+        assertEquals(node.at("/warnings/0/1").asText(),
+                "Parameter corpusQuery is deprecated in favor of cq.");
+    }
+
+    @Test
+    public void testEmptyStatistics () throws KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .queryParam("corpusQuery", "").request().method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String query = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(query);
+        assertEquals(11, node.at("/documents").asInt());
+        assertEquals(665842, node.at("/tokens").asInt());
+        assertEquals(25074, node.at("/sentences").asInt());
+        assertEquals(772, node.at("/paragraphs").asInt());
+        response = target().path(API_VERSION).path("statistics").request()
+                .method("GET");
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        query = response.readEntity(String.class);
+        node = JsonUtils.readTree(query);
+        assertEquals(11, node.at("/documents").asInt());
+        assertEquals(665842, node.at("/tokens").asInt());
+        assertEquals(25074, node.at("/sentences").asInt());
+        assertEquals(772, node.at("/paragraphs").asInt());
+    }
+
+    @Test
+    public void testGetStatisticsWithKoralQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{ \"collection\" : {\"@type\": "
+                        + "\"koral:doc\", \"key\": \"availability\", \"match\": "
+                        + "\"match:eq\", \"type\": \"type:regex\", \"value\": "
+                        + "\"CC-BY.*\"} }"));
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        assertEquals(response.getHeaders().getFirst("X-Index-Revision"),
+                "Wes8Bd4h1OypPqbWF5njeQ==");
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(2, node.at("/documents").asInt());
+        assertEquals(72770, node.at("/tokens").asInt());
+        assertEquals(2985, node.at("/sentences").asInt());
+        assertEquals(128, node.at("/paragraphs").asInt());
+    }
+
+    @Test
+    public void testGetStatisticsWithEmptyCollection ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{}"));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(node.at("/errors/0/0").asInt(),
+                de.ids_mannheim.korap.util.StatusCodes.MISSING_COLLECTION);
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Collection is not found");
+    }
+
+    @Test
+    public void testGetStatisticsWithIncorrectJson ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request()
+                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+                .post(Entity.json("{ \"collection\" : }"));
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        String ent = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(StatusCodes.DESERIALIZATION_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(node.at("/errors/0/1").asText(),
+                "Failed deserializing json object: { \"collection\" : }");
+    }
+
+    @Test
+    public void testGetStatisticsWithoutKoralQuery ()
+            throws IOException, KustvaktException {
+        Response response = target().path(API_VERSION).path("statistics")
+                .request().post(Entity.json(""));
+        String ent = response.readEntity(String.class);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(ent);
+        assertEquals(11, node.at("/documents").asInt());
+        assertEquals(665842, node.at("/tokens").asInt());
+        assertEquals(25074, node.at("/sentences").asInt());
+        assertEquals(772, node.at("/paragraphs").asInt());
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/lite/SearchNetworkEndpointTest.java b/src/test/java/de/ids_mannheim/korap/web/lite/SearchNetworkEndpointTest.java
new file mode 100644
index 0000000..81c2430
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/web/lite/SearchNetworkEndpointTest.java
@@ -0,0 +1,112 @@
+package de.ids_mannheim.korap.web.lite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+
+import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.config.LiteJerseyTest;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.utils.JsonUtils;
+
+public class SearchNetworkEndpointTest extends LiteJerseyTest {
+
+    @Autowired
+    private KustvaktConfiguration config;
+
+    private ClientAndServer mockServer;
+
+    private MockServerClient mockClient;
+
+    private int port = 6080;
+
+    private String searchResult;
+
+    private String endpointURL = "http://localhost:" + port + "/searchEndpoint";
+
+    public SearchNetworkEndpointTest () throws IOException {
+        searchResult = IOUtils.toString(
+                ClassLoader.getSystemResourceAsStream(
+                        "network-output/search-result.jsonld"),
+                StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void startMockServer () {
+        mockServer = startClientAndServer(port);
+        mockClient = new MockServerClient("localhost", mockServer.getPort());
+    }
+
+    @AfterEach
+    public void stopMockServer () {
+        mockServer.stop();
+    }
+
+    @Test
+    public void testSearchNetwork ()
+            throws IOException, KustvaktException, URISyntaxException {
+        config.setNetworkEndpointURL(endpointURL);
+        mockClient.reset()
+                .when(request().withMethod("POST").withPath("/searchEndpoint")
+                        .withHeaders(
+                                new Header("Content-Type",
+                                        "application/json; charset=utf-8"),
+                                new Header("Accept", "application/json")))
+                .respond(response()
+                        .withHeader(new Header("Content-Type",
+                                "application/json; charset=utf-8"))
+                        .withBody(searchResult).withStatusCode(200));
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(2, node.at("/matches").size());
+    }
+
+    @Test
+    public void testSearchWithUnknownURL ()
+            throws IOException, KustvaktException {
+        config.setNetworkEndpointURL("http://localhost:1040/search");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.SEARCH_NETWORK_ENDPOINT_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    public void testSearchWithUnknownHost () throws KustvaktException {
+        config.setNetworkEndpointURL("http://search.com");
+        Response response = target().path(API_VERSION).path("search")
+                .queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+                .queryParam("engine", "network").request().get();
+        String entity = response.readEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(StatusCodes.SEARCH_NETWORK_ENDPOINT_FAILED,
+                node.at("/errors/0/0").asInt());
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    }
+}
diff --git a/src/test/resources/json/oauth2_public_client.json b/src/test/resources/json/oauth2_public_client.json
new file mode 100644
index 0000000..14ab89a
--- /dev/null
+++ b/src/test/resources/json/oauth2_public_client.json
@@ -0,0 +1,6 @@
+{
+  "name":"my client",
+  "type": "PUBLIC",
+  "redirect_uri": "https://my.client.com",
+  "description":"my public client"
+}
\ No newline at end of file
diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000..f4cf442
--- /dev/null
+++ b/src/test/resources/junit-platform.properties
@@ -0,0 +1,4 @@
+junit.jupiter.testclass.order.default = org.junit.jupiter.api.ClassOrderer$OrderAnnotation
+junit.jupiter.execution.parallel.enabled = true
+
+
diff --git a/src/test/resources/keystore.p12 b/src/test/resources/keystore.p12
new file mode 100644
index 0000000..a1d7980
--- /dev/null
+++ b/src/test/resources/keystore.p12
Binary files differ
diff --git a/src/test/resources/kustvakt-icc.conf b/src/test/resources/kustvakt-icc.conf
new file mode 100644
index 0000000..84a38a8
--- /dev/null
+++ b/src/test/resources/kustvakt-icc.conf
@@ -0,0 +1,105 @@
+# index dir
+krill.indexDir = wiki-index
+
+krill.index.commit.count = 134217000
+krill.index.commit.log = log/krill.commit.log
+krill.index.commit.auto = 500
+krill.index.relations.max = 100
+# Directory path of virtual corpora to cache
+#krill.namedVC = vc
+krill.test = true
+
+# LDAP
+ldap.config = src/test/resources/test-ldap.conf
+
+# Kustvakt
+# multiple versions separated by space
+current.api.version = v1.0
+supported.api.version = v0.1 v1.0
+
+# server
+server.port=8089
+server.host=localhost
+
+# mail settings
+mail.enabled = false
+mail.receiver = test@localhost
+mail.sender = noreply@ids-mannheim.de
+mail.address.retrieval = test
+
+# mail.templates
+template.group.invitation = notification.vm
+
+# default foundries for specific layers
+default.foundry.partOfSpeech = tt
+default.foundry.lemma = tt
+default.foundry.orthography = opennlp
+default.foundry.dependency = malt
+default.foundry.constituent = corenlp
+default.foundry.morphology = marmot
+default.foundry.surface = base
+
+# delete configuration (default hard)
+# delete.auto.group = hard
+delete.group = soft
+delete.group.member = soft
+
+
+# availability regex only support |
+# It should be removed/commented when the data doesn't contain availability field.
+# 
+# availability.regex.free = CC-BY.*
+# availability.regex.public = ACA.*|QAO-NC
+# availability.regex.all = QAO.*
+
+
+# Define resource filters for search and match info web-services
+#
+# AuthenticationFilter activates authentication using OAuth2 tokens
+# DemoUserFilter allows access to the services without login
+# 
+# Default values: AuthenticationFilter,DemoUserFilter
+#
+search.resource.filters=AuthenticationFilter
+
+
+# options referring to the security module!
+
+# OAuth
+# (see de.ids_mannheim.korap.constant.AuthenticationMethod for possible 
+# oauth.password.authentication values)
+oauth2.password.authentication = TEST
+oauth2.native.client.host = korap.ids-mannheim.de
+oauth2.max.attempts = 2
+# expiry in seconds (S), minutes (M), hours (H), days (D)
+oauth2.access.token.expiry = 3M
+oauth2.refresh.token.expiry = 90D
+oauth2.authorization.code.expiry = 10M
+# -- scopes separated by space
+oauth2.default.scopes = search match_info
+oauth2.client.credentials.scopes = client_info
+
+oauth2.initial.super.client=true
+
+# see SecureRandom Number Generation Algorithms
+# optional
+security.secure.random.algorithm=SHA1PRNG
+
+# see MessageDigest Algorithms
+# default MD5
+security.md.algoritm = SHA-256  
+
+# secure hash support: BCRYPT
+security.secure.hash.algorithm=BCRYPT
+security.encryption.loadFactor = 10
+
+
+# DEPRECATED
+# JWT 
+security.jwt.issuer=https://korap.ids-mannheim.de
+security.sharedSecret=this-is-shared-secret-code-for-JWT-Signing.It-must-contains-minimum-256-bits
+
+# token expiration time
+security.longTokenTTL = 1D
+security.tokenTTL = 2S
+security.shortTokenTTL = 1S
diff --git a/src/test/resources/kustvakt-test.conf b/src/test/resources/kustvakt-test.conf
new file mode 100644
index 0000000..facc03a
--- /dev/null
+++ b/src/test/resources/kustvakt-test.conf
@@ -0,0 +1,121 @@
+# Krill settings
+#
+krill.indexDir = sample-index
+
+krill.index.commit.count = 134217000
+krill.index.commit.log = log/krill.commit.log
+krill.index.commit.auto = 500
+krill.index.relations.max = 100
+# Directory path of virtual corpora to cache
+krill.namedVC = vc
+krill.test = true
+
+# LDAP configuration file
+#
+ldap.config = src/test/resources/test-ldap.conf
+
+# Kustvakt versions
+#
+# multiple versions separated by space
+current.api.version = v1.0
+supported.api.version = v0.1 v1.0
+
+# Server
+#
+server.port=8089
+server.host=localhost
+
+# Mail settings
+#
+mail.enabled = false
+mail.receiver = test@localhost
+mail.sender = noreply@ids-mannheim.de
+mail.address.retrieval = test
+
+# Mail.templates
+#
+template.group.invitation = notification.vm
+
+# Default foundries for specific layers (optional)
+#
+default.foundry.partOfSpeech = tt
+default.foundry.lemma = tt
+default.foundry.orthography = opennlp
+default.foundry.dependency = malt
+default.foundry.constituent = corenlp
+default.foundry.morphology = marmot
+default.foundry.surface = base
+
+# Delete configuration (default hard)
+#
+# delete.auto.group = hard
+delete.group = soft
+delete.group.member = soft
+
+# Virtual corpus and queries
+max.user.persistent.queries = 5
+
+# Availability regex only support |
+# It should be removed/commented when the data doesn't contain availability field.
+# 
+availability.regex.free = CC-BY.*
+availability.regex.public = ACA.*|QAO-NC
+availability.regex.all = QAO.*
+
+
+# Define resource filters for search and match info API
+# AuthenticationFilter activates authentication using OAuth2 tokens
+# DemoUserFilter allows access to API without login
+# 
+# Default values: AuthenticationFilter,DemoUserFilter
+#
+search.resource.filters=AuthenticationFilter,DemoUserFilter
+
+
+# options referring to the security module!
+
+# OAuth
+# (see de.ids_mannheim.korap.constant.AuthenticationMethod for possible 
+# oauth.password.authentication values)
+#
+oauth2.password.authentication = TEST
+oauth2.native.client.host = korap.ids-mannheim.de
+oauth2.max.attempts = 2
+# expiry in seconds (S), minutes (M), hours (H), days (D)
+oauth2.access.token.expiry = 3M
+oauth2.refresh.token.expiry = 90D
+oauth2.authorization.code.expiry = 10M
+# -- scopes separated by space
+oauth2.default.scopes = search match_info
+oauth2.client.credentials.scopes = client_info
+
+oauth2.initial.super.client=true
+
+
+# see SecureRandom Number Generation Algorithms
+# optional
+security.secure.random.algorithm=SHA1PRNG
+
+# see MessageDigest Algorithms
+# default MD5
+security.md.algoritm = SHA-256  
+
+# secure hash support: BCRYPT
+security.secure.hash.algorithm=BCRYPT
+security.encryption.loadFactor = 10
+
+# DEPRECATED
+# JWT
+security.jwt.issuer=https://korap.ids-mannheim.de
+security.sharedSecret=this-is-shared-secret-code-for-JWT-Signing.It-must-contains-minimum-256-bits
+
+# token expiration time
+security.longTokenTTL = 1D
+security.tokenTTL = 2S
+security.shortTokenTTL = 1S
+
+# Session authentication
+security.idleTimeoutDuration = 25M
+security.multipleLogIn = true
+security.loginAttemptNum = 3
+security.authAttemptTTL = 45M
diff --git a/src/test/resources/log4j2-test.properties b/src/test/resources/log4j2-test.properties
new file mode 100644
index 0000000..50d78e7
--- /dev/null
+++ b/src/test/resources/log4j2-test.properties
@@ -0,0 +1,56 @@
+appenders = console,ldapFile
+appender.console.type = Console
+appender.console.name = STDOUT
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{yyyy-MM-dd, HH:mm:ss} %C{6} - %M%n %-5p: %m%n
+
+#appender.file.type = File
+#appender.file.name = ERRORLOG
+#appender.file.fileName=./logs/errors.log
+#appender.file.layout.type=PatternLayout
+#appender.file.layout.pattern= %d{yyyy-MM-dd, HH:mm:ss} %C{6} - %M%n %-5p: %m%n
+
+appender.ldapFile.type = File
+appender.ldapFile.name = LDAP_LOG
+appender.ldapFile.fileName=./logs/ldap.log
+appender.ldapFile.layout.type=PatternLayout
+appender.ldapFile.layout.pattern= %d{yyyy-MM-dd, HH:mm:ss} %C{6} - %M%n %-5p: %m%n
+
+rootLogger.level = error
+rootLogger.appenderRefs = console
+rootLogger.appenderRef.stdout.ref = STDOUT
+
+#loggers=console
+#logger.console.name=com.sun.jersey.test.framework.spi.container
+#logger.console.level = info
+#logger.console.appenderRefs = stdout
+#logger.console.appenderRef.file.ref = STDOUT
+#logger.console.additivity=false
+
+#loggers=file
+#logger.file.name=com.sun.jersey.test.framework.spi.container
+#logger.file.level = info
+#logger.file.appenderRefs = file
+#logger.file.appenderRef.file.ref = ERRORLOG
+#logger.file.additivity=false
+
+loggers=console
+logger.console.name=org.hibernate
+logger.console.level = fatal
+logger.console.appenderRefs = stdout
+logger.console.appenderRef.file.ref = STDOUT
+logger.console.additivity=false
+
+loggers=console
+logger.console.name=de.ids_mannheim.korap.web.controller.AuthenticationController
+logger.console.level = warn
+logger.console.appenderRefs = stdout
+logger.console.appenderRef.file.ref = STDOUT
+logger.console.additivity=false
+
+loggers=file
+logger.file.name=de.ids_mannheim.korap.authentication.LdapAuth3
+logger.file.level = info
+logger.file.appenderRefs = file
+logger.file.appenderRef.file.ref = LDAP_LOG
+logger.file.additivity=false
\ No newline at end of file
diff --git a/src/test/resources/network-output/search-result.jsonld b/src/test/resources/network-output/search-result.jsonld
new file mode 100644
index 0000000..da55e09
--- /dev/null
+++ b/src/test/resources/network-output/search-result.jsonld
@@ -0,0 +1,130 @@
+{
+    "@context": "http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld",
+    "meta": {
+        "count": 2,
+        "startIndex": 0,
+        "timeout": 10000,
+        "context": {
+            "left": [
+                "token",
+                6
+            ],
+            "right": [
+                "token",
+                6
+            ]
+        },
+        "fields": [
+            "ID",
+            "UID",
+            "textSigle",
+            "corpusID",
+            "author",
+            "title",
+            "subTitle",
+            "textClass",
+            "pubPlace",
+            "pubDate",
+            "availability",
+            "layerInfos",
+            "docSigle",
+            "corpusSigle"
+        ],
+        "version": "0.60.4",
+        "benchmark": "1.503497374 s",
+        "totalResults": 67249248,
+        "serialQuery": "tokens:s:der",
+        "itemsPerPage": 2
+    },
+    "query": {
+        "@type": "koral:token",
+        "wrap": {
+            "@type": "koral:term",
+            "match": "match:eq",
+            "layer": "orth",
+            "key": "der",
+            "foundry": "opennlp",
+            "rewrites": [
+                {
+                    "@type": "koral:rewrite",
+                    "src": "Kustvakt",
+                    "operation": "operation:injection",
+                    "scope": "foundry"
+                }
+            ]
+        }
+    },
+    "collection": {
+        "@type": "koral:doc",
+        "match": "match:eq",
+        "type": "type:regex",
+        "value": "CC-BY.*",
+        "key": "availability",
+        "rewrites": [
+            {
+                "@type": "koral:rewrite",
+                "src": "Kustvakt",
+                "operation": "operation:insertion",
+                "scope": "availability(FREE)"
+            }
+        ]
+    },
+    "matches": [
+        {
+            "@context": "http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld",
+            "meta": {},
+            "hasSnippet": true,
+            "hasTokens": false,
+            "matchID": "match-WUD17/B96/48580-p40-41",
+            "textClass": "staat-gesellschaft biographien-interviews fiktion vermischtes",
+            "textSigle": "WUD17/B96/48580",
+            "author": "Andy king50, u.a.",
+            "docSigle": "WUD17/B96",
+            "layerInfos": "corenlp/c=spans corenlp/p=tokens corenlp/s=spans dereko/s=spans malt/d=rels marmot/m=tokens marmot/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens",
+            "pubPlace": "URL:http://de.wikipedia.org",
+            "availability": "CC-BY-SA",
+            "title": "Benutzer Diskussion:Brettspieler",
+            "pubDate": "2017-07-01",
+            "corpusSigle": "WUD17",
+            "context": {
+                "left": [
+                    "token",
+                    6
+                ],
+                "right": [
+                    "token",
+                    6
+                ]
+            },
+            "snippet": "<span class=\"context-left\"><span class=\"more\"><\/span>konstruktiv mitarbeiten kannst, erfährst du auf <\/span><span class=\"match\"><mark>der<\/mark><\/span><span class=\"context-right\"> Seite  Starthilfe . Grüße,  19:05, 6. Nov.<span class=\"more\"><\/span><\/span>"
+        },
+        {
+            "@context": "http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld",
+            "meta": {},
+            "hasSnippet": true,
+            "hasTokens": false,
+            "matchID": "match-WUD17/B96/50115-p40-41",
+            "textClass": "staat-gesellschaft biographien-interviews fiktion vermischtes",
+            "textSigle": "WUD17/B96/50115",
+            "author": "Doc.Heintz, u.a.",
+            "docSigle": "WUD17/B96",
+            "layerInfos": "corenlp/c=spans corenlp/p=tokens corenlp/s=spans dereko/s=spans malt/d=rels marmot/m=tokens marmot/p=tokens opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens",
+            "pubPlace": "URL:http://de.wikipedia.org",
+            "availability": "CC-BY-SA",
+            "title": "Benutzer Diskussion:Blkviz",
+            "pubDate": "2017-07-01",
+            "corpusSigle": "WUD17",
+            "context": {
+                "left": [
+                    "token",
+                    6
+                ],
+                "right": [
+                    "token",
+                    6
+                ]
+            },
+            "snippet": "<span class=\"context-left\"><span class=\"more\"><\/span>konstruktiv mitarbeiten kannst, erfährst du auf <\/span><span class=\"match\"><mark>der<\/mark><\/span><span class=\"context-right\"> Seite  Starthilfe . Grüße,  20:11, 7.<span class=\"more\"><\/span><\/span>"
+        }
+    ]
+}
diff --git a/src/test/resources/pipe-output/test-pipes.jsonld b/src/test/resources/pipe-output/test-pipes.jsonld
new file mode 100644
index 0000000..7fde643
--- /dev/null
+++ b/src/test/resources/pipe-output/test-pipes.jsonld
@@ -0,0 +1,25 @@
+{
+    "meta": {
+        "snippets": true,
+        "timeout": 10000
+    },
+    "query": {
+        "@type": "koral:token",
+        "wrap": {
+            "@type": "koral:term",
+            "match": "match:eq",
+            "key": [
+                "der",
+                "das"
+            ],
+            "layer": "orth",
+            "rewrites": [{
+                "@type": "koral:rewrite",
+                "src": "Glemm",
+                "operation": "operation:override",
+                "scope": "key"
+            }]
+        }
+    },
+    "@context": "http://korap.ids-mannheim.de/ns/koral/0.3/context.jsonld"
+}
diff --git a/src/test/resources/pipe-output/with-param.jsonld b/src/test/resources/pipe-output/with-param.jsonld
new file mode 100644
index 0000000..08c7fda
--- /dev/null
+++ b/src/test/resources/pipe-output/with-param.jsonld
@@ -0,0 +1,29 @@
+{
+    "meta": {
+        "snippets": true,
+        "timeout": 10000
+    },
+    "query": {
+        "@type": "koral:token",
+        "wrap": {
+            "@type": "koral:term",
+            "match": "match:eq",
+            "key": [
+                "der",
+                "das",
+		"die"
+            ],
+            "layer": "orth",
+            "rewrites": [
+                {
+                    "@type": "koral:rewrite",
+                    "src": "Glemm",
+                    "operation": "operation:override",
+                    "scope": "key"
+                }
+            ]
+        }
+    },
+    "@context": "http://korap.ids-mannheim.de/ns/koral/0.3/context.jsonld"
+}
+
diff --git a/src/test/resources/test-config-icc.xml b/src/test/resources/test-config-icc.xml
new file mode 100644
index 0000000..d418f05
--- /dev/null
+++ b/src/test/resources/test-config-icc.xml
@@ -0,0 +1,351 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:p="http://www.springframework.org/schema/p"
+	xmlns:util="http://www.springframework.org/schema/util"
+	xmlns:aop="http://www.springframework.org/schema/aop"
+	xmlns:tx="http://www.springframework.org/schema/tx"
+	xmlns="http://www.springframework.org/schema/beans"
+	xmlns:context="http://www.springframework.org/schema/context"
+	xmlns:cache="http://www.springframework.org/schema/cache"
+	xsi:schemaLocation="http://www.springframework.org/schema/beans
+           http://www.springframework.org/schema/beans/spring-beans.xsd
+           http://www.springframework.org/schema/tx
+           http://www.springframework.org/schema/tx/spring-tx.xsd
+           http://www.springframework.org/schema/aop
+           http://www.springframework.org/schema/aop/spring-aop.xsd
+           http://www.springframework.org/schema/context
+           http://www.springframework.org/schema/context/spring-context.xsd
+           http://www.springframework.org/schema/util
+           http://www.springframework.org/schema/util/spring-util.xsd">
+
+	<context:component-scan
+		base-package="de.ids_mannheim.korap" />
+	<context:annotation-config />
+
+	<bean id="props"
+		class="org.springframework.beans.factory.config.PropertiesFactoryBean">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>file:./kustvakt-icc.conf</value>
+				<value>classpath:kustvakt-icc.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<bean id="placeholders"
+		class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>classpath:test-jdbc.properties</value>
+				<value>file:./jdbc.properties</value>
+				<value>classpath:properties/mail.properties</value>
+				<value>file:./mail.properties</value>
+				<value>classpath:properties/hibernate.properties</value>
+				<value>file:./kustvakt-icc.conf</value>
+				<value>classpath:kustvakt-icc.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<!-- <bean id='cacheManager' class='org.springframework.cache.ehcache.EhCacheCacheManager' 
+		p:cacheManager-ref='ehcache' /> <bean id='ehcache' class='org.springframework.cache.ehcache.EhCacheManagerFactoryBean' 
+		p:configLocation='classpath:ehcache.xml' p:shared='true' /> -->
+	<bean id="dataSource"
+		class="org.apache.commons.dbcp2.BasicDataSource" lazy-init="true">
+		<!-- <property name="driverClassName" value="${jdbc.driverClassName}" /> -->
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="maxTotal" value="4" />
+		<property name="maxIdle" value="1" />
+		<property name="minIdle" value="1" />
+		<property name="maxWaitMillis" value="15000" />
+		<!--<property name="poolPreparedStatements" value="true"/> -->
+	</bean>
+
+	<!-- use SingleConnection only for testing! -->
+	<bean id="sqliteDataSource"
+		class="org.springframework.jdbc.datasource.SingleConnectionDataSource"
+		lazy-init="true">
+		<!-- <property name="driverClassName" value="${jdbc.driverClassName}" /> -->
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="connectionProperties">
+			<props>
+				<prop key="date_string_format">yyyy-MM-dd HH:mm:ss</prop>
+			</props>
+		</property>
+
+		<!-- Sqlite can only have a single connection -->
+		<property name="suppressClose">
+			<value>true</value>
+		</property>
+	</bean>
+
+	<bean id="c3p0DataSource"
+		class="com.mchange.v2.c3p0.ComboPooledDataSource"
+		destroy-method="close">
+		<property name="driverClass" value="${jdbc.driverClassName}" />
+		<property name="jdbcUrl" value="${jdbc.url}" />
+		<property name="user" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="maxPoolSize" value="4" />
+		<property name="minPoolSize" value="1" />
+		<property name="maxStatements" value="1" />
+		<property name="testConnectionOnCheckout" value="true" />
+	</bean>
+
+	<!-- to configure database for sqlite, mysql, etc. migrations -->
+	<bean id="flywayConfig"
+		class="org.flywaydb.core.api.configuration.ClassicConfiguration">
+		<!-- drop existing tables and create new tables -->
+		<property name="validateOnMigrate" value="true" />
+		<property name="cleanOnValidationError" value="true" />
+		<property name="baselineOnMigrate" value="false" />
+		<property name="locations"
+			value="#{'${jdbc.schemaPath}'.split(',')}" />
+		<property name="dataSource" ref="sqliteDataSource" />
+		<!-- <property name="dataSource" ref="dataSource" /> -->
+		<property name="outOfOrder" value="true" />
+	</bean>
+
+	<bean id="flyway" class="org.flywaydb.core.Flyway"
+		init-method="migrate">
+		<constructor-arg ref="flywayConfig" />
+	</bean>
+
+
+	<bean id="entityManagerFactory"
+		class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
+		<!-- <property name="dataSource" ref="dataSource" /> -->
+		<property name="dataSource" ref="sqliteDataSource" />
+		<property name="packagesToScan">
+			<array>
+				<value>de.ids_mannheim.korap.core.entity</value>
+				<value>de.ids_mannheim.korap.entity</value>
+				<value>de.ids_mannheim.korap.oauth2.entity</value>
+			</array>
+		</property>
+		<property name="jpaVendorAdapter">
+			<bean id="jpaVendorAdapter"
+				class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
+				<property name="databasePlatform"
+					value="${hibernate.dialect}" />
+			</bean>
+		</property>
+		<property name="jpaProperties">
+			<props>
+				<prop key="hibernate.dialect">${hibernate.dialect}</prop>
+				<prop key="hibernate.hbm2ddl.auto">${hibernate.hbm2ddl.auto}</prop>
+				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
+				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
+				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}
+				</prop>
+				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
+				<prop key="hibernate.cache.region.factory_class">${hibernate.cache.region.factory}</prop>
+				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>
+				<!-- <prop key="net.sf.ehcache.configurationResourceName">classpath:ehcache.xml</prop> -->
+			</props>
+		</property>
+	</bean>
+
+	<tx:annotation-driven proxy-target-class="true"
+		transaction-manager="transactionManager" />
+	<bean id="transactionManager"
+		class="org.springframework.orm.jpa.JpaTransactionManager">
+		<property name="entityManagerFactory"
+			ref="entityManagerFactory" />
+	</bean>
+
+	<bean id="transactionTemplate"
+		class="org.springframework.transaction.support.TransactionTemplate">
+		<constructor-arg ref="transactionManager" />
+	</bean>
+
+	<!-- Data access objects -->
+	<bean id="adminDao" class="de.ids_mannheim.korap.dao.AdminDaoImpl" />
+	<bean id="resourceDao"
+		class="de.ids_mannheim.korap.dao.ResourceDao" />
+	<bean id="accessScopeDao"
+		class="de.ids_mannheim.korap.oauth2.dao.AccessScopeDao" />
+	<bean id="authorizationDao"
+		class="de.ids_mannheim.korap.oauth2.dao.CachedAuthorizationDaoImpl" />
+
+	<!-- Services -->
+	<bean id="scopeService"
+		class="de.ids_mannheim.korap.oauth2.service.OAuth2ScopeServiceImpl" />
+
+
+	<!-- Controller -->
+
+
+	<!-- props are injected from default-config.xml -->
+	<bean id="kustvakt_config"
+		class="de.ids_mannheim.korap.config.FullConfiguration">
+		<constructor-arg name="properties" ref="props" />
+	</bean>
+
+	<bean id="initializator"
+		class="de.ids_mannheim.korap.init.Initializator"
+		init-method="initTest">
+	</bean>
+
+	<!-- Krill -->
+	<bean id="search_krill"
+		class="de.ids_mannheim.korap.web.SearchKrill">
+		<constructor-arg value="${krill.indexDir}" />
+	</bean>
+
+	<!-- Validator -->
+	<bean id="validator"
+		class="de.ids_mannheim.korap.validator.ApacheValidator" />
+
+	<!-- URLValidator -->
+	<bean id="redirectURIValidator"
+		class="org.apache.commons.validator.routines.UrlValidator">
+		<constructor-arg value="http,https" index="0" />
+		<constructor-arg index="1" type="long"
+			value="#{T(org.apache.commons.validator.routines.UrlValidator).ALLOW_LOCAL_URLS + 
+		T(org.apache.commons.validator.routines.UrlValidator).NO_FRAGMENTS}" />
+	</bean>
+	<bean id="urlValidator"
+		class="org.apache.commons.validator.routines.UrlValidator">
+		<constructor-arg value="http,https" />
+	</bean>
+
+	<!-- Rewrite -->
+	<bean id="foundryRewrite"
+		class="de.ids_mannheim.korap.rewrite.FoundryRewrite" />
+	<bean id="collectionRewrite"
+		class="de.ids_mannheim.korap.rewrite.CollectionRewrite" />
+	<bean id="collectionCleanRewrite"
+		class="de.ids_mannheim.korap.rewrite.CollectionCleanRewrite" />
+	<bean id="virtualCorpusRewrite"
+		class="de.ids_mannheim.korap.rewrite.VirtualCorpusRewrite" />
+	<bean id="collectionConstraint"
+		class="de.ids_mannheim.korap.rewrite.CollectionConstraint" />
+	<bean id="queryReferenceRewrite"
+		class="de.ids_mannheim.korap.rewrite.QueryReferenceRewrite" />
+
+	<util:list id="rewriteTasks"
+		value-type="de.ids_mannheim.korap.rewrite.RewriteTask">
+		<!-- <ref bean="collectionConstraint" /> <ref bean="collectionCleanRewrite" 
+			/> -->
+		<ref bean="foundryRewrite" />
+		<!-- <ref bean="collectionRewrite" /> -->
+		<ref bean="virtualCorpusRewrite" />
+		<ref bean="queryReferenceRewrite" />
+	</util:list>
+
+	<bean id="rewriteHandler"
+		class="de.ids_mannheim.korap.rewrite.RewriteHandler">
+		<constructor-arg ref="rewriteTasks" />
+	</bean>
+
+	<bean id="kustvaktResponseHandler"
+		class="de.ids_mannheim.korap.web.KustvaktResponseHandler">
+	</bean>
+
+	<!-- OAuth -->
+	<bean id="oauth2ResponseHandler"
+		class="de.ids_mannheim.korap.web.OAuth2ResponseHandler">
+	</bean>
+
+	<bean name="kustvakt_encryption"
+		class="de.ids_mannheim.korap.encryption.KustvaktEncryption">
+		<constructor-arg ref="kustvakt_config" />
+	</bean>
+
+	<!-- authentication providers to use -->
+	<bean id="basic_auth"
+		class="de.ids_mannheim.korap.authentication.BasicAuthentication" />
+
+	<bean id="oauth2_auth"
+		class="de.ids_mannheim.korap.authentication.OAuth2Authentication" />
+
+
+	<util:list id="kustvakt_authproviders"
+		value-type="de.ids_mannheim.korap.interfaces.AuthenticationIface">
+		<ref bean="basic_auth" />
+		<ref bean="oauth2_auth" />
+	</util:list>
+
+
+	<!-- specify type for constructor argument -->
+	<bean id="authenticationManager"
+		class="de.ids_mannheim.korap.authentication.KustvaktAuthenticationManager">
+		<constructor-arg
+			type="de.ids_mannheim.korap.interfaces.EncryptionIface"
+			ref="kustvakt_encryption" />
+		<constructor-arg ref="kustvakt_config" />
+		<!-- inject authentication providers to use -->
+		<property name="providers" ref="kustvakt_authproviders" />
+	</bean>
+
+	<!-- todo: if db interfaces not loaded via spring, does transaction even 
+		work then? -->
+	<!-- the transactional advice (i.e. what 'happens'; see the <aop:advisor/> 
+		bean below) -->
+	<tx:advice id="txAdvice" transaction-manager="txManager">
+		<!-- the transactional semantics... -->
+		<tx:attributes>
+			<!-- all methods starting with 'get' are read-only -->
+			<tx:method name="get*" read-only="true"
+				rollback-for="KorAPException" />
+			<!-- other methods use the default transaction settings (see below) -->
+			<tx:method name="*" rollback-for="KorAPException" />
+		</tx:attributes>
+	</tx:advice>
+
+	<!-- ensure that the above transactional advice runs for any execution of 
+		an operation defined by the service interface -->
+	<aop:config>
+		<aop:pointcut id="service"
+			expression="execution(* de.ids_mannheim.korap.interfaces.db.*.*(..))" />
+		<aop:advisor advice-ref="txAdvice" pointcut-ref="service" />
+	</aop:config>
+
+	<!-- similarly, don't forget the PlatformTransactionManager -->
+	<bean id="txManager"
+		class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
+		<property name="dataSource" ref="dataSource" />
+	</bean>
+
+	<!-- mail -->
+	<bean id="authenticator"
+		class="de.ids_mannheim.korap.service.MailAuthenticator">
+		<constructor-arg index="0" value="${mail.username}" />
+		<constructor-arg index="1" value="${mail.password}" />
+	</bean>
+	<bean id="smtpSession" class="jakarta.mail.Session"
+		factory-method="getInstance">
+		<constructor-arg index="0">
+			<props>
+				<prop key="mail.smtp.submitter">${mail.username}</prop>
+				<prop key="mail.smtp.auth">${mail.auth}</prop>
+				<prop key="mail.smtp.host">${mail.host}</prop>
+				<prop key="mail.smtp.port">${mail.port}</prop>
+				<prop key="mail.smtp.starttls.enable">${mail.starttls.enable}</prop>
+				<prop key="mail.smtp.connectiontimeout">${mail.connectiontimeout}</prop>
+			</props>
+		</constructor-arg>
+		<constructor-arg index="1" ref="authenticator" />
+	</bean>
+	<bean id="mailSender"
+		class="org.springframework.mail.javamail.JavaMailSenderImpl">
+		<property name="session" ref="smtpSession" />
+	</bean>
+	<bean id="velocityEngine"
+		class="org.apache.velocity.app.VelocityEngine">
+		<constructor-arg index="0">
+			<props>
+				<prop key="resource.loader">class</prop>
+				<prop key="class.resource.loader.class">org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader
+				</prop>
+			</props>
+		</constructor-arg>
+	</bean>
+</beans>
diff --git a/src/test/resources/test-config-lite.xml b/src/test/resources/test-config-lite.xml
new file mode 100644
index 0000000..5de7bd6
--- /dev/null
+++ b/src/test/resources/test-config-lite.xml
@@ -0,0 +1,208 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:util="http://www.springframework.org/schema/util"
+	xmlns:context="http://www.springframework.org/schema/context"
+	xmlns:tx="http://www.springframework.org/schema/tx"
+	xsi:schemaLocation="http://www.springframework.org/schema/beans
+           http://www.springframework.org/schema/beans/spring-beans.xsd
+           http://www.springframework.org/schema/tx
+           http://www.springframework.org/schema/tx/spring-tx.xsd
+           http://www.springframework.org/schema/context
+           http://www.springframework.org/schema/context/spring-context.xsd
+           http://www.springframework.org/schema/util
+           http://www.springframework.org/schema/util/spring-util.xsd">
+
+	<context:component-scan
+		base-package="
+		de.ids_mannheim.korap.core.service,
+		de.ids_mannheim.korap.core.web,
+		de.ids_mannheim.korap.web.filter, 
+		de.ids_mannheim.korap.web.utils,
+		de.ids_mannheim.korap.authentication.http" />
+	<context:annotation-config />
+
+	<bean id="placeholders"
+		class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>classpath:test-jdbc.properties</value>
+				<value>file:./test-jdbc.properties</value>
+				<value>classpath:test-hibernate.properties</value>
+				<value>classpath:kustvakt-lite.conf</value>
+				<value>file:./kustvakt-lite.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<bean id="properties"
+		class="org.springframework.beans.factory.config.PropertiesFactoryBean">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>classpath:kustvakt-lite.conf</value>
+				<value>file:./kustvakt-lite.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<bean id="config"
+		class="de.ids_mannheim.korap.config.KustvaktConfiguration">
+		<constructor-arg index="0" name="properties"
+			ref="properties" />
+	</bean>
+
+	<!-- Database -->
+
+	<bean id="dataSource"
+		class="org.apache.commons.dbcp2.BasicDataSource" lazy-init="true">
+		<!-- <property name="driverClassName" value="${jdbc.driverClassName}" /> -->
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="maxTotal" value="4" />
+		<property name="maxIdle" value="1" />
+		<property name="minIdle" value="1" />
+		<property name="maxWaitMillis" value="15000" />
+		<!--<property name="poolPreparedStatements" value="true"/> -->
+	</bean>
+
+	<bean id="sqliteDataSource"
+		class="org.springframework.jdbc.datasource.SingleConnectionDataSource"
+		lazy-init="true">
+		<property name="driverClassName"
+			value="${jdbc.driverClassName}" />
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="connectionProperties">
+			<props>
+				<prop key="date_string_format">yyyy-MM-dd HH:mm:ss</prop>
+			</props>
+		</property>
+
+		<!-- relevant for single connection datasource and sqlite -->
+		<property name="suppressClose">
+			<value>true</value>
+		</property>
+	</bean>
+
+	<bean id="flywayConfig"
+		class="org.flywaydb.core.api.configuration.ClassicConfiguration">
+		<property name="baselineOnMigrate" value="true" />
+		<!-- <property name="validateOnMigrate" value="false" /> -->
+		<!-- <property name="cleanOnValidationError" value="true" /> -->
+		<property name="locations"
+			value="#{'${jdbc.schemaPath}'.split(',')}" />
+		<property name="dataSource" ref="dataSource" />
+		<property name="outOfOrder" value="true" />
+	</bean>
+
+	<bean id="flyway" class="org.flywaydb.core.Flyway"
+		init-method="migrate">
+		<constructor-arg ref="flywayConfig" />
+	</bean>
+
+	<bean id="entityManagerFactory"
+		class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
+		<property name="dataSource" ref="dataSource" />
+
+		<property name="packagesToScan">
+			<array>
+				<value>de.ids_mannheim.korap.core.entity</value>
+			</array>
+		</property>
+		<property name="jpaVendorAdapter">
+			<bean id="jpaVendorAdapter"
+				class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
+				<property name="databasePlatform"
+					value="${hibernate.dialect}" />
+			</bean>
+		</property>
+		<property name="jpaProperties">
+			<props>
+				<prop key="hibernate.dialect">${hibernate.dialect}</prop>
+				<prop key="hibernate.hbm2ddl.auto">${hibernate.hbm2ddl.auto}</prop>
+				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
+				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
+				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}
+				</prop>
+				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
+				<prop key="hibernate.cache.region.factory_class">${hibernate.cache.region.factory}</prop>
+				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>
+			</props>
+		</property>
+	</bean>
+	<tx:annotation-driven proxy-target-class="true"
+		transaction-manager="transactionManager" />
+
+	<bean id="transactionManager"
+		class="org.springframework.orm.jpa.JpaTransactionManager">
+		<property name="entityManagerFactory"
+			ref="entityManagerFactory" />
+	</bean>
+
+	<bean id="transactionTemplate"
+		class="org.springframework.transaction.support.TransactionTemplate">
+		<constructor-arg ref="transactionManager" />
+	</bean>
+	<bean id="txManager"
+		class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
+		<property name="dataSource" ref="sqliteDataSource" />
+	</bean>
+
+	<!-- Search Engine -->
+	<bean id="search_krill"
+		class="de.ids_mannheim.korap.web.SearchKrill">
+		<constructor-arg value="${krill.indexDir}" />
+	</bean>
+
+
+	<!-- Filters -->
+	<!-- <bean id="APIVersionFilter" class="de.ids_mannheim.korap.web.APIVersionFilter" 
+		scope="singleton" /> -->
+	<!-- Authentication -->
+	<bean id="authenticationManager"
+		class="de.ids_mannheim.korap.authentication.DummyAuthenticationManager" />
+
+	<!-- Response handler -->
+	<bean id="kustvaktResponseHandler"
+		class="de.ids_mannheim.korap.web.KustvaktResponseHandler">
+	</bean>
+
+	<!-- Controllers -->
+	<!-- added via component-scan <bean id="annotationController" class="de.ids_mannheim.korap.web.controller.AnnotationController" 
+		/> <bean id="searchController" class="de.ids_mannheim.korap.web.controller.SearchController" 
+		/> <bean id="statisticController" class="de.ids_mannheim.korap.web.controller.StatisticController" 
+		/> -->
+	<!-- Services -->
+	<bean id="scopeService"
+		class="de.ids_mannheim.korap.oauth2.service.DummyOAuth2ScopeServiceImpl" />
+
+	<!-- DAO -->
+	<bean id="adminDao"
+		class="de.ids_mannheim.korap.dao.DummyAdminDaoImpl" />
+	<bean id="annotationDao"
+		class="de.ids_mannheim.korap.dao.AnnotationDao" />
+
+	<!-- DTO Converter -->
+	<bean id="annotationConverter"
+		class="de.ids_mannheim.korap.dto.converter.AnnotationConverter" />
+
+	<!-- Rewrite -->
+	<bean id="layerMapper"
+		class="de.ids_mannheim.korap.rewrite.LayerMapper" />
+	<bean id="foundryInject"
+		class="de.ids_mannheim.korap.rewrite.FoundryInject" />
+
+	<util:list id="rewriteTasks"
+		value-type="de.ids_mannheim.korap.rewrite.RewriteTask">
+		<ref bean="foundryInject" />
+	</util:list>
+
+	<bean id="rewriteHandler"
+		class="de.ids_mannheim.korap.rewrite.RewriteHandler">
+		<constructor-arg ref="rewriteTasks" />
+	</bean>
+</beans>
\ No newline at end of file
diff --git a/src/test/resources/test-config.xml b/src/test/resources/test-config.xml
new file mode 100644
index 0000000..d1dd61c
--- /dev/null
+++ b/src/test/resources/test-config.xml
@@ -0,0 +1,359 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:p="http://www.springframework.org/schema/p"
+	xmlns:util="http://www.springframework.org/schema/util"
+	xmlns:aop="http://www.springframework.org/schema/aop"
+	xmlns:tx="http://www.springframework.org/schema/tx"
+	xmlns="http://www.springframework.org/schema/beans"
+	xmlns:context="http://www.springframework.org/schema/context"
+	xmlns:cache="http://www.springframework.org/schema/cache"
+	xsi:schemaLocation="http://www.springframework.org/schema/beans
+           http://www.springframework.org/schema/beans/spring-beans.xsd
+           http://www.springframework.org/schema/tx
+           http://www.springframework.org/schema/tx/spring-tx.xsd
+           http://www.springframework.org/schema/aop
+           http://www.springframework.org/schema/aop/spring-aop.xsd
+           http://www.springframework.org/schema/context
+           http://www.springframework.org/schema/context/spring-context.xsd
+           http://www.springframework.org/schema/util
+           http://www.springframework.org/schema/util/spring-util.xsd">
+
+	<context:component-scan
+		base-package="de.ids_mannheim.korap" />
+	<context:annotation-config />
+
+	<bean id="props"
+		class="org.springframework.beans.factory.config.PropertiesFactoryBean">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>classpath:kustvakt-test.conf</value>
+				<value>file:./kustvakt-test.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<bean id="placeholders"
+		class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
+		<property name="ignoreResourceNotFound" value="true" />
+		<property name="locations">
+			<array>
+				<value>classpath:test-jdbc.properties</value>
+				<value>file:./test-jdbc.properties</value>
+				<value>classpath:properties/mail.properties</value>
+				<value>file:./mail.properties</value>
+				<value>classpath:test-hibernate.properties</value>
+				<value>file:./kustvakt-test.conf</value>
+				<value>classpath:kustvakt-test.conf</value>
+			</array>
+		</property>
+	</bean>
+
+	<!-- <bean id='cacheManager' class='org.springframework.cache.ehcache.EhCacheCacheManager' 
+		p:cacheManager-ref='ehcache' /> <bean id='ehcache' class='org.springframework.cache.ehcache.EhCacheManagerFactoryBean' 
+		p:configLocation='classpath:ehcache.xml' p:shared='true' /> -->
+
+	<bean id="dataSource"
+		class="org.apache.commons.dbcp2.BasicDataSource" lazy-init="true">
+		<!-- <property name="driverClassName" value="${jdbc.driverClassName}" /> -->
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="maxTotal" value="4" />
+		<property name="maxIdle" value="1" />
+		<property name="minIdle" value="1" />
+		<property name="maxWaitMillis" value="15000" />
+		<!--<property name="poolPreparedStatements" value="true"/> -->
+	</bean>
+
+	<!-- use SingleConnection only for testing! -->
+	<bean id="sqliteDataSource"
+		class="org.springframework.jdbc.datasource.SingleConnectionDataSource"
+		lazy-init="true">
+		<!-- <property name="driverClassName" value="${jdbc.driverClassName}" /> -->
+		<property name="url" value="${jdbc.url}" />
+		<property name="username" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="connectionProperties">
+			<props>
+				<prop key="date_string_format">yyyy-MM-dd HH:mm:ss</prop>
+			</props>
+		</property>
+
+		<!-- Sqlite can only have a single connection -->
+		<property name="suppressClose">
+			<value>true</value>
+		</property>
+	</bean>
+
+	<bean id="c3p0DataSource"
+		class="com.mchange.v2.c3p0.ComboPooledDataSource"
+		destroy-method="close">
+		<property name="driverClass" value="${jdbc.driverClassName}" />
+		<property name="jdbcUrl" value="${jdbc.url}" />
+		<property name="user" value="${jdbc.username}" />
+		<property name="password" value="${jdbc.password}" />
+		<property name="maxPoolSize" value="4" />
+		<property name="minPoolSize" value="1" />
+		<property name="maxStatements" value="1" />
+		<property name="testConnectionOnCheckout" value="true" />
+	</bean>
+
+	<!-- to configure database for sqlite, mysql, etc. migrations -->
+	<bean id="flywayConfig"
+		class="org.flywaydb.core.api.configuration.ClassicConfiguration">
+		<!-- drop existing tables and create new tables -->
+		<property name="validateOnMigrate" value="true" />
+		<property name="cleanOnValidationError" value="true" />
+		<property name="baselineOnMigrate" value="false" />
+		<property name="locations"
+			value="#{'${jdbc.schemaPath}'.split(',')}" />
+		<!-- <property name="dataSource" ref="sqliteDataSource" /> -->
+		<property name="dataSource" ref="dataSource" />
+		<property name="outOfOrder" value="true" />
+	</bean>
+
+	<bean id="flyway" class="org.flywaydb.core.Flyway"
+		init-method="migrate">
+		<constructor-arg ref="flywayConfig" />
+	</bean>
+
+
+	<bean id="entityManagerFactory"
+		class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
+		<property name="dataSource" ref="dataSource" />
+		<!-- <property name="dataSource" ref="sqliteDataSource" /> -->
+		<property name="packagesToScan">
+			<array>
+				<value>de.ids_mannheim.korap.core.entity</value>
+				<value>de.ids_mannheim.korap.entity</value>
+				<value>de.ids_mannheim.korap.oauth2.entity</value>
+			</array>
+		</property>
+		<property name="jpaVendorAdapter">
+			<bean id="jpaVendorAdapter"
+				class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
+				<property name="databasePlatform"
+					value="${hibernate.dialect}" />
+			</bean>
+		</property>
+		<property name="jpaProperties">
+			<props>
+				<prop key="hibernate.dialect">${hibernate.dialect}</prop>
+				<prop key="hibernate.hbm2ddl.auto">${hibernate.hbm2ddl.auto}</prop>
+				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
+				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
+				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}
+				</prop>
+				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
+				<prop key="hibernate.cache.region.factory_class">${hibernate.cache.region.factory}</prop>
+				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>
+				<!-- <prop key="net.sf.ehcache.configurationResourceName">classpath:ehcache.xml</prop> -->
+			</props>
+		</property>
+	</bean>
+
+	<tx:annotation-driven proxy-target-class="true"
+		transaction-manager="transactionManager" />
+	<bean id="transactionManager"
+		class="org.springframework.orm.jpa.JpaTransactionManager">
+		<property name="entityManagerFactory"
+			ref="entityManagerFactory" />
+	</bean>
+
+	<bean id="transactionTemplate"
+		class="org.springframework.transaction.support.TransactionTemplate">
+		<constructor-arg ref="transactionManager" />
+	</bean>
+
+	<!-- Data access objects -->
+	<bean id="adminDao" class="de.ids_mannheim.korap.dao.AdminDaoImpl" />
+	<bean id="resourceDao"
+		class="de.ids_mannheim.korap.dao.ResourceDao" />
+	<bean id="accessScopeDao"
+		class="de.ids_mannheim.korap.oauth2.dao.AccessScopeDao" />
+	<bean id="authorizationDao"
+		class="de.ids_mannheim.korap.oauth2.dao.CachedAuthorizationDaoImpl" />
+
+	<!-- Services -->
+	<bean id="scopeService"
+		class="de.ids_mannheim.korap.oauth2.service.OAuth2ScopeServiceImpl" />
+
+	<!-- props are injected from default-config.xml -->
+	<bean id="kustvakt_config"
+		class="de.ids_mannheim.korap.config.FullConfiguration">
+		<constructor-arg name="properties" ref="props" />
+	</bean>
+
+	<bean id="initializator"
+		class="de.ids_mannheim.korap.init.Initializator"
+		init-method="initTest">
+	</bean>
+
+	<!-- Krill -->
+	<bean id="search_krill"
+		class="de.ids_mannheim.korap.web.SearchKrill">
+		<constructor-arg value="${krill.indexDir}" />
+	</bean>
+
+	<!-- Validator -->
+	<bean id="validator"
+		class="de.ids_mannheim.korap.validator.ApacheValidator" />
+
+	<!-- URLValidator -->
+	<bean id="redirectURIValidator"
+		class="org.apache.commons.validator.routines.UrlValidator">
+		<constructor-arg value="http,https" index="0" />
+		<constructor-arg index="1" type="long"
+			value="#{T(org.apache.commons.validator.routines.UrlValidator).ALLOW_LOCAL_URLS + 
+		T(org.apache.commons.validator.routines.UrlValidator).NO_FRAGMENTS}" />
+	</bean>
+	<bean id="urlValidator"
+		class="org.apache.commons.validator.routines.UrlValidator">
+		<constructor-arg value="http,https" />
+	</bean>
+
+	<!-- Rewrite -->
+	<bean id="foundryRewrite"
+		class="de.ids_mannheim.korap.rewrite.FoundryRewrite" />
+	<bean id="collectionRewrite"
+		class="de.ids_mannheim.korap.rewrite.CollectionRewrite" />
+	<bean id="collectionCleanRewrite"
+		class="de.ids_mannheim.korap.rewrite.CollectionCleanRewrite" />
+	<bean id="virtualCorpusRewrite"
+		class="de.ids_mannheim.korap.rewrite.VirtualCorpusRewrite" />
+	<bean id="collectionConstraint"
+		class="de.ids_mannheim.korap.rewrite.CollectionConstraint" />
+	<bean id="queryReferenceRewrite"
+		class="de.ids_mannheim.korap.rewrite.QueryReferenceRewrite" />
+
+	<util:list id="rewriteTasks"
+		value-type="de.ids_mannheim.korap.rewrite.RewriteTask">
+		<!-- <ref bean="collectionConstraint" /> <ref bean="collectionCleanRewrite" 
+			/> -->
+		<ref bean="foundryRewrite" />
+		<ref bean="collectionRewrite" />
+		<ref bean="virtualCorpusRewrite" />
+		<ref bean="queryReferenceRewrite" />
+	</util:list>
+
+	<bean id="rewriteHandler"
+		class="de.ids_mannheim.korap.rewrite.RewriteHandler">
+		<constructor-arg ref="rewriteTasks" />
+	</bean>
+
+	<bean id="kustvaktResponseHandler"
+		class="de.ids_mannheim.korap.web.KustvaktResponseHandler">
+	</bean>
+
+	<!-- OAuth -->
+	<bean id="oauth2ResponseHandler"
+		class="de.ids_mannheim.korap.web.OAuth2ResponseHandler">
+	</bean>
+
+	<bean name="kustvakt_encryption"
+		class="de.ids_mannheim.korap.encryption.KustvaktEncryption">
+		<constructor-arg ref="kustvakt_config" />
+	</bean>
+
+	<!-- authentication providers to use -->
+	<bean id="basic_auth"
+		class="de.ids_mannheim.korap.authentication.BasicAuthentication" />
+
+
+	<bean id="session_auth"
+		class="de.ids_mannheim.korap.authentication.SessionAuthentication">
+		<constructor-arg
+			type="de.ids_mannheim.korap.config.KustvaktConfiguration"
+			ref="kustvakt_config" />
+		<constructor-arg
+			type="de.ids_mannheim.korap.interfaces.EncryptionIface"
+			ref="kustvakt_encryption" />
+	</bean>
+
+	<bean id="oauth2_auth"
+		class="de.ids_mannheim.korap.authentication.OAuth2Authentication" />
+
+
+	<util:list id="kustvakt_authproviders"
+		value-type="de.ids_mannheim.korap.interfaces.AuthenticationIface">
+		<ref bean="basic_auth" />
+		<ref bean="session_auth" />
+		<ref bean="oauth2_auth" />
+	</util:list>
+
+	<!-- specify type for constructor argument -->
+	<bean id="authenticationManager"
+		class="de.ids_mannheim.korap.authentication.KustvaktAuthenticationManager">
+		<constructor-arg
+			type="de.ids_mannheim.korap.interfaces.EncryptionIface"
+			ref="kustvakt_encryption" />
+		<constructor-arg ref="kustvakt_config" />
+		<!-- inject authentication providers to use -->
+		<property name="providers" ref="kustvakt_authproviders" />
+	</bean>
+
+	<!-- todo: if db interfaces not loaded via spring, does transaction even 
+		work then? -->
+	<!-- the transactional advice (i.e. what 'happens'; see the <aop:advisor/> 
+		bean below) -->
+	<tx:advice id="txAdvice" transaction-manager="txManager">
+		<!-- the transactional semantics... -->
+		<tx:attributes>
+			<!-- all methods starting with 'get' are read-only -->
+			<tx:method name="get*" read-only="true"
+				rollback-for="KorAPException" />
+			<!-- other methods use the default transaction settings (see below) -->
+			<tx:method name="*" rollback-for="KorAPException" />
+		</tx:attributes>
+	</tx:advice>
+
+	<!-- ensure that the above transactional advice runs for any execution of 
+		an operation defined by the service interface -->
+	<aop:config>
+		<aop:pointcut id="service"
+			expression="execution(* de.ids_mannheim.korap.interfaces.db.*.*(..))" />
+		<aop:advisor advice-ref="txAdvice" pointcut-ref="service" />
+	</aop:config>
+
+	<!-- similarly, don't forget the PlatformTransactionManager -->
+	<bean id="txManager"
+		class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
+		<property name="dataSource" ref="dataSource" />
+	</bean>
+
+	<!-- mail -->
+	<bean id="authenticator"
+		class="de.ids_mannheim.korap.service.MailAuthenticator">
+		<constructor-arg index="0" value="${mail.username}" />
+		<constructor-arg index="1" value="${mail.password}" />
+	</bean>
+	<bean id="smtpSession" class="jakarta.mail.Session"
+		factory-method="getInstance">
+		<constructor-arg index="0">
+			<props>
+				<prop key="mail.smtp.submitter">${mail.username}</prop>
+				<prop key="mail.smtp.auth">${mail.auth}</prop>
+				<prop key="mail.smtp.host">${mail.host}</prop>
+				<prop key="mail.smtp.port">${mail.port}</prop>
+				<prop key="mail.smtp.starttls.enable">${mail.starttls.enable}</prop>
+				<prop key="mail.smtp.connectiontimeout">${mail.connectiontimeout}</prop>
+			</props>
+		</constructor-arg>
+		<constructor-arg index="1" ref="authenticator" />
+	</bean>
+	<bean id="mailSender"
+		class="org.springframework.mail.javamail.JavaMailSenderImpl">
+		<property name="session" ref="smtpSession" />
+	</bean>
+	<bean id="velocityEngine"
+		class="org.apache.velocity.app.VelocityEngine">
+		<constructor-arg index="0">
+			<props>
+				<prop key="resource.loader">class</prop>
+				<prop key="class.resource.loader.class">org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader
+				</prop>
+			</props>
+		</constructor-arg>
+	</bean>
+</beans>
diff --git a/src/test/resources/test-embedded-ldap-users.ldif b/src/test/resources/test-embedded-ldap-users.ldif
new file mode 100644
index 0000000..8760df9
--- /dev/null
+++ b/src/test/resources/test-embedded-ldap-users.ldif
@@ -0,0 +1,46 @@
+dn: dc=example,dc=com
+dc: example
+ou: people
+objectClass: dcObject
+objectClass: organizationalUnit
+
+dn: ou=people,dc=example,dc=com
+ou: people
+objectClass: organizationalUnit
+
+dn: uid=user,ou=people,dc=example,dc=com
+cn: user
+uid: user
+mail: user@example.com
+userPassword: {BASE64}cGFzc3dvcmQ=
+
+dn: uid=user1,ou=people,dc=example,dc=com
+cn: user1
+uid: user1
+mail: user1@example.com
+userPassword: {CLEAR}password1
+
+dn: uid=user2,ou=people,dc=example,dc=com
+cn: user2
+uid: user2
+mail: user2@example.com
+userPassword: password2
+
+dn: uid=user3,ou=people,dc=example,dc=com
+cn: user3
+uid: user3
+mail: user3@example.com
+userPassword: {SHA}ERnP037iRzV+A0oI2ETuol9v0g8=
+
+dn: uid=user4,ou=people,dc=example,dc=com
+cn: user4
+uid: user4
+mail: user4@example.com
+userPassword: {SHA256}uXhzpA9zq+3Y1oWnzV5fheSpz7g+rCaIZkCggThQEis=
+
+dn: uid=user5,ou=people,dc=example,dc=com
+cn: user5
+uid: user5
+mail: user5@example.com
+userPassword: {PBKDF2-SHA256}26PFrg++/nI8YOiHum5MyAMp0HdqKMNOcLpY5RuO2bY=
+
diff --git a/src/test/resources/test-embedded-ldap.conf b/src/test/resources/test-embedded-ldap.conf
new file mode 100644
index 0000000..fb9e079
--- /dev/null
+++ b/src/test/resources/test-embedded-ldap.conf
@@ -0,0 +1,10 @@
+# default and sample configuration for an automatically starting
+# embedded LDAP server
+host=localhost
+port=3267
+searchBase=dc=example,dc=com
+sLoginDN=cn=admin,dc=example,dc=com
+pwd=admin
+searchFilter=(uid=${login})
+useEmbeddedServer=true
+ldifFile=src/test/resources/test-embedded-ldap-users.ldif
diff --git a/src/test/resources/test-hibernate.properties b/src/test/resources/test-hibernate.properties
new file mode 100644
index 0000000..e394a88
--- /dev/null
+++ b/src/test/resources/test-hibernate.properties
@@ -0,0 +1,8 @@
+hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect
+hibernate.hbm2ddl.auto=none
+hibernate.show_sql=false
+hibernate.cache.use_query_cache=false
+hibernate.cache.use_second_level_cache=false
+hibernate.cache.provider=org.hibernate.cache.EhCacheProvider
+hibernate.cache.region.factory=org.hibernate.cache.ehcache.EhCacheRegionFactory
+hibernate.jdbc.time_zone=UTC
\ No newline at end of file
diff --git a/src/test/resources/test-invalid-signature.token b/src/test/resources/test-invalid-signature.token
new file mode 100644
index 0000000..665b76d
--- /dev/null
+++ b/src/test/resources/test-invalid-signature.token
@@ -0,0 +1 @@
+eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MTMwOTYwMjA0NjYsInN1YiI6InRlc3RVc2VyIiwiaXNzIjoiaHR0cDpcL1wva29yYXAuaWRzLW1hbm5oZWltLmRlIn0.n4BhCXsFMizEHepNK5AnF32a3kxyvgiesth74ZHimEY
\ No newline at end of file
diff --git a/src/test/resources/test-jdbc.properties b/src/test/resources/test-jdbc.properties
new file mode 100644
index 0000000..1be6a41
--- /dev/null
+++ b/src/test/resources/test-jdbc.properties
@@ -0,0 +1,11 @@
+#-------------------------------------------------------------------------------
+# Sqlite Settings
+
+jdbc.database=sqlite
+jdbc.driverClassName=org.sqlite.JDBC
+#jdbc.url=jdbc:sqlite::memory:
+jdbc.url=jdbc:sqlite:file::memory:?cache=shared
+#jdbc.url=jdbc:sqlite:testDB.sqlite
+jdbc.username=pc
+jdbc.password=pc
+jdbc.schemaPath=classpath:db/sqlite,db/predefined,db/test
\ No newline at end of file
diff --git a/src/test/resources/test-ldap-users.ldif b/src/test/resources/test-ldap-users.ldif
new file mode 100644
index 0000000..0b92701
--- /dev/null
+++ b/src/test/resources/test-ldap-users.ldif
@@ -0,0 +1,75 @@
+dn: dc=example,dc=com
+dc: example
+ou: people
+objectClass: dcObject
+objectClass: organizationalUnit
+
+dn: ou=people,dc=example,dc=com
+ou: people
+objectClass: organizationalUnit
+
+dn: uid=testuser,ou=people,dc=example,dc=com
+cn: Peter Testuser
+sn: Testuser
+givenName: Peter
+mail: testuser@example.com
+userPassword: cGFzc3dvcmQ=
+displayName: Dr. Peter Testuser
+registered: TRUE
+extraProfile: testuser123
+extraPassword: password
+uid: testuser
+idsC2Profile: idsTestUser
+
+dn: uid=test,ou=people,dc=example,dc=com
+cn: Peter Test
+sn: Test
+givenName: Peter
+mail: test@example.com
+userPassword: top*ecret
+displayName: Dr. Peter Test
+registered: TRUE
+userStatus: 1
+extraProfile: test
+extraPassword: top*ecret
+uid: test
+
+dn: uid=not_registered_user,ou=people,dc=example,dc=com
+mail: not_registered_user@example.com
+userPassword: cGFzc3dvcmQ=
+userStatus: 0
+registered: FALSE
+extraProfile: not_registered_user
+extraPassword: topsecret
+uid: not_registered_user
+
+dn: uid=nameOfBlockedUser,ou=people,dc=example,dc=com
+mail: nameOfBlockedUser@example.com
+userPassword: cGFzc3dvcmQ=
+userStatus: 2
+registered: TRUE
+extraPassword: topsecret
+uid: nameOfBlockedUser
+
+dn: uid=testuser2,ou=people,dc=example,dc=com
+cn: Peter Testuser
+sn: Testuser2
+givenName: Peter
+mail: peter@example.org
+userPassword: cGFzc3dvcmQ=
+displayName: Dr. Peter Testuser
+userStatus: 0
+registered: TRUE
+extraProfile: testuser2
+extraPassword: topsecret
+extraNews: TRUE
+title: Herr
+uid: testuser2
+
+dn: uid=uid,ou=people,dc=example,dc=com
+mail: mail@example.org
+userPassword: userPassword
+registered: TRUE
+extraProfile: extraProfile
+extraPassword: extraPassword
+uid: uid
diff --git a/src/test/resources/test-ldap.conf b/src/test/resources/test-ldap.conf
new file mode 100644
index 0000000..614275c
--- /dev/null
+++ b/src/test/resources/test-ldap.conf
@@ -0,0 +1,8 @@
+host=localhost
+port=3268
+searchBase=dc=example,dc=com
+sLoginDN=cn=admin,dc=example,dc=com
+pwd=adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))
+authFilter=(registered=TRUE)
+userNotBlockedFilter=(|(userStatus=0)(userStatus=1)(!(userStatus=*)))
diff --git a/src/test/resources/test-ldaps-with-truststore.conf b/src/test/resources/test-ldaps-with-truststore.conf
new file mode 100644
index 0000000..22d0899
--- /dev/null
+++ b/src/test/resources/test-ldaps-with-truststore.conf
@@ -0,0 +1,10 @@
+host=localhost
+port=3269
+useSSL=true
+trustStore=src/test/resources/truststore.jks
+searchBase=dc=example,dc=com
+sLoginDN=cn=admin,dc=example,dc=com
+pwd=adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))
+authFilter=(registered=TRUE)
+userNotBlockedFilter=(|(userStatus=0)(userStatus=1)(!(userStatus=*)))
diff --git a/src/test/resources/test-ldaps.conf b/src/test/resources/test-ldaps.conf
new file mode 100644
index 0000000..dfbed4f
--- /dev/null
+++ b/src/test/resources/test-ldaps.conf
@@ -0,0 +1,10 @@
+host=localhost
+port=3269
+useSSL=true
+trustStore=
+searchBase=dc=example,dc=com
+sLoginDN=cn=admin,dc=example,dc=com
+pwd=adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))
+authFilter=(registered=TRUE)
+userNotBlockedFilter=(|(userStatus=0)(userStatus=1)(!(userStatus=*)))
diff --git a/src/test/resources/test-resource-config.xml b/src/test/resources/test-resource-config.xml
new file mode 100644
index 0000000..f26182c
--- /dev/null
+++ b/src/test/resources/test-resource-config.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:p="http://www.springframework.org/schema/p"
+	xmlns:util="http://www.springframework.org/schema/util"
+	xmlns:aop="http://www.springframework.org/schema/aop"
+	xmlns:tx="http://www.springframework.org/schema/tx"
+	xmlns="http://www.springframework.org/schema/beans"
+	xmlns:context="http://www.springframework.org/schema/context"
+	xmlns:cache="http://www.springframework.org/schema/cache"
+	xsi:schemaLocation="http://www.springframework.org/schema/beans
+           http://www.springframework.org/schema/beans/spring-beans.xsd
+           http://www.springframework.org/schema/tx
+           http://www.springframework.org/schema/tx/spring-tx.xsd
+           http://www.springframework.org/schema/aop
+           http://www.springframework.org/schema/aop/spring-aop.xsd
+           http://www.springframework.org/schema/context
+           http://www.springframework.org/schema/context/spring-context.xsd
+           http://www.springframework.org/schema/util
+           http://www.springframework.org/schema/util/spring-util.xsd">
+
+	<import resource="classpath:test-config.xml" />
+	<bean id="initializator"
+		class="de.ids_mannheim.korap.init.Initializator"
+		init-method="initResourceTest">
+	</bean>
+
+</beans>
\ No newline at end of file
diff --git a/src/test/resources/truststore.jks b/src/test/resources/truststore.jks
new file mode 100644
index 0000000..50804be
--- /dev/null
+++ b/src/test/resources/truststore.jks
Binary files differ
diff --git a/src/test/resources/vc/named-vc1.jsonld b/src/test/resources/vc/named-vc1.jsonld
new file mode 100644
index 0000000..ec25031
--- /dev/null
+++ b/src/test/resources/vc/named-vc1.jsonld
@@ -0,0 +1,10 @@
+{"collection": {
+    "@type": "koral:doc",
+    "key": "textSigle",
+    "match": "match:eq",
+    "type" : "type:string",
+    "value": [
+        "GOE/AGF/00000",
+        "GOE/AGA/01784"
+    ]
+}}
diff --git a/src/test/resources/vc/named-vc2.jsonld b/src/test/resources/vc/named-vc2.jsonld
new file mode 100644
index 0000000..a79d6e5
--- /dev/null
+++ b/src/test/resources/vc/named-vc2.jsonld
@@ -0,0 +1,10 @@
+{"collection": {
+    "@type": "koral:doc",
+    "key": "textSigle",
+    "match": "match:ne",
+    "type" : "type:string",
+    "value": [
+        "GOE/AGI/04846",
+        "GOE/AGA/01784"
+    ]
+}}
diff --git a/src/test/resources/vc/named-vc3.jsonld b/src/test/resources/vc/named-vc3.jsonld
new file mode 100644
index 0000000..a690744
--- /dev/null
+++ b/src/test/resources/vc/named-vc3.jsonld
@@ -0,0 +1,9 @@
+{
+    "collection": {
+        "@type": "koral:doc",
+        "key": "title",
+        "match": "match:eq",
+        "type": "type:string",
+        "value": "Italienische Reise"
+    }
+}