merge with 2.0.0-m3 branch

This commit is contained in:
Sebastian Sdorra
2018-08-30 11:28:26 +02:00
72 changed files with 3308 additions and 437 deletions

64
pom.xml
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
@@ -118,10 +119,26 @@
<dependencies> <dependencies>
<!-- JUnit 5 -->
<dependency> <dependency>
<groupId>junit</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit</artifactId> <artifactId>junit-jupiter-api</artifactId>
<scope>test</scope> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</dependency> </dependency>
<dependency> <dependency>
@@ -139,15 +156,11 @@
<dependency> <dependency>
<groupId>org.mockito</groupId> <groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId> <artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.assertj</groupId> <groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
<version>3.10.0</version>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
@@ -282,9 +295,32 @@
<version>${jackson.version}</version> <version>${jackson.version}</version>
</dependency> </dependency>
<!-- JUnit 5 -->
<dependency> <dependency>
<groupId>junit</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit</artifactId> <artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>${junit.version}</version> <version>${junit.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
@@ -348,7 +384,11 @@
</pluginManagement> </pluginManagement>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.0</version>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId> <artifactId>maven-enforcer-plugin</artifactId>
@@ -695,7 +735,7 @@
<!-- test libraries --> <!-- test libraries -->
<mockito.version>2.10.0</mockito.version> <mockito.version>2.10.0</mockito.version>
<hamcrest.version>1.3</hamcrest.version> <hamcrest.version>1.3</hamcrest.version>
<junit.version>4.12</junit.version> <junit.version>5.2.0</junit.version>
<!-- logging libraries --> <!-- logging libraries -->
<slf4j.version>1.7.22</slf4j.version> <slf4j.version>1.7.22</slf4j.version>

View File

@@ -1,32 +1,32 @@
/** /*
* Copyright (c) 2010, Sebastian Sdorra Copyright (c) 2010, Sebastian Sdorra
* All rights reserved. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met: modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer. this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice, 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution. and/or other materials provided with the distribution.
* 3. Neither the name of SCM-Manager; nor the names of its 3. Neither the name of SCM-Manager; nor the names of its
* contributors may be used to endorse or promote products derived from this contributors may be used to endorse or promote products derived from this
* software without specific prior written permission. software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 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 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* http://bitbucket.org/sdorra/scm-manager http://bitbucket.org/sdorra/scm-manager
*
*/ */
@@ -57,10 +57,11 @@ import javax.xml.bind.annotation.XmlRootElement;
public class Permission implements PermissionObject, Serializable public class Permission implements PermissionObject, Serializable
{ {
/** Field description */
private static final long serialVersionUID = -2915175031430884040L; private static final long serialVersionUID = -2915175031430884040L;
//~--- constructors --------------------------------------------------------- private boolean groupPermission = false;
private String name;
private PermissionType type = PermissionType.READ;
/** /**
* Constructs a new {@link Permission}. * Constructs a new {@link Permission}.
@@ -153,12 +154,7 @@ public class Permission implements PermissionObject, Serializable
return Objects.hashCode(name, type, groupPermission); return Objects.hashCode(name, type, groupPermission);
} }
/**
* Method description
*
*
* @return
*/
@Override @Override
public String toString() public String toString()
{ {
@@ -242,15 +238,4 @@ public class Permission implements PermissionObject, Serializable
{ {
this.type = type; this.type = type;
} }
//~--- fields ---------------------------------------------------------------
/** Field description */
private boolean groupPermission = false;
/** Field description */
private String name;
/** Field description */
private PermissionType type = PermissionType.READ;
} }

View File

@@ -0,0 +1,11 @@
package sonia.scm.repository;
import java.text.MessageFormat;
public class PermissionAlreadyExistsException extends RepositoryException {
public PermissionAlreadyExistsException(Repository repository, String permissionName) {
super(MessageFormat.format("the permission {0} of the repository {1}/{2} already exists", permissionName, repository.getNamespace(), repository.getName()));
}
}

View File

@@ -0,0 +1,12 @@
package sonia.scm.repository;
import java.text.MessageFormat;
public class PermissionNotFoundException extends RepositoryException{
public PermissionNotFoundException(Repository repository, String permissionName) {
super(MessageFormat.format("the permission {0} of the repository {1}/{2} does not exists", permissionName,repository.getNamespace(), repository.getName() ));
}
}

View File

@@ -42,10 +42,10 @@ public enum PermissionType
{ {
/** read permision */ /** read permision */
READ(0, "repository:read:"), READ(0, "repository:read,pull:"),
/** read and write permissionPrefix */ /** read and write permissionPrefix */
WRITE(10, "repository:read,write:"), WRITE(10, "repository:read,pull,push:"),
/** /**
* read, write and * read, write and

View File

@@ -43,12 +43,7 @@ import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
import sonia.scm.util.ValidationUtil; import sonia.scm.util.ValidationUtil;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.*;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlTransient;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -60,7 +55,7 @@ import java.util.List;
*/ */
@StaticPermissions( @StaticPermissions(
value = "repository", value = "repository",
permissions = {"read", "write", "modify", "delete", "healthCheck"} permissions = {"read", "modify", "delete", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"}
) )
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "repositories") @XmlRootElement(name = "repositories")

View File

@@ -16,6 +16,7 @@ public class VndMediaType {
public static final String USER = PREFIX + "user" + SUFFIX; public static final String USER = PREFIX + "user" + SUFFIX;
public static final String GROUP = PREFIX + "group" + SUFFIX; public static final String GROUP = PREFIX + "group" + SUFFIX;
public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; public static final String REPOSITORY = PREFIX + "repository" + SUFFIX;
public static final String PERMISSION = PREFIX + "permission" + SUFFIX;
public static final String BRANCH = PREFIX + "branch" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX;
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;

View File

@@ -36,13 +36,11 @@ package sonia.scm.web.filter;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.ArgumentIsInvalidException; import sonia.scm.ArgumentIsInvalidException;
import sonia.scm.SCMContext; import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
@@ -53,17 +51,14 @@ import sonia.scm.security.ScmSecurityException;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
//~--- JDK imports ------------------------------------------------------------
import java.io.IOException;
import java.util.Iterator;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import org.apache.shiro.authz.AuthorizationException; import java.io.IOException;
import java.util.Iterator;
//~--- JDK imports ------------------------------------------------------------
/** /**
* Abstract http filter to check repository permissions. * Abstract http filter to check repository permissions.
@@ -339,7 +334,7 @@ public abstract class PermissionFilter extends HttpFilter
if (writeRequest) if (writeRequest)
{ {
permitted = RepositoryPermissions.write(repository).isPermitted(); permitted = RepositoryPermissions.push(repository).isPermitted();
} }
else else
{ {

View File

@@ -0,0 +1,225 @@
/*
* 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.it;
import org.apache.http.HttpStatus;
import org.junit.Before;
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.repository.PermissionType;
import sonia.scm.repository.client.api.RepositoryClient;
import sonia.scm.repository.client.api.RepositoryClientException;
import sonia.scm.web.VndMediaType;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
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;
@RunWith(Parameterized.class)
public class PermissionsITCase {
public static final String USER_READ = "user_read";
public static final String USER_PASS = "pass";
private static final String USER_WRITE = "user_write";
private static final String USER_OWNER = "user_owner";
private static final String USER_OTHER = "user_other";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private final String repositoryType;
private int createdPermissions;
public PermissionsITCase(String repositoryType) {
this.repositoryType = repositoryType;
}
@Parameters(name = "{0}")
public static Collection<String> createParameters() {
return availableScmTypes();
}
@Before
public void prepareEnvironment() {
TestData.createDefault();
TestData.createUser(USER_READ, USER_PASS);
TestData.createUserPermission(USER_READ, PermissionType.READ, repositoryType);
TestData.createUser(USER_WRITE, USER_PASS);
TestData.createUserPermission(USER_WRITE, PermissionType.WRITE, repositoryType);
TestData.createUser(USER_OWNER, USER_PASS);
TestData.createUserPermission(USER_OWNER, PermissionType.OWNER, repositoryType);
TestData.createUser(USER_OTHER, USER_PASS);
createdPermissions = 3;
}
@Test
public void readUserShouldNotSeePermissions() {
assertNull(callRepository(USER_READ, USER_PASS, repositoryType, HttpStatus.SC_OK)
.extract()
.body().jsonPath().getString("_links.permissions.href"));
}
@Test
public void readUserShouldNotSeeBruteForcePermissions() {
given(VndMediaType.PERMISSION, USER_READ, USER_PASS)
.when()
.get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType))
.then()
.statusCode(HttpStatus.SC_FORBIDDEN);
}
@Test
public void writeUserShouldNotSeePermissions() {
assertNull(callRepository(USER_WRITE, USER_PASS, repositoryType, HttpStatus.SC_OK)
.extract()
.body().jsonPath().getString("_links.permissions.href"));
}
@Test
public void writeUserShouldNotSeeBruteForcePermissions() {
given(VndMediaType.PERMISSION, USER_WRITE, USER_PASS)
.when()
.get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType))
.then()
.statusCode(HttpStatus.SC_FORBIDDEN);
}
@Test
public void ownerShouldSeePermissions() {
List<Object> userPermissions = TestData.getUserPermissions(USER_OWNER, USER_PASS, repositoryType);
assertEquals(userPermissions.size(), createdPermissions);
}
@Test
public void otherUserShouldNotSeeRepository() {
callRepository(USER_OTHER, USER_PASS, repositoryType, HttpStatus.SC_FORBIDDEN);
}
@Test
public void otherUserShouldNotSeeBruteForcePermissions() {
given(VndMediaType.PERMISSION, USER_OTHER, USER_PASS)
.when()
.get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType))
.then()
.statusCode(HttpStatus.SC_FORBIDDEN);
}
@Test
public void readUserShouldCloneRepository() throws IOException {
RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), USER_READ, USER_PASS);
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
}
@Test
public void writeUserShouldCloneRepository() throws IOException {
RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), USER_WRITE, USER_PASS);
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
}
@Test
public void ownerShouldCloneRepository() throws IOException {
RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), USER_OWNER, USER_PASS);
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
}
@Test
public void otherUserShouldNotCloneRepository() {
TestData.callRepository(USER_OTHER, USER_PASS, repositoryType, HttpStatus.SC_FORBIDDEN);
}
@Test(expected = RepositoryClientException.class)
public void userWithReadPermissionShouldBeNotAuthorizedToCommit() throws IOException {
createAndCommit(USER_READ);
}
@Test
public void userWithOwnerPermissionShouldBeAuthorizedToCommit() throws IOException {
createAndCommit(USER_OWNER);
}
@Test
public void userWithWritePermissionShouldBeAuthorizedToCommit() throws IOException {
createAndCommit(USER_WRITE);
}
private void createAndCommit(String username) throws IOException {
RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), username, PermissionsITCase.USER_PASS);
addAndCommitRandomFile(client, username);
}
@Test
public void userWithOwnerPermissionShouldBeAuthorizedToDeleteRepository(){
assertDeleteRepositoryOperation(HttpStatus.SC_NO_CONTENT, HttpStatus.SC_NOT_FOUND, USER_OWNER, repositoryType);
}
@Test
public void userWithReadPermissionShouldNotBeAuthorizedToDeleteRepository(){
assertDeleteRepositoryOperation(HttpStatus.SC_FORBIDDEN, HttpStatus.SC_OK, USER_READ, repositoryType);
}
@Test
public void userWithWritePermissionShouldNotBeAuthorizedToDeleteRepository(){
assertDeleteRepositoryOperation(HttpStatus.SC_FORBIDDEN, HttpStatus.SC_OK, USER_WRITE, repositoryType);
}
private void assertDeleteRepositoryOperation(int expectedDeleteStatus, int expectedGetStatus, String user, String repositoryType) {
given(VndMediaType.REPOSITORY, user, PermissionsITCase.USER_PASS)
.when()
.delete(TestData.getDefaultRepositoryUrl(repositoryType))
.then()
.statusCode(expectedDeleteStatus);
given(VndMediaType.REPOSITORY, user, PermissionsITCase.USER_PASS)
.when()
.get(TestData.getDefaultRepositoryUrl(repositoryType))
.then()
.statusCode(expectedGetStatus);
}
}

View File

@@ -34,6 +34,7 @@ package sonia.scm.it;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import org.apache.http.HttpStatus; import org.apache.http.HttpStatus;
import org.assertj.core.api.Assertions;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -41,17 +42,12 @@ import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.junit.runners.Parameterized; import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters; import org.junit.runners.Parameterized.Parameters;
import sonia.scm.repository.Person;
import sonia.scm.repository.client.api.ClientCommand;
import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.repository.client.api.RepositoryClient;
import sonia.scm.repository.client.api.RepositoryClientFactory;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.Collection; import java.util.Collection;
import java.util.UUID; import java.util.Objects;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
@@ -66,8 +62,6 @@ import static sonia.scm.it.TestData.repositoryJson;
@RunWith(Parameterized.class) @RunWith(Parameterized.class)
public class RepositoriesITCase { public class RepositoriesITCase {
public static final Person AUTHOR = new Person("SCM Administrator", "scmadmin@scm-manager.org");
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@@ -142,57 +136,16 @@ public class RepositoriesITCase {
@Test @Test
public void shouldCloneRepository() throws IOException { public void shouldCloneRepository() throws IOException {
RepositoryClient client = createRepositoryClient(); RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.getRoot());
assertEquals("expected metadata dir", 1, client.getWorkingCopy().list().length); assertEquals("expected metadata dir", 1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
} }
@Test @Test
public void shouldCommitFiles() throws IOException { public void shouldCommitFiles() throws IOException {
RepositoryClient client = createRepositoryClient(); RepositoryClient client = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), "scmadmin", "scmadmin");
String name = RepositoryUtil.addAndCommitRandomFile(client, "scmadmin");
for (int i = 0; i < 5; i++) { RepositoryClient checkClient = RepositoryUtil.createRepositoryClient(repositoryType, temporaryFolder.newFolder(), "scmadmin", "scmadmin");
createRandomFile(client); Assertions.assertThat(checkClient.getWorkingCopy().list()).contains(name);
}
commit(client);
RepositoryClient checkClient = createRepositoryClient();
assertEquals("expected 5 files and metadata dir", 6, checkClient.getWorkingCopy().list().length);
} }
private static void createRandomFile(RepositoryClient client) throws IOException {
String uuid = UUID.randomUUID().toString();
String name = "file-" + uuid + ".uuid";
File file = new File(client.getWorkingCopy(), name);
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(uuid.getBytes());
}
client.getAddCommand().add(name);
}
private static void commit(RepositoryClient repositoryClient) throws IOException {
repositoryClient.getCommitCommand().commit(AUTHOR, "commit");
if ( repositoryClient.isCommandSupported(ClientCommand.PUSH) ) {
repositoryClient.getPushCommand().push();
}
}
private RepositoryClient createRepositoryClient() throws IOException {
RepositoryClientFactory clientFactory = new RepositoryClientFactory();
String cloneUrl = readCloneUrl();
return clientFactory.create(repositoryType, cloneUrl, "scmadmin", "scmadmin", temporaryFolder.newFolder());
}
private String readCloneUrl() {
return given(VndMediaType.REPOSITORY)
.when()
.get(repositoryUrl)
.then()
.extract()
.path("_links.httpProtocol.href");
}
} }

View File

@@ -1,18 +1,21 @@
package sonia.scm.it; package sonia.scm.it;
import org.apache.http.HttpStatus; import org.apache.http.HttpStatus;
import org.junit.Assume;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.junit.runners.Parameterized; import org.junit.runners.Parameterized;
import sonia.scm.repository.client.api.ClientCommand;
import sonia.scm.repository.client.api.RepositoryClient;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Collection; import java.util.Collection;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assume.assumeFalse;
import static sonia.scm.it.RestUtil.given; import static sonia.scm.it.RestUtil.given;
import static sonia.scm.it.ScmTypes.availableScmTypes; import static sonia.scm.it.ScmTypes.availableScmTypes;
@@ -23,7 +26,7 @@ public class RepositoryAccessITCase {
public TemporaryFolder tempFolder = new TemporaryFolder(); public TemporaryFolder tempFolder = new TemporaryFolder();
private final String repositoryType; private final String repositoryType;
private RepositoryUtil repositoryUtil; private File folder;
public RepositoryAccessITCase(String repositoryType) { public RepositoryAccessITCase(String repositoryType) {
this.repositoryType = repositoryType; this.repositoryType = repositoryType;
@@ -35,16 +38,18 @@ public class RepositoryAccessITCase {
} }
@Before @Before
public void initClient() throws IOException { public void initClient() {
TestData.createDefault(); TestData.createDefault();
repositoryUtil = new RepositoryUtil(repositoryType, tempFolder.getRoot()); folder = tempFolder.getRoot();
} }
@Test @Test
public void shouldFindBranches() throws IOException { public void shouldFindBranches() throws IOException {
assumeFalse("There are no branches for SVN", repositoryType.equals("svn")); RepositoryClient repositoryClient = RepositoryUtil.createRepositoryClient(repositoryType, folder);
repositoryUtil.createAndCommitFile("a.txt", "a"); Assume.assumeTrue("There are no branches for " + repositoryType, repositoryClient.isCommandSupported(ClientCommand.BRANCH));
RepositoryUtil.createAndCommitFile(repositoryClient, "scmadmin", "a.txt", "a");
String branchesUrl = given() String branchesUrl = given()
.when() .when()

View File

@@ -8,52 +8,42 @@ import sonia.scm.repository.Person;
import sonia.scm.repository.client.api.ClientCommand; import sonia.scm.repository.client.api.ClientCommand;
import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.repository.client.api.RepositoryClient;
import sonia.scm.repository.client.api.RepositoryClientFactory; import sonia.scm.repository.client.api.RepositoryClientFactory;
import sonia.scm.web.VndMediaType;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.UUID;
import static sonia.scm.it.RestUtil.ADMIN_PASSWORD;
import static sonia.scm.it.RestUtil.ADMIN_USERNAME;
import static sonia.scm.it.RestUtil.given;
public class RepositoryUtil { public class RepositoryUtil {
private static final RepositoryClientFactory REPOSITORY_CLIENT_FACTORY = new RepositoryClientFactory(); private static final RepositoryClientFactory REPOSITORY_CLIENT_FACTORY = new RepositoryClientFactory();
private final RepositoryClient repositoryClient; static RepositoryClient createRepositoryClient(String repositoryType, File folder) throws IOException {
private final File folder; return createRepositoryClient(repositoryType, folder, "scmadmin", "scmadmin");
RepositoryUtil(String repositoryType, File folder) throws IOException {
this.repositoryClient = createRepositoryClient(repositoryType, folder);
this.folder = folder;
} }
static RepositoryClient createRepositoryClient(String repositoryType, File folder) throws IOException { static RepositoryClient createRepositoryClient(String repositoryType, File folder, String username, String password) throws IOException {
String httpProtocolUrl = given(VndMediaType.REPOSITORY) String httpProtocolUrl = TestData.callRepository(username, password, repositoryType, HttpStatus.SC_OK)
.when()
.get(TestData.getDefaultRepositoryUrl(repositoryType))
.then()
.statusCode(HttpStatus.SC_OK)
.extract() .extract()
.path("_links.httpProtocol.href"); .path("_links.httpProtocol.href");
return REPOSITORY_CLIENT_FACTORY.create(repositoryType, httpProtocolUrl, username, password, folder);
return REPOSITORY_CLIENT_FACTORY.create(repositoryType, httpProtocolUrl, ADMIN_USERNAME, ADMIN_PASSWORD, folder);
} }
void createAndCommitFile(String fileName, String content) throws IOException { static String addAndCommitRandomFile(RepositoryClient client, String username) throws IOException {
Files.write(content, new File(folder, fileName), Charsets.UTF_8); String uuid = UUID.randomUUID().toString();
String name = "file-" + uuid + ".uuid";
createAndCommitFile(client, username, name, uuid);
return name;
}
static void createAndCommitFile(RepositoryClient repositoryClient, String username, String fileName, String content) throws IOException {
Files.write(content, new File(repositoryClient.getWorkingCopy(), fileName), Charsets.UTF_8);
repositoryClient.getAddCommand().add(fileName); repositoryClient.getAddCommand().add(fileName);
commit("added " + fileName); commit(repositoryClient, username, "added " + fileName);
} }
Changeset commit(String message) throws IOException { static Changeset commit(RepositoryClient repositoryClient, String username, String message) throws IOException {
Changeset changeset = repositoryClient.getCommitCommand().commit( Changeset changeset = repositoryClient.getCommitCommand().commit(new Person(username, username + "@scm-manager.org"), message);
new Person("scmadmin", "scmadmin@scm-manager.org"), message
);
if (repositoryClient.isCommandSupported(ClientCommand.PUSH)) { if (repositoryClient.isCommandSupported(ClientCommand.PUSH)) {
repositoryClient.getPushCommand().push(); repositoryClient.getPushCommand().push();
} }

View File

@@ -19,15 +19,19 @@ public class RestUtil {
public static final String ADMIN_USERNAME = "scmadmin"; public static final String ADMIN_USERNAME = "scmadmin";
public static final String ADMIN_PASSWORD = "scmadmin"; public static final String ADMIN_PASSWORD = "scmadmin";
public static RequestSpecification given(String mediaType) {
return RestAssured.given()
.contentType(mediaType)
.accept(mediaType)
.auth().preemptive().basic(ADMIN_USERNAME, ADMIN_PASSWORD);
}
public static RequestSpecification given() { public static RequestSpecification given() {
return RestAssured.given() return RestAssured.given()
.auth().preemptive().basic(ADMIN_USERNAME, ADMIN_PASSWORD); .auth().preemptive().basic(ADMIN_USERNAME, ADMIN_PASSWORD);
} }
public static RequestSpecification given(String mediaType) {
return given(mediaType, ADMIN_USERNAME, ADMIN_PASSWORD);
}
public static RequestSpecification given(String mediaType, String username, String password) {
return RestAssured.given()
.contentType(mediaType)
.accept(mediaType)
.auth().preemptive().basic(username, password);
}
} }

View File

@@ -1,8 +1,10 @@
package sonia.scm.it; package sonia.scm.it;
import io.restassured.response.ValidatableResponse;
import org.apache.http.HttpStatus; import org.apache.http.HttpStatus;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.PermissionType;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import javax.json.Json; import javax.json.Json;
@@ -19,7 +21,9 @@ public class TestData {
private static final Logger LOG = LoggerFactory.getLogger(TestData.class); private static final Logger LOG = LoggerFactory.getLogger(TestData.class);
private static final List<String> PROTECTED_USERS = asList("scmadmin", "anonymous"); public static final String USER_SCM_ADMIN = "scmadmin";
public static final String USER_ANONYMOUS = "anonymous";
private static final List<String> PROTECTED_USERS = asList(USER_SCM_ADMIN, USER_ANONYMOUS);
private static Map<String, String> DEFAULT_REPOSITORIES = new HashMap<>(); private static Map<String, String> DEFAULT_REPOSITORIES = new HashMap<>();
@@ -38,6 +42,77 @@ public class TestData {
return DEFAULT_REPOSITORIES.get(repositoryType); return DEFAULT_REPOSITORIES.get(repositoryType);
} }
public static void createUser(String username, String password) {
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"))
.then()
.statusCode(HttpStatus.SC_CREATED)
;
}
public static void createUserPermission(String name, PermissionType permissionType, String repositoryType) {
given(VndMediaType.PERMISSION)
.when()
.content("{\n" +
"\t\"type\": \"" + permissionType.name() + "\",\n" +
"\t\"name\": \"" + name + "\",\n" +
"\t\"groupPermission\": false\n" +
"\t\n" +
"}")
.post(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType))
.then()
.statusCode(HttpStatus.SC_CREATED)
;
}
public static List<Object> getUserPermissions(String username, String password, String repositoryType) {
return callUserPermissions(username, password, repositoryType, HttpStatus.SC_OK)
.extract()
.body().jsonPath().getList("_embedded.permissions");
}
public static ValidatableResponse callUserPermissions(String username, String password, String repositoryType, int expectedStatusCode) {
return given(VndMediaType.PERMISSION, username, password)
.when()
.get(TestData.getDefaultPermissionUrl(username, password, repositoryType))
.then()
.statusCode(expectedStatusCode);
}
public static ValidatableResponse callRepository(String username, String password, String repositoryType, int expectedStatusCode) {
return given(VndMediaType.REPOSITORY, username, password)
.when()
.get(getDefaultRepositoryUrl(repositoryType))
.then()
.statusCode(expectedStatusCode);
}
public static String getDefaultPermissionUrl(String username, String password, String repositoryType) {
return given(VndMediaType.REPOSITORY, username, password)
.when()
.get(getDefaultRepositoryUrl(repositoryType))
.then()
.statusCode(HttpStatus.SC_OK)
.extract()
.body().jsonPath().getString("_links.permissions.href");
}
private static void cleanupRepositories() { private static void cleanupRepositories() {
List<String> repositories = given(VndMediaType.REPOSITORY_COLLECTION) List<String> repositories = given(VndMediaType.REPOSITORY_COLLECTION)
.when() .when()

View File

@@ -28,12 +28,6 @@
<version>2.0.0-SNAPSHOT</version> <version>2.0.0-SNAPSHOT</version>
</dependency> </dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>compile</scope>
</dependency>
<dependency> <dependency>
<groupId>com.github.sdorra</groupId> <groupId>com.github.sdorra</groupId>
<artifactId>shiro-unit</artifactId> <artifactId>shiro-unit</artifactId>

View File

@@ -32,7 +32,8 @@
"repositories": "Repositories", "repositories": "Repositories",
"users": "Users", "users": "Users",
"logout": "Logout", "logout": "Logout",
"groups": "Groups" "groups": "Groups",
"config": "Configuration"
}, },
"paginator": { "paginator": {
"next": "Next", "next": "Next",

View File

@@ -0,0 +1,68 @@
{
"config": {
"navigation-title": "Navigation"
},
"global-config": {
"title": "Configuration",
"navigation-label": "Global Configuration",
"error-title": "Error",
"error-subtitle": "Unknown Config Error"
},
"config-form": {
"submit": "Submit",
"no-permission-notification": "Please note: You do not have the permission to edit the config!"
},
"proxy-settings": {
"name": "Proxy Settings",
"proxy-password": "Proxy Password",
"proxy-port": "Proxy Port",
"proxy-server": "Proxy Server",
"proxy-user": "Proxy User",
"enable-proxy": "Enable Proxy",
"proxy-excludes": "Proxy Excludes",
"remove-proxy-exclude-button": "Remove Proxy Exclude",
"add-proxy-exclude-error": "The proxy exclude you want to add is not valid",
"add-proxy-exclude-textfield": "Add proxy exclude you want to add to proxy excludes here",
"add-proxy-exclude-button": "Add Proxy Exclude"
},
"base-url-settings": {
"name": "Base URL Settings",
"base-url": "Base URL",
"force-base-url": "Force Base URL"
},
"admin-settings": {
"name": "Administration Settings",
"admin-groups": "Admin Groups",
"admin-users": "Admin Users",
"remove-group-button": "Remove Admin Group",
"remove-user-button": "Remove Admin User",
"add-group-error": "The group name you want to add is not valid",
"add-group-textfield": "Add group you want to add to admin groups here",
"add-group-button": "Add Admin Group",
"add-user-error": "The user name you want to add is not valid",
"add-user-textfield": "Add user you want to add to admin users here",
"add-user-button": "Add Admin User"
},
"login-attempt": {
"name": "Login Attempt",
"login-attempt-limit": "Login Attempt Limit",
"login-attempt-limit-timeout": "Login Attempt Limit Timeout"
},
"general-settings": {
"realm-description": "Realm Description",
"enable-repository-archive": "Enable Repository Archive",
"disable-grouping-grid": "Disable Grouping Grid",
"date-format": "Date Format",
"anonymous-access-enabled": "Anonymous Access Enabled",
"skip-failed-authenticators": "Skip Failed Authenticators",
"plugin-url": "Plugin URL",
"enabled-xsrf-protection": "Enabled XSRF Protection",
"default-namespace-strategy": "Default Namespace Strategy"
},
"validation": {
"date-format-invalid": "The date format is not valid",
"login-attempt-limit-timeout-invalid": "This is not a number",
"login-attempt-limit-invalid": "This is not a number",
"plugin-url-invalid": "This is not a valid url"
}
}

View File

@@ -0,0 +1,33 @@
//@flow
import React from "react";
import { DeleteButton } from ".";
import classNames from "classnames";
type Props = {
entryname: string,
removeEntry: string => void,
disabled: boolean,
label: string
};
type State = {};
class RemoveEntryOfTableButton extends React.Component<Props, State> {
render() {
const { label, entryname, removeEntry, disabled } = this.props;
return (
<div className={classNames("is-pulled-right")}>
<DeleteButton
label={label}
action={(event: Event) => {
event.preventDefault();
removeEntry(entryname);
}}
disabled={disabled}
/>
</div>
);
}
}
export default RemoveEntryOfTableButton;

View File

@@ -4,3 +4,4 @@ export { default as CreateButton } from "./CreateButton";
export { default as DeleteButton } from "./DeleteButton"; export { default as DeleteButton } from "./DeleteButton";
export { default as EditButton } from "./EditButton"; export { default as EditButton } from "./EditButton";
export { default as SubmitButton } from "./SubmitButton"; export { default as SubmitButton } from "./SubmitButton";
export {default as RemoveEntryOfTableButton} from "./RemoveEntryOfTableButton";

View File

@@ -0,0 +1,68 @@
//@flow
import React from "react";
import { AddButton } from "../buttons";
import InputField from "./InputField";
type Props = {
addEntry: string => void,
disabled: boolean,
buttonLabel: string,
fieldLabel: string,
errorMessage: string
};
type State = {
entryToAdd: string
};
class AddEntryToTableField extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
entryToAdd: ""
};
}
render() {
const { disabled, buttonLabel, fieldLabel, errorMessage } = this.props;
return (
<div className="field">
<InputField
label={fieldLabel}
errorMessage={errorMessage}
onChange={this.handleAddEntryChange}
validationError={false}
value={this.state.entryToAdd}
onReturnPressed={this.appendEntry}
disabled={disabled}
/>
<AddButton
label={buttonLabel}
action={this.addButtonClicked}
disabled={disabled}
/>
</div>
);
}
addButtonClicked = (event: Event) => {
event.preventDefault();
this.appendEntry();
};
appendEntry = () => {
const { entryToAdd } = this.state;
this.props.addEntry(entryToAdd);
this.setState({ ...this.state, entryToAdd: "" });
};
handleAddEntryChange = (entryname: string) => {
this.setState({
...this.state,
entryToAdd: entryname
});
};
}
export default AddEntryToTableField;

View File

@@ -4,7 +4,8 @@ import React from "react";
type Props = { type Props = {
label?: string, label?: string,
checked: boolean, checked: boolean,
onChange?: boolean => void onChange?: boolean => void,
disabled?: boolean
}; };
class Checkbox extends React.Component<Props> { class Checkbox extends React.Component<Props> {
onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => { onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => {
@@ -17,11 +18,12 @@ class Checkbox extends React.Component<Props> {
return ( return (
<div className="field"> <div className="field">
<div className="control"> <div className="control">
<label className="checkbox"> <label className="checkbox" disabled={this.props.disabled}>
<input <input
type="checkbox" type="checkbox"
checked={this.props.checked} checked={this.props.checked}
onChange={this.onCheckboxChange} onChange={this.onCheckboxChange}
disabled={this.props.disabled}
/> />
{this.props.label} {this.props.label}
</label> </label>

View File

@@ -11,7 +11,8 @@ type Props = {
onChange: string => void, onChange: string => void,
onReturnPressed?: () => void, onReturnPressed?: () => void,
validationError: boolean, validationError: boolean,
errorMessage: string errorMessage: string,
disabled?: boolean
}; };
class InputField extends React.Component<Props> { class InputField extends React.Component<Props> {
@@ -40,21 +41,33 @@ class InputField extends React.Component<Props> {
return ""; return "";
}; };
handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => { handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
const onReturnPressed = this.props.onReturnPressed; const onReturnPressed = this.props.onReturnPressed;
if (!onReturnPressed) { if (!onReturnPressed) {
return return;
} }
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
onReturnPressed(); onReturnPressed();
} }
} };
render() { render() {
const { type, placeholder, value, validationError, errorMessage } = this.props; const {
type,
placeholder,
value,
validationError,
errorMessage,
disabled
} = this.props;
const errorView = validationError ? "is-danger" : ""; const errorView = validationError ? "is-danger" : "";
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : ""; const helper = validationError ? (
<p className="help is-danger">{errorMessage}</p>
) : (
""
);
return ( return (
<div className="field"> <div className="field">
{this.renderLabel()} {this.renderLabel()}
@@ -63,15 +76,13 @@ class InputField extends React.Component<Props> {
ref={input => { ref={input => {
this.field = input; this.field = input;
}} }}
className={ classNames( className={classNames("input", errorView)}
"input",
errorView
)}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={this.handleInput} onChange={this.handleInput}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
disabled={disabled}
/> />
</div> </div>
{helper} {helper}

View File

@@ -2,9 +2,11 @@
import * as React from "react"; import * as React from "react";
import Loading from "./../Loading"; import Loading from "./../Loading";
import ErrorNotification from "./../ErrorNotification"; import ErrorNotification from "./../ErrorNotification";
import Title from "./Title";
import Subtitle from "./Subtitle";
type Props = { type Props = {
title: string, title?: string,
subtitle?: string, subtitle?: string,
loading?: boolean, loading?: boolean,
error?: Error, error?: Error,
@@ -14,12 +16,12 @@ type Props = {
class Page extends React.Component<Props> { class Page extends React.Component<Props> {
render() { render() {
const { title, error } = this.props; const { title, error, subtitle } = this.props;
return ( return (
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<h1 className="title">{title}</h1> <Title title={title} />
{this.renderSubtitle()} <Subtitle subtitle={subtitle} />
<ErrorNotification error={error} /> <ErrorNotification error={error} />
{this.renderContent()} {this.renderContent()}
</div> </div>
@@ -27,14 +29,6 @@ class Page extends React.Component<Props> {
); );
} }
renderSubtitle() {
const { subtitle } = this.props;
if (subtitle) {
return <h2 className="subtitle">{subtitle}</h2>;
}
return null;
}
renderContent() { renderContent() {
const { loading, children, showContentOnError, error } = this.props; const { loading, children, showContentOnError, error } = this.props;
if (error && !showContentOnError) { if (error && !showContentOnError) {

View File

@@ -0,0 +1,17 @@
import React from "react";
type Props = {
subtitle?: string
};
class Subtitle extends React.Component<Props> {
render() {
const { subtitle } = this.props;
if (subtitle) {
return <h1 className="subtitle">{subtitle}</h1>;
}
return null;
}
}
export default Subtitle;

View File

@@ -0,0 +1,17 @@
import React from "react";
type Props = {
title?: string
};
class Title extends React.Component<Props> {
render() {
const { title } = this.props;
if (title) {
return <h1 className="title">{title}</h1>;
}
return null;
}
}
export default Title;

View File

@@ -28,6 +28,10 @@ class PrimaryNavigation extends React.Component<Props> {
match="/(group|groups)" match="/(group|groups)"
label={t("primary-navigation.groups")} label={t("primary-navigation.groups")}
/> />
<PrimaryNavigationLink
to="/config"
label={t("primary-navigation.config")}
/>
<PrimaryNavigationLink <PrimaryNavigationLink
to="/logout" to="/logout"
label={t("primary-navigation.logout")} label={t("primary-navigation.logout")}

View File

@@ -10,3 +10,7 @@ const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
export const isMailValid = (mail: string) => { export const isMailValid = (mail: string) => {
return mailRegex.test(mail); return mailRegex.test(mail);
}; };
export const isNumberValid = (number: string) => {
return !isNaN(number);
};

View File

@@ -85,3 +85,18 @@ describe("test mail validation", () => {
} }
}); });
}); });
describe("test number validation", () => {
it("should return false", () => {
const invalid = ["1a", "35gu", "dj6", "45,5", "test"];
for (let number of invalid) {
expect(validator.isNumberValid(number)).toBe(false);
}
});
it("should return true", () => {
const valid = ["1", "35", "2", "235", "34.4"];
for (let number of valid) {
expect(validator.isNumberValid(number)).toBe(true);
}
});
});

View File

@@ -0,0 +1,87 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import Subtitle from "../../../components/layout/Subtitle";
import AdminGroupTable from "../table/AdminGroupTable";
import AdminUserTable from "../table/AdminUserTable";
import AddEntryToTableField from "../../../components/forms/AddEntryToTableField";
type Props = {
adminGroups: string[],
adminUsers: string[],
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class AdminSettings extends React.Component<Props> {
render() {
const { t, adminGroups, adminUsers, hasUpdatePermission } = this.props;
return (
<div>
<Subtitle subtitle={t("admin-settings.name")} />
<AdminGroupTable
adminGroups={adminGroups}
onChange={(isValid, changedValue, name) =>
this.props.onChange(isValid, changedValue, name)
}
disabled={!hasUpdatePermission}
/>
<AddEntryToTableField
addEntry={this.addGroup}
disabled={!hasUpdatePermission}
buttonLabel={t("admin-settings.add-group-button")}
fieldLabel={t("admin-settings.add-group-textfield")}
errorMessage={t("admin-settings.add-group-error")}
/>
<AdminUserTable
adminUsers={adminUsers}
onChange={(isValid, changedValue, name) =>
this.props.onChange(isValid, changedValue, name)
}
disabled={!hasUpdatePermission}
/>
<AddEntryToTableField
addEntry={this.addUser}
disabled={!hasUpdatePermission}
buttonLabel={t("admin-settings.add-user-button")}
fieldLabel={t("admin-settings.add-user-textfield")}
errorMessage={t("admin-settings.add-user-error")}
/>
</div>
);
}
addGroup = (groupname: string) => {
if (this.isAdminGroupMember(groupname)) {
return;
}
this.props.onChange(
true,
[...this.props.adminGroups, groupname],
"adminGroups"
);
};
isAdminGroupMember = (groupname: string) => {
return this.props.adminGroups.includes(groupname);
};
addUser = (username: string) => {
if (this.isAdminUserMember(username)) {
return;
}
this.props.onChange(
true,
[...this.props.adminUsers, username],
"adminUsers"
);
};
isAdminUserMember = (username: string) => {
return this.props.adminUsers.includes(username);
};
}
export default translate("config")(AdminSettings);

View File

@@ -0,0 +1,46 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField } from "../../../components/forms/index";
import Subtitle from "../../../components/layout/Subtitle";
type Props = {
baseUrl: string,
forceBaseUrl: boolean,
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class BaseUrlSettings extends React.Component<Props> {
render() {
const { t, baseUrl, forceBaseUrl, hasUpdatePermission } = this.props;
return (
<div>
<Subtitle subtitle={t("base-url-settings.name")} />
<Checkbox
checked={forceBaseUrl}
label={t("base-url-settings.force-base-url")}
onChange={this.handleForceBaseUrlChange}
disabled={!hasUpdatePermission}
/>
<InputField
label={t("base-url-settings.base-url")}
onChange={this.handleBaseUrlChange}
value={baseUrl}
disabled={!hasUpdatePermission}
/>
</div>
);
}
handleBaseUrlChange = (value: string) => {
this.props.onChange(true, value, "baseUrl");
};
handleForceBaseUrlChange = (value: boolean) => {
this.props.onChange(true, value, "forceBaseUrl");
};
}
export default translate("config")(BaseUrlSettings);

View File

@@ -0,0 +1,195 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { SubmitButton } from "../../../components/buttons/index";
import type { Config } from "../../types/Config";
import ProxySettings from "./ProxySettings";
import GeneralSettings from "./GeneralSettings";
import BaseUrlSettings from "./BaseUrlSettings";
import AdminSettings from "./AdminSettings";
import Notification from "../../../components/Notification";
import LoginAttempt from "./LoginAttempt";
type Props = {
submitForm: Config => void,
config?: Config,
loading?: boolean,
t: string => string,
configUpdatePermission: boolean
};
type State = {
config: Config,
showNotification: boolean,
error: {
loginAttemptLimitTimeout: boolean,
loginAttemptLimit: boolean
}
};
class ConfigForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
config: {
proxyPassword: null,
proxyPort: 0,
proxyServer: "",
proxyUser: null,
enableProxy: false,
realmDescription: "",
enableRepositoryArchive: false,
disableGroupingGrid: false,
dateFormat: "",
anonymousAccessEnabled: false,
adminGroups: [],
adminUsers: [],
baseUrl: "",
forceBaseUrl: false,
loginAttemptLimit: 0,
proxyExcludes: [],
skipFailedAuthenticators: false,
pluginUrl: "",
loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "",
_links: {}
},
showNotification: false,
error: {
loginAttemptLimitTimeout: false,
loginAttemptLimit: false
}
};
}
componentDidMount() {
const { config, configUpdatePermission } = this.props;
if (config) {
this.setState({ ...this.state, config: { ...config } });
}
if (!configUpdatePermission) {
this.setState({ ...this.state, showNotification: true });
}
}
submit = (event: Event) => {
event.preventDefault();
this.props.submitForm(this.state.config);
};
render() {
const { loading, t, configUpdatePermission } = this.props;
const config = this.state.config;
let noPermissionNotification = null;
if (this.state.showNotification) {
noPermissionNotification = (
<Notification
type={"info"}
children={t("config-form.no-permission-notification")}
onClose={() => this.onClose()}
/>
);
}
return (
<form onSubmit={this.submit}>
{noPermissionNotification}
<GeneralSettings
realmDescription={config.realmDescription}
enableRepositoryArchive={config.enableRepositoryArchive}
disableGroupingGrid={config.disableGroupingGrid}
dateFormat={config.dateFormat}
anonymousAccessEnabled={config.anonymousAccessEnabled}
skipFailedAuthenticators={config.skipFailedAuthenticators}
pluginUrl={config.pluginUrl}
enabledXsrfProtection={config.enabledXsrfProtection}
defaultNamespaceStrategy={config.defaultNamespaceStrategy}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<LoginAttempt
loginAttemptLimit={config.loginAttemptLimit}
loginAttemptLimitTimeout={config.loginAttemptLimitTimeout}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<BaseUrlSettings
baseUrl={config.baseUrl}
forceBaseUrl={config.forceBaseUrl}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<AdminSettings
adminGroups={config.adminGroups}
adminUsers={config.adminUsers}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<ProxySettings
proxyPassword={config.proxyPassword ? config.proxyPassword : ""}
proxyPort={config.proxyPort}
proxyServer={config.proxyServer ? config.proxyServer : ""}
proxyUser={config.proxyUser ? config.proxyUser : ""}
enableProxy={config.enableProxy}
proxyExcludes={config.proxyExcludes}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<SubmitButton
loading={loading}
label={t("config-form.submit")}
disabled={!configUpdatePermission || this.hasError()}
/>
</form>
);
}
onChange = (isValid: boolean, changedValue: any, name: string) => {
this.setState({
...this.state,
config: {
...this.state.config,
[name]: changedValue
},
error: {
...this.state.error,
[name]: !isValid
}
});
};
hasError = () => {
return (
this.state.error.loginAttemptLimit ||
this.state.error.loginAttemptLimitTimeout
);
};
onClose = () => {
this.setState({
...this.state,
showNotification: false
});
};
}
export default translate("config")(ConfigForm);

View File

@@ -0,0 +1,128 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField } from "../../../components/forms/index";
type Props = {
realmDescription: string,
enableRepositoryArchive: boolean,
disableGroupingGrid: boolean,
dateFormat: string,
anonymousAccessEnabled: boolean,
skipFailedAuthenticators: boolean,
pluginUrl: string,
enabledXsrfProtection: boolean,
defaultNamespaceStrategy: string,
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class GeneralSettings extends React.Component<Props> {
render() {
const {
t,
realmDescription,
enableRepositoryArchive,
disableGroupingGrid,
dateFormat,
anonymousAccessEnabled,
skipFailedAuthenticators,
pluginUrl,
enabledXsrfProtection,
defaultNamespaceStrategy,
hasUpdatePermission
} = this.props;
return (
<div>
<InputField
label={t("general-settings.realm-description")}
onChange={this.handleRealmDescriptionChange}
value={realmDescription}
disabled={!hasUpdatePermission}
/>
<InputField
label={t("general-settings.date-format")}
onChange={this.handleDateFormatChange}
value={dateFormat}
disabled={!hasUpdatePermission}
/>
<InputField
label={t("general-settings.plugin-url")}
onChange={this.handlePluginUrlChange}
value={pluginUrl}
disabled={!hasUpdatePermission}
/>
<InputField
label={t("general-settings.default-namespace-strategy")}
onChange={this.handleDefaultNamespaceStrategyChange}
value={defaultNamespaceStrategy}
disabled={!hasUpdatePermission}
/>
<Checkbox
checked={enabledXsrfProtection}
label={t("general-settings.enabled-xsrf-protection")}
onChange={this.handleEnabledXsrfProtectionChange}
disabled={!hasUpdatePermission}
/>
<Checkbox
checked={enableRepositoryArchive}
label={t("general-settings.enable-repository-archive")}
onChange={this.handleEnableRepositoryArchiveChange}
disabled={!hasUpdatePermission}
/>
<Checkbox
checked={disableGroupingGrid}
label={t("general-settings.disable-grouping-grid")}
onChange={this.handleDisableGroupingGridChange}
disabled={!hasUpdatePermission}
/>
<Checkbox
checked={anonymousAccessEnabled}
label={t("general-settings.anonymous-access-enabled")}
onChange={this.handleAnonymousAccessEnabledChange}
disabled={!hasUpdatePermission}
/>
<Checkbox
checked={skipFailedAuthenticators}
label={t("general-settings.skip-failed-authenticators")}
onChange={this.handleSkipFailedAuthenticatorsChange}
disabled={!hasUpdatePermission}
/>
</div>
);
}
handleRealmDescriptionChange = (value: string) => {
this.props.onChange(true, value, "realmDescription");
};
handleEnableRepositoryArchiveChange = (value: boolean) => {
this.props.onChange(true, value, "enableRepositoryArchive");
};
handleDisableGroupingGridChange = (value: boolean) => {
this.props.onChange(true, value, "disableGroupingGrid");
};
handleDateFormatChange = (value: string) => {
this.props.onChange(true, value, "dateFormat");
};
handleAnonymousAccessEnabledChange = (value: string) => {
this.props.onChange(true, value, "anonymousAccessEnabled");
};
handleSkipFailedAuthenticatorsChange = (value: string) => {
this.props.onChange(true, value, "skipFailedAuthenticators");
};
handlePluginUrlChange = (value: string) => {
this.props.onChange(true, value, "pluginUrl");
};
handleEnabledXsrfProtectionChange = (value: boolean) => {
this.props.onChange(true, value, "enabledXsrfProtection");
};
handleDefaultNamespaceStrategyChange = (value: string) => {
this.props.onChange(true, value, "defaultNamespaceStrategy");
};
}
export default translate("config")(GeneralSettings);

View File

@@ -0,0 +1,87 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { InputField } from "../../../components/forms/index";
import Subtitle from "../../../components/layout/Subtitle";
import * as validator from "../../../components/validation";
type Props = {
loginAttemptLimit: number,
loginAttemptLimitTimeout: number,
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
type State = {
loginAttemptLimitError: boolean,
loginAttemptLimitTimeoutError: boolean
};
class LoginAttempt extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loginAttemptLimitError: false,
loginAttemptLimitTimeoutError: false
};
}
render() {
const {
t,
loginAttemptLimit,
loginAttemptLimitTimeout,
hasUpdatePermission
} = this.props;
return (
<div>
<Subtitle subtitle={t("login-attempt.name")} />
<InputField
label={t("login-attempt.login-attempt-limit")}
onChange={this.handleLoginAttemptLimitChange}
value={loginAttemptLimit}
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitError}
errorMessage={t("validation.login-attempt-limit-invalid")}
/>
<InputField
label={t("login-attempt.login-attempt-limit-timeout")}
onChange={this.handleLoginAttemptLimitTimeoutChange}
value={loginAttemptLimitTimeout}
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitTimeoutError}
errorMessage={t("validation.login-attempt-limit-timeout-invalid")}
/>
</div>
);
}
//TODO: set Error in ConfigForm to disable Submit Button!
handleLoginAttemptLimitChange = (value: string) => {
this.setState({
...this.state,
loginAttemptLimitError: !validator.isNumberValid(value)
});
this.props.onChange(
validator.isNumberValid(value),
value,
"loginAttemptLimit"
);
};
handleLoginAttemptLimitTimeoutChange = (value: string) => {
this.setState({
...this.state,
loginAttemptLimitTimeoutError: !validator.isNumberValid(value)
});
this.props.onChange(
validator.isNumberValid(value),
value,
"loginAttemptLimitTimeout"
);
};
}
export default translate("config")(LoginAttempt);

View File

@@ -0,0 +1,118 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField } from "../../../components/forms/index";
import Subtitle from "../../../components/layout/Subtitle";
import ProxyExcludesTable from "../table/ProxyExcludesTable";
import AddEntryToTableField from "../../../components/forms/AddEntryToTableField";
type Props = {
proxyPassword: string,
proxyPort: number,
proxyServer: string,
proxyUser: string,
enableProxy: boolean,
proxyExcludes: string[],
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class ProxySettings extends React.Component<Props> {
render() {
const {
t,
proxyPassword,
proxyPort,
proxyServer,
proxyUser,
enableProxy,
proxyExcludes,
hasUpdatePermission
} = this.props;
return (
<div>
<Subtitle subtitle={t("proxy-settings.name")} />
<Checkbox
checked={enableProxy}
label={t("proxy-settings.enable-proxy")}
onChange={this.handleEnableProxyChange}
disabled={!hasUpdatePermission}
/>
<InputField
label={t("proxy-settings.proxy-password")}
onChange={this.handleProxyPasswordChange}
value={proxyPassword}
type="password"
disabled={!enableProxy || !hasUpdatePermission}
/>
<InputField
label={t("proxy-settings.proxy-port")}
value={proxyPort}
onChange={this.handleProxyPortChange}
disabled={!enableProxy || !hasUpdatePermission}
/>
<InputField
label={t("proxy-settings.proxy-server")}
value={proxyServer}
onChange={this.handleProxyServerChange}
disabled={!enableProxy || !hasUpdatePermission}
/>
<InputField
label={t("proxy-settings.proxy-user")}
value={proxyUser}
onChange={this.handleProxyUserChange}
disabled={!enableProxy || !hasUpdatePermission}
/>
<ProxyExcludesTable
proxyExcludes={proxyExcludes}
onChange={(isValid, changedValue, name) =>
this.props.onChange(isValid, changedValue, name)
}
disabled={!enableProxy || !hasUpdatePermission}
/>
<AddEntryToTableField
addEntry={this.addProxyExclude}
disabled={!enableProxy || !hasUpdatePermission}
buttonLabel={t("proxy-settings.add-proxy-exclude-button")}
fieldLabel={t("proxy-settings.add-proxy-exclude-textfield")}
errorMessage={t("proxy-settings.add-proxy-exclude-error")}
/>
</div>
);
}
handleProxyPasswordChange = (value: string) => {
this.props.onChange(true, value, "proxyPassword");
};
handleProxyPortChange = (value: string) => {
this.props.onChange(true, value, "proxyPort");
};
handleProxyServerChange = (value: string) => {
this.props.onChange(true, value, "proxyServer");
};
handleProxyUserChange = (value: string) => {
this.props.onChange(true, value, "proxyUser");
};
handleEnableProxyChange = (value: string) => {
this.props.onChange(true, value, "enableProxy");
};
addProxyExclude = (proxyExcludeName: string) => {
if (this.isProxyExcludeMember(proxyExcludeName)) {
return;
}
this.props.onChange(
true,
[...this.props.proxyExcludes, proxyExcludeName],
"proxyExcludes"
);
};
isProxyExcludeMember = (proxyExcludeName: string) => {
return this.props.proxyExcludes.includes(proxyExcludeName);
};
}
export default translate("config")(ProxySettings);

View File

@@ -0,0 +1,36 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import ArrayConfigTable from "./ArrayConfigTable";
type Props = {
adminGroups: string[],
onChange: (boolean, any, string) => void,
disabled: boolean,
// context props
t: string => string
};
type State = {};
class AdminGroupTable extends React.Component<Props, State> {
render() {
const { t, disabled, adminGroups } = this.props;
return (
<ArrayConfigTable
items={adminGroups}
label={t("admin-settings.admin-groups")}
removeLabel={t("admin-settings.remove-group-button")}
onRemove={this.removeEntry}
disabled={disabled}
/>
);
}
removeEntry = (newGroups: string[]) => {
this.props.onChange(true, newGroups, "adminGroups");
};
}
export default translate("config")(AdminGroupTable);

View File

@@ -0,0 +1,34 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import ArrayConfigTable from "./ArrayConfigTable";
type Props = {
adminUsers: string[],
onChange: (boolean, any, string) => void,
disabled: boolean,
// context props
t: string => string
};
class AdminUserTable extends React.Component<Props> {
render() {
const { adminUsers, t, disabled } = this.props;
return (
<ArrayConfigTable
items={adminUsers}
label={t("admin-settings.admin-users")}
removeLabel={t("admin-settings.remove-user-button")}
onRemove={this.removeEntry}
disabled={disabled}
/>
);
}
removeEntry = (newUsers: string[]) => {
this.props.onChange(true, newUsers, "adminUsers");
};
}
export default translate("config")(AdminUserTable);

View File

@@ -0,0 +1,48 @@
//@flow
import React from "react";
import { RemoveEntryOfTableButton } from "../../../components/buttons";
type Props = {
items: string[],
label: string,
removeLabel: string,
onRemove: (string[], string) => void,
disabled: boolean
};
class ArrayConfigTable extends React.Component<Props> {
render() {
const { label, disabled, removeLabel, items } = this.props;
return (
<div>
<label className="label">{label}</label>
<table className="table is-hoverable is-fullwidth">
<tbody>
{items.map(item => {
return (
<tr key={item}>
<td>{item}</td>
<td>
<RemoveEntryOfTableButton
entryname={item}
removeEntry={this.removeEntry}
disabled={disabled}
label={removeLabel}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
removeEntry = (item: string) => {
const newItems = this.props.items.filter(name => name !== item);
this.props.onRemove(newItems, item);
};
}
export default ArrayConfigTable;

View File

@@ -0,0 +1,34 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import ArrayConfigTable from "./ArrayConfigTable";
type Props = {
proxyExcludes: string[],
t: string => string,
onChange: (boolean, any, string) => void,
disabled: boolean
};
type State = {};
class ProxyExcludesTable extends React.Component<Props, State> {
render() {
const { proxyExcludes, disabled, t } = this.props;
return (
<ArrayConfigTable
items={proxyExcludes}
label={t("proxy-settings.proxy-excludes")}
removeLabel={t("proxy-settings.remove-proxy-exclude-button")}
onRemove={this.removeEntry}
disabled={disabled}
/>
);
}
removeEntry = (newExcludes: string[]) => {
this.props.onChange(true, newExcludes, "proxyExcludes");
};
}
export default translate("config")(ProxyExcludesTable);

View File

@@ -0,0 +1,56 @@
import React from "react";
import { translate } from "react-i18next";
import { Route } from "react-router";
import { Page } from "../../components/layout";
import { Navigation, NavLink, Section } from "../../components/navigation";
import GlobalConfig from "./GlobalConfig";
import type { History } from "history";
type Props = {
// context objects
t: string => string,
match: any,
history: History
};
class Config extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t } = this.props;
const url = this.matchedUrl();
return (
<Page>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={GlobalConfig} />
</div>
<div className="column">
<Navigation>
<Section label={t("config.navigation-title")}>
<NavLink
to={`${url}`}
label={t("global-config.navigation-label")}
/>
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
export default translate("config")(Config);

View File

@@ -0,0 +1,109 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import Title from "../../components/layout/Title";
import {
fetchConfig,
getFetchConfigFailure,
isFetchConfigPending,
getConfig,
modifyConfig,
isModifyConfigPending,
getConfigUpdatePermission,
getModifyConfigFailure,
modifyConfigReset
} from "../modules/config";
import { connect } from "react-redux";
import ErrorPage from "../../components/ErrorPage";
import type { Config } from "../types/Config";
import ConfigForm from "../components/form/ConfigForm";
import Loading from "../../components/Loading";
type Props = {
loading: boolean,
error: Error,
config: Config,
configUpdatePermission: boolean,
// dispatch functions
modifyConfig: (config: Config, callback?: () => void) => void,
fetchConfig: void => void,
configReset: void => void,
// context objects
t: string => string
};
class GlobalConfig extends React.Component<Props> {
componentDidMount() {
this.props.configReset();
this.props.fetchConfig();
}
modifyConfig = (config: Config) => {
this.props.modifyConfig(config);
};
render() {
const { t, error, loading, config, configUpdatePermission } = this.props;
if (error) {
return (
<ErrorPage
title={t("global-config.error-title")}
subtitle={t("global-config.error-subtitle")}
error={error}
configUpdatePermission={configUpdatePermission}
/>
);
}
if (loading) {
return <Loading />;
}
return (
<div>
<Title title={t("global-config.title")} />
<ConfigForm
submitForm={config => this.modifyConfig(config)}
config={config}
loading={loading}
configUpdatePermission={configUpdatePermission}
/>
</div>
);
}
}
const mapDispatchToProps = dispatch => {
return {
fetchConfig: () => {
dispatch(fetchConfig());
},
modifyConfig: (config: Config, callback?: () => void) => {
dispatch(modifyConfig(config, callback));
},
configReset: () => {
dispatch(modifyConfigReset());
}
};
};
const mapStateToProps = state => {
const loading = isFetchConfigPending(state) || isModifyConfigPending(state);
const error = getFetchConfigFailure(state) || getModifyConfigFailure(state);
const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state);
return {
loading,
error,
config,
configUpdatePermission
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("config")(GlobalConfig));

View File

@@ -0,0 +1,178 @@
// @flow
import { apiClient } from "../../apiclient";
import * as types from "../../modules/types";
import type { Action } from "../../types/Action";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import { Dispatch } from "redux";
import type { Config } from "../types/Config";
export const FETCH_CONFIG = "scm/config/FETCH_CONFIG";
export const FETCH_CONFIG_PENDING = `${FETCH_CONFIG}_${types.PENDING_SUFFIX}`;
export const FETCH_CONFIG_SUCCESS = `${FETCH_CONFIG}_${types.SUCCESS_SUFFIX}`;
export const FETCH_CONFIG_FAILURE = `${FETCH_CONFIG}_${types.FAILURE_SUFFIX}`;
export const MODIFY_CONFIG = "scm/config/MODIFY_CONFIG";
export const MODIFY_CONFIG_PENDING = `${MODIFY_CONFIG}_${types.PENDING_SUFFIX}`;
export const MODIFY_CONFIG_SUCCESS = `${MODIFY_CONFIG}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_CONFIG_FAILURE = `${MODIFY_CONFIG}_${types.FAILURE_SUFFIX}`;
export const MODIFY_CONFIG_RESET = `${MODIFY_CONFIG}_${types.RESET_SUFFIX}`;
const CONFIG_URL = "config";
const CONTENT_TYPE_CONFIG = "application/vnd.scmm-config+json;v=2";
//fetch config
export function fetchConfig() {
return function(dispatch: any) {
dispatch(fetchConfigPending());
return apiClient
.get(CONFIG_URL)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchConfigSuccess(data));
})
.catch(cause => {
const error = new Error(`could not fetch config: ${cause.message}`);
dispatch(fetchConfigFailure(error));
});
};
}
export function fetchConfigPending(): Action {
return {
type: FETCH_CONFIG_PENDING
};
}
export function fetchConfigSuccess(config: Config): Action {
return {
type: FETCH_CONFIG_SUCCESS,
payload: config
};
}
export function fetchConfigFailure(error: Error): Action {
return {
type: FETCH_CONFIG_FAILURE,
payload: {
error
}
};
}
// modify config
export function modifyConfig(config: Config, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyConfigPending(config));
return apiClient
.put(config._links.update.href, config, CONTENT_TYPE_CONFIG)
.then(() => {
dispatch(modifyConfigSuccess(config));
if (callback) {
callback();
}
})
.catch(cause => {
dispatch(
modifyConfigFailure(
config,
new Error(`could not modify config: ${cause.message}`)
)
);
});
};
}
export function modifyConfigPending(config: Config): Action {
return {
type: MODIFY_CONFIG_PENDING,
payload: config
};
}
export function modifyConfigSuccess(config: Config): Action {
return {
type: MODIFY_CONFIG_SUCCESS,
payload: config
};
}
export function modifyConfigFailure(config: Config, error: Error): Action {
return {
type: MODIFY_CONFIG_FAILURE,
payload: {
error,
config
}
};
}
export function modifyConfigReset() {
return {
type: MODIFY_CONFIG_RESET
};
}
//reducer
function removeNullValues(config: Config) {
if (!config.adminGroups) {
config.adminGroups = [];
}
if (!config.adminUsers) {
config.adminUsers = [];
}
if (!config.proxyExcludes) {
config.proxyExcludes = [];
}
return config;
}
function reducer(state: any = {}, action: any = {}) {
switch (action.type) {
case MODIFY_CONFIG_SUCCESS:
case FETCH_CONFIG_SUCCESS:
const config = removeNullValues(action.payload);
return {
...state,
entries: config,
configUpdatePermission: action.payload._links.update ? true : false
};
default:
return state;
}
}
export default reducer;
// selectors
export function isFetchConfigPending(state: Object) {
return isPending(state, FETCH_CONFIG);
}
export function getFetchConfigFailure(state: Object) {
return getFailure(state, FETCH_CONFIG);
}
export function isModifyConfigPending(state: Object) {
return isPending(state, MODIFY_CONFIG);
}
export function getModifyConfigFailure(state: Object) {
return getFailure(state, MODIFY_CONFIG);
}
export function getConfig(state: Object) {
if (state.config && state.config.entries) {
return state.config.entries;
}
}
export function getConfigUpdatePermission(state: Object) {
if (state.config && state.config.configUpdatePermission) {
return state.config.configUpdatePermission;
}
}

View File

@@ -0,0 +1,287 @@
//@flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_CONFIG,
FETCH_CONFIG_PENDING,
FETCH_CONFIG_SUCCESS,
FETCH_CONFIG_FAILURE,
MODIFY_CONFIG,
MODIFY_CONFIG_PENDING,
MODIFY_CONFIG_SUCCESS,
MODIFY_CONFIG_FAILURE,
fetchConfig,
fetchConfigSuccess,
getFetchConfigFailure,
isFetchConfigPending,
modifyConfig,
isModifyConfigPending,
getModifyConfigFailure,
getConfig,
getConfigUpdatePermission
} from "./config";
const CONFIG_URL = "/scm/api/rest/v2/config";
const error = new Error("You have an error!");
const config = {
proxyPassword: null,
proxyPort: 8080,
proxyServer: "proxy.mydomain.com",
proxyUser: null,
enableProxy: false,
realmDescription: "SONIA :: SCM Manager",
enableRepositoryArchive: false,
disableGroupingGrid: false,
dateFormat: "YYYY-MM-DD HH:mm:ss",
anonymousAccessEnabled: false,
adminGroups: [],
adminUsers: [],
baseUrl: "http://localhost:8081/scm",
forceBaseUrl: false,
loginAttemptLimit: -1,
proxyExcludes: [],
skipFailedAuthenticators: false,
pluginUrl:
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/scm/api/rest/v2/config" },
update: { href: "http://localhost:8081/scm/api/rest/v2/config" }
}
};
const configWithNullValues = {
proxyPassword: null,
proxyPort: 8080,
proxyServer: "proxy.mydomain.com",
proxyUser: null,
enableProxy: false,
realmDescription: "SONIA :: SCM Manager",
enableRepositoryArchive: false,
disableGroupingGrid: false,
dateFormat: "YYYY-MM-DD HH:mm:ss",
anonymousAccessEnabled: false,
adminGroups: null,
adminUsers: null,
baseUrl: "http://localhost:8081/scm",
forceBaseUrl: false,
loginAttemptLimit: -1,
proxyExcludes: null,
skipFailedAuthenticators: false,
pluginUrl:
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/scm/api/rest/v2/config" },
update: { href: "http://localhost:8081/scm/api/rest/v2/config" }
}
};
const responseBody = {
entries: config,
configUpdatePermission: false
};
const response = {
headers: { "content-type": "application/json" },
responseBody
};
describe("config fetch()", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch config", () => {
fetchMock.getOnce(CONFIG_URL, response);
const expectedActions = [
{ type: FETCH_CONFIG_PENDING },
{
type: FETCH_CONFIG_SUCCESS,
payload: response
}
];
const store = mockStore({});
return store.dispatch(fetchConfig()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail getting config on HTTP 500", () => {
fetchMock.getOnce(CONFIG_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchConfig()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING);
expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully modify config", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/config", {
status: 204
});
const store = mockStore({});
return store.dispatch(modifyConfig(config)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_SUCCESS);
expect(actions[1].payload).toEqual(config);
});
});
it("should call the callback after modifying config", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/config", {
status: 204
});
let called = false;
const callback = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(modifyConfig(config, callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_SUCCESS);
expect(called).toBe(true);
});
});
it("should fail modifying config on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/config", {
status: 500
});
const store = mockStore({});
return store.dispatch(modifyConfig(config)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("config reducer", () => {
it("should update state correctly according to FETCH_CONFIG_SUCCESS action", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState).toEqual({
entries: config,
configUpdatePermission: true
});
});
it("should set configUpdatePermission to true if update link is present", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState.configUpdatePermission).toBeTruthy();
});
it("should update state according to FETCH_CONFIG_SUCCESS action", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState.entries).toBe(config);
});
it("should return empty arrays for null values", () => {
const config = reducer({}, fetchConfigSuccess(configWithNullValues))
.entries;
expect(config.adminUsers).toEqual([]);
expect(config.adminGroups).toEqual([]);
expect(config.proxyExcludes).toEqual([]);
});
});
describe("selector tests", () => {
it("should return true, when fetch config is pending", () => {
const state = {
pending: {
[FETCH_CONFIG]: true
}
};
expect(isFetchConfigPending(state)).toEqual(true);
});
it("should return false, when fetch config is not pending", () => {
expect(isFetchConfigPending({})).toEqual(false);
});
it("should return error when fetch config did fail", () => {
const state = {
failure: {
[FETCH_CONFIG]: error
}
};
expect(getFetchConfigFailure(state)).toEqual(error);
});
it("should return undefined when fetch config did not fail", () => {
expect(getFetchConfigFailure({})).toBe(undefined);
});
it("should return true, when modify group is pending", () => {
const state = {
pending: {
[MODIFY_CONFIG]: true
}
};
expect(isModifyConfigPending(state)).toEqual(true);
});
it("should return false, when modify config is not pending", () => {
expect(isModifyConfigPending({})).toEqual(false);
});
it("should return error when modify config did fail", () => {
const state = {
failure: {
[MODIFY_CONFIG]: error
}
};
expect(getModifyConfigFailure(state)).toEqual(error);
});
it("should return undefined when modify config did not fail", () => {
expect(getModifyConfigFailure({})).toBe(undefined);
});
it("should return config", () => {
const state = {
config: {
entries: config
}
};
expect(getConfig(state)).toEqual(config);
});
it("should return configUpdatePermission", () => {
const state = {
config: {
configUpdatePermission: true
}
};
expect(getConfigUpdatePermission(state)).toEqual(true);
});
});

View File

@@ -0,0 +1,27 @@
//@flow
import type { Links } from "../../types/hal";
export type Config = {
proxyPassword: string | null,
proxyPort: number,
proxyServer: string,
proxyUser: string | null,
enableProxy: boolean,
realmDescription: string,
enableRepositoryArchive: boolean,
disableGroupingGrid: boolean,
dateFormat: string,
anonymousAccessEnabled: boolean,
adminGroups: string[],
adminUsers: string[],
baseUrl: string,
forceBaseUrl: boolean,
loginAttemptLimit: number,
proxyExcludes: string[],
skipFailedAuthenticators: boolean,
pluginUrl: string,
loginAttemptLimitTimeout: number,
enabledXsrfProtection: boolean,
defaultNamespaceStrategy: string,
_links: Links
};

View File

@@ -19,6 +19,8 @@ import Groups from "../groups/containers/Groups";
import SingleGroup from "../groups/containers/SingleGroup"; import SingleGroup from "../groups/containers/SingleGroup";
import AddGroup from "../groups/containers/AddGroup"; import AddGroup from "../groups/containers/AddGroup";
import Config from "../config/containers/Config";
type Props = { type Props = {
authenticated?: boolean authenticated?: boolean
}; };
@@ -99,6 +101,12 @@ class Main extends React.Component<Props> {
component={Groups} component={Groups}
authenticated={authenticated} authenticated={authenticated}
/> />
<ProtectedRoute
exact
path="/config"
component={Config}
authenticated={authenticated}
/>
</Switch> </Switch>
</div> </div>
); );

View File

@@ -11,6 +11,7 @@ import groups from "./groups/modules/groups";
import auth from "./modules/auth"; import auth from "./modules/auth";
import pending from "./modules/pending"; import pending from "./modules/pending";
import failure from "./modules/failure"; import failure from "./modules/failure";
import config from "./config/modules/config";
import type { BrowserHistory } from "history/createBrowserHistory"; import type { BrowserHistory } from "history/createBrowserHistory";
@@ -26,7 +27,8 @@ function createReduxStore(history: BrowserHistory) {
repos, repos,
repositoryTypes, repositoryTypes,
groups, groups,
auth auth,
config
}); });
return createStore( return createStore(

View File

@@ -1,71 +0,0 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { AddButton } from "../../components/buttons";
import InputField from "../../components/forms/InputField";
import { isMemberNameValid } from "./groupValidation";
type Props = {
t: string => string,
addMember: string => void
};
type State = {
memberToAdd: string,
validationError: boolean
};
class AddMemberField extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
memberToAdd: "",
validationError: false
};
}
render() {
const { t } = this.props;
return (
<div className="field">
<InputField
label={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")}
onChange={this.handleAddMemberChange}
validationError={this.state.validationError}
value={this.state.memberToAdd}
onReturnPressed={this.appendMember}
/>
<AddButton
label={t("add-member-button.label")}
action={this.addButtonClicked}
disabled={!isMemberNameValid(this.state.memberToAdd)}
/>
</div>
);
}
addButtonClicked = (event: Event) => {
event.preventDefault();
this.appendMember();
};
appendMember = () => {
const { memberToAdd } = this.state;
if (isMemberNameValid(memberToAdd)) {
this.props.addMember(memberToAdd);
this.setState({ ...this.state, memberToAdd: "" });
}
};
handleAddMemberChange = (membername: string) => {
this.setState({
...this.state,
memberToAdd: membername,
validationError: membername.length > 0 && !isMemberNameValid(membername)
});
};
}
export default translate("groups")(AddMemberField);

View File

@@ -6,9 +6,9 @@ import { SubmitButton } from "../../components/buttons";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import type { Group } from "../types/Group"; import type { Group } from "../types/Group";
import * as validator from "./groupValidation"; import * as validator from "./groupValidation";
import AddMemberField from "./AddMemberField";
import MemberNameTable from "./MemberNameTable"; import MemberNameTable from "./MemberNameTable";
import Textarea from "../../components/forms/Textarea"; import Textarea from "../../components/forms/Textarea";
import AddEntryToTableField from "../../components/forms/AddEntryToTableField";
type Props = { type Props = {
t: string => string, t: string => string,
@@ -96,7 +96,13 @@ class GroupForm extends React.Component<Props, State> {
members={this.state.group.members} members={this.state.group.members}
memberListChanged={this.memberListChanged} memberListChanged={this.memberListChanged}
/> />
<AddMemberField addMember={this.addMember} /> <AddEntryToTableField
addEntry={this.addMember}
disabled={false}
buttonLabel={t("add-member-button.label")}
fieldLabel={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")}
/>
<SubmitButton <SubmitButton
disabled={!this.isValid()} disabled={!this.isValid()}
label={t("group-form.submit")} label={t("group-form.submit")}

View File

@@ -1,7 +1,7 @@
//@flow //@flow
import React from "react"; import React from "react";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import RemoveMemberButton from "./buttons/RemoveMemberButton"; import { RemoveEntryOfTableButton } from "../../components/buttons";
type Props = { type Props = {
members: string[], members: string[],
@@ -24,9 +24,11 @@ class MemberNameTable extends React.Component<Props, State> {
<tr key={member}> <tr key={member}>
<td key={member}>{member}</td> <td key={member}>{member}</td>
<td> <td>
<RemoveMemberButton <RemoveEntryOfTableButton
membername={member} entryname={member}
removeMember={this.removeMember} removeEntry={this.removeEntry}
disabled={false}
label={t("remove-member-button.label")}
/> />
</td> </td>
</tr> </tr>
@@ -38,7 +40,7 @@ class MemberNameTable extends React.Component<Props, State> {
); );
} }
removeMember = (membername: string) => { removeEntry = (membername: string) => {
const newMembers = this.props.members.filter(name => name !== membername); const newMembers = this.props.members.filter(name => name !== membername);
this.props.memberListChanged(newMembers); this.props.memberListChanged(newMembers);
}; };

View File

@@ -1,34 +0,0 @@
//@flow
import React from "react";
import { DeleteButton } from "../../../components/buttons";
import { translate } from "react-i18next";
import classNames from "classnames";
type Props = {
t: string => string,
membername: string,
removeMember: string => void
};
type State = {};
class RemoveMemberButton extends React.Component<Props, State> {
render() {
const { t , membername, removeMember} = this.props;
return (
<div className={classNames("is-pulled-right")}>
<DeleteButton
label={t("remove-member-button.label")}
action={(event: Event) => {
event.preventDefault();
removeMember(membername);
}}
/>
</div>
);
}
}
export default translate("groups")(RemoveMemberButton);

View File

@@ -1,30 +1,30 @@
/** /*
* Copyright (c) 2010, Sebastian Sdorra All rights reserved. Copyright (c) 2010, Sebastian Sdorra All rights reserved.
*
* Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met: modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer. 2. Redistributions in this list of conditions and the following disclaimer. 2. Redistributions in
* binary form must reproduce the above copyright notice, this list of binary form must reproduce the above copyright notice, this list of
* conditions and the following disclaimer in the documentation and/or other conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution. 3. Neither the name of SCM-Manager; 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 nor the names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission. derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 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 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. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* http://bitbucket.org/sdorra/scm-manager http://bitbucket.org/sdorra/scm-manager
*
*/ */
@@ -56,14 +56,14 @@ public class StatusExceptionMapper<E extends Throwable>
private static final Logger logger = private static final Logger logger =
LoggerFactory.getLogger(StatusExceptionMapper.class); LoggerFactory.getLogger(StatusExceptionMapper.class);
//~--- constructors --------------------------------------------------------- private final Response.Status status;
private final Class<E> type;
/** /**
* Constructs ... * Map an Exception to a HTTP Response
* *
* * @param type the exception class
* @param type * @param status the http status to be mapped
* @param status
*/ */
public StatusExceptionMapper(Class<E> type, Response.Status status) public StatusExceptionMapper(Class<E> type, Response.Status status)
{ {
@@ -71,15 +71,12 @@ public class StatusExceptionMapper<E extends Throwable>
this.status = status; this.status = status;
} }
//~--- methods --------------------------------------------------------------
/** /**
* Method description * provide a http responses from an exception
* *
* @param exception the thrown exception
* *
* @param exception * @return the http response with the exception presentation
*
* @return
*/ */
@Override @Override
public Response toResponse(E exception) public Response toResponse(E exception)
@@ -95,12 +92,4 @@ public class StatusExceptionMapper<E extends Throwable>
return Response.status(status).build(); return Response.status(status).build();
} }
//~--- fields ---------------------------------------------------------------
/** Field description */
private final Response.Status status;
/** Field description */
private final Class<E> type;
} }

View File

@@ -25,6 +25,8 @@ public class MapperModule extends AbstractModule {
bind(RepositoryTypeCollectionToDtoMapper.class); bind(RepositoryTypeCollectionToDtoMapper.class);
bind(BranchToBranchDtoMapper.class).to(Mappers.getMapper(BranchToBranchDtoMapper.class).getClass()); bind(BranchToBranchDtoMapper.class).to(Mappers.getMapper(BranchToBranchDtoMapper.class).getClass());
bind(PermissionDtoToPermissionMapper.class).to(Mappers.getMapper(PermissionDtoToPermissionMapper.class).getClass());
bind(PermissionToPermissionDtoMapper.class).to(Mappers.getMapper(PermissionToPermissionDtoMapper.class).getClass());
// no mapstruct required // no mapstruct required
bind(UIPluginDtoMapper.class); bind(UIPluginDtoMapper.class);

View File

@@ -0,0 +1,49 @@
/*
Copyright (c) 2014, 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.api.v2.resources;
import sonia.scm.api.rest.StatusExceptionMapper;
import sonia.scm.repository.PermissionAlreadyExistsException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
/**
* @since 2.0.0
*/
@Provider
public class PermissionAlreadyExistsExceptionMapper extends StatusExceptionMapper<PermissionAlreadyExistsException> {
public PermissionAlreadyExistsExceptionMapper() {
super(PermissionAlreadyExistsException.class, Response.Status.CONFLICT);
}
}

View File

@@ -1,18 +0,0 @@
package sonia.scm.api.v2.resources;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
public class PermissionCollectionResource {
@GET
@Path("")
public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("10") @QueryParam("pageSize") int pageSize,
@QueryParam("sortBy") String sortBy,
@DefaultValue("false") @QueryParam("desc") boolean desc) {
throw new UnsupportedOperationException();
}
}

View File

@@ -0,0 +1,51 @@
package sonia.scm.api.v2.resources;
import com.google.inject.Inject;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import java.util.List;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Links.linkingTo;
import static java.util.stream.Collectors.toList;
public class PermissionCollectionToDtoMapper {
private final ResourceLinks resourceLinks;
private final PermissionToPermissionDtoMapper permissionToPermissionDtoMapper;
@Inject
public PermissionCollectionToDtoMapper(PermissionToPermissionDtoMapper permissionToPermissionDtoMapper, ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
this.permissionToPermissionDtoMapper = permissionToPermissionDtoMapper;
}
public HalRepresentation map(Repository repository) {
List<PermissionDto> permissionDtoList = repository.getPermissions()
.stream()
.map(permission -> permissionToPermissionDtoMapper.map(permission, repository))
.collect(toList());
return new HalRepresentation(createLinks(repository), embedDtos(permissionDtoList));
}
private Links createLinks(Repository repository) {
RepositoryPermissions.permissionRead(repository).check();
Links.Builder linksBuilder = linkingTo()
.with(Links.linkingTo().self(resourceLinks.permission().all(repository.getNamespace(), repository.getName())).build());
if (RepositoryPermissions.permissionWrite(repository).isPermitted()) {
linksBuilder.single(link("create", resourceLinks.permission().create(repository.getNamespace(), repository.getName())));
}
return linksBuilder.build();
}
private Embedded embedDtos(List<PermissionDto> permissionDtoList) {
return embeddedBuilder()
.with("permissions", permissionDtoList)
.build();
}
}

View File

@@ -0,0 +1,35 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Setter @ToString
public class PermissionDto extends HalRepresentation {
@JsonInclude(JsonInclude.Include.NON_NULL)
private String name;
/**
* the type can be replaced with a dto enum if the mapstruct 1.3.0 is stable
* the mapstruct has a Bug on mapping enums in the 1.2.0-Final Version
*
* see the bug fix: https://github.com/mapstruct/mapstruct/commit/460e87eef6eb71245b387fdb0509c726676a8e19
*
**/
@JsonInclude(JsonInclude.Include.NON_NULL)
private String type ;
private boolean groupPermission = false;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) {
return super.add(links);
}
}

View File

@@ -0,0 +1,21 @@
package sonia.scm.api.v2.resources;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import sonia.scm.repository.Permission;
@Mapper
public abstract class PermissionDtoToPermissionMapper {
public abstract Permission map(PermissionDto permissionDto);
/**
* this method is needed to modify an existing permission object
*
* @param target the target permission
* @param permissionDto the source dto
* @return the mapped target permission object
*/
public abstract void modify(@MappingTarget Permission target, PermissionDto permissionDto);
}

View File

@@ -0,0 +1,49 @@
/*
Copyright (c) 2014, 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.api.v2.resources;
import sonia.scm.api.rest.StatusExceptionMapper;
import sonia.scm.repository.PermissionNotFoundException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
/**
* @since 2.0.0
*/
@Provider
public class PermissionNotFoundExceptionMapper extends StatusExceptionMapper<PermissionNotFoundException> {
public PermissionNotFoundExceptionMapper() {
super(PermissionNotFoundException.class, Response.Status.NOT_FOUND);
}
}

View File

@@ -1,20 +1,220 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import javax.inject.Inject; import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import javax.inject.Provider; import com.webcohesion.enunciate.metadata.rs.ResponseHeader;
import javax.ws.rs.Path; import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import sonia.scm.repository.*;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.core.Response;
import java.net.URI;
import java.util.Optional;
@Slf4j
public class PermissionRootResource { public class PermissionRootResource {
private final Provider<PermissionCollectionResource> permissionCollectionResource; private PermissionDtoToPermissionMapper dtoToModelMapper;
private PermissionToPermissionDtoMapper modelToDtoMapper;
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
private ResourceLinks resourceLinks;
private final RepositoryManager manager;
@Inject @Inject
public PermissionRootResource(Provider<PermissionCollectionResource> permissionCollectionResource) { public PermissionRootResource(PermissionDtoToPermissionMapper dtoToModelMapper, PermissionToPermissionDtoMapper modelToDtoMapper, PermissionCollectionToDtoMapper permissionCollectionToDtoMapper, ResourceLinks resourceLinks, RepositoryManager manager) {
this.permissionCollectionResource = permissionCollectionResource; this.dtoToModelMapper = dtoToModelMapper;
this.modelToDtoMapper = modelToDtoMapper;
this.permissionCollectionToDtoMapper = permissionCollectionToDtoMapper;
this.resourceLinks = resourceLinks;
this.manager = manager;
} }
/**
* Adds a new permission to the user or group managed by the repository
*
* @param permission permission to add
* @return a web response with the status code 201 and the url to GET the added permission
*/
@POST
@StatusCodes({
@ResponseCode(code = 201, condition = "creates", additionalHeaders = {
@ResponseHeader(name = "Location", description = "uri of the created permission")
}),
@ResponseCode(code = 500, condition = "internal server error"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 409, condition = "conflict")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(VndMediaType.PERMISSION)
@Path("") @Path("")
public PermissionCollectionResource getPermissionCollectionResource() { public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, PermissionDto permission) throws RepositoryException {
return permissionCollectionResource.get(); log.info("try to add new permission: {}", permission);
Repository repository = load(namespace, name);
RepositoryPermissions.permissionWrite(repository).check();
checkPermissionAlreadyExists(permission, repository);
repository.getPermissions().add(dtoToModelMapper.map(permission));
manager.modify(repository);
return Response.created(URI.create(resourceLinks.permission().self(namespace, name, permission.getName()))).build();
}
/**
* Get the searched permission with permission name related to a repository
*
* @param namespace the repository namespace
* @param name the repository name
* @return the http response with a list of permissionDto objects
* @throws RepositoryNotFoundException if the repository does not exists
*/
@GET
@StatusCodes({
@ResponseCode(code = 200, condition = "ok"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.PERMISSION)
@TypeHint(PermissionDto.class)
@Path("{permission-name}")
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName) throws RepositoryException {
Repository repository = load(namespace, name);
RepositoryPermissions.permissionRead(repository).check();
return Response.ok(
repository.getPermissions()
.stream()
.filter(permission -> permissionName.equals(permission.getName()))
.map(permission -> modelToDtoMapper.map(permission, repository))
.findFirst()
.orElseThrow(() -> new PermissionNotFoundException(repository, permissionName))
).build();
}
/**
* Get all permissions related to a repository
*
* @param namespace the repository namespace
* @param name the repository name
* @return the http response with a list of permissionDto objects
* @throws RepositoryNotFoundException if the repository does not exists
*/
@GET
@StatusCodes({
@ResponseCode(code = 200, condition = "ok"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.PERMISSION)
@TypeHint(PermissionDto.class)
@Path("")
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws RepositoryNotFoundException {
Repository repository = load(namespace, name);
RepositoryPermissions.permissionRead(repository).check();
return Response.ok(permissionCollectionToDtoMapper.map(repository)).build();
}
/**
* Update a permission to the user or group managed by the repository
*
* @param permission permission to modify
* @param permissionName permission to modify
* @return a web response with the status code 204
*/
@PUT
@StatusCodes({
@ResponseCode(code = 204, condition = "update success"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(VndMediaType.PERMISSION)
@Path("{permission-name}")
public Response update(@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("permission-name") String permissionName,
PermissionDto permission) throws RepositoryException {
log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission);
Repository repository = load(namespace, name);
RepositoryPermissions.permissionWrite(repository).check();
Permission existingPermission = repository.getPermissions()
.stream()
.filter(perm -> StringUtils.isNotBlank(perm.getName()) && perm.getName().equals(permissionName))
.findFirst()
.orElseThrow(() -> new PermissionNotFoundException(repository, permissionName));
dtoToModelMapper.modify(existingPermission, permission);
manager.modify(repository);
log.info("the permission with name: {} is updated.", permissionName);
return Response.noContent().build();
}
/**
* Update a permission to the user or group managed by the repository
*
* @param permissionName permission to delete
* @return a web response with the status code 204
*/
@DELETE
@StatusCodes({
@ResponseCode(code = 204, condition = "delete success or nothing to delete"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 403, condition = "not authorized"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Path("{permission-name}")
public Response delete(@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("permission-name") String permissionName) throws RepositoryException {
log.info("try to delete the permission with name: {}.", permissionName);
Repository repository = load(namespace, name);
RepositoryPermissions.modify(repository).check();
repository.getPermissions()
.stream()
.filter(perm -> StringUtils.isNotBlank(perm.getName()) && perm.getName().equals(permissionName))
.findFirst()
.ifPresent(p -> repository.getPermissions().remove(p))
;
manager.modify(repository);
log.info("the permission with name: {} is updated.", permissionName);
return Response.noContent().build();
}
/**
* check if the actual user is permitted to manage the repository permissions
* return the repository if the user is permitted
*
* @param namespace the repository namespace
* @param name the repository name
* @return the repository if the user is permitted
* @throws RepositoryNotFoundException if the repository does not exists
*/
private Repository load(String namespace, String name) throws RepositoryNotFoundException {
return Optional.ofNullable(manager.get(new NamespaceAndName(namespace, name)))
.orElseThrow(() -> new RepositoryNotFoundException(name));
}
/**
* check if the permission already exists in the repository
*
* @param permission the searched permission
* @param repository the repository to be inspected
* @throws PermissionAlreadyExistsException if the permission already exists in the repository
*/
private void checkPermissionAlreadyExists(PermissionDto permission, Repository repository) throws PermissionAlreadyExistsException {
boolean isPermissionAlreadyExist = repository.getPermissions()
.stream()
.anyMatch(p -> p.getName().equals(permission.getName()));
if (isPermissionAlreadyExist) {
throw new PermissionAlreadyExistsException(repository, permission.getName());
}
} }
} }

View File

@@ -0,0 +1,46 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import org.mapstruct.*;
import sonia.scm.repository.Permission;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import javax.inject.Inject;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Links.linkingTo;
@Mapper
public abstract class PermissionToPermissionDtoMapper {
@Inject
private ResourceLinks resourceLinks;
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
public abstract PermissionDto map(Permission permission, @Context Repository repository);
@BeforeMapping
void validatePermissions(@Context Repository repository) {
RepositoryPermissions.permissionRead(repository).check();
}
/**
* Add the self, update and delete links.
*
* @param target the mapped dto
* @param repository the repository
*/
@AfterMapping
void appendLinks(@MappingTarget PermissionDto target, @Context Repository repository) {
Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.permission().self(repository.getNamespace(), repository.getName(), target.getName()));
if (RepositoryPermissions.permissionWrite(repository).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.permission().update(repository.getNamespace(), repository.getName(), target.getName())));
linksBuilder.single(link("delete", resourceLinks.permission().delete(repository.getNamespace(), repository.getName(), target.getName())));
}
target.add(linksBuilder.build());
}
}

View File

@@ -0,0 +1,49 @@
/*
Copyright (c) 2014, 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.api.v2.resources;
import sonia.scm.api.rest.StatusExceptionMapper;
import sonia.scm.repository.RepositoryNotFoundException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
/**
* @since 2.0.0
*/
@Provider
public class RepositoryNotFoundExceptionMapper extends StatusExceptionMapper<RepositoryNotFoundException> {
public RepositoryNotFoundExceptionMapper() {
super(RepositoryNotFoundException.class, Response.Status.NOT_FOUND);
}
}

View File

@@ -128,11 +128,17 @@ public class RepositoryResource {
public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, RepositoryDto repositoryDto) { public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, RepositoryDto repositoryDto) {
return adapter.update( return adapter.update(
loadBy(namespace, name), loadBy(namespace, name),
existing -> dtoToRepositoryMapper.map(repositoryDto, existing.getId()), existing -> processUpdate(repositoryDto, existing),
nameAndNamespaceStaysTheSame(namespace, name) nameAndNamespaceStaysTheSame(namespace, name)
); );
} }
private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) {
Repository changedRepository = dtoToRepositoryMapper.map(repositoryDto, existing.getId());
changedRepository.setPermissions(existing.getPermissions());
return changedRepository;
}
@Path("tags/") @Path("tags/")
public TagRootResource tags() { public TagRootResource tags() {
return tagRootResource.get(); return tagRootResource.get();
@@ -176,6 +182,6 @@ public class RepositoryResource {
} }
private Predicate<Repository> nameAndNamespaceStaysTheSame(String namespace, String name) { private Predicate<Repository> nameAndNamespaceStaysTheSame(String namespace, String name) {
return changed -> changed.getName().equals(name) && changed.getNamespace().equals(namespace); return changed -> name.equals(changed.getName()) && namespace.equals(changed.getNamespace());
} }
} }

View File

@@ -36,7 +36,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
} }
if (RepositoryPermissions.modify(repository).isPermitted()) { if (RepositoryPermissions.modify(repository).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.repository().update(target.getNamespace(), target.getName()))); linksBuilder.single(link("update", resourceLinks.repository().update(target.getNamespace(), target.getName())));
linksBuilder.single(link("permissions", resourceLinks.permissionCollection().self(target.getNamespace(), target.getName()))); linksBuilder.single(link("permissions", resourceLinks.permission().all(target.getNamespace(), target.getName())));
} }
try (RepositoryService repositoryService = serviceFactory.create(repository)) { try (RepositoryService repositoryService = serviceFactory.create(repository)) {
if (repositoryService.isSupported(Command.TAGS)) { if (repositoryService.isSupported(Command.TAGS)) {

View File

@@ -298,20 +298,39 @@ class ResourceLinks {
return sourceLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("sources").parameters().method("get").parameters(revision).href(); return sourceLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("sources").parameters().method("get").parameters(revision).href();
} }
} }
public PermissionLinks permission() {
public PermissionCollectionLinks permissionCollection() { return new PermissionLinks(uriInfoStore.get());
return new PermissionCollectionLinks(uriInfoStore.get());
} }
static class PermissionCollectionLinks { static class PermissionLinks {
private final LinkBuilder permissionLinkBuilder; private final LinkBuilder permissionLinkBuilder;
PermissionCollectionLinks(UriInfo uriInfo) { PermissionLinks(UriInfo uriInfo) {
permissionLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, PermissionRootResource.class, PermissionCollectionResource.class); permissionLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, PermissionRootResource.class);
} }
String self(String namespace, String name) { String all(String namespace, String name) {
return permissionLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("permissions").parameters().method("getPermissionCollectionResource").parameters().method("getAll").parameters().href(); return permissionLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("permissions").parameters().method("getAll").parameters().href();
}
String create(String repositoryNamespace, String repositoryName) {
return permissionLinkBuilder.method("getRepositoryResource").parameters(repositoryNamespace, repositoryName).method("permissions").parameters().method("create").parameters().href();
}
String self(String repositoryNamespace, String repositoryName, String permissionName) {
return getLink(repositoryNamespace, repositoryName, permissionName, "get");
}
String update(String repositoryNamespace, String repositoryName, String permissionName) {
return getLink(repositoryNamespace, repositoryName, permissionName, "update");
}
String delete(String repositoryNamespace, String repositoryName, String permissionName) {
return getLink(repositoryNamespace, repositoryName, permissionName, "delete");
}
private String getLink(String repositoryNamespace, String repositoryName, String permissionName, String methodName) {
return permissionLinkBuilder.method("getRepositoryResource").parameters(repositoryNamespace, repositoryName).method("permissions").parameters().method(methodName).parameters(permissionName).href();
} }
} }

View File

@@ -207,9 +207,9 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
boolean hasPermission = false; boolean hasPermission = false;
for (sonia.scm.repository.Permission permission : repositoryPermissions) for (sonia.scm.repository.Permission permission : repositoryPermissions)
{ {
if (isUserPermitted(user, groups, permission)) hasPermission = isUserPermitted(user, groups, permission);
if (hasPermission)
{ {
String perm = permission.getType().getPermissionPrefix().concat(repository.getId()); String perm = permission.getType().getPermissionPrefix().concat(repository.getId());
if (logger.isTraceEnabled()) if (logger.isTraceEnabled())
{ {

View File

@@ -0,0 +1,447 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableList;
import de.otto.edison.hal.HalRepresentation;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext;
import org.apache.shiro.util.ThreadState;
import org.assertj.core.util.Lists;
import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.jboss.resteasy.spi.HttpRequest;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.api.rest.AuthorizationExceptionMapper;
import sonia.scm.repository.*;
import sonia.scm.web.VndMediaType;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Links.linkingTo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.fail;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.*;
import static org.mockito.MockitoAnnotations.initMocks;
@Slf4j
@SubjectAware(
username = "trillian",
password = "secret",
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
public class PermissionRootResourceTest {
private static final String REPOSITORY_NAMESPACE = "repo_namespace";
private static final String REPOSITORY_NAME = "repo";
private static final String PERMISSION_WRITE = "repository:permissionWrite:" + REPOSITORY_NAME;
private static final String PERMISSION_READ = "repository:permissionRead:" + REPOSITORY_NAME;
private static final String PERMISSION_OWNER = "repository:modify:" + REPOSITORY_NAME;
private static final String PERMISSION_NAME = "perm";
private static final String PATH_OF_ALL_PERMISSIONS = REPOSITORY_NAMESPACE + "/" + REPOSITORY_NAME + "/permissions/";
private static final String PATH_OF_ONE_PERMISSION = PATH_OF_ALL_PERMISSIONS + PERMISSION_NAME;
private static final String PERMISSION_TEST_PAYLOAD = "{ \"name\" : \"permission_name\", \"type\" : \"READ\" }";
private static final ArrayList<Permission> TEST_PERMISSIONS = Lists
.newArrayList(
new Permission("user_write", PermissionType.WRITE, false),
new Permission("user_read", PermissionType.READ, false),
new Permission("user_owner", PermissionType.OWNER, false),
new Permission("group_read", PermissionType.READ, true),
new Permission("group_write", PermissionType.WRITE, true),
new Permission("group_owner", PermissionType.OWNER, true)
);
private final ExpectedRequest requestGETAllPermissions = new ExpectedRequest()
.description("GET all permissions")
.method("GET")
.path(PATH_OF_ALL_PERMISSIONS);
private final ExpectedRequest requestPOSTPermission = new ExpectedRequest()
.description("create new permission")
.method("POST")
.content(PERMISSION_TEST_PAYLOAD)
.path(PATH_OF_ALL_PERMISSIONS);
private final ExpectedRequest requestGETPermission = new ExpectedRequest()
.description("GET permission")
.method("GET")
.path(PATH_OF_ONE_PERMISSION);
private final ExpectedRequest requestDELETEPermission = new ExpectedRequest()
.description("delete permission")
.method("DELETE")
.path(PATH_OF_ONE_PERMISSION);
private final ExpectedRequest requestPUTPermission = new ExpectedRequest()
.description("update permission")
.method("PUT")
.content(PERMISSION_TEST_PAYLOAD)
.path(PATH_OF_ONE_PERMISSION);
private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
@Rule
public ShiroRule shiro = new ShiroRule();
@Mock
private RepositoryManager repositoryManager;
private final URI baseUri = URI.create("/");
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@InjectMocks
private PermissionToPermissionDtoMapperImpl permissionToPermissionDtoMapper;
@InjectMocks
private PermissionDtoToPermissionMapperImpl permissionDtoToPermissionMapper;
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
private PermissionRootResource permissionRootResource;
private final Subject subject = mock(Subject.class);
private final ThreadState subjectThreadState = new SubjectThreadState(subject);
@BeforeEach
@Before
public void prepareEnvironment() {
initMocks(this);
permissionCollectionToDtoMapper = new PermissionCollectionToDtoMapper(permissionToPermissionDtoMapper, resourceLinks);
permissionRootResource = new PermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, permissionCollectionToDtoMapper, resourceLinks, repositoryManager);
RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider
.of(new RepositoryResource(null, null, null, null, null, null, null, null, MockProvider.of(permissionRootResource))), null);
subjectThreadState.bind();
ThreadContext.bind(subject);
dispatcher.getRegistry().addSingletonResource(repositoryRootResource);
dispatcher.getProviderFactory().registerProvider(RepositoryNotFoundExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(PermissionNotFoundExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(PermissionAlreadyExistsExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class);
}
@TestFactory
@DisplayName("test endpoints on missing repository")
Stream<DynamicTest> missedRepositoryTestFactory() {
return createDynamicTestsToAssertResponses(
requestGETAllPermissions.expectedResponseStatus(404),
requestGETPermission.expectedResponseStatus(404),
requestPOSTPermission.expectedResponseStatus(404),
requestDELETEPermission.expectedResponseStatus(404),
requestPUTPermission.expectedResponseStatus(404));
}
@TestFactory
@DisplayName("test endpoints on missing permissions and user is Admin")
Stream<DynamicTest> missedPermissionTestFactory() {
Repository mockRepository = mock(Repository.class);
when(mockRepository.getId()).thenReturn(REPOSITORY_NAME);
when(mockRepository.getNamespace()).thenReturn(REPOSITORY_NAMESPACE);
when(mockRepository.getName()).thenReturn(REPOSITORY_NAME);
when(repositoryManager.get(any(NamespaceAndName.class))).thenReturn(mockRepository);
return createDynamicTestsToAssertResponses(
requestGETPermission.expectedResponseStatus(404),
requestPOSTPermission.expectedResponseStatus(201),
requestGETAllPermissions.expectedResponseStatus(200),
requestDELETEPermission.expectedResponseStatus(204),
requestPUTPermission.expectedResponseStatus(404));
}
@TestFactory
@DisplayName("test endpoints on missing permissions and user is not Admin")
Stream<DynamicTest> missedPermissionUserForbiddenTestFactory() {
Repository mockRepository = mock(Repository.class);
when(mockRepository.getId()).thenReturn(REPOSITORY_NAME);
when(mockRepository.getNamespace()).thenReturn(REPOSITORY_NAMESPACE);
when(mockRepository.getName()).thenReturn(REPOSITORY_NAME);
doThrow(AuthorizationException.class).when(repositoryManager).get(any(NamespaceAndName.class));
return createDynamicTestsToAssertResponses(
requestGETPermission.expectedResponseStatus(403),
requestPOSTPermission.expectedResponseStatus(403),
requestGETAllPermissions.expectedResponseStatus(403),
requestDELETEPermission.expectedResponseStatus(403),
requestPUTPermission.expectedResponseStatus(403));
}
@Test
public void userWithPermissionWritePermissionShouldGetAllPermissionsWithCreateAndUpdateLinks() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
assertGettingExpectedPermissions(ImmutableList.copyOf(TEST_PERMISSIONS), PERMISSION_WRITE);
}
@Test
public void userWithPermissionReadPermissionShouldGetAllPermissionsWithoutCreateAndUpdateLinks() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_READ);
assertGettingExpectedPermissions(ImmutableList.copyOf(TEST_PERMISSIONS), PERMISSION_READ);
}
@Test
public void shouldGetAllPermissions() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_READ);
assertGettingExpectedPermissions(ImmutableList.copyOf(TEST_PERMISSIONS), PERMISSION_READ);
}
@Test
public void shouldGetPermissionByName() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_READ);
Permission expectedPermission = TEST_PERMISSIONS.get(0);
assertExpectedRequest(requestGETPermission
.expectedResponseStatus(200)
.path(PATH_OF_ALL_PERMISSIONS + expectedPermission.getName())
.responseValidator((response) -> {
String body = response.getContentAsString();
ObjectMapper mapper = new ObjectMapper();
try {
PermissionDto actualPermissionDto = mapper.readValue(body, PermissionDto.class);
assertThat(actualPermissionDto)
.as("response payload match permission object model")
.isEqualToComparingFieldByFieldRecursively(getExpectedPermissionDto(expectedPermission, PERMISSION_READ))
;
} catch (IOException e) {
fail();
}
})
);
}
@Test
public void shouldGetCreatedPermissions() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
Permission newPermission = new Permission("new_group_perm", PermissionType.WRITE, true);
ArrayList<Permission> permissions = Lists.newArrayList(TEST_PERMISSIONS);
permissions.add(newPermission);
ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(permissions);
assertExpectedRequest(requestPOSTPermission
.content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : true}")
.expectedResponseStatus(201)
.responseValidator(response -> assertThat(response.getContentAsString())
.as("POST response has no body")
.isBlank())
);
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_WRITE);
}
@Test
public void shouldNotAddExistingPermission() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
Permission newPermission = TEST_PERMISSIONS.get(0);
assertExpectedRequest(requestPOSTPermission
.content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : true}")
.expectedResponseStatus(409)
);
}
@Test
public void shouldGetUpdatedPermissions() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
Permission modifiedPermission = TEST_PERMISSIONS.get(0);
// modify the type to owner
modifiedPermission.setType(PermissionType.OWNER);
ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS);
assertExpectedRequest(requestPUTPermission
.content("{\"name\" : \"" + modifiedPermission.getName() + "\" , \"type\" : \"OWNER\" , \"groupPermission\" : false}")
.path(PATH_OF_ALL_PERMISSIONS + modifiedPermission.getName())
.expectedResponseStatus(204)
.responseValidator(response -> assertThat(response.getContentAsString())
.as("PUT response has no body")
.isBlank())
);
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_WRITE);
}
@Test
public void shouldDeletePermissions() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
Permission deletedPermission = TEST_PERMISSIONS.get(0);
ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size()));
assertExpectedRequest(requestDELETEPermission
.path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName())
.expectedResponseStatus(204)
.responseValidator(response -> assertThat(response.getContentAsString())
.as("DELETE response has no body")
.isBlank())
);
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_READ);
}
@Test
public void deletingNotExistingPermissionShouldProcess() throws URISyntaxException {
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
Permission deletedPermission = TEST_PERMISSIONS.get(0);
ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size()));
assertExpectedRequest(requestDELETEPermission
.path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName())
.expectedResponseStatus(204)
.responseValidator(response -> assertThat(response.getContentAsString())
.as("DELETE response has no body")
.isBlank())
);
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_READ);
assertExpectedRequest(requestDELETEPermission
.path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName())
.expectedResponseStatus(204)
.responseValidator(response -> assertThat(response.getContentAsString())
.as("DELETE response has no body")
.isBlank())
);
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_READ);
}
private void assertGettingExpectedPermissions(ImmutableList<Permission> expectedPermissions, String userPermission) throws URISyntaxException {
assertExpectedRequest(requestGETAllPermissions
.expectedResponseStatus(200)
.responseValidator((response) -> {
String body = response.getContentAsString();
ObjectMapper mapper = new ObjectMapper();
try {
HalRepresentation halRepresentation = mapper.readValue(body, HalRepresentation.class);
List<HalRepresentation> actualPermissionDtos = halRepresentation.getEmbedded().getItemsBy("permissions", HalRepresentation.class);
List<PermissionDto> permissionDtoStream = actualPermissionDtos.stream()
.map(hal -> {
PermissionDto result = new PermissionDto();
result.setName(hal.getAttribute("name").asText());
result.setType(hal.getAttribute("type").asText());
result.setGroupPermission(hal.getAttribute("groupPermission").asBoolean());
result.add(hal.getLinks());
return result;
}).collect(Collectors.toList());
assertThat(permissionDtoStream)
.as("response payload match permission object models")
.hasSize(expectedPermissions.size())
.usingRecursiveFieldByFieldElementComparator()
.containsExactlyInAnyOrder(getExpectedPermissionDtos(Lists.newArrayList(expectedPermissions), userPermission))
;
} catch (IOException e) {
fail();
}
})
);
}
private PermissionDto[] getExpectedPermissionDtos(ArrayList<Permission> permissions, String userPermission) {
return permissions
.stream()
.map(p -> getExpectedPermissionDto(p, userPermission))
.toArray(PermissionDto[]::new);
}
private PermissionDto getExpectedPermissionDto(Permission permission, String userPermission) {
PermissionDto result = new PermissionDto();
result.setName(permission.getName());
result.setGroupPermission(permission.isGroupPermission());
result.setType(permission.getType().name());
String permissionHref = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS + permission.getName();
if (PERMISSION_READ.equals(userPermission)) {
result.add(linkingTo()
.self(permissionHref)
.build());
} else {
result.add(linkingTo()
.self(permissionHref)
.single(link("update", permissionHref))
.single(link("delete", permissionHref))
.build());
}
return result;
}
private Repository createUserWithRepository(String userPermission) {
Repository mockRepository = mock(Repository.class);
when(mockRepository.getId()).thenReturn(REPOSITORY_NAME);
when(mockRepository.getNamespace()).thenReturn(REPOSITORY_NAMESPACE);
when(mockRepository.getName()).thenReturn(REPOSITORY_NAME);
when(repositoryManager.get(any(NamespaceAndName.class))).thenReturn(mockRepository);
when(subject.isPermitted(userPermission != null ? eq(userPermission) : any(String.class))).thenReturn(true);
return mockRepository;
}
private void createUserWithRepositoryAndPermissions(ArrayList<Permission> permissions, String userPermission) {
when(createUserWithRepository(userPermission).getPermissions()).thenReturn(permissions);
}
private Stream<DynamicTest> createDynamicTestsToAssertResponses(ExpectedRequest... expectedRequests) {
return Stream.of(expectedRequests)
.map(entry -> dynamicTest("the endpoint " + entry.description + " should return the status code " + entry.expectedResponseStatus, () -> assertExpectedRequest(entry)));
}
private MockHttpResponse assertExpectedRequest(ExpectedRequest entry) throws URISyntaxException {
MockHttpResponse response = new MockHttpResponse();
HttpRequest request = null;
request = MockHttpRequest
.create(entry.method, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + entry.path)
.content(entry.content)
.contentType(VndMediaType.PERMISSION);
dispatcher.invoke(request, response);
log.info("Test the Request :{}", entry);
assertThat(response.getStatus())
.as("assert status code")
.isEqualTo(entry.expectedResponseStatus);
if (entry.responseValidator != null) {
entry.responseValidator.accept(response);
}
return response;
}
@ToString
public class ExpectedRequest {
private String description;
private String method;
private String path;
private int expectedResponseStatus;
private byte[] content = new byte[]{};
private Consumer<MockHttpResponse> responseValidator;
public ExpectedRequest description(String description) {
this.description = description;
return this;
}
public ExpectedRequest method(String method) {
this.method = method;
return this;
}
public ExpectedRequest path(String path) {
this.path = path;
return this;
}
public ExpectedRequest content(String content) {
if (content != null) {
this.content = content.getBytes();
}
return this;
}
public ExpectedRequest expectedResponseStatus(int expectedResponseStatus) {
this.expectedResponseStatus = expectedResponseStatus;
return this;
}
public ExpectedRequest responseValidator(Consumer<MockHttpResponse> responseValidator) {
this.responseValidator = responseValidator;
return this;
}
}
}

View File

@@ -11,10 +11,13 @@ import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.mockito.Answers; import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Permission;
import sonia.scm.repository.PermissionType;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryException; import sonia.scm.repository.RepositoryException;
import sonia.scm.repository.RepositoryIsNotArchivedException; import sonia.scm.repository.RepositoryIsNotArchivedException;
@@ -35,10 +38,13 @@ import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static javax.servlet.http.HttpServletResponse.SC_OK; import static javax.servlet.http.HttpServletResponse.SC_OK;
import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED; import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentCaptor.forClass;
import static org.mockito.Matchers.any; import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyObject; import static org.mockito.Matchers.anyObject;
import static org.mockito.Matchers.eq; import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -240,6 +246,28 @@ public class RepositoryRootResourceTest {
verify(repositoryManager).create(any(Repository.class)); verify(repositoryManager).create(any(Repository.class));
} }
@Test
public void shouldNotOverwriteExistingPermissionsOnUpdate() throws Exception {
Repository existingRepository = mockRepository("space", "repo");
existingRepository.setPermissions(singletonList(new Permission("user", PermissionType.READ)));
URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json");
byte[] repository = Resources.toByteArray(url);
ArgumentCaptor<Repository> modifiedRepositoryCaptor = forClass(Repository.class);
doNothing().when(repositoryManager).modify(modifiedRepositoryCaptor.capture());
MockHttpRequest request = MockHttpRequest
.put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo")
.contentType(VndMediaType.REPOSITORY)
.content(repository);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertFalse(modifiedRepositoryCaptor.getValue().getPermissions().isEmpty());
}
private PageResult<Repository> createSingletonPageResult(Repository repository) { private PageResult<Repository> createSingletonPageResult(Repository repository) {
return new PageResult<>(singletonList(repository), 0); return new PageResult<>(singletonList(repository), 0);
} }

View File

@@ -23,7 +23,7 @@ public class ResourceLinksMock {
when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo));
when(resourceLinks.changeset()).thenReturn(new ResourceLinks.ChangesetLinks(uriInfo)); when(resourceLinks.changeset()).thenReturn(new ResourceLinks.ChangesetLinks(uriInfo));
when(resourceLinks.source()).thenReturn(new ResourceLinks.SourceLinks(uriInfo)); when(resourceLinks.source()).thenReturn(new ResourceLinks.SourceLinks(uriInfo));
when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo)); when(resourceLinks.permission()).thenReturn(new ResourceLinks.PermissionLinks(uriInfo));
when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo)); when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo));
when(resourceLinks.branch()).thenReturn(new ResourceLinks.BranchLinks(uriInfo)); when(resourceLinks.branch()).thenReturn(new ResourceLinks.BranchLinks(uriInfo));
when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo)); when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo));

View File

@@ -120,15 +120,15 @@ public final class IntegrationTestUtil
} }
} }
public static Collection<String> createRepositoryTypeParameters() { public static Collection<String[]> createRepositoryTypeParameters() {
Collection<String> params = new ArrayList<>(); Collection<String[]> params = new ArrayList<>();
params.add("git"); params.add(new String[]{"git"});
params.add("svn" ); params.add(new String[]{"svn"});
if (IOUtil.search("hg") != null) if (IOUtil.search("hg") != null)
{ {
params.add("hg"); params.add(new String[]{"hg"});
} }
return params; return params;

View File

@@ -51,11 +51,7 @@ import java.util.Collection;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static sonia.scm.it.IntegrationTestUtil.createAdminClient; import static sonia.scm.it.IntegrationTestUtil.*;
import static sonia.scm.it.IntegrationTestUtil.createResource;
import static sonia.scm.it.IntegrationTestUtil.getLink;
import static sonia.scm.it.IntegrationTestUtil.readJson;
import static sonia.scm.it.IntegrationTestUtil.serialize;
import static sonia.scm.it.RepositoryITUtil.createRepository; import static sonia.scm.it.RepositoryITUtil.createRepository;
import static sonia.scm.it.RepositoryITUtil.deleteRepository; import static sonia.scm.it.RepositoryITUtil.deleteRepository;
@@ -81,7 +77,7 @@ public class RepositoryArchiveITCase
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
@Parameterized.Parameters(name = "{0}") @Parameterized.Parameters(name = "{0}")
public static Collection<String> createParameters() { public static Collection<String[]> createParameters() {
return IntegrationTestUtil.createRepositoryTypeParameters(); return IntegrationTestUtil.createRepositoryTypeParameters();
} }

View File

@@ -33,11 +33,7 @@ package sonia.scm.it;
import com.google.common.base.Charsets; import com.google.common.base.Charsets;
import com.google.common.io.Files; import com.google.common.io.Files;
import com.sun.jersey.api.client.WebResource; import com.sun.jersey.api.client.WebResource;
import org.junit.After; import org.junit.*;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.junit.runners.Parameterized; import org.junit.runners.Parameterized;
@@ -208,7 +204,7 @@ public class RepositoryHookITCase extends AbstractAdminITCaseBase
* @return repository types test parameter * @return repository types test parameter
*/ */
@Parameters(name = "{0}") @Parameters(name = "{0}")
public static Collection<String> createParameters() public static Collection<String[]> createParameters()
{ {
return IntegrationTestUtil.createRepositoryTypeParameters(); return IntegrationTestUtil.createRepositoryTypeParameters();
} }

View File

@@ -40,15 +40,12 @@ import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito; import org.mockito.Mockito;
import static org.mockito.Mockito.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import org.junit.Rule;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.cache.Cache; import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
@@ -60,6 +57,12 @@ import sonia.scm.repository.RepositoryTestData;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserTestData; import sonia.scm.user.UserTestData;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.*;
/** /**
* Unit tests for {@link AuthorizationCollector}. * Unit tests for {@link AuthorizationCollector}.
* *
@@ -200,7 +203,7 @@ public class DefaultAuthorizationCollectorTest {
AuthorizationInfo authInfo = collector.collect(); AuthorizationInfo authInfo = collector.collect();
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
assertThat(authInfo.getObjectPermissions(), nullValue()); assertThat(authInfo.getObjectPermissions(), nullValue());
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("repository:read:one", "repository:read,write:two")); assertThat(authInfo.getStringPermissions(), containsInAnyOrder("repository:read,pull:one", "repository:read,pull,push:two"));
} }
/** /**