diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..0609164d73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# ignore everything except scm-server.tar.gz +** +!scm-server/target/*.tar.gz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..781a3e890b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM openjdk:8u171-alpine3.8 + +ENV SCM_HOME=/var/lib/scm + +RUN set -x \ + && apk add --no-cache mercurial bash \ + && addgroup -S -g 1000 scm \ + && adduser -S -s /bin/false -G scm -h /opt/scm-server -D -H -u 1000 scm \ + && mkdir ${SCM_HOME} \ + && chown scm:scm ${SCM_HOME} + +ADD scm-server/target/scm-server-app.tar.gz /opt +RUN chown -R scm:scm /opt/scm-server + +WORKDIR /opt/scm-server +VOLUME [ "${SCM_HOME}", "/opt/scm-server/var/log" ] +EXPOSE 8080 +USER scm + +ENTRYPOINT [ "/opt/scm-server/bin/scm-server" ] diff --git a/Jenkinsfile b/Jenkinsfile index 50a2374544..57cc3b901f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,14 +4,15 @@ @Library('github.com/cloudogu/ces-build-lib@59d3e94') import com.cloudogu.ces.cesbuildlib.* -node() { // No specific label +node('docker') { // Change this as when we go back to default - necessary for proper SonarQube analysis mainBranch = "2.0.0-m3" properties([ // Keep only the last 10 build to preserve space - buildDiscarder(logRotator(numToKeepStr: '10')) + buildDiscarder(logRotator(numToKeepStr: '10')), + disableConcurrentBuilds() ]) timeout(activity: true, time: 20, unit: 'MINUTES') { @@ -44,6 +45,26 @@ node() { // No specific label currentBuild.result = 'UNSTABLE' } } + + def commitHash = getCommitHash() + def dockerImageTag = "2.0.0-dev-${commitHash.substring(0,7)}-${BUILD_NUMBER}" + + if (isMainBranch()) { + stage('Docker') { + def image = docker.build('cloudogu/scm-manager') + docker.withRegistry('', 'hub.docker.com-cesmarvin') { + image.push(dockerImageTag) + image.push('latest') + } + } + + stage('Deployment') { + build job: 'scm-manager/next-scm.cloudogu.com', propagate: false, wait: false, parameters: [ + string(name: 'changeset', value: commitHash), + string(name: 'imageTag', value: dockerImageTag) + ] + } + } } // Archive Unit and integration test results, if any @@ -62,7 +83,7 @@ Maven setupMavenBuild() { // Keep this version number in sync with .mvn/maven-wrapper.properties Maven mvn = new MavenInDocker(this, "3.5.2-jdk-8") - if (mainBranch.equals(env.BRANCH_NAME)) { + if (isMainBranch()) { // Release starts javadoc, which takes very long, so do only for certain branches mvn.additionalArgs += ' -DperformRelease' // JDK8 is more strict, we should fix this before the next release. Right now, this is just not the focus, yet. @@ -89,7 +110,7 @@ void analyzeWith(Maven mvn) { "-Dsonar.pullrequest.bitbucketcloud.repository=scm-manager " } else { mvnArgs += " -Dsonar.branch.name=${env.BRANCH_NAME} " - if (!mainBranch.equals(env.BRANCH_NAME)) { + if (!isMainBranch()) { // Avoid exception "The main branch must not have a target" on main branch mvnArgs += " -Dsonar.branch.target=${mainBranch} " } @@ -98,6 +119,10 @@ void analyzeWith(Maven mvn) { } } +boolean isMainBranch() { + return mainBranch.equals(env.BRANCH_NAME) +} + boolean waitForQualityGateWebhookToBeCalled() { boolean isQualityGateSucceeded = true timeout(time: 2, unit: 'MINUTES') { // Needed when there is no webhook for example @@ -114,6 +139,10 @@ String getCommitAuthorComplete() { new Sh(this).returnStdOut 'hg log --branch . --limit 1 --template "{author}"' } +String getCommitHash() { + new Sh(this).returnStdOut 'hg log --branch . --limit 1 --template "{node}"' +} + String getCommitAuthorEmail() { def matcher = getCommitAuthorComplete() =~ "<(.*?)>" matcher ? matcher[0][1] : "" diff --git a/deployments/helm/.helmignore b/deployments/helm/.helmignore new file mode 100644 index 0000000000..f0c1319444 --- /dev/null +++ b/deployments/helm/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/deployments/helm/Chart.yaml b/deployments/helm/Chart.yaml new file mode 100644 index 0000000000..c5b5fff4cc --- /dev/null +++ b/deployments/helm/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for SCM-Manager +name: scm-manager +version: 0.1.0 diff --git a/deployments/helm/templates/NOTES.txt b/deployments/helm/templates/NOTES.txt new file mode 100644 index 0000000000..a58c8f124a --- /dev/null +++ b/deployments/helm/templates/NOTES.txt @@ -0,0 +1,19 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "scm-manager.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get svc -w {{ include "scm-manager.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "scm-manager.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ include "scm-manager.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/deployments/helm/templates/_helpers.tpl b/deployments/helm/templates/_helpers.tpl new file mode 100644 index 0000000000..23d4e1b03e --- /dev/null +++ b/deployments/helm/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "scm-manager.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "scm-manager.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "scm-manager.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/deployments/helm/templates/configmap.yaml b/deployments/helm/templates/configmap.yaml new file mode 100644 index 0000000000..dd52b6fa8c --- /dev/null +++ b/deployments/helm/templates/configmap.yaml @@ -0,0 +1,160 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "scm-manager.fullname" . }} + labels: + app: {{ include "scm-manager.name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +data: + server-config.xml: | + + + + + + + 16384 + 16384 + + {{- if .Values.ingress.enabled -}} + + + + + {{- end }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /scm + + /var/webapp/scm-webapp.war + + + org.eclipse.jetty.servlet.Default.dirAllowed + false + + + /work/scm + + + + + / + + + + + + /var/webapp/docroot + + + + + + /work/docroot + + + + + + + + + + + + + + + + + + + + + logging.xml: | + + + + <-- + in a container environment we only need stdout + --> + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deployments/helm/templates/deployment.yaml b/deployments/helm/templates/deployment.yaml new file mode 100644 index 0000000000..928daa5f06 --- /dev/null +++ b/deployments/helm/templates/deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + name: {{ include "scm-manager.fullname" . }} + labels: + app: {{ include "scm-manager.name" . }} + chart: {{ include "scm-manager.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: 1 # could not be scaled + strategy: + type: Recreate + selector: + matchLabels: + app: {{ include "scm-manager.name" . }} + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ include "scm-manager.name" . }} + release: {{ .Release.Name }} + spec: + initContainers: + - name: volume-permissions + image: alpine:3.8 + imagePullPolicy: IfNotPresent + command: ['sh', '-c', 'chown 1000:1000 /data'] + volumeMounts: + - name: data + mountPath: /data + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /scm + port: http + readinessProbe: + httpGet: + path: /scm + port: http + resources: +{{ toYaml .Values.resources | indent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/scm + - name: config + mountPath: /opt/scm-server/conf + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "scm-manager.fullname" . }} + {{- else }} + emptyDir: {} + {{- end }} + - name: config + configMap: + name: {{ include "scm-manager.fullname" . }} + {{- with .Values.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} diff --git a/deployments/helm/templates/ingress.yaml b/deployments/helm/templates/ingress.yaml new file mode 100644 index 0000000000..66912c9d96 --- /dev/null +++ b/deployments/helm/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "scm-manager.fullname" . -}} +{{- $ingressPath := .Values.ingress.path -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app: {{ include "scm-manager.name" . }} + chart: {{ include "scm-manager.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- with .Values.ingress.annotations }} + annotations: +{{ toYaml . | indent 4 }} +{{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $ingressPath }} + backend: + serviceName: {{ $fullName }} + servicePort: http + {{- end }} +{{- end }} diff --git a/deployments/helm/templates/pvc.yaml b/deployments/helm/templates/pvc.yaml new file mode 100644 index 0000000000..0e7d0f6db4 --- /dev/null +++ b/deployments/helm/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- if .Values.persistence.enabled -}} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ include "scm-manager.fullname" . }} + labels: + app: {{ include "scm-manager.name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + accessModes: + - {{ .Values.persistence.accessMode | quote }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- if .Values.persistence.storageClass }} +{{- if (eq "-" .Values.persistence.storageClass) }} + storageClassName: "" +{{- else }} + storageClassName: "{{ .Values.persistence.storageClass }}" +{{- end }} +{{- end }} +{{- end -}} diff --git a/deployments/helm/templates/service.yaml b/deployments/helm/templates/service.yaml new file mode 100644 index 0000000000..f3a8207908 --- /dev/null +++ b/deployments/helm/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "scm-manager.fullname" . }} + labels: + app: {{ include "scm-manager.name" . }} + chart: {{ include "scm-manager.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: 8080 + protocol: TCP + name: http + selector: + app: {{ include "scm-manager.name" . }} + release: {{ .Release.Name }} diff --git a/deployments/helm/values.yaml b/deployments/helm/values.yaml new file mode 100644 index 0000000000..d54088aa8b --- /dev/null +++ b/deployments/helm/values.yaml @@ -0,0 +1,65 @@ +# Default values for scm-manager. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# replicaCount: 1 + +image: + repository: cloudogu/scm-manager + # TODO change after release, to something more stable + tag: latest + pullPolicy: Always + +nameOverride: "" +fullnameOverride: "" + +service: + type: LoadBalancer + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + path: / + hosts: + - scm-manager.local + tls: [] + # - secretName: scm-manager-tls + # hosts: + # - scm-manager.local + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + ## ghost data Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + accessMode: ReadWriteOnce + size: 12Gi + +resources: + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + limits: + cpu: 2000m + memory: 2048Mi + requests: + cpu: 50m + memory: 256Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pom.xml b/pom.xml index 944ebb6eb6..df49023265 100644 --- a/pom.xml +++ b/pom.xml @@ -188,15 +188,14 @@ test - - com.github.sdorra.shiro-static-permissions + com.github.sdorra ssp-lib ${ssp.version} - com.github.sdorra.shiro-static-permissions + com.github.sdorra ssp-processor ${ssp.version} true @@ -765,7 +764,7 @@ 9.2.10.v20150310 - 967c8fd521 + 1.1.0 1.4.0 diff --git a/scm-core/pom.xml b/scm-core/pom.xml index a3fa037c7b..3c90fec779 100644 --- a/scm-core/pom.xml +++ b/scm-core/pom.xml @@ -94,6 +94,12 @@ javax.ws.rs-api + + org.jboss.resteasy + resteasy-jaxrs + test + + @@ -160,14 +166,13 @@ provided - - com.github.sdorra.shiro-static-permissions + com.github.sdorra ssp-lib - com.github.sdorra.shiro-static-permissions + com.github.sdorra ssp-processor true diff --git a/scm-core/src/main/java/sonia/scm/NotFoundException.java b/scm-core/src/main/java/sonia/scm/NotFoundException.java index 0d8c14c61b..8a7ae642bd 100644 --- a/scm-core/src/main/java/sonia/scm/NotFoundException.java +++ b/scm-core/src/main/java/sonia/scm/NotFoundException.java @@ -7,9 +7,4 @@ public class NotFoundException extends Exception { public NotFoundException() { } - - - public NotFoundException(String message) { - super(message); - } } diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ScmPathInfo.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ScmPathInfo.java index fa975520c1..496c87b440 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/ScmPathInfo.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ScmPathInfo.java @@ -4,11 +4,11 @@ import java.net.URI; public interface ScmPathInfo { - String REST_API_PATH = "/api/rest"; + String REST_API_PATH = "/api"; URI getApiRestUri(); default URI getRootUri() { - return getApiRestUri().resolve("../.."); + return getApiRestUri().resolve(".."); } } diff --git a/scm-core/src/main/java/sonia/scm/config/Configuration.java b/scm-core/src/main/java/sonia/scm/config/Configuration.java index e9bf3528d5..823c50b155 100644 --- a/scm-core/src/main/java/sonia/scm/config/Configuration.java +++ b/scm-core/src/main/java/sonia/scm/config/Configuration.java @@ -22,7 +22,7 @@ import com.github.sdorra.ssp.StaticPermissions; @StaticPermissions( value = "configuration", permissions = {"read", "write"}, - globalPermissions = {} + globalPermissions = {"list"} ) public interface Configuration extends PermissionObject { } diff --git a/scm-core/src/main/java/sonia/scm/filter/GZipFilter.java b/scm-core/src/main/java/sonia/scm/filter/GZipFilter.java deleted file mode 100644 index 49fa8ebebf..0000000000 --- a/scm-core/src/main/java/sonia/scm/filter/GZipFilter.java +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.filter; - -//~--- non-JDK imports -------------------------------------------------------- - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import sonia.scm.Priority; -import sonia.scm.util.WebUtil; -import sonia.scm.web.filter.HttpFilter; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -/** - * Filter for gzip encoding. - * - * @author Sebastian Sdorra - * @since 1.15 - */ -@Priority(Filters.PRIORITY_PRE_BASEURL) -@WebElement(value = Filters.PATTERN_RESOURCE_REGEX, regex = true) -public class GZipFilter extends HttpFilter -{ - - /** - * the logger for GZipFilter - */ - private static final Logger logger = - LoggerFactory.getLogger(GZipFilter.class); - - //~--- get methods ---------------------------------------------------------- - - /** - * Return the configuration for the gzip filter. - * - * - * @return gzip filter configuration - */ - public GZipFilterConfig getConfig() - { - return config; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Encodes the response, if the request has support for gzip encoding. - * - * - * @param request http request - * @param response http response - * @param chain filter chain - * - * @throws IOException - * @throws ServletException - */ - @Override - protected void doFilter(HttpServletRequest request, - HttpServletResponse response, FilterChain chain) - throws IOException, ServletException - { - if (WebUtil.isGzipSupported(request)) - { - if (logger.isTraceEnabled()) - { - logger.trace("compress output with gzip"); - } - - GZipResponseWrapper wrappedResponse = new GZipResponseWrapper(response, - config); - - chain.doFilter(request, wrappedResponse); - wrappedResponse.finishResponse(); - } - else - { - chain.doFilter(request, response); - } - } - - //~--- fields --------------------------------------------------------------- - - /** gzip filter configuration */ - private GZipFilterConfig config = new GZipFilterConfig(); -} diff --git a/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java b/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java new file mode 100644 index 0000000000..1fa525d4fd --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java @@ -0,0 +1,24 @@ +package sonia.scm.filter; + +import lombok.extern.slf4j.Slf4j; +import sonia.scm.util.WebUtil; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.zip.GZIPOutputStream; + +@Provider +@Slf4j +public class GZipResponseFilter implements ContainerResponseFilter { + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + if (WebUtil.isGzipSupported(requestContext::getHeaderString)) { + log.trace("compress output with gzip"); + GZIPOutputStream wrappedResponse = new GZIPOutputStream(responseContext.getEntityStream()); + responseContext.getHeaders().add("Content-Encoding", "gzip"); + responseContext.setEntityStream(wrappedResponse); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/group/Group.java b/scm-core/src/main/java/sonia/scm/group/Group.java index 98d9dcc7a3..5e7f596c58 100644 --- a/scm-core/src/main/java/sonia/scm/group/Group.java +++ b/scm-core/src/main/java/sonia/scm/group/Group.java @@ -60,7 +60,7 @@ import java.util.List; * * @author Sebastian Sdorra */ -@StaticPermissions("group") +@StaticPermissions(value = "group", globalPermissions = {"create", "list"}) @XmlRootElement(name = "groups") @XmlAccessorType(XmlAccessType.FIELD) public class Group extends BasicPropertiesAware diff --git a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java new file mode 100644 index 0000000000..19c609ba30 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java @@ -0,0 +1,11 @@ +package sonia.scm.user; + +public class ChangePasswordNotAllowedException extends RuntimeException { + + public static final String WRONG_USER_TYPE = "User of type {0} are not allowed to change password"; + + public ChangePasswordNotAllowedException(String message) { + super(message); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java new file mode 100644 index 0000000000..e06191a8f2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java @@ -0,0 +1,10 @@ +package sonia.scm.user; + +public class InvalidPasswordException extends RuntimeException { + + public static final String INVALID_MATCHING = "The given Password does not match with the stored one."; + + public InvalidPasswordException(String message) { + super(message); + } +} diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 778e573c14..0d909bec8d 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -55,11 +55,10 @@ import java.security.Principal; * * @author Sebastian Sdorra */ -@StaticPermissions("user") +@StaticPermissions(value = "user", globalPermissions = {"create", "list"}) @XmlRootElement(name = "users") @XmlAccessorType(XmlAccessType.FIELD) -public class -User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject +public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject { /** Field description */ @@ -274,6 +273,10 @@ User extends BasicPropertiesAware implements Principal, ModelObject, PermissionO //J+ } + public User changePassword(String password){ + setPassword(password); + return this; + } //~--- get methods ---------------------------------------------------------- /** diff --git a/scm-core/src/main/java/sonia/scm/user/UserManager.java b/scm-core/src/main/java/sonia/scm/user/UserManager.java index 0a705c8d60..1f5aee1f19 100644 --- a/scm-core/src/main/java/sonia/scm/user/UserManager.java +++ b/scm-core/src/main/java/sonia/scm/user/UserManager.java @@ -38,6 +38,11 @@ package sonia.scm.user; import sonia.scm.Manager; import sonia.scm.search.Searchable; +import java.text.MessageFormat; +import java.util.function.Consumer; + +import static sonia.scm.user.ChangePasswordNotAllowedException.WRONG_USER_TYPE; + /** * The central class for managing {@link User} objects. * This class is a singleton and is available via injection. @@ -68,4 +73,22 @@ public interface UserManager * @since 1.14 */ public String getDefaultType(); + + + /** + * Only account of the default type "xml" can change their password + */ + default Consumer getUserTypeChecker() { + return user -> { + if (!isTypeDefault(user)) { + throw new ChangePasswordNotAllowedException(MessageFormat.format(WRONG_USER_TYPE, user.getType())); + } + }; + } + + default boolean isTypeDefault(User user) { + return getDefaultType().equals(user.getType()); + } + + } diff --git a/scm-core/src/main/java/sonia/scm/util/WebUtil.java b/scm-core/src/main/java/sonia/scm/util/WebUtil.java index 2fc0876668..a9337b0598 100644 --- a/scm-core/src/main/java/sonia/scm/util/WebUtil.java +++ b/scm-core/src/main/java/sonia/scm/util/WebUtil.java @@ -49,6 +49,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.TimeZone; +import java.util.function.Function; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -266,7 +267,12 @@ public final class WebUtil */ public static boolean isGzipSupported(HttpServletRequest request) { - String enc = request.getHeader(HEADER_ACCEPTENCODING); + return isGzipSupported(request::getHeader); + } + + public static boolean isGzipSupported(Function headerResolver) + { + String enc = headerResolver.apply(HEADER_ACCEPTENCODING); return (enc != null) && enc.contains("gzip"); } diff --git a/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java new file mode 100644 index 0000000000..1baecb62af --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java @@ -0,0 +1,40 @@ +package sonia.scm.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; + +public abstract class JsonEnricherBase implements JsonEnricher { + + private final ObjectMapper objectMapper; + + protected JsonEnricherBase(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) { + return mediaType.equals(context.getResponseMediaType().toString()); + } + + protected JsonNode value(Object object) { + return objectMapper.convertValue(object, JsonNode.class); + } + + protected ObjectNode createObject() { + return objectMapper.createObjectNode(); + } + + protected ObjectNode createObject(Map values) { + ObjectNode object = createObject(); + + values.forEach((key, value) -> object.set(key, value(value))); + + return object; + } + + protected void addPropertyNode(JsonNode parent, String newKey, JsonNode child) { + ((ObjectNode) parent).set(newKey, child); + } +} diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index d9a6846795..f0711cd1e4 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -15,13 +15,14 @@ public class VndMediaType { public static final String PLAIN_TEXT_PREFIX = "text/" + SUBTYPE_PREFIX; public static final String PLAIN_TEXT_SUFFIX = "+plain;v=" + VERSION; + public static final String INDEX = PREFIX + "index" + SUFFIX; public static final String USER = PREFIX + "user" + SUFFIX; public static final String GROUP = PREFIX + "group" + SUFFIX; public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; public static final String PERMISSION = PREFIX + "permission" + SUFFIX; public static final String CHANGESET = PREFIX + "changeset" + SUFFIX; public static final String CHANGESET_COLLECTION = PREFIX + "changesetCollection" + SUFFIX; - public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX;; + public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX; public static final String TAG = PREFIX + "tag" + SUFFIX; public static final String TAG_COLLECTION = PREFIX + "tagCollection" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX; @@ -35,6 +36,8 @@ public class VndMediaType { public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX; public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX; public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX; + @SuppressWarnings("squid:S2068") + public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX; diff --git a/scm-core/src/test/java/sonia/scm/repository/spi/InitializingHttpScmProtocolWrapperTest.java b/scm-core/src/test/java/sonia/scm/repository/spi/InitializingHttpScmProtocolWrapperTest.java index 8c910f92a9..676549a874 100644 --- a/scm-core/src/test/java/sonia/scm/repository/spi/InitializingHttpScmProtocolWrapperTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/spi/InitializingHttpScmProtocolWrapperTest.java @@ -114,7 +114,7 @@ public class InitializingHttpScmProtocolWrapperTest { } private OngoingStubbing mockSetPathInfo() { - return when(pathInfoStore.get()).thenReturn(() -> URI.create("http://example.com/scm/api/rest/")); + return when(pathInfoStore.get()).thenReturn(() -> URI.create("http://example.com/scm/api/")); } } diff --git a/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java new file mode 100644 index 0000000000..43ed4940fa --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java @@ -0,0 +1,51 @@ +package sonia.scm.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; + +import javax.ws.rs.core.MediaType; + +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; + +public class JsonEnricherBaseTest { + + private ObjectMapper objectMapper = new ObjectMapper(); + private TestJsonEnricher enricher = new TestJsonEnricher(objectMapper); + + @Test + public void testResultHasMediaType() { + JsonEnricherContext context = new JsonEnricherContext(null, MediaType.APPLICATION_JSON_TYPE, null); + + assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_JSON, context)).isTrue(); + assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse(); + } + + @Test + public void testAppendLink() { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode links = objectMapper.createObjectNode(); + root.set("_links", links); + JsonEnricherContext context = new JsonEnricherContext(null, MediaType.APPLICATION_JSON_TYPE, root); + enricher.enrich(context); + + assertThat(links.get("awesome").get("href").asText()).isEqualTo("/my/awesome/link"); + } + + private static class TestJsonEnricher extends JsonEnricherBase { + + public TestJsonEnricher(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public void enrich(JsonEnricherContext context) { + JsonNode gitConfigRefNode = createObject(singletonMap("href", value("/my/awesome/link"))); + + addPropertyNode(context.getResponseEntity().get("_links"), "awesome", gitConfigRefNode); + } + } + +} diff --git a/scm-it/src/test/java/sonia/scm/it/IndexITCase.java b/scm-it/src/test/java/sonia/scm/it/IndexITCase.java new file mode 100644 index 0000000000..4a621d962f --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/IndexITCase.java @@ -0,0 +1,48 @@ +package sonia.scm.it; + +import io.restassured.RestAssured; +import org.apache.http.HttpStatus; +import org.junit.Test; +import sonia.scm.it.utils.RestUtil; +import sonia.scm.web.VndMediaType; + +import static sonia.scm.it.utils.RegExMatcher.matchesPattern; +import static sonia.scm.it.utils.RestUtil.given; + +public class IndexITCase { + + @Test + public void shouldLinkEverythingForAdmin() { + given(VndMediaType.INDEX) + + .when() + .get(RestUtil.createResourceUrl("")) + + .then() + .statusCode(HttpStatus.SC_OK) + .body( + "_links.repositories.href", matchesPattern(".+/repositories/"), + "_links.users.href", matchesPattern(".+/users/"), + "_links.groups.href", matchesPattern(".+/groups/"), + "_links.config.href", matchesPattern(".+/config"), + "_links.gitConfig.href", matchesPattern(".+/config/git"), + "_links.hgConfig.href", matchesPattern(".+/config/hg"), + "_links.svnConfig.href", matchesPattern(".+/config/svn") + ); + } + + @Test + public void shouldCreateLoginLinksForAnonymousAccess() { + RestAssured.given() // do not specify user credentials + + .when() + .get(RestUtil.createResourceUrl("")) + + .then() + .statusCode(HttpStatus.SC_OK) + .body( + "_links.login.href", matchesPattern(".+/auth/.+") + ); + } + +} diff --git a/scm-it/src/test/java/sonia/scm/it/MeITCase.java b/scm-it/src/test/java/sonia/scm/it/MeITCase.java new file mode 100644 index 0000000000..64f06765ea --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/MeITCase.java @@ -0,0 +1,65 @@ +package sonia.scm.it; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; +import sonia.scm.it.utils.TestData; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MeITCase { + + @Before + public void init() { + TestData.cleanup(); + } + + @Test + public void adminShouldChangeOwnPassword() { + String newPassword = TestData.USER_SCM_ADMIN + "1"; + // admin change the own password + ScmRequests.start() + .given() + .url(TestData.getMeUrl()) + .usernameAndPassword(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) + .getMeResource() + .assertStatusCode(200) + .usingMeResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) + .assertPassword(Assert::assertNull) + .assertType(s -> assertThat(s).isEqualTo("xml")) + .requestChangePassword(TestData.USER_SCM_ADMIN, newPassword) + .assertStatusCode(204); + // assert password is changed -> login with the new Password than undo changes + ScmRequests.start() + .given() + .url(TestData.getUserUrl(TestData.USER_SCM_ADMIN)) + .usernameAndPassword(TestData.USER_SCM_ADMIN, newPassword) + .getMeResource() + .assertStatusCode(200) + .usingMeResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE))// still admin + .requestChangePassword(newPassword, TestData.USER_SCM_ADMIN) + .assertStatusCode(204); + } + + @Test + public void shouldHidePasswordLinkIfUserTypeIsNotXML() { + String newUser = "user"; + String password = "pass"; + String type = "not XML Type"; + TestData.createUser(newUser, password, true, type); + ScmRequests.start() + .given() + .url(TestData.getMeUrl()) + .usernameAndPassword(newUser, password) + .getMeResource() + .assertStatusCode(200) + .usingMeResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) + .assertPassword(Assert::assertNull) + .assertType(s -> assertThat(s).isEqualTo(type)) + .assertPasswordLinkDoesNotExists(); + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java index 63d8b46d47..f288d4891c 100644 --- a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java @@ -39,6 +39,8 @@ import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; +import sonia.scm.it.utils.RepositoryUtil; +import sonia.scm.it.utils.TestData; import sonia.scm.repository.PermissionType; import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.repository.client.api.RepositoryClientException; @@ -51,11 +53,11 @@ import java.util.Objects; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -import static sonia.scm.it.RepositoryUtil.addAndCommitRandomFile; -import static sonia.scm.it.RestUtil.given; -import static sonia.scm.it.ScmTypes.availableScmTypes; -import static sonia.scm.it.TestData.USER_SCM_ADMIN; -import static sonia.scm.it.TestData.callRepository; +import static sonia.scm.it.utils.RepositoryUtil.addAndCommitRandomFile; +import static sonia.scm.it.utils.RestUtil.given; +import static sonia.scm.it.utils.ScmTypes.availableScmTypes; +import static sonia.scm.it.utils.TestData.USER_SCM_ADMIN; +import static sonia.scm.it.utils.TestData.callRepository; @RunWith(Parameterized.class) public class PermissionsITCase { diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java index 21d4d97b1b..3c67ca3dc3 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java @@ -36,12 +36,15 @@ package sonia.scm.it; import org.apache.http.HttpStatus; import org.assertj.core.api.Assertions; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; +import sonia.scm.it.utils.RepositoryUtil; +import sonia.scm.it.utils.TestData; import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.web.VndMediaType; @@ -53,11 +56,11 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; -import static sonia.scm.it.RegExMatcher.matchesPattern; -import static sonia.scm.it.RestUtil.createResourceUrl; -import static sonia.scm.it.RestUtil.given; -import static sonia.scm.it.ScmTypes.availableScmTypes; -import static sonia.scm.it.TestData.repositoryJson; +import static sonia.scm.it.utils.RegExMatcher.matchesPattern; +import static sonia.scm.it.utils.RestUtil.createResourceUrl; +import static sonia.scm.it.utils.RestUtil.given; +import static sonia.scm.it.utils.ScmTypes.availableScmTypes; +import static sonia.scm.it.utils.TestData.repositoryJson; @RunWith(Parameterized.class) public class RepositoriesITCase { diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java index 398921a692..3f8832a3f5 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java @@ -4,7 +4,6 @@ import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.apache.http.HttpStatus; import org.assertj.core.util.Lists; -import org.assertj.core.util.Maps; import org.junit.Assume; import org.junit.Before; import org.junit.Rule; @@ -12,6 +11,9 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import sonia.scm.it.utils.RepositoryUtil; +import sonia.scm.it.utils.ScmRequests; +import sonia.scm.it.utils.TestData; import sonia.scm.repository.Changeset; import sonia.scm.repository.client.api.ClientCommand; import sonia.scm.repository.client.api.RepositoryClient; @@ -29,10 +31,10 @@ import static java.lang.Thread.sleep; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertNotNull; -import static sonia.scm.it.RestUtil.ADMIN_PASSWORD; -import static sonia.scm.it.RestUtil.ADMIN_USERNAME; -import static sonia.scm.it.RestUtil.given; -import static sonia.scm.it.ScmTypes.availableScmTypes; +import static sonia.scm.it.utils.RestUtil.ADMIN_PASSWORD; +import static sonia.scm.it.utils.RestUtil.ADMIN_USERNAME; +import static sonia.scm.it.utils.RestUtil.given; +import static sonia.scm.it.utils.ScmTypes.availableScmTypes; @RunWith(Parameterized.class) public class RepositoryAccessITCase { @@ -42,7 +44,7 @@ public class RepositoryAccessITCase { private final String repositoryType; private File folder; - private RepositoryRequests.AppliedRepositoryGetRequest repositoryGetRequest; + private ScmRequests.AppliedRepositoryRequest repositoryGetRequest; public RepositoryAccessITCase(String repositoryType) { this.repositoryType = repositoryType; @@ -57,12 +59,17 @@ public class RepositoryAccessITCase { public void init() { TestData.createDefault(); folder = tempFolder.getRoot(); - repositoryGetRequest = RepositoryRequests.start() + repositoryGetRequest = ScmRequests.start() .given() .url(TestData.getDefaultRepositoryUrl(repositoryType)) .usernameAndPassword(ADMIN_USERNAME, ADMIN_PASSWORD) - .get() + .getRepositoryResource() .assertStatusCode(HttpStatus.SC_OK); + ScmRequests.AppliedMeRequest meGetRequest = ScmRequests.start() + .given() + .url(TestData.getMeUrl()) + .usernameAndPassword(ADMIN_USERNAME, ADMIN_PASSWORD) + .getMeResource(); } @Test @@ -165,7 +172,7 @@ public class RepositoryAccessITCase { .isNotNull() .contains(String.format("%s/sources/%s", repositoryUrl, changeset.getId())); - assertThat(response.body().jsonPath().getString("_embedded.tags.find{it.name=='" + tagName + "'}._links.changesets.href")) + assertThat(response.body().jsonPath().getString("_embedded.tags.find{it.name=='" + tagName + "'}._links.changeset.href")) .as("assert single tag changesets link") .isNotNull() .contains(String.format("%s/changesets/%s", repositoryUrl, changeset.getId())); diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoryRequests.java b/scm-it/src/test/java/sonia/scm/it/RepositoryRequests.java deleted file mode 100644 index 79300d7b45..0000000000 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryRequests.java +++ /dev/null @@ -1,293 +0,0 @@ -package sonia.scm.it; - -import io.restassured.RestAssured; -import io.restassured.response.Response; - -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - - -/** - * Encapsulate rest requests of a repository in builder pattern - *

- * A Get Request can be applied with the methods request*() - * These methods return a AppliedGet*Request object - * This object can be used to apply general assertions over the rest Assured response - * In the AppliedGet*Request classes there is a using*Response() method - * that return the *Response class containing specific operations related to the specific response - * the *Response class contains also the request*() method to apply the next GET request from a link in the response. - */ -public class RepositoryRequests { - - private String url; - private String username; - private String password; - - static RepositoryRequests start() { - return new RepositoryRequests(); - } - - Given given() { - return new Given(); - } - - - /** - * Apply a GET Request to the extracted url from the given link - * - * @param linkPropertyName the property name of link - * @param response the response containing the link - * @return the response of the GET request using the given link - */ - private Response getResponseFromLink(Response response, String linkPropertyName) { - return getResponse(response - .then() - .extract() - .path(linkPropertyName)); - } - - - /** - * Apply a GET Request to the given url and return the response. - * - * @param url the url of the GET request - * @return the response of the GET request using the given url - */ - private Response getResponse(String url) { - return RestAssured.given() - .auth().preemptive().basic(username, password) - .when() - .get(url); - } - - private void setUrl(String url) { - this.url = url; - } - - private void setUsername(String username) { - this.username = username; - } - - private void setPassword(String password) { - this.password = password; - } - - private String getUrl() { - return url; - } - - private String getUsername() { - return username; - } - - private String getPassword() { - return password; - } - - class Given { - - GivenUrl url(String url) { - setUrl(url); - return new GivenUrl(); - } - - } - - class GivenWithUrlAndAuth { - AppliedRepositoryGetRequest get() { - return new AppliedRepositoryGetRequest( - getResponse(url) - ); - } - } - - class AppliedGetRequest { - private Response response; - - public AppliedGetRequest(Response response) { - this.response = response; - } - - /** - * apply custom assertions to the actual response - * - * @param consumer consume the response in order to assert the content. the header, the payload etc.. - * @return the self object - */ - SELF assertResponse(Consumer consumer) { - consumer.accept(response); - return (SELF) this; - } - - /** - * special assertion of the status code - * - * @param expectedStatusCode the expected status code - * @return the self object - */ - SELF assertStatusCode(int expectedStatusCode) { - this.response.then().assertThat().statusCode(expectedStatusCode); - return (SELF) this; - } - - } - - class AppliedRepositoryGetRequest extends AppliedGetRequest { - - AppliedRepositoryGetRequest(Response response) { - super(response); - } - - RepositoryResponse usingRepositoryResponse() { - return new RepositoryResponse(super.response); - } - } - - class RepositoryResponse { - - private Response repositoryResponse; - - public RepositoryResponse(Response repositoryResponse) { - this.repositoryResponse = repositoryResponse; - } - - AppliedGetSourcesRequest requestSources() { - return new AppliedGetSourcesRequest(getResponseFromLink(repositoryResponse, "_links.sources.href")); - } - - AppliedGetChangesetsRequest requestChangesets() { - return new AppliedGetChangesetsRequest(getResponseFromLink(repositoryResponse, "_links.changesets.href")); - } - } - - class AppliedGetChangesetsRequest extends AppliedGetRequest { - - AppliedGetChangesetsRequest(Response response) { - super(response); - } - - ChangesetsResponse usingChangesetsResponse() { - return new ChangesetsResponse(super.response); - } - } - - class ChangesetsResponse { - private Response changesetsResponse; - - public ChangesetsResponse(Response changesetsResponse) { - this.changesetsResponse = changesetsResponse; - } - - ChangesetsResponse assertChangesets(Consumer> changesetsConsumer) { - List changesets = changesetsResponse.then().extract().path("_embedded.changesets"); - changesetsConsumer.accept(changesets); - return this; - } - - AppliedGetDiffRequest requestDiff(String revision) { - return new AppliedGetDiffRequest(getResponseFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.diff.href")); - } - - public AppliedGetModificationsRequest requestModifications(String revision) { - return new AppliedGetModificationsRequest(getResponseFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.modifications.href")); - } - } - - class AppliedGetSourcesRequest extends AppliedGetRequest { - - public AppliedGetSourcesRequest(Response sourcesResponse) { - super(sourcesResponse); - } - - SourcesResponse usingSourcesResponse() { - return new SourcesResponse(super.response); - } - } - - class SourcesResponse { - - private Response sourcesResponse; - - SourcesResponse(Response sourcesResponse) { - this.sourcesResponse = sourcesResponse; - } - - SourcesResponse assertRevision(Consumer assertRevision) { - String revision = sourcesResponse.then().extract().path("revision"); - assertRevision.accept(revision); - return this; - } - - SourcesResponse assertFiles(Consumer assertFiles) { - List files = sourcesResponse.then().extract().path("files"); - assertFiles.accept(files); - return this; - } - - AppliedGetChangesetsRequest requestFileHistory(String fileName) { - return new AppliedGetChangesetsRequest(getResponseFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.history.href")); - } - - AppliedGetSourcesRequest requestSelf(String fileName) { - return new AppliedGetSourcesRequest(getResponseFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.self.href")); - } - } - - class AppliedGetDiffRequest extends AppliedGetRequest { - - AppliedGetDiffRequest(Response response) { - super(response); - } - } - - class GivenUrl { - - GivenWithUrlAndAuth usernameAndPassword(String username, String password) { - setUsername(username); - setPassword(password); - return new GivenWithUrlAndAuth(); - } - } - - class AppliedGetModificationsRequest extends AppliedGetRequest { - public AppliedGetModificationsRequest(Response response) { super(response); } - ModificationsResponse usingModificationsResponse() { - return new ModificationsResponse(super.response); - } - - } - - class ModificationsResponse { - private Response resource; - - public ModificationsResponse(Response resource) { - this.resource = resource; - } - - ModificationsResponse assertRevision(Consumer assertRevision) { - String revision = resource.then().extract().path("revision"); - assertRevision.accept(revision); - return this; - } - - ModificationsResponse assertAdded(Consumer> assertAdded) { - List added = resource.then().extract().path("added"); - assertAdded.accept(added); - return this; - } - - ModificationsResponse assertRemoved(Consumer> assertRemoved) { - List removed = resource.then().extract().path("removed"); - assertRemoved.accept(removed); - return this; - } - - ModificationsResponse assertModified(Consumer> assertModified) { - List modified = resource.then().extract().path("modified"); - assertModified.accept(modified); - return this; - } - - } -} diff --git a/scm-it/src/test/java/sonia/scm/it/UserITCase.java b/scm-it/src/test/java/sonia/scm/it/UserITCase.java new file mode 100644 index 0000000000..33fbe0cc5d --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/UserITCase.java @@ -0,0 +1,96 @@ +package sonia.scm.it; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; +import sonia.scm.it.utils.TestData; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserITCase { + + @Before + public void init(){ + TestData.cleanup(); + } + + @Test + public void adminShouldChangeOwnPassword() { + String newUser = "user"; + String password = "pass"; + TestData.createUser(newUser, password, true, "xml"); + String newPassword = "new_password"; + // admin change the own password + ScmRequests.start() + .given() + .url(TestData.getUserUrl(newUser)) + .usernameAndPassword(newUser, password) + .getUserResource() + .assertStatusCode(200) + .usingUserResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) + .assertPassword(Assert::assertNull) + .requestChangePassword(newPassword) // the oldPassword is not needed in the user resource + .assertStatusCode(204); + // assert password is changed -> login with the new Password + ScmRequests.start() + .given() + .url(TestData.getUserUrl(newUser)) + .usernameAndPassword(newUser, newPassword) + .getUserResource() + .assertStatusCode(200) + .usingUserResponse() + .assertAdmin(isAdmin -> assertThat(isAdmin).isEqualTo(Boolean.TRUE)) + .assertPassword(Assert::assertNull); + + } + + @Test + public void adminShouldChangePasswordOfOtherUser() { + String newUser = "user"; + String password = "pass"; + TestData.createUser(newUser, password, true, "xml"); + String newPassword = "new_password"; + // admin change the password of the user + ScmRequests.start() + .given() + .url(TestData.getUserUrl(newUser))// the admin get the user object + .usernameAndPassword(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) + .getUserResource() + .assertStatusCode(200) + .usingUserResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) // the user anonymous is not an admin + .assertPassword(Assert::assertNull) + .requestChangePassword(newPassword) // the oldPassword is not needed in the user resource + .assertStatusCode(204); + // assert password is changed + ScmRequests.start() + .given() + .url(TestData.getUserUrl(newUser)) + .usernameAndPassword(newUser, newPassword) + .getUserResource() + .assertStatusCode(200); + + } + + + @Test + public void shouldHidePasswordLinkIfUserTypeIsNotXML() { + String newUser = "user"; + String password = "pass"; + String type = "not XML Type"; + TestData.createUser(newUser, password, true, type); + ScmRequests.start() + .given() + .url(TestData.getMeUrl()) + .usernameAndPassword(newUser, password) + .getUserResource() + .assertStatusCode(200) + .usingUserResponse() + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) + .assertPassword(Assert::assertNull) + .assertType(s -> assertThat(s).isEqualTo(type)) + .assertPasswordLinkDoesNotExists(); + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/utils/NullAwareJsonObjectBuilder.java b/scm-it/src/test/java/sonia/scm/it/utils/NullAwareJsonObjectBuilder.java new file mode 100644 index 0000000000..31a12f1969 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/utils/NullAwareJsonObjectBuilder.java @@ -0,0 +1,95 @@ +package sonia.scm.it.utils; + +import javax.json.JsonArrayBuilder; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.json.JsonValue; +import java.math.BigDecimal; +import java.math.BigInteger; + +public class NullAwareJsonObjectBuilder implements JsonObjectBuilder { + public static JsonObjectBuilder wrap(JsonObjectBuilder builder) { + if (builder == null) { + throw new IllegalArgumentException("Can't wrap nothing."); + } + return new NullAwareJsonObjectBuilder(builder); + } + + private final JsonObjectBuilder builder; + + private NullAwareJsonObjectBuilder(JsonObjectBuilder builder) { + this.builder = builder; + } + + public JsonObjectBuilder add(String name, JsonValue value) { + return builder.add(name, (value == null) ? JsonValue.NULL : value); + } + + @Override + public JsonObjectBuilder add(String name, String value) { + if (value != null){ + return builder.add(name, value ); + }else{ + return builder.addNull(name); + } + } + + @Override + public JsonObjectBuilder add(String name, BigInteger value) { + if (value != null){ + return builder.add(name, value ); + }else{ + return builder.addNull(name); + } + } + + @Override + public JsonObjectBuilder add(String name, BigDecimal value) { + if (value != null){ + return builder.add(name, value ); + }else{ + return builder.addNull(name); + } + } + + @Override + public JsonObjectBuilder add(String s, int i) { + return builder.add(s, i); + } + + @Override + public JsonObjectBuilder add(String s, long l) { + return builder.add(s, l); + } + + @Override + public JsonObjectBuilder add(String s, double v) { + return builder.add(s, v); + } + + @Override + public JsonObjectBuilder add(String s, boolean b) { + return builder.add(s, b); + } + + @Override + public JsonObjectBuilder addNull(String s) { + return builder.addNull(s); + } + + @Override + public JsonObjectBuilder add(String s, JsonObjectBuilder jsonObjectBuilder) { + return builder.add(s, jsonObjectBuilder); + } + + @Override + public JsonObjectBuilder add(String s, JsonArrayBuilder jsonArrayBuilder) { + return builder.add(s, jsonArrayBuilder); + } + + @Override + public JsonObject build() { + return builder.build(); + } + +} diff --git a/scm-it/src/test/java/sonia/scm/it/RegExMatcher.java b/scm-it/src/test/java/sonia/scm/it/utils/RegExMatcher.java similarity index 76% rename from scm-it/src/test/java/sonia/scm/it/RegExMatcher.java rename to scm-it/src/test/java/sonia/scm/it/utils/RegExMatcher.java index e5dc7931d3..8fb9fdf798 100644 --- a/scm-it/src/test/java/sonia/scm/it/RegExMatcher.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/RegExMatcher.java @@ -1,4 +1,4 @@ -package sonia.scm.it; +package sonia.scm.it.utils; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; @@ -6,7 +6,7 @@ import org.hamcrest.Matcher; import java.util.regex.Pattern; -class RegExMatcher extends BaseMatcher { +public class RegExMatcher extends BaseMatcher { public static Matcher matchesPattern(String pattern) { return new RegExMatcher(pattern); } @@ -24,6 +24,6 @@ class RegExMatcher extends BaseMatcher { @Override public boolean matches(Object o) { - return Pattern.compile(pattern).matcher(o.toString()).matches(); + return o != null && Pattern.compile(pattern).matcher(o.toString()).matches(); } } diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoryUtil.java b/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java similarity index 81% rename from scm-it/src/test/java/sonia/scm/it/RepositoryUtil.java rename to scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java index 2340588fe1..11db0200f1 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryUtil.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java @@ -1,4 +1,4 @@ -package sonia.scm.it; +package sonia.scm.it.utils; import com.google.common.base.Charsets; import com.google.common.io.Files; @@ -24,11 +24,11 @@ public class RepositoryUtil { private static final RepositoryClientFactory REPOSITORY_CLIENT_FACTORY = new RepositoryClientFactory(); - static RepositoryClient createRepositoryClient(String repositoryType, File folder) throws IOException { + public static RepositoryClient createRepositoryClient(String repositoryType, File folder) throws IOException { return createRepositoryClient(repositoryType, folder, "scmadmin", "scmadmin"); } - static RepositoryClient createRepositoryClient(String repositoryType, File folder, String username, String password) throws IOException { + public static RepositoryClient createRepositoryClient(String repositoryType, File folder, String username, String password) throws IOException { String httpProtocolUrl = TestData.callRepository(username, password, repositoryType, HttpStatus.SC_OK) .extract() .path("_links.protocol.find{it.name=='http'}.href"); @@ -36,14 +36,14 @@ public class RepositoryUtil { return REPOSITORY_CLIENT_FACTORY.create(repositoryType, httpProtocolUrl, username, password, folder); } - static String addAndCommitRandomFile(RepositoryClient client, String username) throws IOException { + public static String addAndCommitRandomFile(RepositoryClient client, String username) throws IOException { String uuid = UUID.randomUUID().toString(); String name = "file-" + uuid + ".uuid"; createAndCommitFile(client, username, name, uuid); return name; } - static Changeset createAndCommitFile(RepositoryClient repositoryClient, String username, String fileName, String content) throws IOException { + public static Changeset createAndCommitFile(RepositoryClient repositoryClient, String username, String fileName, String content) throws IOException { writeAndAddFile(repositoryClient, fileName, content); return commit(repositoryClient, username, "added " + fileName); } @@ -59,7 +59,7 @@ public class RepositoryUtil { * @return the changeset with all modifications * @throws IOException */ - static Changeset commitMultipleFileModifications(RepositoryClient repositoryClient, String username, Map addedFiles, Map modifiedFiles, List removedFiles) throws IOException { + public static Changeset commitMultipleFileModifications(RepositoryClient repositoryClient, String username, Map addedFiles, Map modifiedFiles, List removedFiles) throws IOException { for (String fileName : addedFiles.keySet()) { writeAndAddFile(repositoryClient, fileName, addedFiles.get(fileName)); } @@ -80,7 +80,7 @@ public class RepositoryUtil { return file; } - static Changeset removeAndCommitFile(RepositoryClient repositoryClient, String username, String fileName) throws IOException { + public static Changeset removeAndCommitFile(RepositoryClient repositoryClient, String username, String fileName) throws IOException { deleteFileAndApplyRemoveCommand(repositoryClient, fileName); return commit(repositoryClient, username, "removed " + fileName); } @@ -115,7 +115,7 @@ public class RepositoryUtil { return changeset; } - static Tag addTag(RepositoryClient repositoryClient, String revision, String tagName) throws IOException { + public static Tag addTag(RepositoryClient repositoryClient, String revision, String tagName) throws IOException { if (repositoryClient.isCommandSupported(ClientCommand.TAG)) { Tag tag = repositoryClient.getTagCommand().setRevision(revision).tag(tagName, TestData.USER_SCM_ADMIN); if (repositoryClient.isCommandSupported(ClientCommand.PUSH)) { diff --git a/scm-it/src/test/java/sonia/scm/it/RestUtil.java b/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java similarity index 96% rename from scm-it/src/test/java/sonia/scm/it/RestUtil.java rename to scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java index a7409e1995..645cf06ac8 100644 --- a/scm-it/src/test/java/sonia/scm/it/RestUtil.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java @@ -1,4 +1,4 @@ -package sonia.scm.it; +package sonia.scm.it.utils; import io.restassured.RestAssured; import io.restassured.specification.RequestSpecification; @@ -10,7 +10,7 @@ import static java.net.URI.create; public class RestUtil { public static final URI BASE_URL = create("http://localhost:8081/scm/"); - public static final URI REST_BASE_URL = BASE_URL.resolve("api/rest/v2/"); + public static final URI REST_BASE_URL = BASE_URL.resolve("api/v2/"); public static URI createResourceUrl(String path) { return REST_BASE_URL.resolve(path); diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java new file mode 100644 index 0000000000..41fd9a1290 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java @@ -0,0 +1,465 @@ +package sonia.scm.it.utils; + +import io.restassured.RestAssured; +import io.restassured.response.Response; +import sonia.scm.web.VndMediaType; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.hamcrest.Matchers.is; +import static sonia.scm.it.utils.TestData.createPasswordChangeJson; + + +/** + * Encapsulate rest requests of a repository in builder pattern + *

+ * A Get Request can be applied with the methods request*() + * These methods return a AppliedGet*Request object + * This object can be used to apply general assertions over the rest Assured response + * In the AppliedGet*Request classes there is a using*Response() method + * that return the *Response class containing specific operations related to the specific response + * the *Response class contains also the request*() method to apply the next GET request from a link in the response. + */ +public class ScmRequests { + + private String url; + private String username; + private String password; + + public static ScmRequests start() { + return new ScmRequests(); + } + + public Given given() { + return new Given(); + } + + + /** + * Apply a GET Request to the extracted url from the given link + * + * @param linkPropertyName the property name of link + * @param response the response containing the link + * @return the response of the GET request using the given link + */ + private Response applyGETRequestFromLink(Response response, String linkPropertyName) { + return applyGETRequest(response + .then() + .extract() + .path(linkPropertyName)); + } + + + /** + * Apply a GET Request to the given url and return the response. + * + * @param url the url of the GET request + * @return the response of the GET request using the given url + */ + private Response applyGETRequest(String url) { + return RestAssured.given() + .auth().preemptive().basic(username, password) + .when() + .get(url); + } + + + /** + * Apply a PUT Request to the extracted url from the given link + * + * @param response the response containing the link + * @param linkPropertyName the property name of link + * @param body + * @return the response of the PUT request using the given link + */ + private Response applyPUTRequestFromLink(Response response, String linkPropertyName, String content, String body) { + return applyPUTRequest(response + .then() + .extract() + .path(linkPropertyName), content, body); + } + + + /** + * Apply a PUT Request to the given url and return the response. + * + * @param url the url of the PUT request + * @param mediaType + * @param body + * @return the response of the PUT request using the given url + */ + private Response applyPUTRequest(String url, String mediaType, String body) { + return RestAssured.given() + .auth().preemptive().basic(username, password) + .when() + .contentType(mediaType) + .accept(mediaType) + .body(body) + .put(url); + } + + + private void setUrl(String url) { + this.url = url; + } + + private void setUsername(String username) { + this.username = username; + } + + private void setPassword(String password) { + this.password = password; + } + + private String getUrl() { + return url; + } + + private String getUsername() { + return username; + } + + private String getPassword() { + return password; + } + + public class Given { + + public GivenUrl url(String url) { + setUrl(url); + return new GivenUrl(); + } + + public GivenUrl url(URI url) { + setUrl(url.toString()); + return new GivenUrl(); + } + + } + + public class GivenWithUrlAndAuth { + public AppliedMeRequest getMeResource() { + return new AppliedMeRequest(applyGETRequest(url)); + } + + public AppliedUserRequest getUserResource() { + return new AppliedUserRequest(applyGETRequest(url)); + } + + public AppliedRepositoryRequest getRepositoryResource() { + return new AppliedRepositoryRequest( + applyGETRequest(url) + ); + } + } + + public class AppliedRequest { + private Response response; + + public AppliedRequest(Response response) { + this.response = response; + } + + /** + * apply custom assertions to the actual response + * + * @param consumer consume the response in order to assert the content. the header, the payload etc.. + * @return the self object + */ + public SELF assertResponse(Consumer consumer) { + consumer.accept(response); + return (SELF) this; + } + + /** + * special assertion of the status code + * + * @param expectedStatusCode the expected status code + * @return the self object + */ + public SELF assertStatusCode(int expectedStatusCode) { + this.response.then().assertThat().statusCode(expectedStatusCode); + return (SELF) this; + } + + } + + public class AppliedRepositoryRequest extends AppliedRequest { + + public AppliedRepositoryRequest(Response response) { + super(response); + } + + public RepositoryResponse usingRepositoryResponse() { + return new RepositoryResponse(super.response); + } + } + + public class RepositoryResponse { + + private Response repositoryResponse; + + public RepositoryResponse(Response repositoryResponse) { + this.repositoryResponse = repositoryResponse; + } + + public AppliedSourcesRequest requestSources() { + return new AppliedSourcesRequest(applyGETRequestFromLink(repositoryResponse, "_links.sources.href")); + } + + public AppliedChangesetsRequest requestChangesets() { + return new AppliedChangesetsRequest(applyGETRequestFromLink(repositoryResponse, "_links.changesets.href")); + } + } + + public class AppliedChangesetsRequest extends AppliedRequest { + + public AppliedChangesetsRequest(Response response) { + super(response); + } + + public ChangesetsResponse usingChangesetsResponse() { + return new ChangesetsResponse(super.response); + } + } + + public class ChangesetsResponse { + private Response changesetsResponse; + + public ChangesetsResponse(Response changesetsResponse) { + this.changesetsResponse = changesetsResponse; + } + + public ChangesetsResponse assertChangesets(Consumer> changesetsConsumer) { + List changesets = changesetsResponse.then().extract().path("_embedded.changesets"); + changesetsConsumer.accept(changesets); + return this; + } + + public AppliedDiffRequest requestDiff(String revision) { + return new AppliedDiffRequest(applyGETRequestFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.diff.href")); + } + + public AppliedModificationsRequest requestModifications(String revision) { + return new AppliedModificationsRequest(applyGETRequestFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.modifications.href")); + } + } + + public class AppliedSourcesRequest extends AppliedRequest { + + public AppliedSourcesRequest(Response sourcesResponse) { + super(sourcesResponse); + } + + public SourcesResponse usingSourcesResponse() { + return new SourcesResponse(super.response); + } + } + + public class SourcesResponse { + + private Response sourcesResponse; + + public SourcesResponse(Response sourcesResponse) { + this.sourcesResponse = sourcesResponse; + } + + public SourcesResponse assertRevision(Consumer assertRevision) { + String revision = sourcesResponse.then().extract().path("revision"); + assertRevision.accept(revision); + return this; + } + + public SourcesResponse assertFiles(Consumer assertFiles) { + List files = sourcesResponse.then().extract().path("files"); + assertFiles.accept(files); + return this; + } + + public AppliedChangesetsRequest requestFileHistory(String fileName) { + return new AppliedChangesetsRequest(applyGETRequestFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.history.href")); + } + + public AppliedSourcesRequest requestSelf(String fileName) { + return new AppliedSourcesRequest(applyGETRequestFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.self.href")); + } + } + + public class AppliedDiffRequest extends AppliedRequest { + + public AppliedDiffRequest(Response response) { + super(response); + } + } + + public class GivenUrl { + + public GivenWithUrlAndAuth usernameAndPassword(String username, String password) { + setUsername(username); + setPassword(password); + return new GivenWithUrlAndAuth(); + } + } + + public class AppliedModificationsRequest extends AppliedRequest { + public AppliedModificationsRequest(Response response) { + super(response); + } + + public ModificationsResponse usingModificationsResponse() { + return new ModificationsResponse(super.response); + } + + } + + public class ModificationsResponse { + private Response resource; + + public ModificationsResponse(Response resource) { + this.resource = resource; + } + + public ModificationsResponse assertRevision(Consumer assertRevision) { + String revision = resource.then().extract().path("revision"); + assertRevision.accept(revision); + return this; + } + + public ModificationsResponse assertAdded(Consumer> assertAdded) { + List added = resource.then().extract().path("added"); + assertAdded.accept(added); + return this; + } + + public ModificationsResponse assertRemoved(Consumer> assertRemoved) { + List removed = resource.then().extract().path("removed"); + assertRemoved.accept(removed); + return this; + } + + public ModificationsResponse assertModified(Consumer> assertModified) { + List modified = resource.then().extract().path("modified"); + assertModified.accept(modified); + return this; + } + + } + + public class AppliedMeRequest extends AppliedRequest { + + public AppliedMeRequest(Response response) { + super(response); + } + + public MeResponse usingMeResponse() { + return new MeResponse(super.response); + } + + } + + public class MeResponse extends UserResponse { + + + public MeResponse(Response response) { + super(response); + } + + public AppliedChangePasswordRequest requestChangePassword(String oldPassword, String newPassword) { + return new AppliedChangePasswordRequest(applyPUTRequestFromLink(super.response, "_links.password.href", VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(oldPassword, newPassword))); + } + + + } + + public class UserResponse extends ModelResponse { + + public static final String LINKS_PASSWORD_HREF = "_links.password.href"; + + public UserResponse(Response response) { + super(response); + } + + public SELF assertPassword(Consumer assertPassword) { + return super.assertSingleProperty(assertPassword, "password"); + } + + public SELF assertType(Consumer assertType) { + return assertSingleProperty(assertType, "type"); + } + + public SELF assertAdmin(Consumer assertAdmin) { + return assertSingleProperty(assertAdmin, "admin"); + } + + public SELF assertPasswordLinkDoesNotExists() { + return assertPropertyPathDoesNotExists(LINKS_PASSWORD_HREF); + } + + public SELF assertPasswordLinkExists() { + return assertPropertyPathExists(LINKS_PASSWORD_HREF); + } + + public AppliedChangePasswordRequest requestChangePassword(String newPassword) { + return new AppliedChangePasswordRequest(applyPUTRequestFromLink(super.response, LINKS_PASSWORD_HREF, VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(null, newPassword))); + } + + } + + + /** + * encapsulate standard assertions over model properties + */ + public class ModelResponse { + + protected Response response; + + public ModelResponse(Response response) { + this.response = response; + } + + public SELF assertSingleProperty(Consumer assertSingleProperty, String propertyJsonPath) { + T propertyValue = response.then().extract().path(propertyJsonPath); + assertSingleProperty.accept(propertyValue); + return (SELF) this; + } + + public SELF assertPropertyPathExists(String propertyJsonPath) { + response.then().assertThat().body("any { it.containsKey('" + propertyJsonPath + "')}", is(true)); + return (SELF) this; + } + + public SELF assertPropertyPathDoesNotExists(String propertyJsonPath) { + response.then().assertThat().body("this.any { it.containsKey('" + propertyJsonPath + "')}", is(false)); + return (SELF) this; + } + + public SELF assertArrayProperty(Consumer assertProperties, String propertyJsonPath) { + List properties = response.then().extract().path(propertyJsonPath); + assertProperties.accept(properties); + return (SELF) this; + } + } + + public class AppliedChangePasswordRequest extends AppliedRequest { + + public AppliedChangePasswordRequest(Response response) { + super(response); + } + + } + + public class AppliedUserRequest extends AppliedRequest { + + public AppliedUserRequest(Response response) { + super(response); + } + + public UserResponse usingUserResponse() { + return new UserResponse(super.response); + } + + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/ScmTypes.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java similarity index 72% rename from scm-it/src/test/java/sonia/scm/it/ScmTypes.java rename to scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java index e8ba67e561..4c9ac0ea44 100644 --- a/scm-it/src/test/java/sonia/scm/it/ScmTypes.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java @@ -1,12 +1,12 @@ -package sonia.scm.it; +package sonia.scm.it.utils; import sonia.scm.util.IOUtil; import java.util.ArrayList; import java.util.Collection; -class ScmTypes { - static Collection availableScmTypes() { +public class ScmTypes { + public static Collection availableScmTypes() { Collection params = new ArrayList<>(); params.add("git"); diff --git a/scm-it/src/test/java/sonia/scm/it/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java similarity index 77% rename from scm-it/src/test/java/sonia/scm/it/TestData.java rename to scm-it/src/test/java/sonia/scm/it/utils/TestData.java index ae0e35004d..03da80ea3b 100644 --- a/scm-it/src/test/java/sonia/scm/it/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -1,4 +1,4 @@ -package sonia.scm.it; +package sonia.scm.it.utils; import io.restassured.response.ValidatableResponse; import org.apache.http.HttpStatus; @@ -8,14 +8,16 @@ import sonia.scm.repository.PermissionType; import sonia.scm.web.VndMediaType; import javax.json.Json; +import javax.json.JsonObjectBuilder; +import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; import static java.util.Arrays.asList; -import static sonia.scm.it.RestUtil.createResourceUrl; -import static sonia.scm.it.RestUtil.given; -import static sonia.scm.it.ScmTypes.availableScmTypes; +import static sonia.scm.it.utils.RestUtil.createResourceUrl; +import static sonia.scm.it.utils.RestUtil.given; +import static sonia.scm.it.utils.ScmTypes.availableScmTypes; public class TestData { @@ -26,6 +28,7 @@ public class TestData { private static final List PROTECTED_USERS = asList(USER_SCM_ADMIN, USER_ANONYMOUS); private static Map DEFAULT_REPOSITORIES = new HashMap<>(); + public static final JsonObjectBuilder JSON_BUILDER = NullAwareJsonObjectBuilder.wrap(Json.createObjectBuilder()); public static void createDefault() { cleanup(); @@ -44,27 +47,31 @@ public class TestData { } public static void createUser(String username, String password) { + createUser(username, password, false, "xml"); + } + + public static void createUser(String username, String password, boolean isAdmin, String type) { LOG.info("create user with username: {}", username); + String admin = isAdmin ? "true" : "false"; given(VndMediaType.USER) .when() - .content(" {\n" + - " \"active\": true,\n" + - " \"admin\": false,\n" + - " \"creationDate\": \"2018-08-21T12:26:46.084Z\",\n" + - " \"displayName\": \"" + username + "\",\n" + - " \"mail\": \"user1@scm-manager.org\",\n" + - " \"name\": \"" + username + "\",\n" + - " \"password\": \"" + password + "\",\n" + - " \"type\": \"xml\"\n" + - " \n" + - " }") - .post(createResourceUrl("users")) + .content(new StringBuilder() + .append(" {\n") + .append(" \"active\": true,\n") + .append(" \"admin\": ").append(admin).append(",\n") + .append(" \"creationDate\": \"2018-08-21T12:26:46.084Z\",\n") + .append(" \"displayName\": \"").append(username).append("\",\n") + .append(" \"mail\": \"user1@scm-manager.org\",\n") + .append(" \"name\": \"").append(username).append("\",\n") + .append(" \"password\": \"").append(password).append("\",\n") + .append(" \"type\": \"").append(type).append("\"\n") + .append(" }").toString()) + .post(getUsersUrl()) .then() .statusCode(HttpStatus.SC_CREATED) ; } - public static void createUserPermission(String name, PermissionType permissionType, String repositoryType) { String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType); LOG.info("create permission with name {} and type: {} using the endpoint: {}", name, permissionType, defaultPermissionUrl); @@ -183,7 +190,7 @@ public class TestData { } public static String repositoryJson(String repositoryType) { - return Json.createObjectBuilder() + return JSON_BUILDER .add("contact", "zaphod.beeblebrox@hitchhiker.com") .add("description", "Heart of Gold") .add("name", "HeartOfGold-" + repositoryType) @@ -192,6 +199,29 @@ public class TestData { .build().toString(); } + public static URI getMeUrl() { + return RestUtil.createResourceUrl("me/"); + + } + + public static URI getUsersUrl() { + return RestUtil.createResourceUrl("users/"); + + } + + public static URI getUserUrl(String username) { + return getUsersUrl().resolve(username); + + } + + + public static String createPasswordChangeJson(String oldPassword, String newPassword) { + return JSON_BUILDER + .add("oldPassword", oldPassword) + .add("newPassword", newPassword) + .build().toString(); + } + public static void main(String[] args) { cleanup(); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java new file mode 100644 index 0000000000..a1120adda4 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sonia.scm.config.ConfigurationPermissions; +import sonia.scm.plugin.Extension; +import sonia.scm.web.JsonEnricherBase; +import sonia.scm.web.JsonEnricherContext; + +import javax.inject.Inject; +import javax.inject.Provider; + +import static java.util.Collections.singletonMap; +import static sonia.scm.web.VndMediaType.INDEX; + +@Extension +public class GitConfigInIndexResource extends JsonEnricherBase { + + private final Provider scmPathInfoStore; + + @Inject + public GitConfigInIndexResource(Provider scmPathInfoStore, ObjectMapper objectMapper) { + super(objectMapper); + this.scmPathInfoStore = scmPathInfoStore; + } + + @Override + public void enrich(JsonEnricherContext context) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + String gitConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class) + .method("get") + .parameters() + .href(); + + JsonNode gitConfigRefNode = createObject(singletonMap("href", value(gitConfigUrl))); + + addPropertyNode(context.getResponseEntity().get("_links"), "gitConfig", gitConfigRefNode); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js index 3c1362ba80..d8eb4ae0e0 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js @@ -1,5 +1,6 @@ //@flow import React from "react"; +import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; type Props = { @@ -10,14 +11,16 @@ class ProtocolInformation extends React.Component { render() { const { repository } = this.props; - if (!repository._links.httpProtocol) { + const href = repositories.getProtocolLinkByType(repository, "http"); + if (!href) { return null; } + return (

Clone the repository

-          git clone {repository._links.httpProtocol.href}
+          git clone {href}
         

Create a new repository

@@ -30,7 +33,7 @@ class ProtocolInformation extends React.Component {
             
git commit -m "added readme"
- git remote add origin {repository._links.httpProtocol.href} + git remote add origin {href}
git push -u origin master
@@ -39,7 +42,7 @@ class ProtocolInformation extends React.Component {

Push an existing repository

           
-            git remote add origin {repository._links.httpProtocol.href}
+            git remote add origin {href}
             
git push -u origin master
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java new file mode 100644 index 0000000000..665be19788 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import com.google.inject.util.Providers; +import org.junit.Rule; +import org.junit.Test; +import sonia.scm.web.JsonEnricherContext; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.MediaType; +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") +public class GitConfigInIndexResourceTest { + + @Rule + public final ShiroRule shiroRule = new ShiroRule(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectNode root = objectMapper.createObjectNode(); + private final GitConfigInIndexResource gitConfigInIndexResource; + + public GitConfigInIndexResourceTest() { + root.put("_links", objectMapper.createObjectNode()); + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("/")); + gitConfigInIndexResource = new GitConfigInIndexResource(Providers.of(pathInfoStore), objectMapper); + } + + @Test + @SubjectAware(username = "admin", password = "secret") + public void admin() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + gitConfigInIndexResource.enrich(context); + + assertEquals("/v2/config/git", root.get("_links").get("gitConfig").get("href").asText()); + } + + @Test + @SubjectAware(username = "readOnly", password = "secret") + public void user() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + gitConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } + + @Test + public void anonymous() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + gitConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini index 36226edd7d..5d30a000f2 100644 --- a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini @@ -2,8 +2,10 @@ readOnly = secret, reader writeOnly = secret, writer readWrite = secret, readerWriter +admin = secret, admin [roles] reader = configuration:read:git writer = configuration:write:git readerWriter = configuration:*:git +admin = * diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java new file mode 100644 index 0000000000..3de79b2f81 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sonia.scm.config.ConfigurationPermissions; +import sonia.scm.plugin.Extension; +import sonia.scm.web.JsonEnricherBase; +import sonia.scm.web.JsonEnricherContext; + +import javax.inject.Inject; +import javax.inject.Provider; + +import static java.util.Collections.singletonMap; +import static sonia.scm.web.VndMediaType.INDEX; + +@Extension +public class HgConfigInIndexResource extends JsonEnricherBase { + + private final Provider scmPathInfoStore; + + @Inject + public HgConfigInIndexResource(Provider scmPathInfoStore, ObjectMapper objectMapper) { + super(objectMapper); + this.scmPathInfoStore = scmPathInfoStore; + } + + @Override + public void enrich(JsonEnricherContext context) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + String hgConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), HgConfigResource.class) + .method("get") + .parameters() + .href(); + + JsonNode hgConfigRefNode = createObject(singletonMap("href", value(hgConfigUrl))); + + addPropertyNode(context.getResponseEntity().get("_links"), "hgConfig", hgConfigRefNode); + } + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js index 28c1e53a07..03fc41450a 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js @@ -1,5 +1,6 @@ //@flow import React from "react"; +import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; type Props = { @@ -10,14 +11,15 @@ class ProtocolInformation extends React.Component { render() { const { repository } = this.props; - if (!repository._links.httpProtocol) { + const href = repositories.getProtocolLinkByType(repository, "http"); + if (!href) { return null; } return (

Clone the repository

-          hg clone {repository._links.httpProtocol.href}
+          hg clone {href}
         

Create a new repository

@@ -26,7 +28,7 @@ class ProtocolInformation extends React.Component {
             
echo "[paths]" > .hg/hgrc
- echo "default = {repository._links.httpProtocol.href}" > .hg/hgrc + echo "default = {href}" > .hg/hgrc
echo "# {repository.name}" > README.md
@@ -44,7 +46,7 @@ class ProtocolInformation extends React.Component { # add the repository url as default to your .hg/hgrc e.g:
- default = {repository._links.httpProtocol.href} + default = {href}
# push to remote repository
diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java new file mode 100644 index 0000000000..27ab74932c --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import com.google.inject.util.Providers; +import org.junit.Rule; +import org.junit.Test; +import sonia.scm.web.JsonEnricherContext; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.MediaType; +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") +public class HgConfigInIndexResourceTest { + + @Rule + public final ShiroRule shiroRule = new ShiroRule(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectNode root = objectMapper.createObjectNode(); + private final HgConfigInIndexResource hgConfigInIndexResource; + + public HgConfigInIndexResourceTest() { + root.put("_links", objectMapper.createObjectNode()); + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("/")); + hgConfigInIndexResource = new HgConfigInIndexResource(Providers.of(pathInfoStore), objectMapper); + } + + @Test + @SubjectAware(username = "admin", password = "secret") + public void admin() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + hgConfigInIndexResource.enrich(context); + + assertEquals("/v2/config/hg", root.get("_links").get("hgConfig").get("href").asText()); + } + + @Test + @SubjectAware(username = "readOnly", password = "secret") + public void user() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + hgConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } + + @Test + public void anonymous() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + hgConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/configuration/shiro.ini b/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/configuration/shiro.ini index fc08bb83ac..d8083a04c9 100644 --- a/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/configuration/shiro.ini +++ b/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/configuration/shiro.ini @@ -2,8 +2,10 @@ readOnly = secret, reader writeOnly = secret, writer readWrite = secret, readerWriter +admin = secret, admin [roles] reader = configuration:read:hg writer = configuration:write:hg readerWriter = configuration:*:hg +admin = * diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java new file mode 100644 index 0000000000..5ee1de3169 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sonia.scm.config.ConfigurationPermissions; +import sonia.scm.plugin.Extension; +import sonia.scm.web.JsonEnricherBase; +import sonia.scm.web.JsonEnricherContext; + +import javax.inject.Inject; +import javax.inject.Provider; + +import static java.util.Collections.singletonMap; +import static sonia.scm.web.VndMediaType.INDEX; + +@Extension +public class SvnConfigInIndexResource extends JsonEnricherBase { + + private final Provider scmPathInfoStore; + + @Inject + public SvnConfigInIndexResource(Provider scmPathInfoStore, ObjectMapper objectMapper) { + super(objectMapper); + this.scmPathInfoStore = scmPathInfoStore; + } + + @Override + public void enrich(JsonEnricherContext context) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + String svnConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), SvnConfigResource.class) + .method("get") + .parameters() + .href(); + + JsonNode svnConfigRefNode = createObject(singletonMap("href", value(svnConfigUrl))); + + addPropertyNode(context.getResponseEntity().get("_links"), "svnConfig", svnConfigRefNode); + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnGZipFilter.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnGZipFilter.java index 7cc78180ff..4352299ed5 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnGZipFilter.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnGZipFilter.java @@ -32,122 +32,45 @@ package sonia.scm.web; -//~--- non-JDK imports -------------------------------------------------------- - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.filter.GZipFilter; +import sonia.scm.filter.GZipFilterConfig; +import sonia.scm.filter.GZipResponseWrapper; import sonia.scm.repository.Repository; import sonia.scm.repository.SvnRepositoryHandler; import sonia.scm.repository.spi.ScmProviderHttpServlet; +import sonia.scm.util.WebUtil; -import javax.servlet.FilterChain; -import javax.servlet.FilterConfig; import javax.servlet.ServletConfig; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ -public class SvnGZipFilter extends GZipFilter implements ScmProviderHttpServlet -{ +class SvnGZipFilter implements ScmProviderHttpServlet { private static final Logger logger = LoggerFactory.getLogger(SvnGZipFilter.class); private final SvnRepositoryHandler handler; private final ScmProviderHttpServlet delegate; - //~--- constructors --------------------------------------------------------- + private GZipFilterConfig config = new GZipFilterConfig(); - /** - * Constructs ... - * - * - * @param handler - */ - public SvnGZipFilter(SvnRepositoryHandler handler, ScmProviderHttpServlet delegate) - { + SvnGZipFilter(SvnRepositoryHandler handler, ScmProviderHttpServlet delegate) { this.handler = handler; this.delegate = delegate; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param filterConfig - * - * @throws ServletException - */ - @Override - public void init(FilterConfig filterConfig) throws ServletException - { - super.init(filterConfig); - getConfig().setBufferResponse(false); - } - - /** - * Method description - * - * - * @param request - * @param response - * @param chain - * - * @throws IOException - * @throws ServletException - */ - @Override - protected void doFilter(HttpServletRequest request, - HttpServletResponse response, FilterChain chain) - throws IOException, ServletException - { - if (handler.getConfig().isEnabledGZip()) - { - if (logger.isTraceEnabled()) - { - logger.trace("encode svn request with gzip"); - } - - super.doFilter(request, response, chain); - } - else - { - if (logger.isTraceEnabled()) - { - logger.trace("skip gzip encoding"); - } - - chain.doFilter(request, response); - } + config.setBufferResponse(false); } @Override public void service(HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException { - if (handler.getConfig().isEnabledGZip()) - { - if (logger.isTraceEnabled()) - { - logger.trace("encode svn request with gzip"); - } - - super.doFilter(request, response, (servletRequest, servletResponse) -> delegate.service((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, repository)); - } - else - { - if (logger.isTraceEnabled()) - { - logger.trace("skip gzip encoding"); - } - + if (handler.getConfig().isEnabledGZip() && WebUtil.isGzipSupported(request)) { + logger.trace("compress svn response with gzip"); + GZipResponseWrapper wrappedResponse = new GZipResponseWrapper(response, config); + delegate.service(request, wrappedResponse, repository); + wrappedResponse.finishResponse(); + } else { + logger.trace("skip gzip encoding"); delegate.service(request, response, repository); } } diff --git a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js index ccff4118ba..0ba195887f 100644 --- a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js @@ -1,5 +1,6 @@ //@flow import React from "react"; +import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; type Props = { @@ -10,14 +11,15 @@ class ProtocolInformation extends React.Component { render() { const { repository } = this.props; - if (!repository._links.httpProtocol) { + const href = repositories.getProtocolLinkByType(repository, "http"); + if (!href) { return null; } return (

Checkout the repository

-          svn checkout {repository._links.httpProtocol.href}
+          svn checkout {href}
         
); diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java new file mode 100644 index 0000000000..8b87b57c6c --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import com.google.inject.util.Providers; +import org.junit.Rule; +import org.junit.Test; +import sonia.scm.web.JsonEnricherContext; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.MediaType; +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") +public class SvnConfigInIndexResourceTest { + + @Rule + public final ShiroRule shiroRule = new ShiroRule(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectNode root = objectMapper.createObjectNode(); + private final SvnConfigInIndexResource svnConfigInIndexResource; + + public SvnConfigInIndexResourceTest() { + root.put("_links", objectMapper.createObjectNode()); + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("/")); + svnConfigInIndexResource = new SvnConfigInIndexResource(Providers.of(pathInfoStore), objectMapper); + } + + @Test + @SubjectAware(username = "admin", password = "secret") + public void admin() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + svnConfigInIndexResource.enrich(context); + + assertEquals("/v2/config/svn", root.get("_links").get("svnConfig").get("href").asText()); + } + + @Test + @SubjectAware(username = "readOnly", password = "secret") + public void user() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + svnConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } + + @Test + public void anonymous() { + JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root); + + svnConfigInIndexResource.enrich(context); + + assertFalse(root.get("_links").iterator().hasNext()); + } +} diff --git a/scm-plugins/scm-svn-plugin/src/test/resources/sonia/scm/configuration/shiro.ini b/scm-plugins/scm-svn-plugin/src/test/resources/sonia/scm/configuration/shiro.ini index 7e4233b540..fe84723e0a 100644 --- a/scm-plugins/scm-svn-plugin/src/test/resources/sonia/scm/configuration/shiro.ini +++ b/scm-plugins/scm-svn-plugin/src/test/resources/sonia/scm/configuration/shiro.ini @@ -2,8 +2,10 @@ readOnly = secret, reader writeOnly = secret, writer readWrite = secret, readerWriter +admin = secret, admin [roles] reader = configuration:read:svn writer = configuration:write:svn readerWriter = configuration:*:svn +admin = * diff --git a/scm-ui-components/package.json b/scm-ui-components/package.json index 6fe782506e..73c20625bd 100644 --- a/scm-ui-components/package.json +++ b/scm-ui-components/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "bootstrap": "lerna bootstrap", - "link": "lerna exec -- yarn link" + "link": "lerna exec -- yarn link", + "unlink": "lerna exec --no-bail -- yarn unlink" }, "devDependencies": { "lerna": "^3.2.1" diff --git a/scm-ui-components/packages/ui-components/src/Help.js b/scm-ui-components/packages/ui-components/src/Help.js new file mode 100644 index 0000000000..965d16f145 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/Help.js @@ -0,0 +1,39 @@ +//@flow +import React from "react"; +import injectSheet from "react-jss"; +import classNames from "classnames"; + +const styles = { + img: { + display: "block" + }, + q: { + float: "left", + paddingLeft: "3px", + float: "right" + } +}; + +type Props = { + message: string, + classes: any +}; + +class Help extends React.Component { + render() { + const { message, classes } = this.props; + const multiline = message.length > 60 ? "is-tooltip-multiline" : ""; + return ( +
+ +
+ ); + } +} + +export default injectSheet(styles)(Help); diff --git a/scm-ui-components/packages/ui-components/src/LabelWithHelpIcon.js b/scm-ui-components/packages/ui-components/src/LabelWithHelpIcon.js new file mode 100644 index 0000000000..b5d049e68d --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/LabelWithHelpIcon.js @@ -0,0 +1,46 @@ +//@flow +import React from "react"; +import { Help } from "./index"; + +type Props = { + label: string, + helpText?: string +}; + +class LabelWithHelpIcon extends React.Component { + renderLabel = () => { + const label = this.props.label; + if (label) { + return ; + } + return ""; + }; + + renderHelp = () => { + const helpText = this.props.helpText; + if (helpText) { + return ( +
+ +
+ ); + } else return null; + }; + + renderLabelWithHelpIcon = () => { + if (this.props.label) { + return ( +
+
{this.renderLabel()}
+ {this.renderHelp()} +
+ ); + } else return null; + }; + + render() { + return this.renderLabelWithHelpIcon(); + } +} + +export default LabelWithHelpIcon; diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 7bf3232260..0b57abeada 100644 --- a/scm-ui-components/packages/ui-components/src/apiclient.js +++ b/scm-ui-components/packages/ui-components/src/apiclient.js @@ -32,7 +32,7 @@ export function createUrl(url: string) { if (url.indexOf("/") !== 0) { urlWithStartingSlash = "/" + urlWithStartingSlash; } - return `${contextPath}/api/rest/v2${urlWithStartingSlash}`; + return `${contextPath}/api/v2${urlWithStartingSlash}`; } class ApiClient { diff --git a/scm-ui-components/packages/ui-components/src/apiclient.test.js b/scm-ui-components/packages/ui-components/src/apiclient.test.js index 7bbb3b0119..deb22a3b54 100644 --- a/scm-ui-components/packages/ui-components/src/apiclient.test.js +++ b/scm-ui-components/packages/ui-components/src/apiclient.test.js @@ -9,7 +9,7 @@ describe("create url", () => { }); it("should add prefix for api", () => { - expect(createUrl("/users")).toBe("/api/rest/v2/users"); - expect(createUrl("users")).toBe("/api/rest/v2/users"); + expect(createUrl("/users")).toBe("/api/v2/users"); + expect(createUrl("users")).toBe("/api/v2/users"); }); }); diff --git a/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js b/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js index 1770e07807..e5c04eb613 100644 --- a/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js +++ b/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js @@ -9,7 +9,8 @@ type Props = { disabled: boolean, buttonLabel: string, fieldLabel: string, - errorMessage: string + errorMessage: string, + helpText?: string }; type State = { @@ -25,7 +26,13 @@ class AddEntryToTableField extends React.Component { } render() { - const { disabled, buttonLabel, fieldLabel, errorMessage } = this.props; + const { + disabled, + buttonLabel, + fieldLabel, + errorMessage, + helpText + } = this.props; return (
{ value={this.state.entryToAdd} onReturnPressed={this.appendEntry} disabled={disabled} + helpText={helpText} /> void, - disabled?: boolean + disabled?: boolean, + helpText?: string }; class Checkbox extends React.Component { onCheckboxChange = (event: SyntheticInputEvent) => { @@ -14,9 +16,20 @@ class Checkbox extends React.Component { } }; + renderHelp = () => { + const helpText = this.props.helpText; + if (helpText) { + return ( +
+ +
+ ); + } else return null; + }; + render() { return ( -
+
+ {this.renderHelp()}
); } diff --git a/scm-ui-components/packages/ui-components/src/forms/InputField.js b/scm-ui-components/packages/ui-components/src/forms/InputField.js index 6f87683939..79b71298f8 100644 --- a/scm-ui-components/packages/ui-components/src/forms/InputField.js +++ b/scm-ui-components/packages/ui-components/src/forms/InputField.js @@ -1,6 +1,7 @@ //@flow import React from "react"; import classNames from "classnames"; +import { LabelWithHelpIcon } from "../index"; type Props = { label?: string, @@ -12,7 +13,8 @@ type Props = { onReturnPressed?: () => void, validationError: boolean, errorMessage: string, - disabled?: boolean + disabled?: boolean, + helpText?: string }; class InputField extends React.Component { @@ -33,15 +35,6 @@ class InputField extends React.Component { this.props.onChange(event.target.value); }; - renderLabel = () => { - const label = this.props.label; - if (label) { - return ; - } - return ""; - }; - - handleKeyPress = (event: SyntheticKeyboardEvent) => { const onReturnPressed = this.props.onReturnPressed; if (!onReturnPressed) { @@ -60,7 +53,9 @@ class InputField extends React.Component { value, validationError, errorMessage, - disabled + disabled, + label, + helpText } = this.props; const errorView = validationError ? "is-danger" : ""; const helper = validationError ? ( @@ -70,7 +65,7 @@ class InputField extends React.Component { ); return (
- {this.renderLabel()} +
{ diff --git a/scm-ui-components/packages/ui-components/src/forms/Select.js b/scm-ui-components/packages/ui-components/src/forms/Select.js index 184359cc11..880b375999 100644 --- a/scm-ui-components/packages/ui-components/src/forms/Select.js +++ b/scm-ui-components/packages/ui-components/src/forms/Select.js @@ -1,5 +1,7 @@ //@flow import React from "react"; +import classNames from "classnames"; +import { LabelWithHelpIcon } from "../index"; export type SelectItem = { value: string, @@ -10,7 +12,9 @@ type Props = { label?: string, options: SelectItem[], value?: SelectItem, - onChange: string => void + onChange: string => void, + loading?: boolean, + helpText?: string }; class Select extends React.Component { @@ -28,21 +32,18 @@ class Select extends React.Component { this.props.onChange(event.target.value); }; - renderLabel = () => { - const label = this.props.label; - if (label) { - return ; - } - return ""; - }; - render() { - const { options, value } = this.props; + const { options, value, label, helpText, loading } = this.props; + const loadingClass = loading ? "is-loading" : ""; + return (
- {this.renderLabel()} -
+ +