From 939261957262137ebc09ef693f230701b1a1a90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 14 Dec 2018 10:42:19 +0100 Subject: [PATCH 01/37] Map JAX NotFoundException to 404 --- .../scm/api/FallbackExceptionMapper.java | 11 +----- .../scm/api/JaxNotFoundExceptionMapper.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java index feb5341e2d..c687af4826 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java @@ -4,10 +4,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import sonia.scm.api.v2.resources.ErrorDto; -import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; import sonia.scm.web.VndMediaType; -import javax.inject.Inject; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -20,16 +18,9 @@ public class FallbackExceptionMapper implements ExceptionMapper { private static final String ERROR_CODE = "CmR8GCJb31"; - private final ExceptionWithContextToErrorDtoMapper mapper; - - @Inject - public FallbackExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - this.mapper = mapper; - } - @Override public Response toResponse(Exception exception) { - logger.debug("map {} to status code 500", exception); + logger.debug("map exception to status code 500", exception); ErrorDto errorDto = new ErrorDto(); errorDto.setMessage("internal server error"); errorDto.setContext(Collections.emptyList()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java new file mode 100644 index 0000000000..3283dedf3f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java @@ -0,0 +1,35 @@ +package sonia.scm.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.util.Collections; + +@Provider +public class JaxNotFoundExceptionMapper implements ExceptionMapper { + + private static final Logger logger = LoggerFactory.getLogger(JaxNotFoundExceptionMapper.class); + + private static final String ERROR_CODE = "92RCCCMHO1"; + + @Override + public Response toResponse(NotFoundException exception) { + logger.debug(exception.getMessage()); + ErrorDto errorDto = new ErrorDto(); + errorDto.setMessage("path not found"); + errorDto.setContext(Collections.emptyList()); + errorDto.setErrorCode(ERROR_CODE); + errorDto.setTransactionId(MDC.get("transaction_id")); + return Response.status(Response.Status.NOT_FOUND) + .entity(errorDto) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} From fe9346fee633b6eb82384255ae628550a6846f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 20 Dec 2018 16:27:41 +0100 Subject: [PATCH 02/37] rename css class --- .../src/repos/components/list/RepositoryEntry.js | 2 +- scm-ui/styles/scm.scss | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/scm-ui/src/repos/components/list/RepositoryEntry.js b/scm-ui/src/repos/components/list/RepositoryEntry.js index b0b70fc861..4ba9dbf689 100644 --- a/scm-ui/src/repos/components/list/RepositoryEntry.js +++ b/scm-ui/src/repos/components/list/RepositoryEntry.js @@ -95,7 +95,7 @@ class RepositoryEntry extends React.Component {
-
+

{repository.name} diff --git a/scm-ui/styles/scm.scss b/scm-ui/styles/scm.scss index 9d65bbe26f..f2a373ff09 100644 --- a/scm-ui/styles/scm.scss +++ b/scm-ui/styles/scm.scss @@ -112,14 +112,12 @@ $fa-font-path: "webfonts"; } } -.media { - .media-content { - width: calc(50% - 0.75rem); - .shorten-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.text-box { + width: calc(50% - 0.75rem); + .shorten-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } From 2f6eae4fac42313ebc97e01babd736eb1d801552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 20 Dec 2018 16:45:38 +0100 Subject: [PATCH 03/37] repository link should be clickable everywhere in its box --- scm-ui/src/repos/components/list/RepositoryEntry.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/repos/components/list/RepositoryEntry.js b/scm-ui/src/repos/components/list/RepositoryEntry.js index 4ba9dbf689..122128db3a 100644 --- a/scm-ui/src/repos/components/list/RepositoryEntry.js +++ b/scm-ui/src/repos/components/list/RepositoryEntry.js @@ -9,7 +9,12 @@ import classNames from "classnames"; import RepositoryAvatar from "./RepositoryAvatar"; const styles = { - overlay: { + overlayFullColumn: { + position: "absolute", + height: "calc(120px - 0.5rem)", + width: "calc(100% - 1.5rem)" + }, + overlayHalfColumn: { position: "absolute", height: "calc(120px - 1.5rem)", width: "calc(50% - 3rem)" @@ -80,6 +85,9 @@ class RepositoryEntry extends React.Component { const { repository, classes, fullColumnWidth } = this.props; const repositoryLink = this.createLink(repository); const halfColumn = fullColumnWidth ? "is-full" : "is-half"; + const overlayLinkClass = fullColumnWidth + ? classes.overlayFullColumn + : classes.overlayHalfColumn; return (

{ halfColumn )} > - +
From ac4a57f2f3794375b35dc9d5bf38814d3750a489 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Fri, 21 Dec 2018 08:35:18 +0100 Subject: [PATCH 04/37] replace TokenClaimsValidator with not so generic AccessTokenValidator interface and fixed duplicated code of BearerRealm and JwtAccessTokenResolve --- ...lidator.java => AccessTokenValidator.java} | 15 +- .../java/sonia/scm/security/BearerRealm.java | 111 ++---- .../scm/security/JwtAccessTokenResolver.java | 45 ++- .../main/java/sonia/scm/security/Scopes.java | 2 +- .../scm/security/XsrfAccessTokenEnricher.java | 2 +- ...tor.java => XsrfAccessTokenValidator.java} | 31 +- .../sonia/scm/security/BearerRealmTest.java | 315 ++++-------------- .../security/JwtAccessTokenResolverTest.java | 35 +- ...java => XsrfAccessTokenValidatorTest.java} | 52 +-- 9 files changed, 187 insertions(+), 421 deletions(-) rename scm-core/src/main/java/sonia/scm/security/{TokenClaimsValidator.java => AccessTokenValidator.java} (82%) rename scm-webapp/src/main/java/sonia/scm/security/{XsrfTokenClaimsValidator.java => XsrfAccessTokenValidator.java} (71%) rename scm-webapp/src/test/java/sonia/scm/security/{XsrfTokenClaimsValidatorTest.java => XsrfAccessTokenValidatorTest.java} (68%) diff --git a/scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java similarity index 82% rename from scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java rename to scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java index 4389e7bfb7..24a92929f9 100644 --- a/scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java @@ -30,26 +30,25 @@ */ package sonia.scm.security; -import java.util.Map; import sonia.scm.plugin.ExtensionPoint; /** - * Validates the claims of a jwt token. The validator is called durring authentication - * with a jwt token. + * Validates an {@link AccessToken}. The validator is called during authentication + * with an {@link AccessToken}. * * @author Sebastian Sdorra * @since 2.0.0 */ @ExtensionPoint -public interface TokenClaimsValidator { +public interface AccessTokenValidator { /** - * Returns {@code true} if the claims is valid. If the token is not valid and the + * Returns {@code true} if the {@link AccessToken} is valid. If the token is not valid and the * method returns {@code false}, the authentication is treated as failed. * - * @param claims token claims + * @param token the access token to verify * - * @return {@code true} if the claims is valid + * @return {@code true} if the token is valid */ - boolean validate(Map claims); + boolean validate(AccessToken token); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index 3b19351641..6847fd324c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -31,35 +31,20 @@ package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.common.annotations.VisibleForTesting; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; - -import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; import org.apache.shiro.realm.AuthenticatingRealm; - import sonia.scm.group.GroupDAO; import sonia.scm.plugin.Extension; import sonia.scm.user.UserDAO; -import static com.google.common.base.Preconditions.checkArgument; - -import java.util.List; -import java.util.Set; - -//~--- JDK imports ------------------------------------------------------------ - import javax.inject.Inject; import javax.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import static com.google.common.base.Preconditions.checkArgument; + /** * Realm for authentication with {@link BearerToken}. @@ -71,34 +56,29 @@ import org.slf4j.LoggerFactory; @Extension public class BearerRealm extends AuthenticatingRealm { - - /** - * the logger for BearerRealm - */ - private static final Logger LOG = LoggerFactory.getLogger(BearerRealm.class); /** realm name */ @VisibleForTesting static final String REALM = "BearerRealm"; - //~--- constructors --------------------------------------------------------- + + /** dao realm helper */ + private final DAORealmHelper helper; + + /** access token resolver **/ + private final AccessTokenResolver tokenResolver; /** * Constructs ... * * @param helperFactory dao realm helper factory - * @param resolver key resolver - * @param validators token claims validators + * @param tokenResolver resolve access token from bearer */ @Inject - public BearerRealm( - DAORealmHelperFactory helperFactory, SecureKeyResolver resolver, Set validators - ) - { + public BearerRealm(DAORealmHelperFactory helperFactory, AccessTokenResolver tokenResolver) { this.helper = helperFactory.create(REALM); - this.resolver = resolver; - this.validators = validators; - + this.tokenResolver = tokenResolver; + setCredentialsMatcher(new AllowAllCredentialsMatcher()); setAuthenticationTokenClass(BearerToken.class); } @@ -106,71 +86,26 @@ public class BearerRealm extends AuthenticatingRealm //~--- methods -------------------------------------------------------------- /** - * Validates the given jwt token and retrieves authentication data from + * Validates the given bearer token and retrieves authentication data from * {@link UserDAO} and {@link GroupDAO}. * * - * @param token jwt token + * @param token bearer token * * @return authentication data from user and group dao */ @Override - protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken token) - { - checkArgument(token instanceof BearerToken, "%s is required", - BearerToken.class); + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class); BearerToken bt = (BearerToken) token; - Claims c = checkToken(bt); + AccessToken accessToken = tokenResolver.resolve(bt); - return helper.getAuthenticationInfo(c.getSubject(), bt.getCredentials(), Scopes.fromClaims(c)); + return helper.getAuthenticationInfo( + accessToken.getSubject(), + bt.getCredentials(), + Scopes.fromClaims(accessToken.getClaims()) + ); } - /** - * Validates the jwt token. - * - * - * @param token jwt token - * - * @return claim - */ - private Claims checkToken(BearerToken token) - { - Claims claims; - - try - { - //J- - claims = Jwts.parser() - .setSigningKeyResolver(resolver) - .parseClaimsJws(token.getCredentials()) - .getBody(); - //J+ - - // check all registered claims validators - validators.forEach((validator) -> { - if (!validator.validate(claims)) { - LOG.warn("token claims is invalid, marked by validator {}", validator.getClass()); - throw new AuthenticationException("token claims is invalid"); - } - }); - } - catch (JwtException ex) - { - throw new AuthenticationException("signature is invalid", ex); - } - - return claims; - } - - //~--- fields --------------------------------------------------------------- - - /** token claims validators **/ - private final Set validators; - - /** dao realm helper */ - private final DAORealmHelper helper; - - /** secure key resolver */ - private final SecureKeyResolver resolver; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java index 72b2a85c95..291364b935 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java @@ -55,37 +55,48 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver { private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class); private final SecureKeyResolver keyResolver; - private final Set validators; + private final Set validators; @Inject - public JwtAccessTokenResolver(SecureKeyResolver keyResolver, Set validators) { + public JwtAccessTokenResolver(SecureKeyResolver keyResolver, Set validators) { this.keyResolver = keyResolver; this.validators = validators; } @Override public JwtAccessToken resolve(BearerToken bearerToken) { - Claims claims; - try { - // parse and validate - claims = Jwts.parser() + String compact = bearerToken.getCredentials(); + + Claims claims = Jwts.parser() .setSigningKeyResolver(keyResolver) - .parseClaimsJws(bearerToken.getCredentials()) + .parseClaimsJws(compact) .getBody(); - - // check all registered claims validators - validators.forEach((validator) -> { - if (!validator.validate(claims)) { - LOG.warn("token claims is invalid, marked by validator {}", validator.getClass()); - throw new AuthenticationException("token claims is invalid"); - } - }); + + JwtAccessToken token = new JwtAccessToken(claims, compact); + validate(token); + + return token; } catch (JwtException ex) { throw new AuthenticationException("signature is invalid", ex); } - - return new JwtAccessToken(claims, bearerToken.getCredentials()); + } + + + private void validate(AccessToken accessToken) { + validators.forEach(validator -> validate(validator, accessToken)); + } + + private void validate(AccessTokenValidator validator, AccessToken accessToken) { + if (!validator.validate(accessToken)) { + String msg = createValidationFailedMessage(validator, accessToken); + LOG.debug(msg); + throw new AuthenticationException(msg); + } + } + + private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) { + return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java index d5d6b74021..4286bafe90 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java +++ b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java @@ -47,7 +47,7 @@ import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.authz.permission.PermissionResolver; /** - * Utile methods for {@link Scope}. + * Util methods for {@link Scope}. * * @author Sebastian Sdorra * @since 2.0.0 diff --git a/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java index 7690c48b30..617950ddea 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java +++ b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java @@ -44,7 +44,7 @@ import sonia.scm.util.HttpUtil; /** * Xsrf access token enricher will add an xsrf custom field to the access token. The enricher will only * add the xsrf field, if the authentication request is issued from the web interface and xsrf protection is - * enabled. The xsrf field will be validated on every request by the {@link XsrfTokenClaimsValidator}. Xsrf protection + * enabled. The xsrf field will be validated on every request by the {@link XsrfAccessTokenValidator}. Xsrf protection * can be disabled with {@link ScmConfiguration#setEnabledXsrfProtection(boolean)}. * * @see Issue 793 diff --git a/scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java similarity index 71% rename from scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java rename to scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java index c71be5706e..437174f57e 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java +++ b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java @@ -30,30 +30,23 @@ */ package sonia.scm.security; -import com.google.common.base.Strings; -import java.util.Map; +import sonia.scm.plugin.Extension; + import javax.inject.Inject; import javax.inject.Provider; import javax.servlet.http.HttpServletRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.plugin.Extension; +import java.util.Optional; /** - * Validates xsrf protected token claims. The validator check if the current request contains an xsrf key which is - * equal to the token in the claims. If the claims does not contain a xsrf key, the check is passed by. The xsrf keys - * are added by the {@link XsrfTokenClaimsEnricher}. + * Validates xsrf protected access tokens. The validator check if the current request contains an xsrf key which is + * equal to the one in the access token. If the token does not contain a xsrf key, the check is passed by. The xsrf keys + * are added by the {@link XsrfAccessTokenEnricher}. * * @author Sebastian Sdorra * @since 2.0.0 */ @Extension -public class XsrfTokenClaimsValidator implements TokenClaimsValidator { - - /** - * the logger for XsrfTokenClaimsEnricher - */ - private static final Logger LOG = LoggerFactory.getLogger(XsrfTokenClaimsValidator.class); +public class XsrfAccessTokenValidator implements AccessTokenValidator { private final Provider requestProvider; @@ -64,16 +57,16 @@ public class XsrfTokenClaimsValidator implements TokenClaimsValidator { * @param requestProvider http request provider */ @Inject - public XsrfTokenClaimsValidator(Provider requestProvider) { + public XsrfAccessTokenValidator(Provider requestProvider) { this.requestProvider = requestProvider; } @Override - public boolean validate(Map claims) { - String xsrfClaimValue = (String) claims.get(Xsrf.TOKEN_KEY); - if (!Strings.isNullOrEmpty(xsrfClaimValue)) { + public boolean validate(AccessToken accessToken) { + Optional xsrfClaim = accessToken.getCustom(Xsrf.TOKEN_KEY); + if (xsrfClaim.isPresent()) { String xsrfHeaderValue = requestProvider.get().getHeader(Xsrf.HEADER_KEY); - return xsrfClaimValue.equals(xsrfHeaderValue); + return xsrfClaim.get().equals(xsrfHeaderValue); } return true; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index 26dfcb2099..e66462cd01 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -29,271 +29,98 @@ * */ - - package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Sets; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwsHeader; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.UsernamePasswordToken; -import org.apache.shiro.subject.PrincipalCollection; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.junit.runner.RunWith; +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.Mockito; -import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.group.GroupDAO; -import sonia.scm.user.User; -import sonia.scm.user.UserDAO; -import sonia.scm.user.UserTestData; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; -import javax.crypto.spec.SecretKeySpec; -import java.util.Date; -import java.util.Set; +import java.util.HashMap; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.any; +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; /** * Unit tests for {@link BearerRealm}. * * @author Sebastian Sdorra */ -@SuppressWarnings("unchecked") -@RunWith(MockitoJUnitRunner.class) -public class BearerRealmTest -{ - - @Rule - public ExpectedException expectedException = ExpectedException.none(); +@ExtendWith(MockitoExtension.class) +class BearerRealmTest { - /** - * Method description - * - */ - @Test - public void testDoGetAuthenticationInfo() - { - SecureKey key = createSecureKey(); + @Mock + private DAORealmHelperFactory realmHelperFactory; - User marvin = UserTestData.createMarvin(); + @Mock + private DAORealmHelper realmHelper; - when(userDAO.get(marvin.getName())).thenReturn(marvin); + @Mock + private AccessTokenResolver accessTokenResolver; - resolveKey(key); - - String compact = createCompactToken(marvin.getName(), key); - - BearerToken token = BearerToken.valueOf(compact); - AuthenticationInfo info = realm.doGetAuthenticationInfo(token); - - assertNotNull(info); - - PrincipalCollection principals = info.getPrincipals(); - - assertEquals(marvin.getName(), principals.getPrimaryPrincipal()); - assertEquals(marvin, principals.oneByType(User.class)); - assertNotNull(principals.oneByType(Scope.class)); - assertTrue(principals.oneByType(Scope.class).isEmpty()); - } - - /** - * Test {@link BearerRealm#doGetAuthenticationInfo(AuthenticationToken)} with scope. - * - */ - @Test - public void testDoGetAuthenticationInfoWithScope() - { - SecureKey key = createSecureKey(); - - User marvin = UserTestData.createMarvin(); - - when(userDAO.get(marvin.getName())).thenReturn(marvin); - - resolveKey(key); - - String compact = createCompactToken( - marvin.getName(), - key, - new Date(System.currentTimeMillis() + 60000), - Scope.valueOf("repo:*", "user:*") - ); - - AuthenticationInfo info = realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - Scope scope = info.getPrincipals().oneByType(Scope.class); - assertThat(scope, Matchers.containsInAnyOrder("repo:*", "user:*")); - } - - /** - * Test {@link BearerRealm#doGetAuthenticationInfo(AuthenticationToken)} with a failed - * claims validation. - */ - @Test - public void testDoGetAuthenticationInfoWithInvalidClaims() - { - SecureKey key = createSecureKey(); - User marvin = UserTestData.createMarvin(); - - resolveKey(key); - - String compact = createCompactToken(marvin.getName(), key); - - // treat claims as invalid - when(validator.validate(Mockito.anyMap())).thenReturn(false); - - // expect exception - expectedException.expect(AuthenticationException.class); - expectedException.expectMessage(Matchers.containsString("claims")); - - // kick authentication - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithExpiredToken() - { - User trillian = UserTestData.createTrillian(); - - SecureKey key = createSecureKey(); - - resolveKey(key); - - Date exp = new Date(System.currentTimeMillis() - 600l); - String compact = createCompactToken(trillian.getName(), key, exp, Scope.empty()); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithInvalidSignature() - { - resolveKey(createSecureKey()); - - User trillian = UserTestData.createTrillian(); - String compact = createCompactToken(trillian.getName(), createSecureKey()); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithoutSignature() - { - String compact = Jwts.builder().setSubject("test").compact(); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = IllegalArgumentException.class) - public void testDoGetAuthenticationInfoWrongToken() - { - realm.doGetAuthenticationInfo(new UsernamePasswordToken("test", "test")); - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - */ - @Before - public void setUp() - { - when(validator.validate(Mockito.anyMap())).thenReturn(true); - Set validators = Sets.newHashSet(validator); - realm = new BearerRealm(helperFactory, keyResolver, validators); - } - - //~--- methods -------------------------------------------------------------- - -private String createCompactToken(String subject, SecureKey key) { - return createCompactToken(subject, key, Scope.empty()); - } - - private String createCompactToken(String subject, SecureKey key, Scope scope) { - return createCompactToken(subject, key, new Date(System.currentTimeMillis() + 60000), scope); - } - - private String createCompactToken(String subject, SecureKey key, Date exp, Scope scope) { - return Jwts.builder() - .claim(Scopes.CLAIMS_KEY, ImmutableList.copyOf(scope)) - .setSubject(subject) - .setExpiration(exp) - .signWith(SignatureAlgorithm.HS256, key.getBytes()) - .compact(); - } - - private void resolveKey(SecureKey key) { - when( - keyResolver.resolveSigningKey( - any(JwsHeader.class), - any(Claims.class) - ) - ) - .thenReturn( - new SecretKeySpec( - key.getBytes(), - SignatureAlgorithm.HS256.getJcaName() - ) - ); - } - - //~--- fields --------------------------------------------------------------- - @InjectMocks - private DAORealmHelperFactory helperFactory; - - @Mock - private LoginAttemptHandler loginAttemptHandler; - - @Mock - private TokenClaimsValidator validator; - - /** Field description */ - @Mock - private GroupDAO groupDAO; - - /** Field description */ - @Mock - private SecureKeyResolver keyResolver; - - /** Field description */ private BearerRealm realm; - /** Field description */ @Mock - private UserDAO userDAO; + private AuthenticationInfo authenticationInfo; + + @BeforeEach + void prepareObjectUnderTest() { + when(realmHelperFactory.create(BearerRealm.REALM)).thenReturn(realmHelper); + realm = new BearerRealm(realmHelperFactory, accessTokenResolver); + } + + @Test + void shouldDoGetAuthentication() { + BearerToken bearerToken = BearerToken.valueOf("__bearer__"); + AccessToken accessToken = mock(AccessToken.class); + when(accessToken.getSubject()).thenReturn("trillian"); + when(accessToken.getClaims()).thenReturn(new HashMap<>()); + + when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken); + + // we have to use answer, because we could not mock the result of Scopes + when(realmHelper.getAuthenticationInfo( + anyString(), anyString(), any(Scope.class) + )).thenAnswer(createAnswer("trillian", "__bearer__", true)); + + AuthenticationInfo result = realm.doGetAuthenticationInfo(bearerToken); + assertThat(result).isSameAs(authenticationInfo); + } + + @Test + void shouldThrowIllegalArgumentExceptionForWrongTypeOfToken() { + assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(new UsernamePasswordToken())); + } + + private Answer createAnswer(String expectedSubject, String expectedCredentials, boolean scopeEmpty) { + return (iom) -> { + String subject = iom.getArgument(0); + assertThat(subject).isEqualTo(expectedSubject); + String credentials = iom.getArgument(1); + assertThat(credentials).isEqualTo(expectedCredentials); + Scope scope = iom.getArgument(2); + assertThat(scope.isEmpty()).isEqualTo(scopeEmpty); + + return authenticationInfo; + }; + } + + private class MyAnswer implements Answer { + + @Override + public AuthenticationInfo answer(InvocationOnMock invocationOnMock) throws Throwable { + return null; + } + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java index d4341f104e..a1f1e36c9c 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java @@ -40,26 +40,27 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; -import java.security.SecureRandom; -import java.util.Date; -import java.util.Set; -import javax.crypto.spec.SecretKeySpec; import org.apache.shiro.authc.AuthenticationException; import org.hamcrest.Matchers; -import org.junit.Test; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; import org.junit.Before; import org.junit.Rule; +import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; -import static org.mockito.Mockito.*; -import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; - import org.mockito.junit.MockitoJUnitRunner; +import javax.crypto.spec.SecretKeySpec; +import java.util.Date; +import java.util.Set; + +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + /** * Unit tests for {@link JwtAccessTokenResolver}. * @@ -70,14 +71,12 @@ public class JwtAccessTokenResolverTest { @Rule public ExpectedException expectedException = ExpectedException.none(); - - private final SecureRandom random = new SecureRandom(); - + @Mock private SecureKeyResolver keyResolver; @Mock - private TokenClaimsValidator validator; + private AccessTokenValidator validator; private JwtAccessTokenResolver resolver; @@ -86,8 +85,8 @@ public class JwtAccessTokenResolverTest { */ @Before public void prepareObjectUnderTest() { - Set validators = Sets.newHashSet(validator); - when(validator.validate(anyMap())).thenReturn(true); + Set validators = Sets.newHashSet(validator); + when(validator.validate(Mockito.any(AccessToken.class))).thenReturn(true); resolver = new JwtAccessTokenResolver(keyResolver, validators); } @@ -115,11 +114,11 @@ public class JwtAccessTokenResolverTest { String compact = createCompactToken("marvin", secureKey); // prepare mock - when(validator.validate(anyMap())).thenReturn(false); + when(validator.validate(Mockito.any(AccessToken.class))).thenReturn(false); // expect exception expectedException.expect(AuthenticationException.class); - expectedException.expectMessage(Matchers.containsString("claims")); + expectedException.expectMessage(Matchers.containsString("token")); BearerToken bearer = BearerToken.valueOf(compact); resolver.resolve(bearer); diff --git a/scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java b/scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java similarity index 68% rename from scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java rename to scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java index dbebb2c0cf..a89744cd6e 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java @@ -31,88 +31,90 @@ package sonia.scm.security; -import com.google.common.collect.Maps; -import java.util.Map; -import javax.servlet.http.HttpServletRequest; -import org.junit.Test; -import static org.junit.Assert.*; import org.junit.Before; +import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import static org.mockito.Mockito.*; import org.mockito.junit.MockitoJUnitRunner; +import javax.servlet.http.HttpServletRequest; +import java.util.Optional; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + /** - * Tests {@link XsrfTokenClaimsValidator}. + * Tests {@link XsrfAccessTokenValidator}. * * @author Sebastian Sdorra */ @RunWith(MockitoJUnitRunner.class) -public class XsrfTokenClaimsValidatorTest { +public class XsrfAccessTokenValidatorTest { @Mock private HttpServletRequest request; - private XsrfTokenClaimsValidator validator; + @Mock + private AccessToken accessToken; + + private XsrfAccessTokenValidator validator; /** * Prepare object under test. */ @Before public void prepareObjectUnderTest() { - validator = new XsrfTokenClaimsValidator(() -> request); + validator = new XsrfAccessTokenValidator(() -> request); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)}. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)}. */ @Test public void testValidate() { // prepare - Map claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("abc"); // execute and assert - assertTrue(validator.validate(claims)); + assertTrue(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} with wrong header. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} with wrong header. */ @Test public void testValidateWithWrongHeader() { // prepare - Map claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("123"); // execute and assert - assertFalse(validator.validate(claims)); + assertFalse(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} without header. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} without header. */ @Test public void testValidateWithoutHeader() { // prepare - Map claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); // execute and assert - assertFalse(validator.validate(claims)); + assertFalse(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} without claims key. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} without claims key. */ @Test public void testValidateWithoutClaimsKey() { // prepare - Map claims = Maps.newHashMap(); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.empty()); // execute and assert - assertTrue(validator.validate(claims)); + assertTrue(validator.validate(accessToken)); } } From 5202c80e95ed94127167fb424376ebfe892370d1 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Fri, 21 Dec 2018 13:39:24 +0100 Subject: [PATCH 05/37] Fixed bug causing changes in the sources (e.g. via push) not to be reflected in UI --- scm-ui/src/repos/sources/modules/sources.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js index c6d86d38ee..41ee7935df 100644 --- a/scm-ui/src/repos/sources/modules/sources.js +++ b/scm-ui/src/repos/sources/modules/sources.js @@ -92,8 +92,8 @@ export default function reducer( ): any { if (action.itemId && action.type === FETCH_SOURCES_SUCCESS) { return { - [action.itemId]: action.payload, - ...state + ...state, + [action.itemId]: action.payload }; } return state; From 05c4e722b6524816752ee26a61fcd9f34a419f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 21 Dec 2018 14:05:52 +0100 Subject: [PATCH 06/37] Map com.fasterxml.jackson.core.JsonParseException to proper response --- .../scm/api/FallbackExceptionMapper.java | 11 +----- .../scm/api/JsonParseExceptionMapper.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java index feb5341e2d..4815b22bdc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java @@ -4,10 +4,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import sonia.scm.api.v2.resources.ErrorDto; -import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; import sonia.scm.web.VndMediaType; -import javax.inject.Inject; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -20,16 +18,9 @@ public class FallbackExceptionMapper implements ExceptionMapper { private static final String ERROR_CODE = "CmR8GCJb31"; - private final ExceptionWithContextToErrorDtoMapper mapper; - - @Inject - public FallbackExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - this.mapper = mapper; - } - @Override public Response toResponse(Exception exception) { - logger.debug("map {} to status code 500", exception); + logger.warn("mapping unexpected {} to status code 500", exception.getClass().getName(), exception); ErrorDto errorDto = new ErrorDto(); errorDto.setMessage("internal server error"); errorDto.setContext(Collections.emptyList()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java new file mode 100644 index 0000000000..dacecb350e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java @@ -0,0 +1,35 @@ +package sonia.scm.api; + +import com.fasterxml.jackson.core.JsonParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.util.Collections; + +@Provider +public class JsonParseExceptionMapper implements ExceptionMapper { + + private static final Logger logger = LoggerFactory.getLogger(JsonParseExceptionMapper.class); + + private static final String ERROR_CODE = "2VRCrvpL71"; + + @Override + public Response toResponse(JsonParseException exception) { + logger.trace("got illegal json: {}", exception.getMessage()); + ErrorDto errorDto = new ErrorDto(); + errorDto.setMessage("illegal json content: " + exception.getMessage()); + errorDto.setContext(Collections.emptyList()); + errorDto.setErrorCode(ERROR_CODE); + errorDto.setTransactionId(MDC.get("transaction_id")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorDto) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} From 8d7e59b85043cbfe0dc8fe6675a9706dcf1d0603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 21 Dec 2018 13:09:44 +0000 Subject: [PATCH 07/37] Close branch bugfix/fetch_not_found From 1898a8ffe5615929cea8154aa7cbc9b2d35c5caf Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 27 Dec 2018 13:48:55 +0100 Subject: [PATCH 08/37] Added extension point for changeset description --- .../components/changesets/ChangesetDetails.js | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js index 483042a779..eb9d0992ee 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetDetails.js +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -12,11 +12,12 @@ import { ChangesetDiff, AvatarWrapper, AvatarImage, - changesets, + changesets } from "@scm-manager/ui-components"; import classNames from "classnames"; import type { Tag } from "@scm-manager/ui-types"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; const styles = { spacing: { @@ -38,9 +39,9 @@ class ChangesetDetails extends React.Component { const description = changesets.parseDescription(changeset.description); const id = ( - + ); - const date = ; + const date = ; return (
@@ -54,7 +55,7 @@ class ChangesetDetails extends React.Component {

- +

{

{this.renderTags()}
-

- {description.message.split("\n").map((item, key) => { - return ( - - {item} -
-
- ); - })} -

+ +

+ {description.message.split("\n").map((item, key) => { + return ( + + {item} +
+
+ ); + })} +

+
@@ -95,7 +102,7 @@ class ChangesetDetails extends React.Component { return (
{tags.map((tag: Tag) => { - return ; + return ; })}
); From d0e13fcb09c7813c71fcce9d346e278f84e09379 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 27 Dec 2018 13:56:35 +0100 Subject: [PATCH 09/37] Revert: Added extension point for changeset description --- .../components/changesets/ChangesetDetails.js | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js index eb9d0992ee..86ac54037e 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetDetails.js +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -17,7 +17,6 @@ import { import classNames from "classnames"; import type { Tag } from "@scm-manager/ui-types"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; const styles = { spacing: { @@ -67,22 +66,16 @@ class ChangesetDetails extends React.Component {
{this.renderTags()}
- -

- {description.message.split("\n").map((item, key) => { - return ( - - {item} -
-
- ); - })} -

-
+

+ {description.message.split("\n").map((item, key) => { + return ( + + {item} +
+
+ ); + })} +

From 0e1a37e64f848f86d51ac2005e92d9e99c16a304 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 27 Dec 2018 13:57:45 +0100 Subject: [PATCH 10/37] Added extension point for changeset description --- .../components/changesets/ChangesetDetails.js | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js index 86ac54037e..eb9d0992ee 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetDetails.js +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -17,6 +17,7 @@ import { import classNames from "classnames"; import type { Tag } from "@scm-manager/ui-types"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; const styles = { spacing: { @@ -66,16 +67,22 @@ class ChangesetDetails extends React.Component {
{this.renderTags()}
-

- {description.message.split("\n").map((item, key) => { - return ( - - {item} -
-
- ); - })} -

+ +

+ {description.message.split("\n").map((item, key) => { + return ( + + {item} +
+
+ ); + })} +

+
From 63585eed5d451543d2780db047c1f9917c18f134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Wed, 2 Jan 2019 11:31:05 +0100 Subject: [PATCH 11/37] start bugfix --- scm-ui/public/locales/en/commons.json | 3 ++- scm-ui/src/containers/Login.js | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scm-ui/public/locales/en/commons.json b/scm-ui/public/locales/en/commons.json index 2908a38a4f..3196f3a328 100644 --- a/scm-ui/public/locales/en/commons.json +++ b/scm-ui/public/locales/en/commons.json @@ -22,7 +22,8 @@ "error-notification": { "prefix": "Error", "loginLink": "You can login here again.", - "timeout": "The session has expired." + "timeout": "The session has expired.", + "wrong-login-credentials": "Invalid credentials" }, "loading": { "alt": "Loading ..." diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 8a06478045..60a7579072 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -15,7 +15,8 @@ import { InputField, SubmitButton, ErrorNotification, - Image + Image, + UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import classNames from "classnames"; import { getLoginLink } from "../modules/indexResource"; @@ -92,18 +93,29 @@ class Login extends React.Component { return !this.isValid(); } + areCredentialsInvalid() { + const { t, error } = this.props; + if (error === UNAUTHORIZED_ERROR) { + return new Error(t("login.wrong-login-credentials")); + } else { + return error; + } + } + renderRedirect = () => { const { from } = this.props.location.state || { from: { pathname: "/" } }; return ; }; render() { - const { authenticated, loading, error, t, classes } = this.props; + const { authenticated, loading, t, classes } = this.props; if (authenticated) { return this.renderRedirect(); } + const error = this.areCredentialsInvalid(); + return (
From 4244e00552ab8d8e7a43d3048a9ac837a8483389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Wed, 2 Jan 2019 11:32:43 +0100 Subject: [PATCH 12/37] use correct translation --- scm-ui/src/containers/Login.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 60a7579072..142b623229 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -96,7 +96,7 @@ class Login extends React.Component { areCredentialsInvalid() { const { t, error } = this.props; if (error === UNAUTHORIZED_ERROR) { - return new Error(t("login.wrong-login-credentials")); + return new Error(t("error-notification.wrong-login-credentials")); } else { return error; } From dce0a8996e5fc7d21bbb6a8a02af3564bcb5e8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Wed, 2 Jan 2019 11:33:31 +0100 Subject: [PATCH 13/37] refactoring --- scm-ui/src/containers/Login.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 142b623229..e8c5352d58 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -114,8 +114,6 @@ class Login extends React.Component { return this.renderRedirect(); } - const error = this.areCredentialsInvalid(); - return (
@@ -131,7 +129,7 @@ class Login extends React.Component { alt={t("login.logo-alt")} /> - +
Date: Wed, 2 Jan 2019 11:53:51 +0100 Subject: [PATCH 14/37] pass all index resource links to the primary navigation and added an extension point for logout --- .../src/navigation/PrimaryNavigation.js | 98 +++++++++++-------- scm-ui/src/containers/App.js | 40 ++------ 2 files changed, 63 insertions(+), 75 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index 06ff997e2d..956bf3996c 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -2,60 +2,72 @@ import React from "react"; import { translate } from "react-i18next"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; +import type { Links } from "@scm-manager/ui-types"; +import { binder } from "@scm-manager/ui-extensions"; type Props = { t: string => string, - repositoriesLink: string, - usersLink: string, - groupsLink: string, - configLink: string, - logoutLink: string + links: Links, }; class PrimaryNavigation extends React.Component { - render() { - const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props; - const links = [ - repositoriesLink ? ( - ): null, - usersLink ? ( - ) : null, - groupsLink ? ( - ) : null, - configLink ? ( - ) : null, - logoutLink ? ( - ) : null - ]; + createNavigationAppender = (navigationItems) => { + const { t, links } = this.props; + + return (to: string, match: string, label: string, linkName: string) => { + const link = links[linkName]; + if (link) { + const navigationItem = ( + ) + ; + navigationItems.push(navigationItem); + } + }; + }; + + createLogoutFromExtension = () => { + const { t, links } = this.props; + + const props = { + links, + label: t("primary-navigation.logout") + }; + + return binder.getExtension("primary-navigation.logout", props); + }; + + createNavigationItems = () => { + const navigationItems = []; + + const append = this.createNavigationAppender(navigationItems); + append("/repos", "/(repo|repos)", "primary-navigation.repositories", "repositories"); + append("/users", "/(user|users)", "primary-navigation.users", "users"); + append("/groups", "/(group|groups)", "primary-navigation.groups", "groups"); + append("/config", "/config", "primary-navigation.config", "config"); + + if (binder.hasExtension("primary-navigation.logout")) { + const extension = this.createLogoutFromExtension(); + navigationItems.push(extension); + } else { + append("/logout", "/logout", "primary-navigation.logout", "logout"); + } + + return navigationItems; + }; + + render() { + const navigationItems = this.createNavigationItems(); return ( ); diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js index 50fc805eb2..dd3dd36639 100644 --- a/scm-ui/src/containers/App.js +++ b/scm-ui/src/containers/App.js @@ -19,15 +19,11 @@ import { Footer, Header } from "@scm-manager/ui-components"; -import type { Me } from "@scm-manager/ui-types"; +import type { Links, Me } from "@scm-manager/ui-types"; import { - getConfigLink, getFetchIndexResourcesFailure, - getGroupsLink, - getLogoutLink, + getLinks, getMeLink, - getRepositoriesLink, - getUsersLink, isFetchIndexResourcesPending } from "../modules/indexResource"; @@ -36,11 +32,7 @@ type Props = { authenticated: boolean, error: Error, loading: boolean, - repositoriesLink: string, - usersLink: string, - groupsLink: string, - configLink: string, - logoutLink: string, + links: Links, meLink: string, // dispatcher functions @@ -63,22 +55,14 @@ class App extends Component { loading, error, authenticated, - t, - repositoriesLink, - usersLink, - groupsLink, - configLink, - logoutLink + links, + t } = this.props; let content; const navigation = authenticated ? ( ) : ( "" @@ -120,22 +104,14 @@ const mapStateToProps = state => { isFetchMePending(state) || isFetchIndexResourcesPending(state); const error = getFetchMeFailure(state) || getFetchIndexResourcesFailure(state); - const repositoriesLink = getRepositoriesLink(state); - const usersLink = getUsersLink(state); - const groupsLink = getGroupsLink(state); - const configLink = getConfigLink(state); - const logoutLink = getLogoutLink(state); + const links = getLinks(state); const meLink = getMeLink(state); return { authenticated, me, loading, error, - repositoriesLink, - usersLink, - groupsLink, - configLink, - logoutLink, + links, meLink }; }; From 67052e7bf9b601a4c6f7eca07b730f68da448530 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 2 Jan 2019 12:28:52 +0100 Subject: [PATCH 15/37] improved logout link extension point --- .../src/navigation/PrimaryNavigation.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index 956bf3996c..886890b72e 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -3,7 +3,7 @@ import React from "react"; import { translate } from "react-i18next"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; import type { Links } from "@scm-manager/ui-types"; -import { binder } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { t: string => string, @@ -31,7 +31,7 @@ class PrimaryNavigation extends React.Component { }; }; - createLogoutFromExtension = () => { + appendLogout = (navigationItems, append) => { const { t, links } = this.props; const props = { @@ -39,7 +39,13 @@ class PrimaryNavigation extends React.Component { label: t("primary-navigation.logout") }; - return binder.getExtension("primary-navigation.logout", props); + if (binder.hasExtension("primary-navigation.logout", props)) { + navigationItems.push( + + ); + } else { + append("/logout", "/logout", "primary-navigation.logout", "logout"); + } }; createNavigationItems = () => { @@ -51,12 +57,7 @@ class PrimaryNavigation extends React.Component { append("/groups", "/(group|groups)", "primary-navigation.groups", "groups"); append("/config", "/config", "primary-navigation.config", "config"); - if (binder.hasExtension("primary-navigation.logout")) { - const extension = this.createLogoutFromExtension(); - navigationItems.push(extension); - } else { - append("/logout", "/logout", "primary-navigation.logout", "logout"); - } + this.appendLogout(navigationItems, append); return navigationItems; }; From f752fdbcc6ad929ea72a31683555a0f4fbcdd6a1 Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Wed, 2 Jan 2019 14:06:09 +0100 Subject: [PATCH 16/37] implemented success banner for config changes --- .../ui-components/src/config/Configuration.js | 67 ++++++++++++------- scm-ui/public/locales/en/config.json | 1 + scm-ui/src/config/containers/GlobalConfig.js | 31 ++++++++- 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/config/Configuration.js b/scm-ui-components/packages/ui-components/src/config/Configuration.js index 07b68f39a6..415dcb5f11 100644 --- a/scm-ui-components/packages/ui-components/src/config/Configuration.js +++ b/scm-ui-components/packages/ui-components/src/config/Configuration.js @@ -2,12 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import type { Links } from "@scm-manager/ui-types"; -import { - apiClient, - SubmitButton, - Loading, - ErrorNotification -} from "../"; +import { apiClient, SubmitButton, Loading, ErrorNotification } from "../"; type RenderProps = { readOnly: boolean, @@ -20,10 +15,10 @@ type Props = { render: (props: RenderProps) => any, // ??? // context props - t: (string) => string + t: string => string }; -type ConfigurationType = { +type ConfigurationType = { _links: Links } & Object; @@ -32,6 +27,7 @@ type State = { fetching: boolean, modifying: boolean, contentType?: string, + configChanged: boolean, configuration?: ConfigurationType, modifiedConfiguration?: ConfigurationType, @@ -43,12 +39,12 @@ type State = { * synchronizing the configuration with the backend. */ class Configuration extends React.Component { - constructor(props: Props) { super(props); this.state = { fetching: true, modifying: false, + configChanged: false, valid: false }; } @@ -56,7 +52,8 @@ class Configuration extends React.Component { componentDidMount() { const { link } = this.props; - apiClient.get(link) + apiClient + .get(link) .then(this.captureContentType) .then(response => response.json()) .then(this.loadConfig) @@ -119,19 +116,39 @@ class Configuration extends React.Component { this.setState({ modifying: true }); - const {modifiedConfiguration} = this.state; + const { modifiedConfiguration } = this.state; - apiClient.put(this.getModificationUrl(), modifiedConfiguration, this.getContentType()) - .then(() => this.setState({ modifying: false })) + apiClient + .put( + this.getModificationUrl(), + modifiedConfiguration, + this.getContentType() + ) + .then(() => this.setState({ modifying: false, configChanged: true })) .catch(this.handleError); }; + renderConfigChangedNotification = () => { + if (this.state.configChanged) { + return ( +
+
+ ); + } + return null; + }; + render() { const { t } = this.props; const { fetching, error, configuration, modifying, valid } = this.state; if (error) { - return ; + return ; } else if (fetching || !configuration) { return ; } else { @@ -144,19 +161,21 @@ class Configuration extends React.Component { }; return ( - - { this.props.render(renderProps) } -
- - + <> + {this.renderConfigChangedNotification()} +
+ {this.props.render(renderProps)} +
+ + + ); } } - } export default translate("config")(Configuration); diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index de02006e79..6f72904dcc 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -10,6 +10,7 @@ }, "config-form": { "submit": "Submit", + "submit-success-notification": "Configuration changed!", "no-permission-notification": "Please note: You do not have the permission to edit the config!" }, "proxy-settings": { diff --git a/scm-ui/src/config/containers/GlobalConfig.js b/scm-ui/src/config/containers/GlobalConfig.js index 6046aa4a09..71be3fdd7f 100644 --- a/scm-ui/src/config/containers/GlobalConfig.js +++ b/scm-ui/src/config/containers/GlobalConfig.js @@ -34,7 +34,19 @@ type Props = { t: string => string }; -class GlobalConfig extends React.Component { +type State = { + configChanged: boolean +}; + +class GlobalConfig extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + configChanged: false + }; + } + componentDidMount() { this.props.configReset(); this.props.fetchConfig(this.props.configLink); @@ -42,6 +54,22 @@ class GlobalConfig extends React.Component { modifyConfig = (config: Config) => { this.props.modifyConfig(config); + this.setState({ configChanged: true }); + }; + + renderConfigChangedNotification = () => { + if (this.state.configChanged) { + return ( +
+
+ ); + } + return null; }; render() { @@ -64,6 +92,7 @@ class GlobalConfig extends React.Component { return (
+ {this.renderConfigChangedNotification()} <ConfigForm submitForm={config => this.modifyConfig(config)} config={config} From fc4ed8036b99ab4d18efce224daf17fd6b37fef5 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 2 Jan 2019 14:24:57 +0100 Subject: [PATCH 17/37] disabled button after successful change --- .../packages/ui-components/src/config/Configuration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui-components/packages/ui-components/src/config/Configuration.js b/scm-ui-components/packages/ui-components/src/config/Configuration.js index 415dcb5f11..0eb6f6ffc2 100644 --- a/scm-ui-components/packages/ui-components/src/config/Configuration.js +++ b/scm-ui-components/packages/ui-components/src/config/Configuration.js @@ -124,7 +124,7 @@ class Configuration extends React.Component<Props, State> { modifiedConfiguration, this.getContentType() ) - .then(() => this.setState({ modifying: false, configChanged: true })) + .then(() => this.setState({ modifying: false, configChanged: true, valid: false })) .catch(this.handleError); }; From 3c8379bbf43c3d4b6117c7c622b3f70d7b7b24f0 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 2 Jan 2019 14:37:25 +0100 Subject: [PATCH 18/37] disabled button after successful change for global config --- scm-ui/src/config/components/form/ConfigForm.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scm-ui/src/config/components/form/ConfigForm.js b/scm-ui/src/config/components/form/ConfigForm.js index f5bd6531b5..370925eee9 100644 --- a/scm-ui/src/config/components/form/ConfigForm.js +++ b/scm-ui/src/config/components/form/ConfigForm.js @@ -23,7 +23,8 @@ type State = { error: { loginAttemptLimitTimeout: boolean, loginAttemptLimit: boolean - } + }, + valid: boolean }; class ConfigForm extends React.Component<Props, State> { @@ -59,7 +60,8 @@ class ConfigForm extends React.Component<Props, State> { error: { loginAttemptLimitTimeout: false, loginAttemptLimit: false - } + }, + valid: false }; } @@ -156,7 +158,7 @@ class ConfigForm extends React.Component<Props, State> { <SubmitButton loading={loading} label={t("config-form.submit")} - disabled={!configUpdatePermission || this.hasError()} + disabled={!configUpdatePermission || this.hasError() || !this.state.valid} /> </form> ); @@ -172,7 +174,8 @@ class ConfigForm extends React.Component<Props, State> { error: { ...this.state.error, [name]: !isValid - } + }, + valid: true }); }; From 6fdb249afe65fcd0241b841998b3b58dff967679 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 2 Jan 2019 14:50:08 +0000 Subject: [PATCH 19/37] Close branch bugfix/repos_in_overview_clickable From c0e293b307501de1e613bdc96951da638ad75c28 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 2 Jan 2019 14:59:54 +0000 Subject: [PATCH 20/37] Close branch bugfix/correct_error_message_when_password_is_wrong From c69c8028c93ed8302fa2b98b998e215a4ece85e3 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 3 Jan 2019 09:46:48 +0100 Subject: [PATCH 21/37] added new api to simplify the process of appending links to json responses --- .../scm/api/v2/resources/BaseMapper.java | 2 +- .../sonia/scm/api/v2/resources/Index.java | 10 +++ .../scm/api/v2/resources/LinkAppender.java | 18 +++++ .../api/v2/resources/LinkAppenderMapper.java | 36 +++++++++ .../scm/api/v2/resources/LinkEnricher.java | 19 +++++ .../api/v2/resources/LinkEnricherContext.java | 67 +++++++++++++++++ .../v2/resources/LinkEnricherRegistry.java | 40 ++++++++++ .../java/sonia/scm/api/v2/resources/Me.java | 10 +++ .../v2/resources/LinkAppenderMapperTest.java | 74 +++++++++++++++++++ .../v2/resources/LinkEnricherContextTest.java | 44 +++++++++++ .../resources/LinkEnricherRegistryTest.java | 60 +++++++++++++++ 11 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java create mode 100644 scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java create mode 100644 scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java create mode 100644 scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java index d7f299d989..89cc893131 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java @@ -3,7 +3,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import org.mapstruct.Mapping; -public abstract class BaseMapper<T, D extends HalRepresentation> implements InstantAttributeMapper { +public abstract class BaseMapper<T, D extends HalRepresentation> extends LinkAppenderMapper implements InstantAttributeMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract D map(T modelObject); diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java new file mode 100644 index 0000000000..bf20f26a7a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Index} object can be used to register a {@link LinkEnricher} for the index resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Index { +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java new file mode 100644 index 0000000000..dbf1ff3ff6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link LinkAppender} can be used within an {@link LinkEnricher} to append hateoas links to a json response. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public interface LinkAppender { + + /** + * Appends one link to the json response. + * + * @param rel name of relation + * @param href link uri + */ + void appendOne(String rel, String href); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java new file mode 100644 index 0000000000..7843491b71 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java @@ -0,0 +1,36 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.annotations.VisibleForTesting; + +import javax.inject.Inject; + +public class LinkAppenderMapper { + + @Inject + private LinkEnricherRegistry registry; + + @VisibleForTesting + void setRegistry(LinkEnricherRegistry registry) { + this.registry = registry; + } + + protected void appendLinks(LinkAppender appender, Object source, Object... contextEntries) { + // null check is only their to not break existing tests + if (registry != null) { + + Object[] ctx = new Object[contextEntries.length + 1]; + ctx[0] = source; + for (int i = 0; i < contextEntries.length; i++) { + ctx[i + 1] = contextEntries[i]; + } + + LinkEnricherContext context = LinkEnricherContext.of(ctx); + + Iterable<LinkEnricher> enrichers = registry.allByType(source.getClass()); + for (LinkEnricher enricher : enrichers) { + enricher.enrich(context, appender); + } + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java new file mode 100644 index 0000000000..b9cc3c5059 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java @@ -0,0 +1,19 @@ +package sonia.scm.api.v2.resources; + +/** + * A {@link LinkEnricher} can be used to append hateoas links to a specific json response. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@FunctionalInterface +public interface LinkEnricher { + + /** + * Enriches the response with hateoas links. + * + * @param context contains the source for the json mapping and related objects + * @param appender can be used to append links to the json response + */ + void enrich(LinkEnricherContext context, LinkAppender appender); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java new file mode 100644 index 0000000000..6f6b4ca8f8 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java @@ -0,0 +1,67 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; + +/** + * Context object for the {@link LinkEnricher}. The context holds the source object for the json and all related + * objects, which can be useful for the link creation. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class LinkEnricherContext { + + private final Map<Class, Object> instanceMap; + + private LinkEnricherContext(Map<Class,Object> instanceMap) { + this.instanceMap = instanceMap; + } + + /** + * Creates a context with the given entries + * + * @param instances entries of the context + * + * @return context of given entries + */ + public static LinkEnricherContext of(Object... instances) { + ImmutableMap.Builder<Class, Object> builder = ImmutableMap.builder(); + for (Object instance : instances) { + builder.put(instance.getClass(), instance); + } + return new LinkEnricherContext(builder.build()); + } + + /** + * Returns the registered object from the context. The method will return an empty optional, if no object with the + * given type was registered. + * + * @param type type of instance + * @param <T> type of instance + * @return optional instance + */ + public <T> Optional<T> oneByType(Class<T> type) { + Object instance = instanceMap.get(type); + if (instance != null) { + return Optional.of(type.cast(instance)); + } + return Optional.empty(); + } + + /** + * Returns the registered object from the context, but throws an {@link NoSuchElementException} if the type was not + * registered. + * + * @param type type of instance + * @param <T> type of instance + * @return instance + */ + public <T> T oneRequireByType(Class<T> type) { + return oneByType(type).get(); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java new file mode 100644 index 0000000000..cd95a62ec3 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import sonia.scm.plugin.Extension; + +import javax.inject.Singleton; + +/** + * The {@link LinkEnricherRegistry} is responsible for binding {@link LinkEnricher} instances to their source types. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Extension +@Singleton +public final class LinkEnricherRegistry { + + private final Multimap<Class, LinkEnricher> enrichers = HashMultimap.create(); + + /** + * Registers a new {@link LinkEnricher} for the given source type. + * + * @param sourceType type of json mapping source + * @param enricher link enricher instance + */ + public void register(Class sourceType, LinkEnricher enricher) { + enrichers.put(sourceType, enricher); + } + + /** + * Returns all registered {@link LinkEnricher} for the given type. + * + * @param sourceType type of json mapping source + * @return all registered enrichers + */ + public Iterable<LinkEnricher> allByType(Class sourceType) { + return enrichers.get(sourceType); + } +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java new file mode 100644 index 0000000000..f8f82804a6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Me} object can be used to register a {@link LinkEnricher} for the me resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Me { +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java new file mode 100644 index 0000000000..557eac2020 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java @@ -0,0 +1,74 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LinkAppenderMapperTest { + + @Mock + private LinkAppender appender; + + private LinkEnricherRegistry registry; + private LinkAppenderMapper mapper; + + @BeforeEach + void beforeEach() { + registry = new LinkEnricherRegistry(); + mapper = new LinkAppenderMapper(); + mapper.setRegistry(registry); + } + + @Test + void shouldAppendSimpleLink() { + registry.register(String.class, (ctx, appender) -> appender.appendOne("42", "https://hitchhiker.com")); + + mapper.appendLinks(appender, "hello"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + + @Test + void shouldCallMultipleEnrichers() { + registry.register(String.class, (ctx, appender) -> appender.appendOne("42", "https://hitchhiker.com")); + registry.register(String.class, (ctx, appender) -> appender.appendOne("21", "https://scm.hitchhiker.com")); + + mapper.appendLinks(appender, "hello"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + verify(appender).appendOne("21", "https://scm.hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingSourceFromContext() { + registry.register(String.class, (ctx, appender) -> { + Optional<String> rel = ctx.oneByType(String.class); + appender.appendOne(rel.get(), "https://hitchhiker.com"); + }); + + mapper.appendLinks(appender, "42"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingMultipleContextEntries() { + registry.register(Integer.class, (ctx, appender) -> { + Optional<Integer> rel = ctx.oneByType(Integer.class); + Optional<String> href = ctx.oneByType(String.class); + appender.appendOne(String.valueOf(rel.get()), href.get()); + }); + + mapper.appendLinks(appender, Integer.valueOf(42), "https://hitchhiker.com"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java new file mode 100644 index 0000000000..6eb7bb4c84 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java @@ -0,0 +1,44 @@ +package sonia.scm.api.v2.resources; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +class LinkEnricherContextTest { + + @Test + void shouldCreateContextFromSingleObject() { + LinkEnricherContext context = LinkEnricherContext.of("hello"); + assertThat(context.oneByType(String.class)).contains("hello"); + } + + @Test + void shouldCreateContextFromMultipleObjects() { + LinkEnricherContext context = LinkEnricherContext.of("hello", Integer.valueOf(42), Long.valueOf(21L)); + assertThat(context.oneByType(String.class)).contains("hello"); + assertThat(context.oneByType(Integer.class)).contains(42); + assertThat(context.oneByType(Long.class)).contains(21L); + } + + @Test + void shouldReturnEmptyOptionalForUnknownTypes() { + LinkEnricherContext context = LinkEnricherContext.of(); + assertThat(context.oneByType(String.class)).isNotPresent(); + } + + @Test + void shouldReturnRequiredObject() { + LinkEnricherContext context = LinkEnricherContext.of("hello"); + assertThat(context.oneRequireByType(String.class)).isEqualTo("hello"); + } + + @Test + void shouldThrowAnNoSuchElementExceptionForUnknownTypes() { + LinkEnricherContext context = LinkEnricherContext.of(); + assertThrows(NoSuchElementException.class, () -> context.oneRequireByType(String.class)); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java new file mode 100644 index 0000000000..07441003d7 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java @@ -0,0 +1,60 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LinkEnricherRegistryTest { + + private LinkEnricherRegistry registry; + + @BeforeEach + void setUpObjectUnderTest() { + registry = new LinkEnricherRegistry(); + } + + @Test + void shouldRegisterTheEnricher() { + SampleLinkEnricher enricher = new SampleLinkEnricher(); + registry.register(String.class, enricher); + + Iterable<LinkEnricher> enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(enricher); + } + + @Test + void shouldRegisterMultipleEnrichers() { + SampleLinkEnricher one = new SampleLinkEnricher(); + registry.register(String.class, one); + + SampleLinkEnricher two = new SampleLinkEnricher(); + registry.register(String.class, two); + + Iterable<LinkEnricher> enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one, two); + } + + @Test + void shouldRegisterEnrichersForDifferentTypes() { + SampleLinkEnricher one = new SampleLinkEnricher(); + registry.register(String.class, one); + + SampleLinkEnricher two = new SampleLinkEnricher(); + registry.register(Integer.class, two); + + Iterable<LinkEnricher> enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one); + + enrichers = registry.allByType(Integer.class); + assertThat(enrichers).containsOnly(two); + } + + private static class SampleLinkEnricher implements LinkEnricher { + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + +} From 471852d3609e6d306cf2dad3830c391fb8794a38 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 3 Jan 2019 10:20:39 +0100 Subject: [PATCH 22/37] implement new link enricher api for various resource objects. Repository, Tag, Branch, Changeset, FileObject, Group, User, Me and Index --- .../v2/resources/BranchToBranchDtoMapper.java | 7 +++- .../ChangesetToChangesetDtoMapper.java | 5 ++- .../api/v2/resources/EdisonLinkAppender.java | 18 ++++++++ .../FileObjectToFileObjectDtoMapper.java | 4 +- .../v2/resources/GroupToGroupDtoMapper.java | 3 ++ .../api/v2/resources/IndexDtoGenerator.java | 4 +- .../api/v2/resources/MeToUserDtoMapper.java | 5 ++- .../RepositoryToRepositoryDtoMapper.java | 3 ++ .../api/v2/resources/TagToTagDtoMapper.java | 7 +++- .../api/v2/resources/UserToUserDtoMapper.java | 4 +- .../BranchToBranchDtoMapperTest.java | 42 +++++++++++++++++++ .../FileObjectToFileObjectDtoMapperTest.java | 18 ++++++++ .../resources/GroupToGroupDtoMapperTest.java | 17 +++++++- .../v2/resources/MeToUserDtoMapperTest.java | 16 +++++++ .../RepositoryToRepositoryDtoMapperTest.java | 13 ++++++ .../v2/resources/TagToTagDtoMapperTest.java | 37 ++++++++++++++++ .../v2/resources/UserToUserDtoMapperTest.java | 14 +++++++ 17 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index 7ab3ef25a8..7e6f0c074c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -15,7 +15,7 @@ import static de.otto.edison.hal.Link.linkBuilder; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class BranchToBranchDtoMapper { +public abstract class BranchToBranchDtoMapper extends LinkAppenderMapper { @Inject private ResourceLinks resourceLinks; @@ -24,12 +24,15 @@ public abstract class BranchToBranchDtoMapper { public abstract BranchDto map(Branch branch, @Context NamespaceAndName namespaceAndName); @AfterMapping - void appendLinks(@MappingTarget BranchDto target, @Context NamespaceAndName namespaceAndName) { + void appendLinks(Branch source, @MappingTarget BranchDto target, @Context NamespaceAndName namespaceAndName) { Links.Builder linksBuilder = linkingTo() .self(resourceLinks.branch().self(namespaceAndName, target.getName())) .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build()) .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()) .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()); + + appendLinks(new EdisonLinkAppender(linksBuilder), source, namespaceAndName); + target.add(linksBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java index a189dcec97..219062d320 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java @@ -23,7 +23,7 @@ import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class ChangesetToChangesetDtoMapper implements InstantAttributeMapper { +public abstract class ChangesetToChangesetDtoMapper extends LinkAppenderMapper implements InstantAttributeMapper { @Inject private RepositoryServiceFactory serviceFactory; @@ -67,6 +67,9 @@ public abstract class ChangesetToChangesetDtoMapper implements InstantAttributeM .self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), target.getId())) .single(link("diff", resourceLinks.diff().self(namespace, name, target.getId()))) .single(link("modifications", resourceLinks.modifications().self(namespace, name, target.getId()))); + + appendLinks(new EdisonLinkAppender(linksBuilder), source, repository); + target.add(linksBuilder.build()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java new file mode 100644 index 0000000000..66c35080b5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Link; +import de.otto.edison.hal.Links; + +class EdisonLinkAppender implements LinkAppender { + + private final Links.Builder builder; + + EdisonLinkAppender(Links.Builder builder) { + this.builder = builder; + } + + @Override + public void appendOne(String rel, String href) { + builder.single(Link.link(rel, href)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java index 7ba8d21c75..2432d5168c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java @@ -18,7 +18,7 @@ import java.util.stream.Collectors; import static de.otto.edison.hal.Link.link; @Mapper -public abstract class FileObjectToFileObjectDtoMapper implements InstantAttributeMapper { +public abstract class FileObjectToFileObjectDtoMapper extends LinkAppenderMapper implements InstantAttributeMapper { @Inject private ResourceLinks resourceLinks; @@ -39,6 +39,8 @@ public abstract class FileObjectToFileObjectDtoMapper implements InstantAttribut links.single(link("history", resourceLinks.fileHistory().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), revision, path))); } + appendLinks(new EdisonLinkAppender(links), fileObject, namespaceAndName, revision); + dto.add(links.build()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java index d03fc94387..9a25e711cd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java @@ -31,6 +31,9 @@ public abstract class GroupToGroupDtoMapper extends BaseMapper<Group, GroupDto> if (GroupPermissions.modify(group).isPermitted()) { linksBuilder.single(link("update", resourceLinks.group().update(target.getName()))); } + + appendLinks(new EdisonLinkAppender(linksBuilder), group); + target.add(linksBuilder.build()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index da4368d9b9..108d6fae5d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -14,7 +14,7 @@ import java.util.List; import static de.otto.edison.hal.Link.link; -public class IndexDtoGenerator { +public class IndexDtoGenerator extends LinkAppenderMapper { private final ResourceLinks resourceLinks; private final SCMContextProvider scmContextProvider; @@ -56,6 +56,8 @@ public class IndexDtoGenerator { builder.single(link("login", resourceLinks.authentication().jsonLogin())); } + appendLinks(new EdisonLinkAppender(builder), new Index()); + return new IndexDto(scmContextProvider.getVersion(), builder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java index 2a872eadd9..c6d98a826e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java @@ -14,7 +14,7 @@ import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class MeToUserDtoMapper extends UserToUserDtoMapper{ +public abstract class MeToUserDtoMapper extends UserToUserDtoMapper { @Inject private UserManager userManager; @@ -36,6 +36,9 @@ public abstract class MeToUserDtoMapper extends UserToUserDtoMapper{ if (userManager.isTypeDefault(user)) { linksBuilder.single(link("password", resourceLinks.me().passwordChange())); } + + appendLinks(new EdisonLinkAppender(linksBuilder), new Me(), user); + target.add(linksBuilder.build()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 30ccb79735..743474825b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -67,6 +67,9 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit } linksBuilder.single(link("changesets", resourceLinks.changeset().all(target.getNamespace(), target.getName()))); linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(target.getNamespace(), target.getName()))); + + appendLinks(new EdisonLinkAppender(linksBuilder), repository); + target.add(linksBuilder.build()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java index ee0488e037..5ede1cb55b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java @@ -15,7 +15,7 @@ import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class TagToTagDtoMapper { +public abstract class TagToTagDtoMapper extends LinkAppenderMapper { @Inject private ResourceLinks resourceLinks; @@ -24,11 +24,14 @@ public abstract class TagToTagDtoMapper { public abstract TagDto map(Tag tag, @Context NamespaceAndName namespaceAndName); @AfterMapping - void appendLinks(@MappingTarget TagDto target, @Context NamespaceAndName namespaceAndName) { + void appendLinks(Tag tag, @MappingTarget TagDto target, @Context NamespaceAndName namespaceAndName) { Links.Builder linksBuilder = linkingTo() .self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getName())) .single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision()))) .single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision()))); + + appendLinks(new EdisonLinkAppender(linksBuilder), tag, namespaceAndName); + target.add(linksBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index eb49ff5f3d..5874e5767a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -1,6 +1,5 @@ package sonia.scm.api.v2.resources; -import com.google.common.annotations.VisibleForTesting; import de.otto.edison.hal.Links; import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; @@ -43,6 +42,9 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> { linksBuilder.single(link("password", resourceLinks.user().passwordChange(target.getName()))); } } + + appendLinks(new EdisonLinkAppender(linksBuilder), user); + target.add(linksBuilder.build()); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java new file mode 100644 index 0000000000..d2e202576a --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java @@ -0,0 +1,42 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Branch; +import sonia.scm.repository.NamespaceAndName; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class BranchToBranchDtoMapperTest { + + private final URI baseUri = URI.create("https://hitchhiker.com"); + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private BranchToBranchDtoMapperImpl mapper; + + @Test + void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(Branch.class, (ctx, appender) -> { + NamespaceAndName namespaceAndName = ctx.oneRequireByType(NamespaceAndName.class); + Branch branch = ctx.oneRequireByType(Branch.class); + + appender.appendOne("ka", "http://" + namespaceAndName.logString() + "/" + branch.getName()); + }); + mapper.setRegistry(registry); + + Branch branch = new Branch("master", "42"); + + BranchDto dto = mapper.map(branch, new NamespaceAndName("hitchhiker", "heart-of-gold")); + assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/heart-of-gold/master"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java index 23b723b748..b25410210f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java @@ -71,6 +71,24 @@ public class FileObjectToFileObjectDtoMapperTest { assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo(expectedBaseUri.resolve("namespace/name/content/revision/foo/bar").toString()); } + @Test + public void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(FileObject.class, (ctx, appender) -> { + NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); + FileObject fo = ctx.oneRequireByType(FileObject.class); + String rev = ctx.oneRequireByType(String.class); + + appender.appendOne("hog", "http://" + repository.logString() + "/" + fo.getName() + "/" + rev); + }); + mapper.setRegistry(registry); + + FileObject fileObject = createFileObject(); + FileObjectDto dto = mapper.map(fileObject, new NamespaceAndName("hitchhiker", "hog"), "42"); + + assertThat(dto.getLinks().getLinkBy("hog").get().getHref()).isEqualTo("http://hitchhiker/hog/foo/42"); + } + private FileObject createDirectoryObject() { FileObject fileObject = createFileObject(); fileObject.setDirectory(true); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java index e519a9c3e5..b681dff21f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java @@ -35,7 +35,7 @@ public class GroupToGroupDtoMapperTest { private URI expectedBaseUri; @Before - public void init() throws URISyntaxException { + public void init() { initMocks(this); expectedBaseUri = baseUri.resolve(GroupRootResource.GROUPS_PATH_V2 + "/"); subjectThreadState.bind(); @@ -89,6 +89,21 @@ public class GroupToGroupDtoMapperTest { assertEquals("http://example.com/base/v2/users/user0", actualMember.getLinks().getLinkBy("self").get().getHref()); } + @Test + public void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(Group.class, (ctx, appender) -> { + Group group = ctx.oneRequireByType(Group.class); + appender.appendOne("some", "http://" + group.getName()); + }); + mapper.setRegistry(registry); + + Group group = createDefaultGroup(); + GroupDto dto = mapper.map(group); + + assertEquals("http://abc", dto.getLinks().getLinkBy("some").get().getHref()); + } + private Group createDefaultGroup() { Group group = new Group(); group.setName("abc"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java index 4f40098da5..5aa940304e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java @@ -11,6 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.user.User; import sonia.scm.user.UserManager; +import sonia.scm.user.UserTestData; import java.net.URI; @@ -124,6 +125,21 @@ public class MeToUserDtoMapperTest { assertThat(userDto.getPassword()).as("hide password for the me resource").isBlank(); } + @Test + public void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(Me.class, (ctx, appender) -> { + User user = ctx.oneRequireByType(User.class); + appender.appendOne("profile", "http://hitchhiker.com/users/" + user.getName()); + }); + mapper.setRegistry(registry); + + User trillian = UserTestData.createTrillian(); + UserDto dto = mapper.map(trillian); + + assertEquals("http://hitchhiker.com/users/trillian", dto.getLinks().getLinkBy("profile").get().getHref()); + } + private User createDefaultUser() { User user = new User(); user.setName("abc"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index 2e6048d6b8..1ddae1d107 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -211,6 +211,19 @@ public class RepositoryToRepositoryDtoMapperTest { assertTrue(dto.getLinks().getLinksBy("protocol").isEmpty()); } + @Test + public void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(Repository.class, (ctx, appender) -> { + Repository repository = ctx.oneRequireByType(Repository.class); + appender.appendOne("id", "http://" + repository.getId()); + }); + mapper.setRegistry(registry); + + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals("http://1", dto.getLinks().getLinkBy("id").get().getHref()); + } + private ScmProtocol mockProtocol(String type, String protocol) { return new MockScmProtocol(type, protocol); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java new file mode 100644 index 0000000000..aa8eb3e7ab --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java @@ -0,0 +1,37 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Tag; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class TagToTagDtoMapperTest { + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("https://hitchhiker.com")); + + @InjectMocks + private TagToTagDtoMapperImpl mapper; + + @Test + void shouldAppendLinks() { + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(Tag.class, (ctx, appender) -> { + NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); + Tag tag = ctx.oneRequireByType(Tag.class); + appender.appendOne("yo", "http://" + repository.logString() + "/" + tag.getName()); + }); + mapper.setRegistry(registry); + + TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog")); + assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/hog/1.0.0"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index dfff933f19..9924dae81b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -11,6 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.user.User; import sonia.scm.user.UserManager; +import sonia.scm.user.UserTestData; import java.net.URI; import java.time.Instant; @@ -149,4 +150,17 @@ public class UserToUserDtoMapperTest { assertEquals(expectedCreationDate, userDto.getCreationDate()); assertEquals(expectedModificationDate, userDto.getLastModified()); } + + @Test + public void shouldAppendLink() { + User trillian = UserTestData.createTrillian(); + + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + registry.register(User.class, (ctx, appender) -> appender.appendOne("sample", "http://" + ctx.oneByType(User.class).get().getName())); + mapper.setRegistry(registry); + + UserDto userDto = mapper.map(trillian); + + assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref()); + } } From 7821b68d9c648328746f540b9870a5f5e5a91a99 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 3 Jan 2019 10:52:37 +0100 Subject: [PATCH 23/37] implemented LinkEnricher registration via Enrich annotation --- .../sonia/scm/api/v2/resources/Enrich.java | 26 ++++++++ .../scm/api/v2/resources/LinkEnricher.java | 7 ++ .../LinkEnricherAutoRegistration.java | 45 +++++++++++++ .../LinkEnricherAutoRegistrationTest.java | 64 +++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistrationTest.java diff --git a/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java new file mode 100644 index 0000000000..a1269dfc00 --- /dev/null +++ b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify the source of an enricher. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Enrich { + + /** + * Source mapping class. + * + * @return source mapping class + */ + Class<?> value(); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java index b9cc3c5059..c16d6f6482 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java @@ -1,11 +1,18 @@ package sonia.scm.api.v2.resources; +import sonia.scm.plugin.ExtensionPoint; + /** * A {@link LinkEnricher} can be used to append hateoas links to a specific json response. + * To register an enricher use the {@link Enrich} annotation or the {@link LinkEnricherRegistry} which is available + * via injection. + * + * <b>Warning:</b> enrichers are always registered as singletons. * * @author Sebastian Sdorra * @since 2.0.0 */ +@ExtensionPoint @FunctionalInterface public interface LinkEnricher { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java new file mode 100644 index 0000000000..890e268ed5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java @@ -0,0 +1,45 @@ +package sonia.scm.api.v2.resources; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import java.util.Set; + +/** + * Registers every {@link LinkEnricher} which is annotated with an {@link Enrich} annotation. + */ +@Extension +public class LinkEnricherAutoRegistration implements ServletContextListener { + + private static final Logger LOG = LoggerFactory.getLogger(LinkEnricherAutoRegistration.class); + + private final LinkEnricherRegistry registry; + private final Set<LinkEnricher> enrichers; + + @Inject + public LinkEnricherAutoRegistration(LinkEnricherRegistry registry, Set<LinkEnricher> enrichers) { + this.registry = registry; + this.enrichers = enrichers; + } + + @Override + public void contextInitialized(ServletContextEvent sce) { + for (LinkEnricher enricher : enrichers) { + Enrich annotation = enricher.getClass().getAnnotation(Enrich.class); + if (annotation != null) { + registry.register(annotation.value(), enricher); + } else { + LOG.warn("found LinkEnricher extension {} without Enrich annotation", enricher.getClass()); + } + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + // nothing todo + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistrationTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistrationTest.java new file mode 100644 index 0000000000..a2b72abc49 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistrationTest.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Java6Assertions.assertThat; + +class LinkEnricherAutoRegistrationTest { + + @Test + void shouldRegisterAllAvailableLinkEnrichers() { + LinkEnricher one = new One(); + LinkEnricher two = new Two(); + LinkEnricher three = new Three(); + LinkEnricher four = new Four(); + Set<LinkEnricher> enrichers = ImmutableSet.of(one, two, three, four); + + LinkEnricherRegistry registry = new LinkEnricherRegistry(); + + LinkEnricherAutoRegistration autoRegistration = new LinkEnricherAutoRegistration(registry, enrichers); + autoRegistration.contextInitialized(null); + + assertThat(registry.allByType(String.class)).containsOnly(one, two); + assertThat(registry.allByType(Integer.class)).containsOnly(three); + } + + @Enrich(String.class) + public static class One implements LinkEnricher { + + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + + @Enrich(String.class) + public static class Two implements LinkEnricher { + + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + + @Enrich(Integer.class) + public static class Three implements LinkEnricher { + + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + + public static class Four implements LinkEnricher { + + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + +} From 5f80f5c4afe5043e732049e04bcfead86e0a67bc Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 3 Jan 2019 11:18:22 +0100 Subject: [PATCH 24/37] extends LinkEnricher api to append link arrays to the response --- .../scm/api/v2/resources/LinkAppender.java | 29 +++++++++++++ .../api/v2/resources/EdisonLinkAppender.java | 31 +++++++++++++ .../v2/resources/EdisonLinkAppenderTest.java | 43 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonLinkAppenderTest.java diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java index dbf1ff3ff6..d3864dc798 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java @@ -15,4 +15,33 @@ public interface LinkAppender { * @param href link uri */ void appendOne(String rel, String href); + + /** + * Returns a builder which is able to append an array of links to the resource. + * + * @param rel name of link relation + * @return multi link builder + */ + LinkArrayBuilder arrayBuilder(String rel); + + + /** + * Builder for link arrays. + */ + interface LinkArrayBuilder { + + /** + * Append an link to the array. + * + * @param name name of link + * @param href link target + * @return {@code this} + */ + LinkArrayBuilder append(String name, String href); + + /** + * Builds the array and appends the it to the json response. + */ + void build(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java index 66c35080b5..c4e699cb58 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonLinkAppender.java @@ -3,6 +3,9 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; +import java.util.ArrayList; +import java.util.List; + class EdisonLinkAppender implements LinkAppender { private final Links.Builder builder; @@ -15,4 +18,32 @@ class EdisonLinkAppender implements LinkAppender { public void appendOne(String rel, String href) { builder.single(Link.link(rel, href)); } + + @Override + public LinkArrayBuilder arrayBuilder(String rel) { + return new EdisonLinkArrayBuilder(builder, rel); + } + + private static class EdisonLinkArrayBuilder implements LinkArrayBuilder { + + private final Links.Builder builder; + private final String rel; + private final List<Link> linkArray = new ArrayList<>(); + + private EdisonLinkArrayBuilder(Links.Builder builder, String rel) { + this.builder = builder; + this.rel = rel; + } + + @Override + public LinkArrayBuilder append(String name, String href) { + linkArray.add(Link.linkBuilder(rel, href).withName(name).build()); + return this; + } + + @Override + public void build() { + builder.array(linkArray); + } + } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonLinkAppenderTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonLinkAppenderTest.java new file mode 100644 index 0000000000..e97415cc09 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonLinkAppenderTest.java @@ -0,0 +1,43 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Link; +import de.otto.edison.hal.Links; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static de.otto.edison.hal.Links.linkingTo; +import static org.assertj.core.api.Assertions.assertThat; + +class EdisonLinkAppenderTest { + + private Links.Builder builder; + private EdisonLinkAppender appender; + + @BeforeEach + void prepare() { + builder = linkingTo(); + appender = new EdisonLinkAppender(builder); + } + + @Test + void shouldAppendOneLink() { + appender.appendOne("self", "https://scm.hitchhiker.com"); + + Links links = builder.build(); + assertThat(links.getLinkBy("self").get().getHref()).isEqualTo("https://scm.hitchhiker.com"); + } + + @Test + void shouldAppendMultipleLinks() { + appender.arrayBuilder("items") + .append("one", "http://one") + .append("two", "http://two") + .build(); + + List<Link> items = builder.build().getLinksBy("items"); + assertThat(items).hasSize(2); + } + +} From 3497ffddae05ce5083ea2cc1062be8422a5dd5eb Mon Sep 17 00:00:00 2001 From: Philipp Czora <philipp.czora@cloudogu.com> Date: Thu, 3 Jan 2019 14:45:19 +0100 Subject: [PATCH 25/37] Fixed bug --- .../sonia/scm/api/v2/resources/LinkEnricherContext.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java index 6f6b4ca8f8..2808a923e9 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java @@ -61,7 +61,12 @@ public final class LinkEnricherContext { * @return instance */ public <T> T oneRequireByType(Class<T> type) { - return oneByType(type).get(); + Optional<T> instance = oneByType(type); + if (instance.isPresent()) { + return instance.get(); + } else { + throw new NoSuchElementException("No instance for given type present"); + } } } From 59fbcc927184f9961fcb3e989197362e654d44d7 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Fri, 4 Jan 2019 07:37:55 +0000 Subject: [PATCH 26/37] Close branch feature/changeset_desc_ext_point From 9dd8405d6bdcf8a3a943632ebef5dca586207563 Mon Sep 17 00:00:00 2001 From: Philipp Czora <philipp.czora@cloudogu.com> Date: Fri, 4 Jan 2019 10:45:58 +0000 Subject: [PATCH 27/37] Close branch feature/changes-for-cas-plugin From 43355fbfca9963acf6e3cbf99c91588a3d101672 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 7 Jan 2019 07:57:58 +0100 Subject: [PATCH 28/37] fix classloading, if the class was not found at the first plugin --- .../sonia/scm/plugin/UberClassLoader.java | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java index 6906afc7d4..311cb9e879 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java @@ -73,43 +73,39 @@ public final class UberClassLoader extends ClassLoader //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param name - * - * @return - * - * @throws ClassNotFoundException - */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { Class<?> clazz = getFromCache(name); - if (clazz == null) - { - for (PluginWrapper plugin : plugins) - { - ClassLoader cl = plugin.getClassLoader(); - - // load class could be slow, perhaps we should call - // find class via reflection ??? - clazz = cl.loadClass(name); - - if (clazz != null) - { - cache.put(name, new WeakReference<Class<?>>(clazz)); - - break; - } - } + if (clazz == null) { + clazz = findClassInPlugins(name); + cache.put(name, new WeakReference<>(clazz)); } return clazz; } + private Class<?> findClassInPlugins(String name) throws ClassNotFoundException { + for (PluginWrapper plugin : plugins) { + Class<?> clazz = findClass(plugin.getClassLoader(), name); + if (clazz != null) { + return clazz; + } + } + throw new ClassNotFoundException("could not find class " + name + " in any of the installed plugins"); + } + + private Class<?> findClass(ClassLoader classLoader, String name) { + try { + // load class could be slow, perhaps we should call + // find class via reflection ??? + return classLoader.loadClass(name); + } catch (ClassNotFoundException ex) { + return null; + } + } + /** * Method description * From b80f0572df2b881958756baa6a48adedda27a45c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 7 Jan 2019 10:10:06 +0100 Subject: [PATCH 29/37] added extension point for primary-navigation and main.route --- .../ui-components/src/navigation/PrimaryNavigation.js | 8 ++++++++ scm-ui/src/containers/Main.js | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index 886890b72e..f2401729d6 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -57,6 +57,14 @@ class PrimaryNavigation extends React.Component<Props> { append("/groups", "/(group|groups)", "primary-navigation.groups", "groups"); append("/config", "/config", "primary-navigation.config", "config"); + navigationItems.push( + <ExtensionPoint + name="primary-navigation" + renderAll={true} + props={{links: this.props.links}} + /> + ); + this.appendLogout(navigationItems, append); return navigationItems; diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 4bbdf6812a..586d0036c5 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -9,6 +9,8 @@ import Login from "../containers/Login"; import Logout from "../containers/Logout"; import { ProtectedRoute } from "@scm-manager/ui-components"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; + import AddUser from "../users/containers/AddUser"; import SingleUser from "../users/containers/SingleUser"; import RepositoryRoot from "../repos/containers/RepositoryRoot"; @@ -112,6 +114,12 @@ class Main extends React.Component<Props> { component={Profile} authenticated={authenticated} /> + + <ExtensionPoint + name="main.route" + renderAll={true} + props={{authenticated}} + /> </Switch> </div> ); From 51f3bc3f73814b4731ae6ac1bf8f24b18cf0aa1e Mon Sep 17 00:00:00 2001 From: Philipp Czora <philipp.czora@cloudogu.com> Date: Wed, 9 Jan 2019 09:34:15 +0100 Subject: [PATCH 30/37] Added disabled attribute to DropDown --- .../packages/ui-components/src/forms/DropDown.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/forms/DropDown.js b/scm-ui-components/packages/ui-components/src/forms/DropDown.js index 5098a901f3..62a7f1ebe1 100644 --- a/scm-ui-components/packages/ui-components/src/forms/DropDown.js +++ b/scm-ui-components/packages/ui-components/src/forms/DropDown.js @@ -7,17 +7,19 @@ type Props = { options: string[], optionSelected: string => void, preselectedOption?: string, - className: any + className: any, + disabled?: boolean }; class DropDown extends React.Component<Props> { render() { - const { options, preselectedOption, className } = this.props; + const { options, preselectedOption, className, disabled } = this.props; return ( <div className={classNames(className, "select")}> <select value={preselectedOption ? preselectedOption : ""} onChange={this.change} + disabled={disabled} > <option key="" /> {options.map(option => { From 987126a7dd9d762cd87f943d7163ec1d691e4f18 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 9 Jan 2019 09:21:35 +0000 Subject: [PATCH 31/37] Close branch feature/disable_dropdown From 07840309410912abb4274a860720d9160eb7a362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= <rene.pfeuffer@cloudogu.com> Date: Thu, 10 Jan 2019 12:09:07 +0100 Subject: [PATCH 32/37] Fix double loading of plugins (PluginProcessor#appendPluginWrapper) Additionally: Add logging. --- .../java/sonia/scm/plugin/ExplodedSmp.java | 7 ++-- .../java/sonia/scm/plugin/PluginNode.java | 5 +++ .../sonia/scm/plugin/PluginProcessor.java | 24 ++++++------- .../java/sonia/scm/plugin/PluginTree.java | 36 +++++++++---------- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index eb48534ed1..d1fe214f50 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java @@ -34,6 +34,8 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; //~--- JDK imports ------------------------------------------------------------ @@ -52,17 +54,18 @@ import java.util.Set; public final class ExplodedSmp implements Comparable<ExplodedSmp> { + private static final Logger logger = LoggerFactory.getLogger(ExplodedSmp.class); + /** * Constructs ... * * * @param path - * @param pluginId - * @param dependencies * @param plugin */ ExplodedSmp(Path path, Plugin plugin) { + logger.trace("create exploded scm for plugin {} and dependencies {}", plugin.getInformation().getName(), plugin.getDependencies()); this.path = path; this.plugin = plugin; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java index 281fb2eab1..e28ccff2ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java @@ -175,6 +175,11 @@ public final class PluginNode this.wrapper = wrapper; } + @Override + public String toString() { + return plugin.getPath().toString() + " -> " + children; + } + //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 4e4e97591a..11308789f4 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -162,34 +162,29 @@ public final class PluginProcessor Set<Path> archives = collect(pluginDirectory, new PluginArchiveFilter()); - if (logger.isDebugEnabled()) - { - logger.debug("extract {} archives", archives.size()); - } + logger.debug("extract {} archives", archives.size()); extract(archives); List<Path> dirs = collectPluginDirectories(pluginDirectory); - if (logger.isDebugEnabled()) - { - logger.debug("process {} directories", dirs.size()); - } + logger.debug("process {} directories: {}", dirs.size(), dirs); List<ExplodedSmp> smps = Lists.transform(dirs, new PathTransformer()); logger.trace("start building plugin tree"); - List<PluginNode> rootNodes = new PluginTree(smps).getRootNodes(); + PluginTree pluginTree = new PluginTree(smps); + + logger.trace("build plugin tree: {}", pluginTree); + + List<PluginNode> rootNodes = pluginTree.getRootNodes(); logger.trace("create plugin wrappers and build classloaders"); Set<PluginWrapper> wrappers = createPluginWrappers(classLoader, rootNodes); - if (logger.isDebugEnabled()) - { - logger.debug("collected {} plugins", wrappers.size()); - } + logger.debug("collected {} plugins", wrappers.size()); return ImmutableSet.copyOf(wrappers); } @@ -208,6 +203,9 @@ public final class PluginProcessor ClassLoader classLoader, PluginNode node) throws IOException { + if (node.getWrapper() != null) { + return; + } ExplodedSmp smp = node.getPlugin(); List<ClassLoader> parents = Lists.newArrayList(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java index 9757fa2513..7e57fb3d57 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java @@ -112,14 +112,14 @@ public final class PluginTree } else { - appendNode(rootNodes, dependencies, smp); + appendNode(smp); } } else { //J- throw new PluginConditionFailedException( - condition, + condition, String.format( "could not load plugin %s, the plugin condition does not match", plugin.getInformation().getId() @@ -149,23 +149,20 @@ public final class PluginTree * Method description * * - * @param nodes - * @param dependencies * @param smp */ - private void appendNode(List<PluginNode> nodes, Set<String> dependencies, - ExplodedSmp smp) + private void appendNode(ExplodedSmp smp) { PluginNode child = new PluginNode(smp); - for (String dependency : dependencies) + for (String dependency : smp.getPlugin().getDependencies()) { - if (!appendNode(nodes, child, dependency)) + if (!appendNode(rootNodes, child, dependency)) { //J- throw new PluginNotInstalledException( String.format( - "dependency %s of %s is not installed", + "dependency %s of %s is not installed", dependency, child.getId() ) @@ -188,7 +185,7 @@ public final class PluginTree private boolean appendNode(List<PluginNode> nodes, PluginNode child, String dependency) { - logger.debug("check for {} {}", dependency, child.getId()); + logger.debug("check for {} as dependency of {}", dependency, child.getId()); boolean found = false; @@ -196,29 +193,28 @@ public final class PluginTree { if (node.getId().equals(dependency)) { - logger.debug("add plugin {} as child of {}", child.getId(), - node.getId()); + logger.debug("add plugin {} as child of {}", child.getId(), node.getId()); node.addChild(child); found = true; break; } - else + else if (appendNode(node.getChildren(), child, dependency)) { - if (appendNode(node.getChildren(), child, dependency)) - { - found = true; - - break; - } + found = true; + break; } } return found; } - //~--- fields --------------------------------------------------------------- + @Override + public String toString() { + return "plugin tree: " + rootNodes.toString(); + } +//~--- fields --------------------------------------------------------------- /** Field description */ private final List<PluginNode> rootNodes = Lists.newArrayList(); From f96b16bda4588dc26c8507ddb480779f675a9574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= <maren.suewer@cloudogu.com> Date: Fri, 11 Jan 2019 08:24:47 +0100 Subject: [PATCH 33/37] make success message more intuitive --- scm-ui/public/locales/en/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index 6f72904dcc..1a33da8c8b 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -10,7 +10,7 @@ }, "config-form": { "submit": "Submit", - "submit-success-notification": "Configuration changed!", + "submit-success-notification": "Configuration changed successfully!", "no-permission-notification": "Please note: You do not have the permission to edit the config!" }, "proxy-settings": { From 7fc8c1a9059eaeb43022264eafe3ca29aaf14f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= <maren.suewer@cloudogu.com> Date: Fri, 11 Jan 2019 08:31:00 +0100 Subject: [PATCH 34/37] disable button after submit-method is triggered and rename state 'valid' to 'changed' as it does not check validity --- scm-ui/src/config/components/form/ConfigForm.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scm-ui/src/config/components/form/ConfigForm.js b/scm-ui/src/config/components/form/ConfigForm.js index 370925eee9..dc3f20c95d 100644 --- a/scm-ui/src/config/components/form/ConfigForm.js +++ b/scm-ui/src/config/components/form/ConfigForm.js @@ -24,7 +24,7 @@ type State = { loginAttemptLimitTimeout: boolean, loginAttemptLimit: boolean }, - valid: boolean + changed: boolean }; class ConfigForm extends React.Component<Props, State> { @@ -61,7 +61,7 @@ class ConfigForm extends React.Component<Props, State> { loginAttemptLimitTimeout: false, loginAttemptLimit: false }, - valid: false + changed: false }; } @@ -77,6 +77,9 @@ class ConfigForm extends React.Component<Props, State> { submit = (event: Event) => { event.preventDefault(); + this.setState({ + changed: false + }); this.props.submitForm(this.state.config); }; @@ -158,7 +161,9 @@ class ConfigForm extends React.Component<Props, State> { <SubmitButton loading={loading} label={t("config-form.submit")} - disabled={!configUpdatePermission || this.hasError() || !this.state.valid} + disabled={ + !configUpdatePermission || this.hasError() || !this.state.changed + } /> </form> ); @@ -175,7 +180,7 @@ class ConfigForm extends React.Component<Props, State> { ...this.state.error, [name]: !isValid }, - valid: true + changed: true }); }; From 209076c80e842019f0b4438d113082dc2397cbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= <maren.suewer@cloudogu.com> Date: Fri, 11 Jan 2019 08:27:54 +0000 Subject: [PATCH 35/37] Close branch feature/success_banner_for_config From 3d853d2eaf9971c6a501083f46b7b276fa63901d Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Fri, 11 Jan 2019 13:10:15 +0100 Subject: [PATCH 36/37] pass links to the main.route extension point --- scm-ui/src/containers/App.js | 2 +- scm-ui/src/containers/Main.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js index dd3dd36639..1e1387fd70 100644 --- a/scm-ui/src/containers/App.js +++ b/scm-ui/src/containers/App.js @@ -79,7 +79,7 @@ class App extends Component<Props> { /> ); } else { - content = <Main authenticated={authenticated} />; + content = <Main authenticated={authenticated} links={links} />; } return ( <div className="App"> diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 586d0036c5..d2bc50faf2 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -2,6 +2,7 @@ import React from "react"; import { Redirect, Route, Switch, withRouter } from "react-router-dom"; +import type {Links} from "@scm-manager/ui-types"; import Overview from "../repos/containers/Overview"; import Users from "../users/containers/Users"; @@ -24,12 +25,13 @@ import Config from "../config/containers/Config"; import Profile from "./Profile"; type Props = { - authenticated?: boolean + authenticated?: boolean, + links: Links }; class Main extends React.Component<Props> { render() { - const { authenticated } = this.props; + const { authenticated, links } = this.props; return ( <div className="main"> <Switch> @@ -118,7 +120,7 @@ class Main extends React.Component<Props> { <ExtensionPoint name="main.route" renderAll={true} - props={{authenticated}} + props={{authenticated, links}} /> </Switch> </div> From b239ebdc7cf0bdcbd63b015dd66e9c6ac4bfb1cf Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Fri, 11 Jan 2019 13:48:18 +0100 Subject: [PATCH 37/37] close branch feature/changes-for-script-plugin