Added new APIs: list user-installed plugins and uninstall plugin.

Moved install and list plugin APIs to PluginController and updated their
service paths under /plugins.

Change-Id: I592408665672d52e2479671b97de5ba49e9a9c5a
diff --git a/core/Changes b/core/Changes
index 6a157f5..87d954c 100644
--- a/core/Changes
+++ b/core/Changes
@@ -1,6 +1,6 @@
 # version 0.68
 
- - Added OAuth2 scope: INSTALL_USER_CLIENT
+ - Added OAuth2 scopes: INSTALL_USER_CLIENT, UNINSTALL_USER_CLIENT
  - Added status codes
  
 
diff --git a/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java b/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
index 915efb5..d6b58ce 100644
--- a/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
+++ b/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
@@ -15,6 +15,7 @@
     
     LIST_USER_CLIENT,
     INSTALL_USER_CLIENT,
+    UNINSTALL_USER_CLIENT,
     
     CLIENT_INFO,
     REGISTER_CLIENT,
diff --git a/full/Changes b/full/Changes
index 4f7de42..b179a17 100644
--- a/full/Changes
+++ b/full/Changes
@@ -13,6 +13,10 @@
  - Updated redirect URI error message for coherence
 2022-05-27
  - Added maximum limit to custom refresh token expiry
+2022-06-01
+ - Added new APIs: list user-installed plugins and uninstall plugin.
+ - Moved install and list plugin APIs to PluginController and updated their
+   service paths under /plugins.
  
  
 # version 0.67.1
diff --git a/full/src/main/java/de/ids_mannheim/korap/dto/InstalledPluginDto.java b/full/src/main/java/de/ids_mannheim/korap/dto/InstalledPluginDto.java
index 1c9943f..eb17a8e 100644
--- a/full/src/main/java/de/ids_mannheim/korap/dto/InstalledPluginDto.java
+++ b/full/src/main/java/de/ids_mannheim/korap/dto/InstalledPluginDto.java
@@ -20,6 +20,8 @@
     private String name;
     private String description;
     private String url;
+    @JsonProperty("redirect_uri")
+    private String redirectUri;
     @JsonProperty("installed_date")
     private String installedDate;
     
@@ -31,5 +33,6 @@
         setName(client.getName());
         setDescription(client.getDescription());
         setUrl(client.getUrl());
+        setRedirectUri(client.getRedirectURI());
     }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/entity/InstalledPlugin.java b/full/src/main/java/de/ids_mannheim/korap/entity/InstalledPlugin.java
index b49b1b4..92ab0f9 100644
--- a/full/src/main/java/de/ids_mannheim/korap/entity/InstalledPlugin.java
+++ b/full/src/main/java/de/ids_mannheim/korap/entity/InstalledPlugin.java
@@ -20,7 +20,7 @@
 @Getter
 @Entity
 @Table(name = "installed_plugin")
-public class InstalledPlugin {
+public class InstalledPlugin implements Comparable<InstalledPlugin>{
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -38,4 +38,9 @@
     @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "super_client_id")
     private OAuth2Client superClient;
+
+    @Override
+    public int compareTo (InstalledPlugin o) {
+        return this.client.compareTo(o.client);
+    }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/InstalledPluginDao.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/InstalledPluginDao.java
index 46ac078..00243ba 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/InstalledPluginDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/InstalledPluginDao.java
@@ -2,14 +2,15 @@
 
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
+import java.util.List;
 
 import javax.persistence.EntityManager;
 import javax.persistence.NoResultException;
 import javax.persistence.PersistenceContext;
 import javax.persistence.Query;
+import javax.persistence.TypedQuery;
 import javax.persistence.criteria.CriteriaBuilder;
 import javax.persistence.criteria.CriteriaQuery;
-import javax.persistence.criteria.Join;
 import javax.persistence.criteria.Root;
 
 import org.springframework.stereotype.Repository;
@@ -56,17 +57,15 @@
                 builder.createQuery(InstalledPlugin.class);
 
         Root<InstalledPlugin> root = query.from(InstalledPlugin.class);
-        Join<InstalledPlugin, OAuth2Client> client =
-                root.join(InstalledPlugin_.client);
-        Join<InstalledPlugin, OAuth2Client> superClient =
-                root.join(InstalledPlugin_.superClient);
         query.select(root);
         query.where(builder.and(
                 builder.equal(root.get(InstalledPlugin_.INSTALLED_BY),
                         installedBy),
-                builder.equal(client.get(OAuth2Client_.id), clientId),
-                builder.equal(superClient.get(OAuth2Client_.id),
-                        superClientId)));
+                builder.equal(
+                        root.get(InstalledPlugin_.client).get(OAuth2Client_.id),
+                        clientId),
+                builder.equal(root.get(InstalledPlugin_.superClient)
+                        .get(OAuth2Client_.id), superClientId)));
 
         Query q = entityManager.createQuery(query);
         try {
@@ -76,4 +75,38 @@
             throw new KustvaktException(StatusCodes.NO_RESOURCE_FOUND);
         }
     }
+
+    public List<InstalledPlugin> retrieveInstalledPlugins (String superClientId,
+            String installedBy) throws KustvaktException {
+        ParameterChecker.checkStringValue(superClientId, "super_client_id");
+        ParameterChecker.checkStringValue(installedBy, "installedBy");
+
+        CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+        CriteriaQuery<InstalledPlugin> query =
+                builder.createQuery(InstalledPlugin.class);
+
+        Root<InstalledPlugin> root = query.from(InstalledPlugin.class);
+        query.select(root);
+        query.where(builder.and(
+                builder.equal(root.get(InstalledPlugin_.INSTALLED_BY),
+                        installedBy),
+                builder.equal(root.get(InstalledPlugin_.superClient)
+                        .get(OAuth2Client_.id), superClientId)));
+
+        TypedQuery<InstalledPlugin> q = entityManager.createQuery(query);
+        try {
+            return q.getResultList();
+        }
+        catch (NoResultException e) {
+            throw new KustvaktException(StatusCodes.NO_RESOURCE_FOUND);
+        }
+    }
+
+    public void uninstallPlugin (String superClientId, String clientId,
+            String username) throws KustvaktException {
+        InstalledPlugin plugin =
+                retrieveInstalledPlugin(superClientId, clientId, username);
+        entityManager.remove(plugin);
+    }
+
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
index 076a762..30e1196 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
@@ -481,7 +481,7 @@
         return revokeRefreshToken(refreshToken);
     }
 
-    private boolean revokeRefreshToken (RefreshToken refreshToken)
+    public boolean revokeRefreshToken (RefreshToken refreshToken)
             throws KustvaktException {
         if (refreshToken != null){
             refreshToken.setRevoked(true);
@@ -511,6 +511,11 @@
         }
 
         String clientId = revokeTokenRequest.getClientId();
+        revokeAllClientTokensForUser(clientId, username);
+    }
+    
+    public void revokeAllClientTokensForUser (String clientId, String username)
+            throws KustvaktException {
         OAuth2Client client = clientService.retrieveClient(clientId);
         if (clientService.isPublicClient(client)) {
             List<AccessToken> accessTokens =
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
index 47ba029..4fee392 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
@@ -34,6 +34,7 @@
 import de.ids_mannheim.korap.oauth2.entity.Authorization;
 import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
 import de.ids_mannheim.korap.oauth2.entity.RefreshToken;
+import de.ids_mannheim.korap.oauth2.oltu.service.OltuTokenService;
 import de.ids_mannheim.korap.utils.ParameterChecker;
 import de.ids_mannheim.korap.web.input.OAuth2ClientJson;
 
@@ -65,6 +66,8 @@
 //                    UrlValidator.NO_FRAGMENTS + UrlValidator.ALLOW_LOCAL_URLS);
 
     @Autowired
+    private OltuTokenService tokenService;
+    @Autowired
     private InstalledPluginDao pluginDao;
     @Autowired
     private OAuth2ClientDao clientDao;
@@ -390,12 +393,24 @@
         return createClientDtos(plugins);
     }
     
+    public List<InstalledPluginDto> listInstalledPlugins (String superClientId,
+            String username) throws KustvaktException {
+
+        List<InstalledPlugin> plugins =
+                pluginDao.retrieveInstalledPlugins(superClientId, username);
+        Collections.sort(plugins); // by client name
+        
+        List<InstalledPluginDto> list = new ArrayList<InstalledPluginDto>(plugins.size());
+        for (InstalledPlugin p : plugins) {
+            list.add(new InstalledPluginDto(p));
+        }
+        
+        return list;
+    }
+    
     public InstalledPluginDto installPlugin (String superClientId,
             String clientId, String installedBy) throws KustvaktException {
-        if (clientId == null || clientId.isEmpty()) {
-            throw new KustvaktException(StatusCodes.MISSING_PARAMETER,
-                    "Missing parameter: client_id");
-        }
+        
         OAuth2Client client = clientDao.retrieveClientById(clientId);
         if (!client.isPermitted()) {
             throw new KustvaktException(StatusCodes.PLUGIN_NOT_PERMITTED,
@@ -414,6 +429,12 @@
         InstalledPluginDto dto = new InstalledPluginDto(plugin);
         return dto;
     }
+    
+    public void uninstallPlugin (String superClientId,
+            String clientId, String username) throws KustvaktException {
+        pluginDao.uninstallPlugin(superClientId, clientId, username);
+        tokenService.revokeAllClientTokensForUser(clientId, username);
+    }
 
     private boolean isPluginInstalled (String superClientId, String clientId,
             String installedBy) {
diff --git a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
index ed380d9..8ffc164 100644
--- a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
+++ b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
@@ -231,53 +231,4 @@
             throw responseHandler.throwit(e);
         }
     }
-    
-    @POST
-    @Path("/plugins")
-    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
-    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
-    public List<OAuth2ClientInfoDto> listPlugins (
-            @Context SecurityContext context,
-            @FormParam("super_client_id") String superClientId,
-            @FormParam("super_client_secret") String superClientSecret,
-            @FormParam("permitted_only") boolean permittedOnly) {
-
-        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
-
-        try {
-            scopeService.verifyScope(tokenContext,
-                    OAuth2Scope.LIST_USER_CLIENT);
-
-            clientService.verifySuperClient(superClientId, superClientSecret);
-            return clientService.listPlugins(permittedOnly);
-        }
-        catch (KustvaktException e) {
-            throw responseHandler.throwit(e);
-        }
-    }
-    
-    @POST
-    @Path("/install")
-    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
-    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
-    public InstalledPluginDto installPlugin (
-            @Context SecurityContext context,
-            @FormParam("super_client_id") String superClientId,
-            @FormParam("super_client_secret") String superClientSecret,
-            @FormParam("client_id") String clientId) {
-
-        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
-        String username = tokenContext.getUsername();
-
-        try {
-            scopeService.verifyScope(tokenContext,
-                    OAuth2Scope.INSTALL_USER_CLIENT);
-
-            clientService.verifySuperClient(superClientId, superClientSecret);
-            return clientService.installPlugin(superClientId,clientId, username);
-        }
-        catch (KustvaktException e) {
-            throw responseHandler.throwit(e);
-        }
-    }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/web/controller/PluginController.java b/full/src/main/java/de/ids_mannheim/korap/web/controller/PluginController.java
new file mode 100644
index 0000000..581b325
--- /dev/null
+++ b/full/src/main/java/de/ids_mannheim/korap/web/controller/PluginController.java
@@ -0,0 +1,141 @@
+package de.ids_mannheim.korap.web.controller;
+
+import java.util.List;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.SecurityContext;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+
+import com.sun.jersey.spi.container.ResourceFilters;
+
+import de.ids_mannheim.korap.constant.OAuth2Scope;
+import de.ids_mannheim.korap.dto.InstalledPluginDto;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.oauth2.dto.OAuth2ClientInfoDto;
+import de.ids_mannheim.korap.oauth2.service.OAuth2ClientService;
+import de.ids_mannheim.korap.oauth2.service.OAuth2ScopeService;
+import de.ids_mannheim.korap.security.context.TokenContext;
+import de.ids_mannheim.korap.web.OAuth2ResponseHandler;
+import de.ids_mannheim.korap.web.filter.APIVersionFilter;
+import de.ids_mannheim.korap.web.filter.AuthenticationFilter;
+import de.ids_mannheim.korap.web.filter.BlockingFilter;
+
+@Controller
+@Path("{version}/plugins")
+@ResourceFilters({ APIVersionFilter.class, AuthenticationFilter.class,
+        BlockingFilter.class })
+public class PluginController {
+
+    @Autowired
+    private OAuth2ScopeService scopeService;
+    @Autowired
+    private OAuth2ClientService clientService;
+    @Autowired
+    private OAuth2ResponseHandler responseHandler;
+
+    @POST
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
+    public List<OAuth2ClientInfoDto> listPlugins (
+            @Context SecurityContext context,
+            @FormParam("super_client_id") String superClientId,
+            @FormParam("super_client_secret") String superClientSecret,
+            @FormParam("permitted_only") boolean permittedOnly) {
+
+        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
+
+        try {
+            scopeService.verifyScope(tokenContext,
+                    OAuth2Scope.LIST_USER_CLIENT);
+
+            clientService.verifySuperClient(superClientId, superClientSecret);
+            return clientService.listPlugins(permittedOnly);
+        }
+        catch (KustvaktException e) {
+            throw responseHandler.throwit(e);
+        }
+    }
+    
+    @POST
+    @Path("/install")
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
+    public InstalledPluginDto installPlugin (
+            @Context SecurityContext context,
+            @FormParam("super_client_id") String superClientId,
+            @FormParam("super_client_secret") String superClientSecret,
+            @FormParam("client_id") String clientId) {
+
+        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
+        String username = tokenContext.getUsername();
+
+        try {
+            scopeService.verifyScope(tokenContext,
+                    OAuth2Scope.INSTALL_USER_CLIENT);
+
+            clientService.verifySuperClient(superClientId, superClientSecret);
+            return clientService.installPlugin(superClientId,clientId, username);
+        }
+        catch (KustvaktException e) {
+            throw responseHandler.throwit(e);
+        }
+    }
+    
+    @POST
+    @Path("/installed")
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
+    public List<InstalledPluginDto> listInstalledPlugins (
+            @Context SecurityContext context,
+            @FormParam("super_client_id") String superClientId,
+            @FormParam("super_client_secret") String superClientSecret) {
+
+        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
+
+        try {
+            scopeService.verifyScope(tokenContext,
+                    OAuth2Scope.LIST_USER_CLIENT);
+
+            clientService.verifySuperClient(superClientId, superClientSecret);
+            return clientService.listInstalledPlugins(superClientId,
+                    tokenContext.getUsername());
+        }
+        catch (KustvaktException e) {
+            throw responseHandler.throwit(e);
+        }
+    }
+    
+    @POST
+    @Path("/uninstall")
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    public Response uninstallPlugin (
+            @Context SecurityContext context,
+            @FormParam("super_client_id") String superClientId,
+            @FormParam("super_client_secret") String superClientSecret,
+            @FormParam("client_id") String clientId) {
+
+        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
+        String username = tokenContext.getUsername();
+
+        try {
+            scopeService.verifyScope(tokenContext,
+                    OAuth2Scope.UNINSTALL_USER_CLIENT);
+
+            clientService.verifySuperClient(superClientId, superClientSecret);
+            clientService.uninstallPlugin(superClientId,clientId, username);
+            return Response.ok().build();
+        }
+        catch (KustvaktException e) {
+            throw responseHandler.throwit(e);
+        }
+    }
+}
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
index eeae3b7..2473d21 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
@@ -138,17 +138,6 @@
                 node.at("/errors/0/1").asText());
     }
 
-    private void testSearchWithOAuth2Token (String accessToken)
-            throws KustvaktException, IOException {
-        ClientResponse response = searchWithAccessToken(accessToken);
-        String entity = response.getEntity(String.class);
-        assertEquals(ClientResponse.Status.OK.getStatusCode(),
-                response.getStatus());
-        JsonNode node = JsonUtils.readTree(entity);
-        assertNotNull(node);
-        assertEquals(25, node.at("/matches").size());
-    }
-
     @Test
     public void testSearchWithUnknownToken ()
             throws KustvaktException, IOException {
@@ -204,20 +193,6 @@
         testSearchWithRevokedAccessToken(accessToken);
     }
 
-    private void testSearchWithRevokedAccessToken (String accessToken)
-            throws KustvaktException {
-        ClientResponse response = searchWithAccessToken(accessToken);
-        String entity = response.getEntity(String.class);
-        assertEquals(ClientResponse.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());
-    }
-
     @Test
     public void testAccessTokenAfterRequestRefreshToken ()
             throws KustvaktException, IOException {
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java
index 958542b..789c2d9 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AdminControllerTest.java
@@ -138,7 +138,7 @@
         updateClientPriviledge(clientId, false);
         testAccessTokenAfterDegradingSuperClient(clientId, accessToken);
 
-        deregisterConfidentialClient(username, clientId);
+        deregisterClient(username, clientId);
     }
 
     // old access tokens retain their scopes
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
index fc1e24c..02b9ba1 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
@@ -125,7 +125,7 @@
         testListConfidentialClient(username, clientId);
         testConfidentialClientInfo(clientId, username);
         testResetConfidentialClientSecret(clientId, clientSecret);
-        deregisterConfidentialClient(username, clientId);
+        deregisterClient(username, clientId);
     }
     
     @Test
@@ -265,7 +265,7 @@
         JsonNode clientInfo = retrieveClientInfo(clientId, username);
         assertEquals(expiry, clientInfo.at("/refresh_token_expiry").asInt());
         
-        deregisterConfidentialClient(username, clientId);
+        deregisterClient(username, clientId);
     }
     
     @Test
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java
index 34081a5..a3f567b 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2PluginTest.java
@@ -5,6 +5,8 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import java.io.IOException;
+
 import javax.ws.rs.core.MultivaluedMap;
 
 import org.apache.http.entity.ContentType;
@@ -36,7 +38,6 @@
     private String username = "plugin-user";
     @Autowired
     private InstalledPluginDao pluginDao;
-    
 
     @Test
     public void testRegisterPlugin () throws UniformInterfaceException,
@@ -44,7 +45,7 @@
         JsonNode source = JsonUtils.readTree("{ \"plugin\" : \"source\"}");
 
         int refreshTokenExpiry = TimeUtils.convertTimeToSeconds("90D");
-        
+
         String clientName = "Plugin";
         OAuth2ClientJson json = new OAuth2ClientJson();
         json.setName(clientName);
@@ -60,9 +61,9 @@
         String clientSecret = node.at("/client_secret").asText();
         assertNotNull(clientId);
         assertNotNull(clientSecret);
-        
+
         testInstallPluginNotPermitted(clientId);
-        testRetrievePluginInfo(clientId,refreshTokenExpiry);
+        testRetrievePluginInfo(clientId, refreshTokenExpiry);
 
         node = listPlugins(false);
         assertEquals(3, node.size());
@@ -71,12 +72,32 @@
 
         testListUserRegisteredPlugins(username, clientId, clientName,
                 refreshTokenExpiry);
-        deregisterConfidentialClient(username, clientId);
+        deregisterClient(username, clientId);
     }
 
-    private void testRetrievePluginInfo (String clientId, int refreshTokenExpiry)
-            throws UniformInterfaceException, ClientHandlerException,
-            KustvaktException {
+    @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);
+
+        ClientResponse response = registerClient(username, json);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
+
+        String clientId = node.at("/client_id").asText(); 
+        assertTrue(node.at("/client_secret").isMissingNode());
+        
+        deregisterClient(username, clientId);
+    }
+
+    private void testRetrievePluginInfo (String clientId,
+            int refreshTokenExpiry) throws UniformInterfaceException,
+            ClientHandlerException, KustvaktException {
         JsonNode clientInfo = retrieveClientInfo(clientId, username);
         assertEquals(clientId, clientInfo.at("/client_id").asText());
         assertEquals("Plugin", clientInfo.at("/client_name").asText());
@@ -136,8 +157,7 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("super_client_id", confidentialClientId);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2")
-                .path("client").path("plugins")
+        ClientResponse response = resource().path(API_VERSION).path("plugins")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -157,8 +177,7 @@
             MultivaluedMap<String, String> form)
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
-        ClientResponse response = resource().path(API_VERSION).path("oauth2")
-                .path("client").path("plugins")
+        ClientResponse response = resource().path(API_VERSION).path("plugins")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -180,8 +199,7 @@
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2")
-                .path("client").path("plugins")
+        ClientResponse response = resource().path(API_VERSION).path("plugins")
                 .header(Attributes.AUTHORIZATION, "Bearer blahblah")
                 .header(HttpHeaders.CONTENT_TYPE,
                         ContentType.APPLICATION_FORM_URLENCODED)
@@ -189,7 +207,7 @@
 
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
-        
+
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
         assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
                 node.at("/errors/0/0").asInt());
@@ -209,7 +227,7 @@
         assertFalse(node.at("/0/source").isMissingNode());
         assertFalse(node.at("/0/refresh_token_expiry").isMissingNode());
 
-//        assertTrue(node.at("/1/refresh_token_expiry").isMissingNode());
+        // assertTrue(node.at("/1/refresh_token_expiry").isMissingNode());
     }
 
     private JsonNode listPlugins (boolean permitted_only)
@@ -220,8 +238,7 @@
         if (permitted_only) {
             form.add("permitted_only", Boolean.toString(permitted_only));
         }
-        ClientResponse response = resource().path(API_VERSION).path("oauth2")
-                .path("client").path("plugins")
+        ClientResponse response = resource().path(API_VERSION).path("plugins")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -234,54 +251,57 @@
         return JsonUtils.readTree(entity);
     }
 
-    @Test
-    public void testInstallConfidentialPlugin () throws UniformInterfaceException,
+    private void testInstallConfidentialPlugin (String superClientId,
+            String clientId, String username) throws UniformInterfaceException,
             ClientHandlerException, KustvaktException {
         MultivaluedMap<String, String> form = getSuperClientForm();
-        form.add("client_id", confidentialClientId2);
+        form.add("client_id", clientId);
         ClientResponse response = installPlugin(form);
-        
+
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
-        assertEquals(confidentialClientId2, node.at("/client_id").asText());
+        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, confidentialClientId2,
-                username);
+
+        testRetrieveInstalledPlugin(superClientId, clientId, username);
     }
-    
+
     @Test
     public void testInstallPublicPlugin () throws UniformInterfaceException,
             ClientHandlerException, KustvaktException {
         MultivaluedMap<String, String> form = getSuperClientForm();
         form.add("client_id", publicClientId2);
         ClientResponse response = installPlugin(form);
-        
+
         String entity = response.getEntity(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);
+
+        testRetrieveInstalledPlugin(superClientId, publicClientId2, username);
+
+        response = uninstallPlugin(publicClientId2, username);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = retrieveUserInstalledPlugin(getSuperClientForm());
+        assertTrue(node.isEmpty());
     }
-    
+
     private void testInstallPluginRedundant (
             MultivaluedMap<String, String> form)
             throws UniformInterfaceException, ClientHandlerException,
@@ -306,21 +326,24 @@
                 node.at("/errors/0/0").asInt());
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
     }
-    
+
     @Test
-    public void testInstallPluginMissingClientId () throws UniformInterfaceException,
-            ClientHandlerException, KustvaktException {
+    public void testInstallPluginMissingClientId ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
         MultivaluedMap<String, String> form = getSuperClientForm();
         ClientResponse response = installPlugin(form);
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
-        assertEquals(StatusCodes.MISSING_PARAMETER, node.at("/errors/0/0").asInt());
+        assertEquals(StatusCodes.INVALID_ARGUMENT,
+                node.at("/errors/0/0").asInt());
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
     }
-    
+
     @Test
-    public void testInstallPluginInvalidClientId () throws UniformInterfaceException,
-            ClientHandlerException, KustvaktException {
+    public void testInstallPluginInvalidClientId ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
         MultivaluedMap<String, String> form = getSuperClientForm();
         form.add("client_id", "unknown");
         ClientResponse response = installPlugin(form);
@@ -331,24 +354,25 @@
         assertEquals("invalid_client", node.at("/error").asText());
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
     }
-    
+
     @Test
-    public void testInstallPluginMissingSuperClientSecret () throws UniformInterfaceException,
-            ClientHandlerException, KustvaktException {
+    public void testInstallPluginMissingSuperClientSecret ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("super_client_id", superClientId);
-        
+
         ClientResponse response = installPlugin(form);
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
-        
+
         assertEquals("Missing parameter: super_client_secret",
                 node.at("/error_description").asText());
         assertEquals("invalid_request", node.at("/error").asText());
-        
+
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
     }
-    
+
     @Test
     public void testInstallPluginMissingSuperClientId ()
             throws UniformInterfaceException, ClientHandlerException,
@@ -364,7 +388,7 @@
 
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
     }
-    
+
     @Test
     public void testInstallPluginUnauthorizedClient ()
             throws UniformInterfaceException, ClientHandlerException,
@@ -372,27 +396,41 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("super_client_id", confidentialClientId);
         form.add("super_client_secret", clientSecret);
-        
+
         ClientResponse response = installPlugin(form);
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
         assertEquals("unauthorized_client", node.at("/error").asText());
-        
+
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
     }
 
     private ClientResponse installPlugin (MultivaluedMap<String, String> form)
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
-        return resource().path(API_VERSION).path("oauth2").path("client")
-                .path("install")
+        return resource().path(API_VERSION).path("plugins").path("install")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
                         ContentType.APPLICATION_FORM_URLENCODED)
                 .entity(form).post(ClientResponse.class);
     }
-    
+
+    private ClientResponse uninstallPlugin (String clientId, String username)
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
+
+        MultivaluedMap<String, String> form = getSuperClientForm();
+        form.add("client_id", clientId);
+
+        return resource().path(API_VERSION).path("plugins").path("uninstall")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .entity(form).post(ClientResponse.class);
+    }
+
     private void testRetrieveInstalledPlugin (String superClientId,
             String clientId, String installedBy) throws KustvaktException {
         InstalledPlugin plugin = pluginDao
@@ -400,8 +438,86 @@
         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 UniformInterfaceException, ClientHandlerException,
+            KustvaktException, IOException {
+
+        testInstallConfidentialPlugin(superClientId, confidentialClientId,
+                username);
         
-        assertTrue(plugin.getId()>0);
-        assertTrue(plugin.getInstalledDate()!= null);
+        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());
+
+        ClientResponse 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());
+
+        testUninstallNotInstalledPlugin();
+    }
+    
+    private JsonNode testRequestAccessToken (String clientId) throws KustvaktException {
+        String userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, "password");
+        String code = requestAuthorizationCode(clientId, userAuthHeader);
+        ClientResponse response = requestTokenWithAuthorizationCodeAndForm(
+                clientId, clientSecret, code);
+        JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
+        
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        return node;
+    }
+
+    private void testUninstallNotInstalledPlugin ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
+        ClientResponse response =
+                uninstallPlugin(confidentialClientId2, username);
+        assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
+        assertEquals(StatusCodes.NO_RESOURCE_FOUND,
+                node.at("/errors/0/0").asInt());
+
+    }
+
+    private JsonNode retrieveUserInstalledPlugin (
+            MultivaluedMap<String, String> form)
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
+        ClientResponse response = resource().path(API_VERSION).path("plugins")
+                .path("installed")
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(username, "pass"))
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .entity(form).post(ClientResponse.class);
+
+        String entity = response.getEntity(String.class);
+        return JsonUtils.readTree(entity);
+
     }
 }
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
index 6526190..9b3d506 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
@@ -4,6 +4,7 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import java.io.IOException;
 import java.net.URI;
 
 import javax.ws.rs.core.MultivaluedMap;
@@ -29,6 +30,7 @@
 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.oauth2.constant.OAuth2ClientType;
 import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
 import de.ids_mannheim.korap.oauth2.dao.RefreshTokenDao;
@@ -287,7 +289,7 @@
 
     }
 
-    protected void deregisterConfidentialClient (String username,
+    protected void deregisterClient (String username,
             String clientId) throws UniformInterfaceException,
             ClientHandlerException, KustvaktException {
 
@@ -322,6 +324,32 @@
                 .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
                 .get(ClientResponse.class);
     }
+    
+    protected void testSearchWithOAuth2Token (String accessToken)
+            throws KustvaktException, IOException {
+        ClientResponse response = searchWithAccessToken(accessToken);
+        String entity = response.getEntity(String.class);
+        assertEquals(ClientResponse.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 {
+        ClientResponse response = searchWithAccessToken(accessToken);
+        String entity = response.getEntity(String.class);
+        assertEquals(ClientResponse.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) {