Add rest resource for api keys

This commit is contained in:
René Pfeuffer
2020-09-29 11:08:36 +02:00
parent 4129f55f27
commit 905fc4158a
18 changed files with 401 additions and 38 deletions

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.user;
//~--- non-JDK imports --------------------------------------------------------
@@ -50,7 +50,7 @@ import java.security.Principal;
@StaticPermissions(
value = "user",
globalPermissions = {"create", "list", "autocomplete"},
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"},
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"},
custom = true, customGlobal = true
)
@XmlRootElement(name = "users")

View File

@@ -85,6 +85,9 @@ public class VndMediaType {
public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX;
public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX;
public static final String API_KEY = PREFIX + "apiKey" + SUFFIX;
public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX;
private VndMediaType() {
}

View File

@@ -0,0 +1,55 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import sonia.scm.security.ApiKey;
import javax.inject.Inject;
import java.util.Collection;
import java.util.List;
import static java.util.stream.Collectors.toList;
public class ApiKeyCollectionToDtoMapper {
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper;
private final ResourceLinks resourceLinks;
@Inject
public ApiKeyCollectionToDtoMapper(ApiKeyToApiKeyDtoMapper apiKeyDtoMapper, ResourceLinks resourceLinks) {
this.apiKeyDtoMapper = apiKeyDtoMapper;
this.resourceLinks = resourceLinks;
}
public HalRepresentation map(Collection<ApiKey> keys) {
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
final Links.Builder links = Links.linkingTo();
links.self(resourceLinks.apiKeyCollection().self());
return new HalRepresentation(links.build(), Embedded.embedded("apiKeys", dtos));
}
}

View File

@@ -0,0 +1,41 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ApiKeyDto extends HalRepresentation {
private String displayName;
private String role;
public ApiKeyDto(Links links) {
super(links);
}
}

View File

@@ -0,0 +1,117 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.ContextEntry;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import static sonia.scm.NotFoundException.notFound;
public class ApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
@Inject
public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper) {
this.apiKeyService = apiKeyService;
this.apiKeyCollectionMapper = apiKeyCollectionMapper;
this.apiKeyMapper = apiKeyMapper;
}
@GET
@Path("")
@Produces(VndMediaType.API_KEY_COLLECTION)
@Operation(summary = "Get the api keys for the current user", description = "Returns the registered api keys for the logged in user.", tags = "User")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getForCurrentUser() {
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
}
@GET
@Path("{id}")
@Produces(VndMediaType.API_KEY_COLLECTION)
@Operation(summary = "Get one api key for the current user", description = "Returns the registered api key with the given id for the logged in user.", tags = "User")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "404",
description = "not found, no api key with the given id for the current user available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public ApiKeyDto get(@PathParam("id") String id) {
return apiKeyService
.getKeys()
.stream()
.filter(key -> key.getId().equals(id))
.map(apiKeyMapper::map).findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
}
}

View File

@@ -0,0 +1,47 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.security.ApiKey;
import javax.inject.Inject;
@Mapper
public abstract class ApiKeyToApiKeyDtoMapper {
@Inject
private ResourceLinks links;
abstract ApiKeyDto map(ApiKey key);
@ObjectFactory
ApiKeyDto createDto(ApiKey key) {
Links.linkingTo().self(links.apiKey().self(key.getId()));
return new ApiKeyDto(Links.linkingTo().self(links.apiKey().self(key.getId())).build());
}
}

View File

@@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule {
bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST);
bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class));
bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class));
}
}

View File

@@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper {
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
if (UserPermissions.changeApiKeys(user).isPermitted()) {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
}
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
@@ -35,6 +35,7 @@ import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
@@ -61,11 +62,14 @@ public class MeResource {
private final UserManager userManager;
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResource;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResource = apiKeyResource;
}
/**
@@ -118,4 +122,9 @@ public class MeResource {
);
return Response.noContent().build();
}
@Path("apiKeys")
public ApiKeyResource apiKeys() {
return apiKeyResource.get();
}
}

View File

@@ -204,6 +204,38 @@ class ResourceLinks {
}
}
public ApiKeyCollectionLinks apiKeyCollection() {
return new ApiKeyCollectionLinks(scmPathInfoStore.get());
}
static class ApiKeyCollectionLinks {
private final LinkBuilder collectionLinkBuilder;
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
}
String self() {
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
}
}
public ApiKeyLinks apiKey() {
return new ApiKeyLinks(scmPathInfoStore.get());
}
static class ApiKeyLinks {
private final LinkBuilder apiKeyLinkBuilder;
ApiKeyLinks(ScmPathInfo pathInfo) {
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
}
String self(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
}
}
UserCollectionLinks userCollection() {
return new UserCollectionLinks(scmPathInfoStore.get());
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation;
@@ -131,7 +131,7 @@ public class UserResource {
*
* <strong>Note:</strong> This method requires "user" privilege.
*
* @param name name of the user to be modified
* @param name name of the user to be modified
* @param user user object to modify
*/
@PUT

View File

@@ -30,10 +30,11 @@ import lombok.Getter;
@Getter
@AllArgsConstructor
public class ApiKey {
private final String name;
private final String id;
private final String displayName;
private final String role;
ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) {
this(apiKeyWithPassphrase.getName(), apiKeyWithPassphrase.getRole());
this(apiKeyWithPassphrase.getId(), apiKeyWithPassphrase.getDisplayName(), apiKeyWithPassphrase.getRole());
}
}

View File

@@ -44,30 +44,32 @@ import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang.RandomStringUtils.random;
import static sonia.scm.AlreadyExistsException.alreadyExists;
class ApiKeyService {
public class ApiKeyService {
public static final int KEY_LENGTH = 20;
private final ConfigurationEntryStore<ApiKeyCollection> store;
private final PasswordService passwordService;
private final Supplier<String> keyGenerator;
private final KeyGenerator keyGenerator;
private final Supplier<String> passphraseGenerator;
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
@Inject
ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService) {
this(storeFactory, passwordService, () -> random(KEY_LENGTH, 0, 0, true, true, null, new SecureRandom()));
ApiKeyService(ConfigurationEntryStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService) {
this(storeFactory, passwordService, keyGenerator, () -> random(KEY_LENGTH, 0, 0, true, true, null, new SecureRandom()));
}
ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, Supplier<String> keyGenerator) {
ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, Supplier<String> passphraseGenerator) {
this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
this.passwordService = passwordService;
this.keyGenerator = keyGenerator;
this.passphraseGenerator = passphraseGenerator;
}
String createNewKey(String name, String role) {
public String createNewKey(String name, String role) {
String user = currentUser();
String passphrase = keyGenerator.get();
String passphrase = passphraseGenerator.get();
String hashedPassphrase = passwordService.encryptPassword(passphrase);
Lock lock = locks.get(user).writeLock();
lock.lock();
@@ -76,7 +78,7 @@ class ApiKeyService {
throw alreadyExists(ContextEntry.ContextBuilder.entity(ApiKeyWithPassphrase.class, name));
}
final ApiKeyCollection apiKeyCollection = store.getOptional(user).orElse(new ApiKeyCollection(emptyList()));
final ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(new ApiKeyWithPassphrase(name, role, hashedPassphrase));
final ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(new ApiKeyWithPassphrase(keyGenerator.createKey(), name, role, hashedPassphrase));
store.put(user, newApiKeyCollection);
} finally {
lock.unlock();
@@ -84,17 +86,17 @@ class ApiKeyService {
return passphrase;
}
void remove(String name) {
public void remove(String id) {
String user = currentUser();
Lock lock = locks.get(user).writeLock();
lock.lock();
try {
if (!containsName(user, name)) {
if (!containsId(user, id)) {
return;
}
store.getOptional(user).ifPresent(
apiKeyCollection -> {
final ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(apiKeyWithPassphrase -> name.equals(apiKeyWithPassphrase.getName()));
final ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
store.put(user, newApiKeyCollection);
}
);
@@ -103,7 +105,7 @@ class ApiKeyService {
}
}
Optional<String> check(String user, String keyName, String passphrase) {
Optional<String> check(String user, String id, String passphrase) {
Lock lock = locks.get(user).readLock();
lock.lock();
try {
@@ -111,7 +113,7 @@ class ApiKeyService {
.get(user)
.getKeys()
.stream()
.filter(key -> key.getName().equals(keyName))
.filter(key -> key.getId().equals(id))
.filter(key -> passwordService.passwordsMatch(passphrase, key.getPassphrase()))
.map(ApiKeyWithPassphrase::getRole)
.findAny();
@@ -120,7 +122,7 @@ class ApiKeyService {
}
}
Collection<ApiKey> getKeys() {
public Collection<ApiKey> getKeys() {
return store.get(currentUser()).getKeys().stream().map(ApiKey::new).collect(toList());
}
@@ -128,12 +130,21 @@ class ApiKeyService {
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
private boolean containsId(String user, String id) {
return store
.getOptional(user)
.map(ApiKeyCollection::getKeys)
.orElse(emptyList())
.stream()
.anyMatch(key -> key.getId().equals(id));
}
private boolean containsName(String user, String name) {
return store
.getOptional(user)
.map(ApiKeyCollection::getKeys)
.orElse(emptyList())
.stream()
.anyMatch(key -> key.getName().equals(name));
.anyMatch(key -> key.getDisplayName().equals(name));
}
}

View File

@@ -37,7 +37,8 @@ import javax.xml.bind.annotation.XmlAccessorType;
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
class ApiKeyWithPassphrase {
private String name;
private String id;
private String displayName;
private String role;
private String passphrase;
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.ShiroRule;
@@ -41,6 +41,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.ContextEntry;
import sonia.scm.group.GroupCollector;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
import sonia.scm.user.InvalidPasswordException;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
@@ -51,7 +53,10 @@ import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import static com.google.inject.util.Providers.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
@@ -87,8 +92,13 @@ public class MeResourceTest {
@Mock
private UserManager userManager;
@Mock
private ApiKeyService apiKeyService;
@InjectMocks
private MeDtoFactory meDtoFactory;
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
@@ -96,6 +106,8 @@ public class MeResourceTest {
private PasswordService passwordService;
private User originalUser;
private MockHttpResponse response = new MockHttpResponse();
@Before
public void prepareEnvironment() {
initMocks(this);
@@ -106,7 +118,9 @@ public class MeResourceTest {
when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2"));
when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod();
when(userManager.getDefaultType()).thenReturn("xml");
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService);
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper);
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource));
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
when(scmPathInfoStore.get()).thenReturn(uriInfo);
dispatcher.addSingletonResource(meResource);
@@ -118,14 +132,14 @@ public class MeResourceTest {
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
request.accept(VndMediaType.ME);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("\"name\":\"trillian\""));
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/me/\"}"));
assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}"));
assertThat(response.getContentAsString()).contains("\"name\":\"trillian\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/\"}");
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian\"}");
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/me/apiKeys\"}");
}
private void applyUserToSubject(User user) {
@@ -149,7 +163,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword);
when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword);
@@ -174,7 +187,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -190,7 +202,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -206,7 +217,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-")))
.when(userManager).changePasswordForLoggedInUser(any(), any());
@@ -216,6 +226,33 @@ public class MeResourceTest {
assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus());
}
@Test
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys()).thenReturn(Arrays.asList(new ApiKey("1", "key 1", "READ"), new ApiKey("2", "key 2", "WRITE")));
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "apiKeys");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"role\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"role\":\"WRITE\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/apiKeys\"}");
}
@Test
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys()).thenReturn(Arrays.asList(new ApiKey("1", "key 1", "READ"), new ApiKey("2", "key 2", "WRITE")));
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "apiKeys/1");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\"");
assertThat(response.getContentAsString()).contains("\"role\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/apiKeys/1\"}");
}
private User createDummyUser(String name) {
User user = new User();

View File

@@ -80,6 +80,8 @@ public class ResourceLinksMock {
lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo));
lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo));
lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo));
lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo));
lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo));
return resourceLinks;
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.ShiroRule;

View File

@@ -49,12 +49,14 @@ import static org.mockito.Mockito.when;
class ApiKeyServiceTest {
int nextKey = 1;
int nextId = 1;
PasswordService passwordService = mock(PasswordService.class);
Supplier<String> keyGenerator = () -> Integer.toString(nextKey++);
Supplier<String> passphraseGenerator = () -> Integer.toString(nextKey++);
KeyGenerator keyGenerator = () -> Integer.toString(nextId++);
ConfigurationEntryStoreFactory storeFactory = new InMemoryConfigurationEntryStoreFactory();
ConfigurationEntryStore<ApiKeyCollection> store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator);
ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator, passphraseGenerator);
@BeforeEach
@@ -128,7 +130,7 @@ class ApiKeyServiceTest {
assertThat(service.check("dent", "1", firstKey)).contains("READ");
assertThat(service.check("dent", "2", secondKey)).contains("WRITE");
assertThat(service.getKeys()).extracting("name").contains("1", "2");
assertThat(service.getKeys()).extracting("id").contains("1", "2");
}
@Test