Feature/profile navigation (#1464)

- Fix bug where profile settings wasn't shown if user cannot change password
- Add missing "ApiKey" entry in the single user menu

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Florian Scholdei
2020-12-16 09:23:05 +01:00
committed by GitHub
parent 3f018c2255
commit 88b93dc8b8
30 changed files with 598 additions and 132 deletions

View File

@@ -6,10 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471))
- Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472))
### Fixed
- Add "Api Key" page link to sub-navigation of "User" and "Me" sections ([#1464](https://github.com/scm-manager/scm-manager/pull/1464))
## [2.11.1] - 2020-12-07
### Fixed
- Initialization of new git repository with master set as default branch ([#1467](https://github.com/scm-manager/scm-manager/issues/1467) and [#1470](https://github.com/scm-manager/scm-manager/pull/1470))

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -28,3 +28,8 @@ Für die einzelnen Rechte sind Tooltips verfügbar, welche Auskunft über die Au
Es können öffentliche Schlüssel (Public Keys) zu Benutzern hinzugefügt werden, um die Changeset Signaturen damit zu verifizieren.
![Öffentliche Schlüssel](assets/user-settings-publickeys.png)
### API Schlüssel
Zur Nutzung in anderen Systemen wie z. B. CI Systemen können sogenannte API Schlüssel erstellt werden. Sie können für den Zugriff auf Repositories über die REST API sowie über SCM-Clients genutzt werden. Weitere Informationen zu API Schlüsseln befinden sich im [Abschnitt Profil](../profile/#api-schlüssel).
![API Schlüssel](assets/user-settings-apikeys.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -28,3 +28,9 @@ There is a tooltip for each permission that provide some more details about the
Add public keys to users to enable changeset signature verification.
![Public keys](assets/user-settings-publickeys.png)
### API Keys
To access SCM-Manager from other systems like for example CI servers, API keys can be created. They can be used to call
the REST API and for the access with SCM clients. Read more about API keys in the [Profile section](../profile/#api-keys).
![API Keys](assets/user-settings-apikeys.png)

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React from "react";
import { Route, RouteComponentProps, withRouter } from "react-router-dom";
import { Redirect, Route, RouteComponentProps, Switch, withRouter } from "react-router-dom";
import { getMe } from "../modules/auth";
import { compose } from "redux";
import { connect } from "react-redux";
@@ -37,16 +37,16 @@ import {
SecondaryNavigationColumn,
SecondaryNavigation,
SubNavigation,
StateMenuContextProvider
StateMenuContextProvider,
urls
} from "@scm-manager/ui-components";
import ChangeUserPassword from "./ChangeUserPassword";
import ProfileInfo from "./ProfileInfo";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
import SetPublicKeysNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
import SetApiKeys from "../users/components/apiKeys/SetApiKeys";
import SetApiKeyNavLink from "../users/components/navLinks/SetApiKeysNavLink";
import { urls } from "@scm-manager/ui-components";
import SetApiKeysNavLink from "../users/components/navLinks/SetApiKeysNavLink";
type Props = RouteComponentProps &
WithTranslation & {
@@ -57,24 +57,22 @@ type Props = RouteComponentProps &
};
class Profile extends React.Component<Props> {
mayChangePassword = () => {
const { me } = this.props;
return !!me?._links?.password;
};
mayChangePassword = () => !!this.props.me?._links?.password;
canManagePublicKeys = () => !!this.props.me?._links?.publicKeys;
canManageApiKeys = () => !!this.props.me?._links?.apiKeys;
canManagePublicKeys = () => {
shouldRenderNavigation = () => {
const { me } = this.props;
return !!me?._links?.publicKeys;
};
canManageApiKeys = () => {
const { me } = this.props;
return !!me?._links?.apiKeys;
return (
!!me?._links?.password ||
!!me?._links?.publicKeys ||
!!me?._links?.apiKeys ||
binder.hasExtension("profile.route")
);
};
render() {
const url = urls.matchedUrl(this.props);
const { me, t } = this.props;
if (!me) {
@@ -101,6 +99,19 @@ class Profile extends React.Component<Props> {
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
{this.shouldRenderNavigation() && (
<Switch>
{this.mayChangePassword() && (
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/password`} />
)}
{this.canManagePublicKeys() && (
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/publicKeys`} />
)}
{this.canManageApiKeys() && (
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/apiKeys`} />
)}
</Switch>
)}
{this.mayChangePassword() && (
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
)}
@@ -120,15 +131,17 @@ class Profile extends React.Component<Props> {
label={t("profile.informationNavLink")}
title={t("profile.informationNavLink")}
/>
{this.mayChangePassword() && (
{this.shouldRenderNavigation() && (
<SubNavigation
to={`${url}/settings/password`}
to={`${url}/settings/`}
label={t("profile.settingsNavLink")}
title={t("profile.settingsNavLink")}
>
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
{this.mayChangePassword() && (
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
)}
<SetPublicKeysNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
<SetApiKeysNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
)}

View File

@@ -26,3 +26,4 @@ export { default as EditUserNavLink } from "./EditUserNavLink";
export { default as SetPasswordNavLink } from "./SetPasswordNavLink";
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";
export { default as SetPublicKeysNavLink } from "./SetPublicKeysNavLink";
export { default as SetApiKeysNavLink } from "./SetApiKeysNavLink";

View File

@@ -36,18 +36,25 @@ import {
SecondaryNavigationColumn,
SecondaryNavigation,
SubNavigation,
StateMenuContextProvider
StateMenuContextProvider,
urls
} from "@scm-manager/ui-components";
import { Details } from "./../components/table";
import EditUser from "./EditUser";
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
import {
EditUserNavLink,
SetPasswordNavLink,
SetPermissionsNavLink,
SetPublicKeysNavLink,
SetApiKeysNavLink
} from "./../components/navLinks";
import { WithTranslation, withTranslation } from "react-i18next";
import { mustGetUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
import { urls } from "@scm-manager/ui-components";
import SetApiKeys from "../components/apiKeys/SetApiKeys";
type Props = RouteComponentProps &
WithTranslation & {
@@ -96,10 +103,8 @@ class SingleUser extends React.Component<Props> {
path={`${url}/settings/permissions`}
component={() => <SetPermissions selectedPermissionsLink={user._links.permissions} />}
/>
<Route
path={`${url}/settings/publickeys`}
component={() => <SetPublicKeys user={user} />}
/>
<Route path={`${url}/settings/publickeys`} component={() => <SetPublicKeys user={user} />} />
<Route path={`${url}/settings/apiKeys`} component={() => <SetApiKeys user={user} />} />
<ExtensionPoint name="user.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>
<SecondaryNavigationColumn>
@@ -121,6 +126,7 @@ class SingleUser extends React.Component<Props> {
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
<SetPermissionsNavLink user={user} permissionsUrl={`${url}/settings/permissions`} />
<SetPublicKeysNavLink user={user} publicKeyUrl={`${url}/settings/publickeys`} />
<SetApiKeysNavLink user={user} apiKeyUrl={`${url}/settings/apiKeys`} />
<ExtensionPoint name="user.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>

View File

@@ -47,11 +47,11 @@ public class ApiKeyCollectionToDtoMapper {
this.resourceLinks = resourceLinks;
}
public HalRepresentation map(Collection<ApiKey> keys) {
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
public HalRepresentation map(Collection<ApiKey> keys, String user) {
List<ApiKeyDto> dtos = keys.stream().map(key -> apiKeyDtoMapper.map(key, user)).collect(toList());
final Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKeyCollection().self())
.single(link("create", resourceLinks.apiKeyCollection().create()));
.self(resourceLinks.apiKeyCollection().self(user))
.single(link("create", resourceLinks.apiKeyCollection().create(user)));
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
}
}

View File

@@ -32,6 +32,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.SecurityUtils;
import sonia.scm.ContextEntry;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
@@ -48,13 +49,17 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.net.URI;
import static javax.ws.rs.core.Response.Status.CREATED;
import static sonia.scm.NotFoundException.notFound;
public class ApiKeyResource {
/**
* Use {@link UserApiKeyResource} instead.
* @deprecated
*/
@Deprecated
public class ApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
@@ -90,7 +95,8 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getForCurrentUser() {
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
String currentUser = getCurrentUser();
return apiKeyCollectionMapper.map(apiKeyService.getKeys(currentUser), currentUser);
}
@GET
@@ -121,11 +127,13 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public ApiKeyDto get(@PathParam("id") String id) {
String currentUser = getCurrentUser();
return apiKeyService
.getKeys()
.getKeys(currentUser)
.stream()
.filter(key -> key.getId().equals(id))
.map(apiKeyMapper::map).findAny()
.map(key -> apiKeyMapper.map(key, currentUser)).findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
}
@@ -171,10 +179,12 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid ApiKeyDto apiKey) {
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole());
String currentUser = getCurrentUser();
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(currentUser, apiKey.getDisplayName(), apiKey.getPermissionRole());
return Response.status(CREATED)
.entity(newKey.getToken())
.location(URI.create(resourceLinks.apiKey().self(newKey.getId())))
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), currentUser)))
.build();
}
@@ -185,7 +195,10 @@ public class ApiKeyResource {
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "500", description = "internal server error")
public void delete(@PathParam("id") String id) {
apiKeyService.remove(id);
apiKeyService.remove(getCurrentUser(), id);
}
private String getCurrentUser() {
return SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
}

View File

@@ -39,13 +39,13 @@ public abstract class ApiKeyToApiKeyDtoMapper {
@Inject
private ResourceLinks resourceLinks;
abstract ApiKeyDto map(ApiKey key);
abstract ApiKeyDto map(ApiKey key, String user);
@ObjectFactory
ApiKeyDto createDto(ApiKey key) {
ApiKeyDto createDto(ApiKey key, String user) {
Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKey().self(key.getId()))
.single(link("delete", resourceLinks.apiKey().delete(key.getId())));
.self(resourceLinks.apiKey().self(key.getId(), user))
.single(link("delete", resourceLinks.apiKey().delete(key.getId(), user)));
return new ApiKeyDto(links.build());
}
}

View File

@@ -28,8 +28,6 @@ import com.google.common.base.Strings;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import sonia.scm.group.GroupCollector;
import sonia.scm.user.EMail;
import sonia.scm.user.User;
@@ -59,8 +57,7 @@ public class MeDtoFactory extends HalAppenderMapper {
}
public MeDto create() {
PrincipalCollection principals = getPrincipalCollection();
User user = principals.oneByType(User.class);
User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class);
MeDto dto = createDto(user);
mapUserProperties(user, dto);
@@ -79,18 +76,12 @@ public class MeDtoFactory extends HalAppenderMapper {
dto.setMail(user.getMail());
}
private PrincipalCollection getPrincipalCollection() {
Subject subject = SecurityUtils.getSubject();
return subject.getPrincipals();
}
private void setGeneratedMail(User user, MeDto dto) {
if (Strings.isNullOrEmpty(user.getMail())) {
dto.setFallbackMail(eMail.getMailOrFallback(user));
}
}
private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (UserPermissions.delete(user).isPermitted()) {
@@ -106,7 +97,7 @@ public class MeDtoFactory extends HalAppenderMapper {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
if (UserPermissions.changeApiKeys(user).isPermitted()) {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
}
Embedded.Builder embeddedBuilder = embeddedBuilder();

View File

@@ -64,14 +64,14 @@ public class MeResource {
private final UserManager userManager;
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResource;
private final Provider<ApiKeyResource> apiKeyResourceProvider;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResource = apiKeyResource;
this.apiKeyResourceProvider = apiKeyResourceProvider;
}
/**
@@ -142,6 +142,6 @@ public class MeResource {
@Path("api_keys")
public ApiKeyResource apiKeys() {
return apiKeyResource.get();
return apiKeyResourceProvider.get();
}
}

View File

@@ -101,10 +101,12 @@ class ResourceLinks {
static class UserLinks {
private final LinkBuilder userLinkBuilder;
private final LinkBuilder publicKeyLinkBuilder;
private final LinkBuilder apiKeyLinkBuilder;
UserLinks(ScmPathInfo pathInfo) {
userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class);
publicKeyLinkBuilder = new LinkBuilder(pathInfo, UserPublicKeyResource.class);
apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserApiKeyResource.class);
}
String self(String name) {
@@ -134,6 +136,10 @@ class ResourceLinks {
public String publicKeys(String name) {
return publicKeyLinkBuilder.method("findAll").parameters(name).href();
}
public String apiKeys(String name) {
return apiKeyLinkBuilder.method("findAll").parameters(name).href();
}
}
interface WithPermissionLinks {
@@ -220,15 +226,15 @@ class ResourceLinks {
private final LinkBuilder collectionLinkBuilder;
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
this.collectionLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
}
String self() {
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
String self(String username) {
return collectionLinkBuilder.method("apiKeys").parameters().method("findAll").parameters(username).href();
}
String create() {
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href();
String create(String username) {
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters(username).href();
}
}
@@ -240,15 +246,15 @@ class ResourceLinks {
private final LinkBuilder apiKeyLinkBuilder;
ApiKeyLinks(ScmPathInfo pathInfo) {
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
}
String self(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
String self(String id, String name) {
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("get").parameters(id).href();
}
String delete(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href();
String delete(String id, String name) {
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("delete").parameters(id).href();
}
}

View File

@@ -0,0 +1,192 @@
/*
* 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.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
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.validation.Valid;
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.MediaType;
import javax.ws.rs.core.Response;
import java.net.URI;
import static javax.ws.rs.core.Response.Status.CREATED;
import static sonia.scm.NotFoundException.notFound;
@Path("v2/users/{id}/api_keys")
public class UserApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
private final ResourceLinks resourceLinks;
@Inject
public UserApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) {
this.apiKeyService = apiKeyService;
this.apiKeyCollectionMapper = apiKeyCollectionMapper;
this.apiKeyMapper = apiKeyMapper;
this.resourceLinks = links;
}
@GET
@Path("")
@Produces(VndMediaType.API_KEY_COLLECTION)
@Operation(summary = "Get all api keys for user", description = "Returns all registered api keys for the given username.", tags = "User", operationId = "get_all_api_keys")
@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 findAll(@PathParam("id") String id) {
return apiKeyCollectionMapper.map(apiKeyService.getKeys(id), id);
}
@GET
@Path("{keyId}")
@Produces(VndMediaType.API_KEY)
@Operation(summary = "Get single api key for user", description = "Returns a single registered api key with the given id for user.", tags = "User", operationId = "get_single_api_key")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@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 ApiKeyDto get(@PathParam("id") String id, @PathParam("keyId") String keyId) {
return apiKeyService
.getKeys(id)
.stream()
.filter(key -> key.getId().equals(keyId))
.map(key -> apiKeyMapper.map(key, id))
.findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, keyId)));
}
@POST
@Path("")
@Consumes(VndMediaType.API_KEY)
@Produces(MediaType.TEXT_PLAIN)
@Operation(
summary = "Create new api key for user",
description = "Creates a new api key for the given user with the role specified in the given key.",
tags = "User",
operationId = "create_api_key",
requestBody = @RequestBody(
content = @Content(
mediaType = VndMediaType.API_KEY,
schema = @Schema(implementation = CreateApiKeyDto.class),
examples = @ExampleObject(
name = "Create a new api key named readKey with READ permission role.",
value = "{\n \"displayName\":\"readKey\",\n \"permissionRole\":\"READ\"\n}",
summary = "Create new api key"
)
)
)
)
@ApiResponse(
responseCode = "201",
description = "create success",
headers = @Header(
name = "Location",
description = "uri to the created user",
schema = @Schema(type = "string")
),
content = @Content(
mediaType = MediaType.TEXT_PLAIN
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "409", description = "conflict, a key with the given display name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid ApiKeyDto apiKey, @PathParam("id") String id) {
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(id, apiKey.getDisplayName(), apiKey.getPermissionRole());
return Response.status(CREATED)
.entity(newKey.getToken())
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), id)))
.build();
}
@DELETE
@Path("{keyId}")
@Operation(summary = "Delete api key", description = "Deletes the api key with the given id for user.", tags = "User", operationId = "delete_api_key")
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "500", description = "internal server error")
public void delete( @PathParam("id") String id, @PathParam("keyId") String keyId) {
apiKeyService.remove(id, keyId);
}
}

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;
@@ -32,7 +32,7 @@ import javax.inject.Provider;
import javax.ws.rs.Path;
/**
* RESTful Web Service Resource to manage users.
* RESTful Web Service Resource to manage users.
*/
@OpenAPIDefinition(tags = {
@Tag(name = "User", description = "User related endpoints")
@@ -44,12 +44,14 @@ public class UserRootResource {
private final Provider<UserCollectionResource> userCollectionResource;
private final Provider<UserResource> userResource;
private final Provider<UserApiKeyResource> userApiKeyResource;
@Inject
public UserRootResource(Provider<UserCollectionResource> userCollectionResource,
Provider<UserResource> userResource) {
Provider<UserResource> userResource, Provider<UserApiKeyResource> userApiKeyResource) {
this.userCollectionResource = userCollectionResource;
this.userResource = userResource;
this.userApiKeyResource = userApiKeyResource;
}
@Path("")
@@ -61,4 +63,9 @@ public class UserRootResource {
public UserResource getUserResource() {
return userResource.get();
}
@Path("{id}/api_keys")
public UserApiKeyResource apiKeys() {
return userApiKeyResource.get();
}
}

View File

@@ -66,6 +66,7 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.user().update(user.getName())));
linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
linksBuilder.single(link("apiKeys", resourceLinks.user().apiKeys(user.getName())));
if (user.isExternal()) {
linksBuilder.single(link("convertToInternal", resourceLinks.user().toInternal(user.getName())));
} else {

View File

@@ -30,7 +30,6 @@ import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.shiro.authc.credential.PasswordService;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.util.ThreadContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
@@ -80,19 +79,18 @@ public class ApiKeyService {
this.passphraseGenerator = passphraseGenerator;
}
public CreationResult createNewKey(String name, String permissionRole) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
public CreationResult createNewKey(String username, String keyDisplayName, String permissionRole) {
UserPermissions.changeApiKeys(username).check();
String passphrase = passphraseGenerator.get();
String hashedPassphrase = passwordService.encryptPassword(passphrase);
String id = keyGenerator.createKey();
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, name, permissionRole, hashedPassphrase, now());
doSynchronized(user, true, () -> {
persistKey(name, user, key);
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, keyDisplayName, permissionRole, hashedPassphrase, now());
doSynchronized(username, true, () -> {
persistKey(keyDisplayName, username, key);
return null;
});
String token = tokenHandler.createToken(user, new ApiKey(key), passphrase);
LOG.info("created new api key for user {} with role {}", user, permissionRole);
String token = tokenHandler.createToken(username, new ApiKey(key), passphrase);
LOG.info("created new api key for user {} with role {}", username, permissionRole);
return new CreationResult(token, id);
}
@@ -105,18 +103,17 @@ public class ApiKeyService {
store.put(user, newApiKeyCollection);
}
public void remove(String id) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
doSynchronized(user, true, () -> {
if (!containsId(user, id)) {
public void remove(String username, String id) {
UserPermissions.changeApiKeys(username).check();
doSynchronized(username, true, () -> {
if (!containsId(username, id)) {
return null;
}
store.getOptional(user).ifPresent(
store.getOptional(username).ifPresent(
apiKeyCollection -> {
ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
store.put(user, newApiKeyCollection);
LOG.info("removed api key for user {}", user);
store.put(username, newApiKeyCollection);
LOG.info("removed api key for user {}", username);
}
);
return null;
@@ -154,8 +151,8 @@ public class ApiKeyService {
return result;
}
public Collection<ApiKey> getKeys() {
return store.getOptional(currentUser())
public Collection<ApiKey> getKeys(String user) {
return store.getOptional(user)
.map(ApiKeyCollection::getKeys)
.map(Collection::stream)
.orElse(Stream.empty())
@@ -163,10 +160,6 @@ public class ApiKeyService {
.collect(toList());
}
private String currentUser() {
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
private boolean containsId(String user, String id) {
return store
.getOptional(user)

View File

@@ -0,0 +1,68 @@
/*
* 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 org.junit.Test;
import sonia.scm.security.ApiKey;
import java.net.URI;
import java.util.Collections;
import static java.time.Instant.now;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ApiKeyCollectionToDtoMapperTest {
public static final ApiKey API_KEY = new ApiKey("1", "key 1", "READ", now());
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper = mock(ApiKeyToApiKeyDtoMapper.class);
private final ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyDtoMapper, ResourceLinksMock.createMock(URI.create("/")));
@Test
public void shouldMapCollection() {
ApiKeyDto expectedApiKeyDto = new ApiKeyDto();
when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto);
HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user");
assertThat(halRepresentation.getEmbedded().hasItem("keys")).isTrue();
assertThat(halRepresentation.getEmbedded().getItemsBy("keys")).containsExactly(expectedApiKeyDto);
}
@Test
public void shouldEmbedLinks() {
ApiKeyDto expectedApiKeyDto = new ApiKeyDto();
when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto);
HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user");
assertThat(halRepresentation.getLinks().getLinkBy("self")).isPresent();
assertThat(halRepresentation.getLinks().getLinkBy("create")).isPresent();
}
}

View File

@@ -79,8 +79,9 @@ public class MeResourceTest {
@Rule
public ShiroRule shiro = new ShiroRule();
private RestDispatcher dispatcher = new RestDispatcher();
private final RestDispatcher dispatcher = new RestDispatcher();
private final MockHttpResponse response = new MockHttpResponse();
private final ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@Mock
@@ -105,13 +106,10 @@ public class MeResourceTest {
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
@Mock
private PasswordService passwordService;
private User originalUser;
private MockHttpResponse response = new MockHttpResponse();
@Before
public void prepareEnvironment() {
@@ -144,7 +142,7 @@ public class MeResourceTest {
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/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/users/trillian/api_keys\"}");
}
private void applyUserToSubject(User user) {
@@ -233,7 +231,7 @@ public class MeResourceTest {
@Test
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys())
when(apiKeyService.getKeys("trillian"))
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
@@ -245,13 +243,13 @@ public class MeResourceTest {
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}");
}
@Test
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys())
when(apiKeyService.getKeys("trillian"))
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
@@ -263,13 +261,14 @@ public class MeResourceTest {
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\"");
assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys/1\"}");
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/me/api_keys/1\"}");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
}
@Test
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
when(apiKeyService.createNewKey("trillian","guide", "READ"))
.thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
@@ -280,12 +279,13 @@ public class MeResourceTest {
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getContentAsString()).isEqualTo("abc");
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/api_keys/1"));
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/users/trillian/api_keys/1"));
}
@Test
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
when(apiKeyService.createNewKey("trillian","guide", "READ"))
.thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
@@ -303,9 +303,10 @@ public class MeResourceTest {
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(204);
verify(apiKeyService).remove("1");
verify(apiKeyService).remove("trillian","1");
}
private User createDummyUser(String name) {
User user = new User();
user.setName(name);

View File

@@ -21,14 +21,13 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.repository.NamespaceAndName;
import java.net.URI;
@@ -78,6 +77,12 @@ public class ResourceLinksTest {
assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2, url);
}
@Test
public void shouldCreateCorrectUserApiKeysUrl() {
String url = resourceLinks.user().apiKeys("ich");
assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2 + "ich/api_keys", url);
}
@Test
public void shouldCreateCorrectGroupSelfUrl() {
String url = resourceLinks.group().self("nobodies");
@@ -179,6 +184,7 @@ public class ResourceLinksTest {
String url = resourceLinks.source().sourceWithPath("foo", "bar", "rev", "file");
assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "foo/bar/sources/rev/file", url);
}
@Test
public void shouldCreateCorrectPermissionCollectionUrl() {
String url = resourceLinks.source().selfWithoutRevision("space", "repo");

View File

@@ -0,0 +1,147 @@
/*
* 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 com.google.inject.util.Providers;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import static java.time.Instant.now;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
public class UserApiKeyResourceTest {
private final RestDispatcher dispatcher = new RestDispatcher();
private final MockHttpResponse response = new MockHttpResponse();
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@Mock
private ApiKeyService apiKeyService;
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
@Before
public void prepareEnvironment() {
initMocks(this);
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
UserRootResource userRootResource = new UserRootResource(null, null, Providers.of(userApiKeyResource));
dispatcher.addSingletonResource(userRootResource);
}
@Test
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys("trillian"))
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}");
}
@Test
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys("trillian"))
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/1");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\"");
assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
}
@Test
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/")
.contentType(VndMediaType.API_KEY)
.content("{\"displayName\":\"guide\",\"permissionRole\":\"READ\"}".getBytes());
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getContentAsString()).isEqualTo("abc");
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/users/trillian/api_keys/1"));
}
@Test
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException {
when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/")
.contentType(VndMediaType.API_KEY)
.content("{\"displayName\":\"guide\",\"pemissionRole\":\"\"}".getBytes());
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
public void shouldDeleteExistingApiKey() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/1");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(204);
verify(apiKeyService).remove("trillian", "1");
}
}

View File

@@ -28,7 +28,6 @@ import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.common.io.Resources;
import com.google.inject.util.Providers;
import com.sun.mail.iap.Argument;
import org.apache.shiro.authc.credential.PasswordService;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
@@ -42,6 +41,7 @@ import org.mockito.Mock;
import sonia.scm.ContextEntry;
import sonia.scm.NotFoundException;
import sonia.scm.PageResult;
import sonia.scm.security.ApiKeyService;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.ChangePasswordNotAllowedException;
@@ -83,7 +83,7 @@ public class UserRootResourceTest {
@Rule
public ShiroRule shiro = new ShiroRule();
private RestDispatcher dispatcher = new RestDispatcher();
private final RestDispatcher dispatcher = new RestDispatcher();
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@@ -92,6 +92,8 @@ public class UserRootResourceTest {
@Mock
private UserManager userManager;
@Mock
private ApiKeyService apiKeyService;
@Mock
private PermissionAssigner permissionAssigner;
@InjectMocks
private UserDtoToUserMapperImpl dtoToUserMapper;
@@ -99,6 +101,8 @@ public class UserRootResourceTest {
private UserToUserDtoMapperImpl userToDtoMapper;
@InjectMocks
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
@Captor
private ArgumentCaptor<User> userCaptor;
@@ -122,8 +126,10 @@ public class UserRootResourceTest {
userCollectionToDtoMapper, resourceLinks, passwordService);
UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper);
UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource);
ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks);
UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource),
Providers.of(userResource));
Providers.of(userResource), Providers.of(userApiKeyResource));
dispatcher.addSingletonResource(userRootResource);
}

View File

@@ -89,7 +89,7 @@ class ApiKeyServiceTest {
@Test
void shouldCreateNewKeyAndStoreItHashed() {
service.createNewKey("1", "READ");
service.createNewKey("dent","1", "READ");
ApiKeyCollection apiKeys = store.get("dent");
@@ -105,7 +105,7 @@ class ApiKeyServiceTest {
@Test
void shouldReturnRoleForKey() {
String newKey = service.createNewKey("1", "READ").getToken();
String newKey = service.createNewKey("dent","1", "READ").getToken();
ApiKeyService.CheckResult role = service.check(newKey);
@@ -114,20 +114,20 @@ class ApiKeyServiceTest {
@Test
void shouldHandleNewUser() {
assertThat(service.getKeys()).isEmpty();
assertThat(service.getKeys("zaphod")).isEmpty();
}
@Test
void shouldNotReturnAnythingWithWrongKey() {
service.createNewKey("1", "READ");
service.createNewKey("dent","1", "READ");
assertThrows(AuthorizationException.class, () -> service.check("dent", "1", "wrong"));
}
@Test
void shouldAddSecondKey() {
ApiKeyService.CreationResult firstKey = service.createNewKey("1", "READ");
ApiKeyService.CreationResult secondKey = service.createNewKey("2", "WRITE");
ApiKeyService.CreationResult firstKey = service.createNewKey("dent","1", "READ");
ApiKeyService.CreationResult secondKey = service.createNewKey("dent","2", "WRITE");
ApiKeyCollection apiKeys = store.get("dent");
@@ -136,16 +136,16 @@ class ApiKeyServiceTest {
assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ");
assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE");
assertThat(service.getKeys()).extracting("id")
assertThat(service.getKeys("dent")).extracting("id")
.contains(firstKey.getId(), secondKey.getId());
}
@Test
void shouldRemoveKey() {
String firstKey = service.createNewKey("first", "READ").getToken();
String secondKey = service.createNewKey("second", "WRITE").getToken();
String firstKey = service.createNewKey("dent","first", "READ").getToken();
String secondKey = service.createNewKey("dent","second", "WRITE").getToken();
service.remove("1");
service.remove("dent","1");
assertThrows(AuthorizationException.class, () -> service.check(firstKey));
assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE");
@@ -153,23 +153,23 @@ class ApiKeyServiceTest {
@Test
void shouldFailWhenAddingSameNameTwice() {
String firstKey = service.createNewKey("1", "READ").getToken();
String firstKey = service.createNewKey("dent","1", "READ").getToken();
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE"));
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("dent","1", "WRITE"));
assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ");
}
@Test
void shouldIgnoreCorrectPassphraseWithWrongName() {
String firstKey = service.createNewKey("1", "READ").getToken();
String firstKey = service.createNewKey("dent","1", "READ").getToken();
assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey));
}
@Test
void shouldDeleteTokensWhenUserIsDeleted() {
service.createNewKey("1", "READ").getToken();
service.createNewKey("dent","1", "READ").getToken();
assertThat(store.get("dent").getKeys()).hasSize(1);