From 0ac8b90c2fef7e23850c98edaf582d92d87da7ac Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Wed, 5 Aug 2020 17:08:15 +0200 Subject: [PATCH] first functional snapshot with unit test, no frontend changes --- .../scm/api/v2/resources/ResourceLinks.java | 4 +- .../scm/security/gpg/PrivateKeyStore.java | 5 +- .../gpg/PublicKeyCollectionMapper.java | 4 +- .../scm/security/gpg/PublicKeyMapper.java | 12 +- .../scm/security/gpg/PublicKeyResource.java | 106 +--------- .../scm/security/gpg/PublicKeyStore.java | 28 ++- .../sonia/scm/security/gpg/RawGpgKey.java | 5 + .../security/gpg/UserPublicKeyResource.java | 190 ++++++++++++++++++ .../scm/security/gpg/PublicKeyMapperTest.java | 14 +- .../security/gpg/PublicKeyResourceTest.java | 93 +-------- .../scm/security/gpg/PublicKeyStoreTest.java | 37 ++++ .../gpg/UserPublicKeyResourceTest.java | 143 +++++++++++++ 12 files changed, 436 insertions(+), 205 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 57c962059b..1ed150a2f1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -25,7 +25,7 @@ package sonia.scm.api.v2.resources; import sonia.scm.repository.NamespaceAndName; -import sonia.scm.security.gpg.PublicKeyResource; +import sonia.scm.security.gpg.UserPublicKeyResource; import javax.inject.Inject; import java.net.URI; @@ -104,7 +104,7 @@ class ResourceLinks { UserLinks(ScmPathInfo pathInfo) { userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class); - publicKeyLinkBuilder = new LinkBuilder(pathInfo, PublicKeyResource.class); + publicKeyLinkBuilder = new LinkBuilder(pathInfo, UserPublicKeyResource.class); } String self(String name) { diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java index 1c18c9c10f..94962b4f37 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java @@ -25,9 +25,9 @@ package sonia.scm.security.gpg; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import sonia.scm.security.CipherUtil; -import sonia.scm.security.PrivateKey; import sonia.scm.store.DataStore; import sonia.scm.store.DataStoreFactory; import sonia.scm.xml.XmlInstantAdapter; @@ -66,7 +66,8 @@ class PrivateKeyStore { @XmlAccessorType(XmlAccessType.FIELD) @AllArgsConstructor @NoArgsConstructor - private class RawPrivateKey { + @Getter + static class RawPrivateKey { private String key; @XmlJavaTypeAdapter(XmlInstantAdapter.class) diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java index eeffff49c9..c8d46be647 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java @@ -71,14 +71,14 @@ public class PublicKeyCollectionMapper { } private String createLink(String username) { - return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class) + return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class) .method("create") .parameters(username) .href(); } private String selfLink(String username) { - return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class) + return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class) .method("findAll") .parameters(username) .href(); diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java index ede00b9f9d..91af754a3a 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java @@ -51,7 +51,7 @@ public abstract class PublicKeyMapper { } @Mapping(target = "attributes", ignore = true) - @Mapping(target = "raw", ignore = true) +// @Mapping(target = "raw", ignore = true) // TODO: Why is there ? abstract RawGpgKeyDto map(RawGpgKey rawGpgKey); @ObjectFactory @@ -65,16 +65,16 @@ public abstract class PublicKeyMapper { } private String createSelfLink(RawGpgKey rawGpgKey) { - return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class) - .method("findById") - .parameters(rawGpgKey.getId()) + return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class) + .method("findByIdJson") + .parameters(rawGpgKey.getOwner(), rawGpgKey.getId()) .href(); } private String createDeleteLink(RawGpgKey rawGpgKey) { - return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class) + return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class) .method("deleteById") - .parameters(rawGpgKey.getId()) + .parameters(rawGpgKey.getOwner(), rawGpgKey.getId()) .href(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java index d2338f4ecf..0876207144 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java @@ -24,7 +24,6 @@ package sonia.scm.security.gpg; -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; @@ -34,71 +33,27 @@ import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; -import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriBuilder; -import javax.ws.rs.core.UriInfo; import java.util.Optional; @Path("v2/public_keys") public class PublicKeyResource { - private static final String MEDIA_TYPE = VndMediaType.PREFIX + "publicKey" + VndMediaType.SUFFIX; - private static final String MEDIA_TYPE_COLLECTION = VndMediaType.PREFIX + "publicKeyCollection" + VndMediaType.SUFFIX; - private final PublicKeyMapper mapper; - private final PublicKeyCollectionMapper collectionMapper; private final PublicKeyStore store; @Inject - public PublicKeyResource(PublicKeyMapper mapper, PublicKeyCollectionMapper collectionMapper, PublicKeyStore store) { - this.mapper = mapper; - this.collectionMapper = collectionMapper; + public PublicKeyResource(PublicKeyStore store) { this.store = store; } - @GET - @Path("{username}") - @Produces(MEDIA_TYPE_COLLECTION) - @Operation( - summary = "Get all public keys for user", - description = "Returns all keys for the given username.", - tags = "User", - operationId = "get_all_public_keys" - ) - @ApiResponse( - responseCode = "200", - description = "success", - content = @Content( - mediaType = MEDIA_TYPE_COLLECTION, - schema = @Schema(implementation = HalRepresentation.class) - ) - ) - @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") - @ApiResponse( - responseCode = "500", - description = "internal server error", - content = @Content( - mediaType = VndMediaType.ERROR_TYPE, - schema = @Schema(implementation = ErrorDto.class) - ) - ) - public HalRepresentation findAll(@PathParam("username") String username) { - return collectionMapper.map(username, store.findByUsername(username)); - } - @GET @Path("{id}") - @Produces(MEDIA_TYPE) + @Produces("application/pgp-keys") @AllowAnonymousAccess @Operation( summary = "Get single key for user", @@ -110,7 +65,7 @@ public class PublicKeyResource { responseCode = "200", description = "success", content = @Content( - mediaType = MEDIA_TYPE, + mediaType = "application/pgp-keys", schema = @Schema(implementation = RawGpgKeyDto.class) ) ) @@ -132,63 +87,12 @@ public class PublicKeyResource { schema = @Schema(implementation = ErrorDto.class) ) ) - public Response findByIdJson(@PathParam("id") String id) { + public Response findByIdGpg(@PathParam("id") String id) { Optional byId = store.findById(id); if (byId.isPresent()) { - return Response.ok(mapper.map(byId.get())).build(); + return Response.ok(byId.get().getRaw()).build(); } return Response.status(Response.Status.NOT_FOUND).build(); } - @POST - @Path("{username}") - @Consumes(MEDIA_TYPE) - @Operation( - summary = "Create new key", - description = "Creates new key for user.", - tags = "User", - operationId = "create_public_key" - ) - @ApiResponse(responseCode = "201", description = "create success") - @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") - @ApiResponse( - responseCode = "500", - description = "internal server error", - content = @Content( - mediaType = VndMediaType.ERROR_TYPE, - schema = @Schema(implementation = ErrorDto.class) - ) - ) - public Response create(@Context UriInfo uriInfo, @PathParam("username") String username, RawGpgKeyDto publicKey) { - String id = store.add(publicKey.getDisplayName(), username, publicKey.getRaw()).getId(); - UriBuilder builder = uriInfo.getAbsolutePathBuilder(); - builder.path(id); - return Response.created(builder.build()).build(); - } - - @DELETE - @Path("delete/{id}") - @Operation( - summary = "Deletes public key", - description = "Deletes public key for user.", - tags = "User", - operationId = "delete_public_key" - ) - @ApiResponse(responseCode = "204", description = "delete success") - @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") - @ApiResponse( - responseCode = "500", - description = "internal server error", - content = @Content( - mediaType = VndMediaType.ERROR_TYPE, - schema = @Schema(implementation = ErrorDto.class) - ) - ) - public Response deleteById(@PathParam("id") String id) { - store.delete(id); - return Response.noContent().build(); - } - } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java index a443d45443..f853fc4789 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java @@ -25,6 +25,7 @@ package sonia.scm.security.gpg; import org.bouncycastle.openpgp.PGPPublicKey; +import sonia.scm.BadRequestException; import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Person; @@ -39,7 +40,6 @@ import javax.inject.Inject; import javax.inject.Singleton; import java.time.Instant; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -102,9 +102,13 @@ public class PublicKeyStore { public void delete(String id) { RawGpgKey rawGpgKey = store.get(id); if (rawGpgKey != null) { - UserPermissions.changePublicKeys(rawGpgKey.getOwner()).check(); - store.remove(id); - eventBus.post(new PublicKeyDeletedEvent()); + if (!rawGpgKey.isReadonly()) { + UserPermissions.changePublicKeys(rawGpgKey.getOwner()).check(); + store.remove(id); + eventBus.post(new PublicKeyDeletedEvent()); + } else { + throw new DeletingReadonlyKeyNotAllowedException(id); + } } } @@ -125,4 +129,20 @@ public class PublicKeyStore { .collect(Collectors.toList()); } + @SuppressWarnings("squid:MaximumInheritanceDepth") + // exceptions have a deep inheritance depth themselves; therefore we accept this here + public static class DeletingReadonlyKeyNotAllowedException extends BadRequestException { + + public DeletingReadonlyKeyNotAllowedException(String keyId) { + super(ContextEntry.ContextBuilder.entity(RawGpgKey.class, keyId).build(), "deleting readonly gpg keys is not allowed"); + } + + private static final String CODE = "3US6mweXy1"; + + @Override + public String getCode() { + return CODE; + } + } + } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java index b884a9c69a..9b724320fa 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java @@ -60,12 +60,17 @@ public class RawGpgKey { RawGpgKey(String id) { this.id = id; } + RawGpgKey(String id, String raw) { + this.id = id; + this.raw = raw; + } RawGpgKey(String id, String displayName, String owner, String raw, Set contacts, Instant created) { this.id = id; this.displayName = displayName; this.owner = owner; this.contacts = contacts; this.created = created; + this.raw = raw; } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java new file mode 100644 index 0000000000..c7fbb38709 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java @@ -0,0 +1,190 @@ +/* + * 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.security.gpg; + +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.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriInfo; +import java.util.Optional; + +@Path("v2/users/{username}/public_keys") +public class UserPublicKeyResource { + + private static final String MEDIA_TYPE_COLLECTION = VndMediaType.PREFIX + "publicKeyCollection" + VndMediaType.SUFFIX; + private static final String MEDIA_TYPE = VndMediaType.PREFIX + "publicKey" + VndMediaType.SUFFIX; + + private final PublicKeyCollectionMapper collectionMapper; + private final PublicKeyStore store; + private final PublicKeyMapper mapper; + + @Inject + public UserPublicKeyResource(PublicKeyCollectionMapper collectionMapper, PublicKeyMapper mapper, PublicKeyStore store) { + this.collectionMapper = collectionMapper; + this.store = store; + this.mapper = mapper; + } + + @GET + @Path("") + @Produces(MEDIA_TYPE_COLLECTION) + @Operation( + summary = "Get all public keys for user", + description = "Returns all keys for the given username.", + tags = "User", + operationId = "get_all_public_keys" + ) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = MEDIA_TYPE_COLLECTION, + schema = @Schema(implementation = HalRepresentation.class) + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public HalRepresentation findAll(@PathParam("username") String id) { + return collectionMapper.map(id, store.findByUsername(id)); + } + + @POST + @Path("") + @Consumes(MEDIA_TYPE) + @Operation( + summary = "Create new key", + description = "Creates new key for user.", + tags = "User", + operationId = "create_public_key" + ) + @ApiResponse(responseCode = "201", description = "create success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response create(@Context UriInfo uriInfo, @PathParam("username") String username, RawGpgKeyDto publicKey) { + String id = store.add(publicKey.getDisplayName(), username, publicKey.getRaw()).getId(); + UriBuilder builder = uriInfo.getAbsolutePathBuilder(); + builder.path(id); + return Response.created(builder.build()).build(); + } + + @DELETE + @Path("{id}") + @Operation( + summary = "Deletes public key", + description = "Deletes public key for user.", + tags = "User", + operationId = "delete_public_key" + ) + @ApiResponse(responseCode = "204", description = "delete success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response deleteById(@PathParam("id") String id) { + store.delete(id); + return Response.noContent().build(); + } + + @GET + @Path("{id}") + @Produces(MEDIA_TYPE) + @Operation( + summary = "Get single key for user", + description = "Returns a single public key for username by id.", + tags = "User", + operationId = "get_single_public_key" + ) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = MEDIA_TYPE, + schema = @Schema(implementation = RawGpgKeyDto.class) + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege") + @ApiResponse( + responseCode = "404", + description = "not found / key for given id not 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 Response findByIdJson(@PathParam("id") String id) { + Optional byId = store.findById(id); + if (byId.isPresent()) { + return Response.ok(mapper.map(byId.get())).build(); + } + return Response.status(Response.Status.NOT_FOUND).build(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java index 764e037eea..d4d6eb67e5 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java @@ -81,7 +81,7 @@ class PublicKeyMapperTest { } @Test - void shouldNotAppendDeleteLink() throws IOException { + void shouldNotAppendDeleteLinkIfPermissionMissing() throws IOException { String raw = GPGTestHelper.readResourceAsString("single.asc"); RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Collections.emptySet(), Instant.now()); @@ -89,4 +89,16 @@ class PublicKeyMapperTest { assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); } + + @Test + void shouldNotAppendDeleteLinkIfReadonly() throws IOException { + when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true); + + String raw = GPGTestHelper.readResourceAsString("single.asc"); + RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Collections.emptySet(), Instant.now(), true); + + RawGpgKeyDto dto = mapper.map(key); + + assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java index 68d3387f04..0fb51de383 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java @@ -24,11 +24,6 @@ package sonia.scm.security.gpg; -import de.otto.edison.hal.HalRepresentation; -import org.apache.shiro.subject.Subject; -import org.apache.shiro.util.ThreadContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -36,18 +31,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriBuilder; -import javax.ws.rs.core.UriInfo; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -56,87 +43,19 @@ class PublicKeyResourceTest { @Mock private PublicKeyStore store; - @Mock - private PublicKeyCollectionMapper collectionMapper; - - @Mock - private PublicKeyMapper mapper; - @InjectMocks private PublicKeyResource resource; - @Mock - private Subject subject; - - @BeforeEach - void setUpSubject() { - ThreadContext.bind(subject); - } - - @AfterEach - void clearSubject() { - ThreadContext.unbindSubject(); - } - @Test - void shouldFindAll() { - List keys = new ArrayList<>(); - when(store.findByUsername("trillian")).thenReturn(keys); - - HalRepresentation collection = new HalRepresentation(); - when(collectionMapper.map("trillian", keys)).thenReturn(collection); - - HalRepresentation result = resource.findAll("trillian"); - assertThat(result).isSameAs(collection); - } - - @Test - void shouldFindById() { - RawGpgKey key = new RawGpgKey("42"); - when(store.findById("42")).thenReturn(Optional.of(key)); - RawGpgKeyDto dto = new RawGpgKeyDto(); - when(mapper.map(key)).thenReturn(dto); - - Response response = resource.findByIdJson("42"); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getEntity()).isSameAs(dto); - } - - @Test - void shouldReturn404IfIdDoesNotExists() { - when(store.findById("42")).thenReturn(Optional.empty()); - - Response response = resource.findByIdJson("42"); - assertThat(response.getStatus()).isEqualTo(404); - } - - @Test - void shouldAddToStore() throws URISyntaxException, IOException { + void shouldFindByIdGpg() throws IOException { String raw = GPGTestHelper.readResourceAsString("single.asc"); + RawGpgKey key = new RawGpgKey("42", raw); + when(store.findById("42")).thenReturn(Optional.of(key)); - UriInfo uriInfo = mock(UriInfo.class); - UriBuilder builder = mock(UriBuilder.class); - when(uriInfo.getAbsolutePathBuilder()).thenReturn(builder); - when(builder.path("42")).thenReturn(builder); - when(builder.build()).thenReturn(new URI("/v2/public_keys/42")); - - RawGpgKey key = new RawGpgKey("42"); - RawGpgKeyDto dto = new RawGpgKeyDto(); - dto.setDisplayName("key_42"); - dto.setRaw(raw); - when(store.add(dto.getDisplayName(), "trillian", dto.getRaw())).thenReturn(key); - - Response response = resource.create(uriInfo, "trillian", dto); - - assertThat(response.getStatus()).isEqualTo(201); - assertThat(response.getLocation().toASCIIString()).isEqualTo("/v2/public_keys/42"); + Response response = resource.findByIdGpg("42"); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isSameAs(raw); } - @Test - void shouldDeleteFromStore() { - Response response = resource.deleteById("42"); - assertThat(response.getStatus()).isEqualTo(204); - verify(store).delete("42"); - } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java index 93e999c426..85b594297f 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java @@ -27,10 +27,12 @@ package sonia.scm.security.gpg; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; +import org.junit.Rule; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.rules.ExpectedException; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.event.ScmEventBus; @@ -50,6 +52,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -105,6 +108,24 @@ class PublicKeyStoreTest { assertThat(key.getOwner()).isEqualTo("trillian"); assertThat(key.getCreated()).isAfterOrEqualTo(now); assertThat(key.getRaw()).isEqualTo(rawKey); + assertThat(key.isReadonly()).isFalse(); + assertThat(key.getContacts()).contains(Person.toPerson("SCM Packages (signing key for packages.scm-manager.org) ")); + + verify(eventBus).post(any(PublicKeyCreatedEvent.class)); + } + + @Test + void shouldReturnReadonlyStoredKey() throws IOException { + String rawKey = GPGTestHelper.readResourceAsString("single.asc"); + Instant now = Instant.now(); + + RawGpgKey key = keyStore.add("SCM Package Key", "trillian", rawKey, true); + assertThat(key.getId()).isEqualTo("0x975922F193B07D6E"); + assertThat(key.getDisplayName()).isEqualTo("SCM Package Key"); + assertThat(key.getOwner()).isEqualTo("trillian"); + assertThat(key.getCreated()).isAfterOrEqualTo(now); + assertThat(key.getRaw()).isEqualTo(rawKey); + assertThat(key.isReadonly()).isTrue(); assertThat(key.getContacts()).contains(Person.toPerson("SCM Packages (signing key for packages.scm-manager.org) ")); verify(eventBus).post(any(PublicKeyCreatedEvent.class)); @@ -134,6 +155,22 @@ class PublicKeyStoreTest { verify(eventBus).post(any(PublicKeyDeletedEvent.class)); } + @Test() + void shouldThrowOnDeletingReadonlyKey() throws IOException { + String rawKey = GPGTestHelper.readResourceAsString("single.asc"); + keyStore.add("SCM Package Key", "trillian", rawKey, true); + Optional key = keyStore.findById("0x975922F193B07D6E"); + + assertThat(key).isPresent(); + + assertThrows(PublicKeyStore.DeletingReadonlyKeyNotAllowedException.class, () -> keyStore.delete("0x975922F193B07D6E")); + key = keyStore.findById("0x975922F193B07D6E"); + + assertThat(key).isPresent(); + + verify(eventBus, never()).post(any(PublicKeyDeletedEvent.class)); + } + @Test void shouldReturnEmptyListIfNoKeysAvailable() { List keys = keyStore.findByUsername("zaphod"); diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java new file mode 100644 index 0000000000..14621357c3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java @@ -0,0 +1,143 @@ +/* + * 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.security.gpg; + +import de.otto.edison.hal.HalRepresentation; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) + +class UserPublicKeyResourceTest { + + @Mock + private PublicKeyStore store; + + @Mock + private PublicKeyCollectionMapper collectionMapper; + + @Mock + private PublicKeyMapper mapper; + + @InjectMocks + private UserPublicKeyResource resource; + + @Mock + private Subject subject; + + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void clearSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldFindAll() { + List keys = new ArrayList<>(); + when(store.findByUsername("trillian")).thenReturn(keys); + + HalRepresentation collection = new HalRepresentation(); + when(collectionMapper.map("trillian", keys)).thenReturn(collection); + + HalRepresentation result = resource.findAll("trillian"); + assertThat(result).isSameAs(collection); + } + + @Test + void shouldFindByIdJson() { + RawGpgKey key = new RawGpgKey("42"); + when(store.findById("42")).thenReturn(Optional.of(key)); + RawGpgKeyDto dto = new RawGpgKeyDto(); + when(mapper.map(key)).thenReturn(dto); + + Response response = resource.findByIdJson("42"); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isSameAs(dto); + } + + @Test + void shouldReturn404IfIdDoesNotExists() { + when(store.findById("42")).thenReturn(Optional.empty()); + + Response response = resource.findByIdJson("42"); + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldAddToStore() throws URISyntaxException, IOException { + String raw = GPGTestHelper.readResourceAsString("single.asc"); + + UriInfo uriInfo = mock(UriInfo.class); + UriBuilder builder = mock(UriBuilder.class); + when(uriInfo.getAbsolutePathBuilder()).thenReturn(builder); + when(builder.path("42")).thenReturn(builder); + when(builder.build()).thenReturn(new URI("/v2/public_keys/42")); + + RawGpgKey key = new RawGpgKey("42"); + RawGpgKeyDto dto = new RawGpgKeyDto(); + dto.setDisplayName("key_42"); + dto.setRaw(raw); + when(store.add(dto.getDisplayName(), "trillian", dto.getRaw())).thenReturn(key); + + Response response = resource.create(uriInfo, "trillian", dto); + + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getLocation().toASCIIString()).isEqualTo("/v2/public_keys/42"); + } + + @Test + void shouldDeleteFromStore() { + Response response = resource.deleteById("42"); + assertThat(response.getStatus()).isEqualTo(204); + verify(store).delete("42"); + } + +}