Archive repository (#1477)

This adds a flag "archived" to repositories. Repositories marked with this can no longer be modified in any way. To do this, we switch to a new version of Shiro Static Permissions (sdorra/shiro-static-permissions#4) and specify a permission guard to check for every permission request, whether the repository in question is archived or not. Further we implement checks in stores and other activies so that no writing request may be executed by mistake.

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
René Pfeuffer
2020-12-16 10:58:29 +01:00
committed by GitHub
parent b167d90fea
commit 8e3b0e4145
77 changed files with 2066 additions and 438 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471))
- Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472))
- Add "archive" flag for repositories to make them immutable ([#1477](https://github.com/scm-manager/scm-manager/pull/1477))
### Fixed
- Add "Api Key" page link to sub-navigation of "User" and "Me" sections ([#1464](https://github.com/scm-manager/scm-manager/pull/1464))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,22 +1,36 @@
---
title: Repository
subtitle: Einstellungen
title: Repository subtitle: Einstellungen
---
Unter den Repository Einstellungen befinden sich zwei Einträge. Wenn weitere Plugins installiert sind, können es deutlich mehr Unterseiten sein.
Unter den Repository Einstellungen befinden sich zwei Einträge. Wenn weitere Plugins installiert sind, können es
deutlich mehr Unterseiten sein.
### Generell
Unter dem Eintrag "Generell" kann man die Zusatzinformationen zum Repository editieren. Da es sich im Beispiel um ein Git Repository handelt, kann ebenfalls der Standard-Branch für dieses Repository gesetzt werden. Der Standard-Branch sorgt dafür, dass beim Arbeiten mit diesem Repository dieser Branch vorrangig geöffnet wird, falls kein expliziter Branch ausgewählt wurde.
Innerhalb der Gefahrenzone unten auf der Seite gibt es mit entsprechenden Rechten die Möglichkeit das Repository umzubenennen oder zu löschen. Wenn in der globalen SCM-Manager Konfiguration die Namespace Strategie `benutzerdefiniert` ausgewählt ist, kann zusätzlich zum Repository Namen auch der Namespace umbenannt werden.
Unter dem Eintrag "Generell" kann man die Zusatzinformationen zum Repository editieren. Da es sich im Beispiel um ein
Git Repository handelt, kann ebenfalls der Standard-Branch für dieses Repository gesetzt werden. Der Standard-Branch
sorgt dafür, dass beim Arbeiten mit diesem Repository dieser Branch vorrangig geöffnet wird, falls kein expliziter
Branch ausgewählt wurde.
Innerhalb der Gefahrenzone unten auf der Seite gibt es mit entsprechenden Rechten die Möglichkeit das Repository
umzubenennen, zu löschen oder als archiviert zu markieren. Wenn in der globalen SCM-Manager Konfiguration die Namespace
Strategie `benutzerdefiniert` ausgewählt ist, kann zusätzlich zum Repository Namen auch der Namespace umbenannt werden.
Ein archiviertes Repository kann nicht mehr verändert werden.
![Repository-Settings-General-Git](assets/repository-settings-general-git.png)
### Berechtigungen
Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global, auf Namespace-Ebene und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen.
Die Berechtigungen können jeweils für Gruppen und für Benutzer vergeben werden. Dabei gibt es die Möglichkeiten die Berechtigungen über Berechtigungsrollen zu definieren oder jede Berechtigung einzeln zu vergeben. Die Berechtigungsrollen können in der Administrations-Oberfläche definiert werden.
Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren
Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global, auf Namespace-Ebene
und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des
SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen.
Berechtigungen auf Namespace-Ebene können über die Einstellungen für Namespaces bearbeitet werden. Diese sind über das Einstellungs-Symbol neben den Namespace-Überschriften auf der Repository-Übersicht erreichbar.
Die Berechtigungen können jeweils für Gruppen und für Benutzer vergeben werden. Dabei gibt es die Möglichkeiten die
Berechtigungen über Berechtigungsrollen zu definieren oder jede Berechtigung einzeln zu vergeben. Die
Berechtigungsrollen können in der Administrations-Oberfläche definiert werden.
Berechtigungen auf Namespace-Ebene können über die Einstellungen für Namespaces bearbeitet werden. Diese sind über das
Einstellungs-Symbol neben den Namespace-Überschriften auf der Repository-Übersicht erreichbar.
![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -1,22 +1,33 @@
---
title: Repository
subtitle: Settings
title: Repository subtitle: Settings
---
By default, there are two items in the repository settings. Depending on additional plugins that are installed, there can be considerably more items.
By default, there are two items in the repository settings. Depending on additional plugins that are installed, there
can be considerably more items.
### General
The "General" item allows you to edit the additional information of the repository. Git repositories for example also have the option to change the default branch here. The default branch is the one that is used when working with the repository if no specific branch is selected.
In the danger zone at the bottom you may rename the repository or delete it. If the namespace strategy in the global SCM-Manager config is set to `custom` you may even rename the repository namespace.
The "General" item allows you to edit the additional information of the repository. Git repositories for example also
have the option to change the default branch here. The default branch is the one that is used when working with the
repository if no specific branch is selected.
In the danger zone at the bottom you may rename the repository, delete it or mark it as archived. If the namespace
strategy in the global SCM-Manager config is set to `custom` you may even rename the repository namespace. If a
repository is marked as archived, it can no longer be modified.
![Repository-Settings-General-Git](assets/repository-settings-general-git.png)
### Permissions
Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable roles or individual settings. Permissions can be granted globally, namespace-wide, or repository-specific. Global permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific permissions.
Permissions can be granted to groups or users. It is possible to manage each permission individually or to create roles that contain several permissions. Roles can be defined in the administration area.
Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable
roles or individual settings. Permissions can be granted globally, namespace-wide, or repository-specific. Global
permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific
permissions.
Namespace-wide permissions can be configured in the namespace settings. These can be accessed via the settings icon on the right-hand side of the namespace heading in the repository overview.
Permissions can be granted to groups or users. It is possible to manage each permission individually or to create roles
that contain several permissions. Roles can be defined in the administration area.
Namespace-wide permissions can be configured in the namespace settings. These can be accessed via the settings icon on
the right-hand side of the namespace heading in the repository overview.
![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png)

View File

@@ -935,7 +935,7 @@
<jetty.maven.version>9.4.34.v20201102</jetty.maven.version>
<!-- security libraries -->
<ssp.version>1.2.0</ssp.version>
<ssp.version>1.3.0</ssp.version>
<shiro.version>1.7.0</shiro.version>
<!-- repository libraries -->

View File

@@ -0,0 +1,72 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import com.github.legman.Subscribe;
import sonia.scm.EagerSingleton;
import sonia.scm.plugin.Extension;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
@Extension
@EagerSingleton
/**
* Default implementation of {@link RepositoryArchivedCheck}. This tracks the archive status of repositories by using
* {@link RepositoryModificationEvent}s. The initial set of archived repositories is read by
* {@link EventDrivenRepositoryArchiveCheckInitializer} on startup.
*/
public final class EventDrivenRepositoryArchiveCheck implements RepositoryArchivedCheck {
private static final Collection<String> ARCHIVED_REPOSITORIES = Collections.synchronizedSet(new HashSet<>());
static void setAsArchived(String repositoryId) {
ARCHIVED_REPOSITORIES.add(repositoryId);
}
static void removeFromArchived(String repositoryId) {
ARCHIVED_REPOSITORIES.remove(repositoryId);
}
static boolean isRepositoryArchived(String repositoryId) {
return ARCHIVED_REPOSITORIES.contains(repositoryId);
}
@Override
public boolean isArchived(String repositoryId) {
return isRepositoryArchived(repositoryId);
}
@Subscribe(async = false)
public void updateListener(RepositoryModificationEvent event) {
Repository repository = event.getItem();
if (repository.isArchived()) {
EventDrivenRepositoryArchiveCheck.setAsArchived(repository.getId());
} else {
EventDrivenRepositoryArchiveCheck.removeFromArchived(repository.getId());
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import sonia.scm.EagerSingleton;
import sonia.scm.Initable;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.Extension;
import javax.inject.Inject;
@Extension
@EagerSingleton
final class EventDrivenRepositoryArchiveCheckInitializer implements Initable {
private final RepositoryDAO repositoryDAO;
@Inject
EventDrivenRepositoryArchiveCheckInitializer(RepositoryDAO repositoryDAO) {
this.repositoryDAO = repositoryDAO;
}
@Override
public void init(SCMContextProvider context) {
repositoryDAO.getAll()
.stream()
.filter(Repository::isArchived)
.map(Repository::getId)
.forEach(EventDrivenRepositoryArchiveCheck::setAsArchived);
}
}

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import java.util.Collection;
/**
* Provider for available verbs and roles for repository permissions, such as "read", "modify", "pull", "push", etc.
* This collection of verbs can be extended by plugins and be grouped to roles, such as "READ", "WRITE", etc.
* The permissions are configured by "repository-permissions.xml" files from the core and from plugins.
*
* @since 2.12.0
*/
public interface PermissionProvider {
/**
* The collection of all registered verbs.
*/
Collection<String> availableVerbs();
/**
* The collection of verbs that are marked as "read only". These verbs are safe for archived or otherwise read only
* repositories.
*/
Collection<String> readOnlyVerbs();
/**
* The collection of roles defined and extended by core and plugins.
*/
Collection<RepositoryRole> availableRoles();
}

View File

@@ -24,6 +24,7 @@
package sonia.scm.repository;
import com.github.sdorra.ssp.Guard;
import com.github.sdorra.ssp.PermissionObject;
import com.github.sdorra.ssp.StaticPermissions;
import com.google.common.base.MoreObjects;
@@ -51,13 +52,16 @@ import java.util.Set;
*
* @author Sebastian Sdorra
*/
@StaticPermissions(
value = "repository",
permissions = {"read", "modify", "delete", "rename", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"},
custom = true, customGlobal = true
)
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "repositories")
@StaticPermissions(
value = "repository",
permissions = {"read", "modify", "delete", "rename", "healthCheck", "pull", "push", "permissionRead", "permissionWrite", "archive"},
custom = true, customGlobal = true,
guards = {
@Guard(guard = RepositoryPermissionGuard.class)
}
)
public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject {
private static final long serialVersionUID = 3486560714961909711L;
@@ -75,6 +79,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
@XmlElement(name = "permission")
private Set<RepositoryPermission> permissions = new HashSet<>();
private String type;
private boolean archived;
/**
@@ -204,6 +209,15 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
return type;
}
/**
* Returns <code>true</code>, when the repository is marked as "archived". An archived repository cannot be modified.
*
* @since 2.11.0
*/
public boolean isArchived() {
return archived;
}
/**
* Returns {@code true} if the repository is healthy.
*
@@ -276,6 +290,15 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
this.type = type;
}
/**
* Set this to <code>true</code> to mark the repository as "archived". An archived repository cannot be modified.
*
* @since 2.11.0
*/
public void setArchived(boolean archived) {
this.archived = archived;
}
public void setHealthCheckFailures(List<HealthCheckFailure> healthCheckFailures) {
this.healthCheckFailures = healthCheckFailures;
}

View File

@@ -0,0 +1,50 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
/**
* Implementations of this class can be used to check whether a repository is archived.
*
* @since 1.12.0
*/
public interface RepositoryArchivedCheck {
/**
* Checks whether the repository with the given id is archived or not.
* @param repositoryId The id of the repository to check.
* @return <code>true</code> when the repository with the given id is archived, <code>false</code> otherwise.
*/
boolean isArchived(String repositoryId);
/**
* Checks whether the given repository is archived or not. This checks the status on behalf of the id of the
* repository, not by the archive flag provided by the repository itself.
* @param repository The repository to check.
* @return <code>true</code> when the given repository is archived, <code>false</code> otherwise.
*/
default boolean isArchived(Repository repository) {
return isArchived(repository.getId());
}
}

View File

@@ -113,4 +113,18 @@ public interface RepositoryManager
afterCreation.accept(newRepository);
return newRepository;
}
/**
* @param repository the {@link Repository} to be archived.
*
* @since 2.12.0
*/
void archive(Repository repository);
/**
* @param repository the {@link Repository} to be "unarchived".
*
* @since 2.12.0
*/
void unarchive(Repository repository);
}

View File

@@ -136,6 +136,16 @@ public class RepositoryManagerDecorator
return decorated.getAllNamespaces();
}
@Override
public void archive(Repository repository) {
decorated.archive(repository);
}
@Override
public void unarchive(Repository repository) {
decorated.unarchive(repository);
}
//~--- fields ---------------------------------------------------------------
/**

View File

@@ -0,0 +1,74 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import com.github.sdorra.ssp.PermissionActionCheckInterceptor;
import com.github.sdorra.ssp.PermissionGuard;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.function.BooleanSupplier;
import static sonia.scm.repository.EventDrivenRepositoryArchiveCheck.isRepositoryArchived;
/**
* This intercepts permission checks for repositories and blocks write permissions for archived repositories.
* Read only permissions are set at startup by {@link RepositoryPermissionGuardInitializer}.
*/
public class RepositoryPermissionGuard implements PermissionGuard<Repository> {
private static final Collection<String> READ_ONLY_VERBS = Collections.synchronizedSet(new HashSet<>());
static void setReadOnlyVerbs(Collection<String> readOnlyVerbs) {
READ_ONLY_VERBS.addAll(readOnlyVerbs);
}
@Override
public PermissionActionCheckInterceptor<Repository> intercept(String permission) {
if (READ_ONLY_VERBS.contains(permission)) {
return new PermissionActionCheckInterceptor<Repository>() {};
} else {
return new WriteInterceptor();
}
}
private static class WriteInterceptor implements PermissionActionCheckInterceptor<Repository> {
@Override
public void check(Subject subject, String id, Runnable delegate) {
delegate.run();
if (isRepositoryArchived(id)) {
throw new AuthorizationException("repository is archived");
}
}
@Override
public boolean isPermitted(Subject subject, String id, BooleanSupplier delegate) {
return !isRepositoryArchived(id) && delegate.getAsBoolean();
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import sonia.scm.EagerSingleton;
import sonia.scm.Initable;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.Extension;
import javax.inject.Inject;
/**
* Initializes read only permissions for {@link RepositoryPermissionGuard} at startup.
*/
@Extension
@EagerSingleton
final class RepositoryPermissionGuardInitializer implements Initable {
private final PermissionProvider permissionProvider;
@Inject
RepositoryPermissionGuardInitializer(PermissionProvider permissionProvider) {
this.permissionProvider = permissionProvider;
}
@Override
public void init(SCMContextProvider context) {
RepositoryPermissionGuard.setReadOnlyVerbs(permissionProvider.readOnlyVerbs());
}
}

View File

@@ -0,0 +1,45 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import sonia.scm.ExceptionWithContext;
import sonia.scm.repository.Repository;
import static java.lang.String.format;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
public class RepositoryArchivedException extends ExceptionWithContext {
public static final String CODE = "3hSIlptme1";
protected RepositoryArchivedException(Repository repository) {
super(entity(repository).build(), format("Repository %s is marked as archived and must not be modified", repository));
}
@Override
public String getCode() {
return CODE;
}
}

View File

@@ -182,6 +182,7 @@ public final class RepositoryService implements Closeable {
* by the implementation of the repository service provider.
*/
public BranchCommandBuilder getBranchCommand() {
verifyNotArchived();
RepositoryPermissions.push(getRepository()).check();
LOG.debug("create branch command for repository {}",
repository.getNamespaceAndName());
@@ -332,6 +333,7 @@ public final class RepositoryService implements Closeable {
* @since 1.31
*/
public PullCommandBuilder getPullCommand() {
verifyNotArchived();
LOG.debug("create pull command for repository {}",
repository.getNamespaceAndName());
@@ -386,6 +388,7 @@ public final class RepositoryService implements Closeable {
* by the implementation of the repository service provider.
*/
public TagCommandBuilder getTagCommand() {
verifyNotArchived();
return new TagCommandBuilder(provider.getTagCommand());
}
@@ -415,6 +418,7 @@ public final class RepositoryService implements Closeable {
* @since 2.0.0
*/
public MergeCommandBuilder getMergeCommand() {
verifyNotArchived();
LOG.debug("create merge command for repository {}",
repository.getNamespaceAndName());
@@ -436,6 +440,7 @@ public final class RepositoryService implements Closeable {
* @since 2.0.0
*/
public ModifyCommandBuilder getModifyCommand() {
verifyNotArchived();
LOG.debug("create modify command for repository {}",
repository.getNamespaceAndName());
@@ -484,6 +489,12 @@ public final class RepositoryService implements Closeable {
.filter(protocol -> !Authentications.isAuthenticatedSubjectAnonymous() || protocol.isAnonymousEnabled());
}
private void verifyNotArchived() {
if (getRepository().isArchived()) {
throw new RepositoryArchivedException(getRepository());
}
}
@SuppressWarnings({"rawtypes", "java:S3740"})
private ScmProtocol createProviderInstanceForRepository(ScmProtocolProvider protocolProvider) {
return protocolProvider.get(repository);

View File

@@ -38,6 +38,11 @@ public abstract class AbstractStore<T> implements ConfigurationStore<T> {
* stored object
*/
protected T storeObject;
private final boolean readOnly;
protected AbstractStore(boolean readOnly) {
this.readOnly = readOnly;
}
@Override
public T get() {
@@ -49,9 +54,12 @@ public abstract class AbstractStore<T> implements ConfigurationStore<T> {
}
@Override
public void set(T obejct) {
writeObject(obejct);
this.storeObject = obejct;
public void set(T object) {
if (readOnly) {
throw new StoreReadOnlyException(object);
}
writeObject(object);
this.storeObject = object;
}
/**

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ExceptionWithContext;
import static sonia.scm.ContextEntry.ContextBuilder.noContext;
public class StoreReadOnlyException extends ExceptionWithContext {
private static final Logger LOG = LoggerFactory.getLogger(StoreReadOnlyException.class);
public static final String CODE = "3FSIYtBJw1";
public StoreReadOnlyException(String location) {
super(noContext(), String.format("Store is read only, could not write location %s", location));
LOG.error(getMessage());
}
public StoreReadOnlyException(Object object) {
super(noContext(), String.format("Store is read only, could not write object of type %s: %s", object.getClass(), object));
LOG.error(getMessage());
}
@Override
public String getCode () {
return CODE;
}
}

View File

@@ -0,0 +1,75 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import org.junit.jupiter.api.Test;
import sonia.scm.HandlerEventType;
import java.util.Collections;
import static java.util.Collections.singleton;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static sonia.scm.repository.EventDrivenRepositoryArchiveCheck.setAsArchived;
class EventDrivenRepositoryArchiveCheckTest {
private static final Repository NORMAL_REPOSITORY = new Repository("hog", "git", "hitchhiker", "hog");
private static final Repository ARCHIVED_REPOSITORY = new Repository("hog", "git", "hitchhiker", "hog");
static {
ARCHIVED_REPOSITORY.setArchived(true);
}
EventDrivenRepositoryArchiveCheck check = new EventDrivenRepositoryArchiveCheck();
@Test
void shouldBeNotArchivedByDefault() {
assertThat(check.isArchived("hog")).isFalse();
}
@Test
void shouldBeArchivedAfterFlagHasBeenSet() {
check.updateListener(new RepositoryModificationEvent(HandlerEventType.MODIFY, ARCHIVED_REPOSITORY, NORMAL_REPOSITORY));
assertThat(check.isArchived("hog")).isTrue();
}
@Test
void shouldNotBeArchivedAfterFlagHasBeenRemoved() {
setAsArchived("hog");
check.updateListener(new RepositoryModificationEvent(HandlerEventType.MODIFY, NORMAL_REPOSITORY, ARCHIVED_REPOSITORY));
assertThat(check.isArchived("hog")).isFalse();
}
@Test
void shouldBeInitialized() {
RepositoryDAO repositoryDAO = mock(RepositoryDAO.class);
when(repositoryDAO.getAll()).thenReturn(singleton(ARCHIVED_REPOSITORY));
new EventDrivenRepositoryArchiveCheckInitializer(repositoryDAO).init(null);
assertThat(check.isArchived("hog")).isTrue();
}
}

View File

@@ -0,0 +1,146 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import com.github.sdorra.ssp.PermissionActionCheckInterceptor;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.function.BooleanSupplier;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RepositoryPermissionGuardTest {
@Mock
private Subject subject;
@Mock
private BooleanSupplier permittedDelegate;
@Mock
private Runnable checkDelegate;
@BeforeAll
static void setReadOnlyVerbs() {
RepositoryPermissionGuard.setReadOnlyVerbs(asList("read"));
}
@Nested
class ForReadOnlyVerb {
PermissionActionCheckInterceptor<Repository> readInterceptor = new RepositoryPermissionGuard().intercept("read");
@Test
void shouldNotInterceptPermissionCheck() {
when(permittedDelegate.getAsBoolean()).thenReturn(true);
assertThat(readInterceptor.isPermitted(subject, "1", permittedDelegate)).isTrue();
verify(permittedDelegate).getAsBoolean();
}
@Test
void shouldNotInterceptCheckRequest() {
readInterceptor.check(subject, "1", checkDelegate);
verify(checkDelegate).run();
}
}
@Nested
class ForModifyingVerb {
PermissionActionCheckInterceptor<Repository> readInterceptor = new RepositoryPermissionGuard().intercept("modify");
@Nested
class WithNormalRepository {
@Test
void shouldInterceptPermissionCheck() {
when(permittedDelegate.getAsBoolean()).thenReturn(true);
assertThat(readInterceptor.isPermitted(subject, "1", permittedDelegate)).isTrue();
verify(permittedDelegate).getAsBoolean();
}
@Test
void shouldInterceptCheckRequest() {
readInterceptor.check(subject, "1", checkDelegate);
verify(checkDelegate).run();
}
}
@Nested
class WithArchivedRepository {
@BeforeEach
void mockArchivedRepository() {
EventDrivenRepositoryArchiveCheck.setAsArchived("1");
}
@AfterEach
void removeArchiveFlag() {
EventDrivenRepositoryArchiveCheck.removeFromArchived("1");
}
@Test
void shouldInterceptPermissionCheck() {
assertThat(readInterceptor.isPermitted(subject, "1", permittedDelegate)).isFalse();
verify(permittedDelegate, never()).getAsBoolean();
}
@Test
void shouldInterceptCheckRequest() {
assertThrows(AuthorizationException.class, () -> readInterceptor.check(subject, "1", checkDelegate));
}
@Test
void shouldThrowConcretePermissionExceptionOverArchiveException() {
doThrow(new AuthorizationException()).when(checkDelegate).run();
assertThrows(AuthorizationException.class, () -> readInterceptor.check(subject, "1", checkDelegate));
verify(checkDelegate).run();
}
}
}
}

View File

@@ -109,6 +109,19 @@ class RepositoryServiceTest {
assertThrows(IllegalArgumentException.class, () -> repositoryService.getProtocol(UnknownScmProtocol.class));
}
@Test
void shouldFailForArchivedRepository() {
repository.setArchived(true);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail);
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getModifyCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getBranchCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getPullCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getTagCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getMergeCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getModifyCommand());
}
private static class DummyHttpProtocol extends HttpScmProtocol {
private final boolean anonymousEnabled;

View File

@@ -34,6 +34,7 @@ import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.store.StoreReadOnlyException;
import javax.inject.Inject;
import java.io.IOException;
@@ -139,6 +140,9 @@ public class XmlRepositoryDAO implements RepositoryDAO {
@Override
public void modify(Repository repository) {
Repository clone = repository.clone();
if (clone.isArchived() && byId.get(clone.getId()).isArchived()) {
throw new StoreReadOnlyException(repository);
}
synchronized (this) {
// remove old namespaceAndName from map, in case of rename
@@ -158,6 +162,9 @@ public class XmlRepositoryDAO implements RepositoryDAO {
@Override
public void delete(Repository repository) {
if (repository.isArchived()) {
throw new StoreReadOnlyException(repository);
}
Path path;
synchronized (this) {
Repository prev = byId.remove(repository.getId());

View File

@@ -60,10 +60,11 @@ public abstract class FileBasedStore<T> implements MultiEntryStore<T>
* @param directory
* @param suffix
*/
public FileBasedStore(File directory, String suffix)
public FileBasedStore(File directory, String suffix, boolean readOnly)
{
this.directory = directory;
this.suffix = suffix;
this.readOnly = readOnly;
}
//~--- methods --------------------------------------------------------------
@@ -145,6 +146,8 @@ public abstract class FileBasedStore<T> implements MultiEntryStore<T>
{
logger.trace("delete store entry {}", file);
assertNotReadOnly();
if (file.exists() &&!file.delete())
{
throw new StoreException(
@@ -185,6 +188,12 @@ public abstract class FileBasedStore<T> implements MultiEntryStore<T>
return name.substring(0, name.length() - suffix.length());
}
protected void assertNotReadOnly() {
if (readOnly) {
throw new StoreReadOnlyException(directory.getAbsoluteFile().toString());
}
}
//~--- fields ---------------------------------------------------------------
/** Field description */
@@ -192,4 +201,6 @@ public abstract class FileBasedStore<T> implements MultiEntryStore<T>
/** Field description */
private final String suffix;
private final boolean readOnly;
}

View File

@@ -29,6 +29,7 @@ package sonia.scm.store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.util.IOUtil;
@@ -48,14 +49,16 @@ public abstract class FileBasedStoreFactory {
* the logger for FileBasedStoreFactory
*/
private static final Logger LOG = LoggerFactory.getLogger(FileBasedStoreFactory.class);
private SCMContextProvider contextProvider;
private RepositoryLocationResolver repositoryLocationResolver;
private Store store;
private final SCMContextProvider contextProvider;
private final RepositoryLocationResolver repositoryLocationResolver;
private final Store store;
private final RepositoryArchivedCheck archivedCheck;
protected FileBasedStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, Store store) {
protected FileBasedStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, Store store, RepositoryArchivedCheck archivedCheck) {
this.contextProvider = contextProvider;
this.repositoryLocationResolver = repositoryLocationResolver;
this.store = store;
this.archivedCheck = archivedCheck;
}
protected File getStoreLocation(StoreParameters storeParameters) {
@@ -79,6 +82,10 @@ public abstract class FileBasedStoreFactory {
return new File(storeDirectory, name);
}
protected boolean mustBeReadOnly(StoreParameters storeParameters) {
return storeParameters.getRepositoryId() != null && archivedCheck.isArchived(storeParameters.getRepositoryId());
}
/**
* Get the store directory of a specific repository
* @param store the type of the store

View File

@@ -58,8 +58,8 @@ public class FileBlobStore extends FileBasedStore<Blob> implements BlobStore {
private final KeyGenerator keyGenerator;
FileBlobStore(KeyGenerator keyGenerator, File directory) {
super(directory, SUFFIX);
FileBlobStore(KeyGenerator keyGenerator, File directory, boolean readOnly) {
super(directory, SUFFIX, readOnly);
this.keyGenerator = keyGenerator;
}
@@ -74,6 +74,8 @@ public class FileBlobStore extends FileBasedStore<Blob> implements BlobStore {
"id argument is required");
LOG.debug("create new blob with id {}", id);
assertNotReadOnly();
File file = getFile(id);
try {
@@ -94,6 +96,7 @@ public class FileBlobStore extends FileBasedStore<Blob> implements BlobStore {
@Override
public void remove(Blob blob) {
assertNotReadOnly();
Preconditions.checkNotNull(blob, "blob argument is required");
remove(blob.getId());
}

View File

@@ -31,6 +31,7 @@ import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.security.KeyGenerator;
import sonia.scm.util.IOUtil;
@@ -59,8 +60,8 @@ public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobS
* @param keyGenerator key generator
*/
@Inject
public FileBlobStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) {
super(contextProvider, repositoryLocationResolver, Store.BLOB);
public FileBlobStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.BLOB, archivedCheck);
this.keyGenerator = keyGenerator;
}
@@ -69,8 +70,6 @@ public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobS
public BlobStore getStore(StoreParameters storeParameters) {
File storeLocation = getStoreLocation(storeParameters);
IOUtil.mkdirs(storeLocation);
return new FileBlobStore(keyGenerator, storeLocation);
return new FileBlobStore(keyGenerator, storeLocation, mustBeReadOnly(storeParameters));
}
}

View File

@@ -29,6 +29,7 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.security.KeyGenerator;
@@ -45,8 +46,8 @@ public class JAXBConfigurationEntryStoreFactory extends FileBasedStoreFactory
private KeyGenerator keyGenerator;
@Inject
public JAXBConfigurationEntryStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG);
public JAXBConfigurationEntryStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, archivedCheck);
this.keyGenerator = keyGenerator;
}

View File

@@ -46,7 +46,8 @@ public class JAXBConfigurationStore<T> extends AbstractStore<T> {
private final Class<T> type;
private final File configFile;
public JAXBConfigurationStore(TypedStoreContext<T> context, Class<T> type, File configFile) {
public JAXBConfigurationStore(TypedStoreContext<T> context, Class<T> type, File configFile, boolean readOnly) {
super(readOnly);
this.context = context;
this.type = type;
this.configFile = configFile;

View File

@@ -27,6 +27,7 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
/**
@@ -43,8 +44,8 @@ public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory impleme
* @param repositoryLocationResolver Resolver to get the repository Directory
*/
@Inject
public JAXBConfigurationStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG);
public JAXBConfigurationStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, archivedCheck);
}
@Override
@@ -55,7 +56,8 @@ public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory impleme
storeParameters.getType(),
getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION),
storeParameters.getType(),
storeParameters.getRepositoryId())
storeParameters.getRepositoryId()),
mustBeReadOnly(storeParameters)
);
}
}

View File

@@ -52,8 +52,8 @@ public class JAXBDataStore<T> extends FileBasedStore<T> implements DataStore<T>
private final KeyGenerator keyGenerator;
private final TypedStoreContext<T> context;
JAXBDataStore(KeyGenerator keyGenerator, TypedStoreContext<T> context, File directory) {
super(directory, StoreConstants.FILE_EXTENSION);
JAXBDataStore(KeyGenerator keyGenerator, TypedStoreContext<T> context, File directory, boolean readOnly) {
super(directory, StoreConstants.FILE_EXTENSION, readOnly);
this.keyGenerator = keyGenerator;
this.directory = directory;
this.context = context;
@@ -63,6 +63,8 @@ public class JAXBDataStore<T> extends FileBasedStore<T> implements DataStore<T>
public void put(String id, T item) {
LOG.debug("put item {} to store", id);
assertNotReadOnly();
File file = getFile(id);
try {

View File

@@ -28,8 +28,8 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.security.KeyGenerator;
import sonia.scm.util.IOUtil;
@@ -44,11 +44,11 @@ import java.io.File;
public class JAXBDataStoreFactory extends FileBasedStoreFactory
implements DataStoreFactory {
private KeyGenerator keyGenerator;
private final KeyGenerator keyGenerator;
@Inject
public JAXBDataStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) {
super(contextProvider, repositoryLocationResolver, Store.DATA);
public JAXBDataStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.DATA, archivedCheck);
this.keyGenerator = keyGenerator;
}
@@ -56,6 +56,6 @@ public class JAXBDataStoreFactory extends FileBasedStoreFactory
public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) {
File storeLocation = getStoreLocation(storeParameters);
IOUtil.mkdirs(storeLocation);
return new JAXBDataStore<>(keyGenerator, TypedStoreContext.of(storeParameters), storeLocation);
return new JAXBDataStore<>(keyGenerator, TypedStoreContext.of(storeParameters), storeLocation, mustBeReadOnly(storeParameters));
}
}

View File

@@ -43,6 +43,7 @@ import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.store.StoreReadOnlyException;
import java.io.IOException;
import java.net.URL;
@@ -55,6 +56,7 @@ import java.util.function.Consumer;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
@@ -233,6 +235,16 @@ class XmlRepositoryDAOTest {
assertThat(dao.get("42").getDescription()).isEqualTo("Heart of Gold");
}
@Test
void shouldNotModifyArchivedRepository() {
REPOSITORY.setArchived(true);
dao.add(REPOSITORY);
Repository heartOfGold = createRepository("42");
heartOfGold.setArchived(true);
assertThrows(StoreReadOnlyException.class, () -> dao.modify(heartOfGold));
}
@Test
void shouldRemoveRepository() {
dao.add(REPOSITORY);
@@ -247,6 +259,15 @@ class XmlRepositoryDAOTest {
assertThat(storePath).doesNotExist();
}
@Test
void shouldNotRemoveArchivedRepository() {
REPOSITORY.setArchived(true);
dao.add(REPOSITORY);
assertThat(dao.contains("42")).isTrue();
assertThrows(StoreReadOnlyException.class, () -> dao.delete(REPOSITORY));
}
@Test
void shouldRenameTheRepository() {
dao.add(REPOSITORY);

View File

@@ -24,51 +24,209 @@
package sonia.scm.store;
//~--- non-JDK imports --------------------------------------------------------
import org.junit.Test;
import com.google.common.io.ByteStreams;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import sonia.scm.AbstractTestBase;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.security.UUIDKeyGenerator;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
*
* @author Sebastian Sdorra
*/
public class FileBlobStoreTest extends BlobStoreTestBase
class FileBlobStoreTest extends AbstractTestBase
{
/**
* Method description
*
*
* @return
*/
@Override
protected BlobStoreFactory createBlobStoreFactory()
private Repository repository = RepositoryTestData.createHeartOfGold();
private RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
private BlobStore store;
@BeforeEach
void createBlobStore()
{
return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator());
store = createBlobStoreFactory()
.withName("test")
.forRepository(repository)
.build();
}
@Test
@SuppressWarnings("unchecked")
public void shouldStoreAndLoadInRepository() {
BlobStore store = createBlobStoreFactory()
.withName("test")
.forRepository(new Repository("id", "git", "ns", "n"))
.build();
void testClear()
{
store.create("1");
store.create("2");
store.create("3");
Blob createdBlob = store.create("abc");
List<Blob> storedBlobs = store.getAll();
assertNotNull(store.get("1"));
assertNotNull(store.get("2"));
assertNotNull(store.get("3"));
assertNotNull(createdBlob);
assertThat(storedBlobs)
.isNotNull()
.hasSize(1)
.usingElementComparatorOnFields("id").containsExactly(createdBlob);
store.clear();
assertNull(store.get("1"));
assertNull(store.get("2"));
assertNull(store.get("3"));
}
@Test
void testContent() throws IOException
{
Blob blob = store.create();
write(blob, "Hello");
assertEquals("Hello", read(blob));
blob = store.get(blob.getId());
assertEquals("Hello", read(blob));
write(blob, "Other Text");
assertEquals("Other Text", read(blob));
blob = store.get(blob.getId());
assertEquals("Other Text", read(blob));
}
@Test
void testCreateAlreadyExistingEntry()
{
assertNotNull(store.create("1"));
assertThrows(EntryAlreadyExistsStoreException.class, () -> store.create("1"));
}
@Test
void testCreateWithId()
{
Blob blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
@Test
void testCreateWithoutId()
{
Blob blob = store.create();
assertNotNull(blob);
String id = blob.getId();
assertNotNull(id);
blob = store.get(id);
assertNotNull(blob);
}
@Test
void testGet()
{
Blob blob = store.get("1");
assertNull(blob);
blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
@Test
void testGetAll()
{
store.create("1");
store.create("2");
store.create("3");
List<Blob> all = store.getAll();
assertNotNull(all);
assertFalse(all.isEmpty());
assertEquals(3, all.size());
boolean c1 = false;
boolean c2 = false;
boolean c3 = false;
for (Blob b : all)
{
if ("1".equals(b.getId()))
{
c1 = true;
}
else if ("2".equals(b.getId()))
{
c2 = true;
}
else if ("3".equals(b.getId()))
{
c3 = true;
}
}
assertTrue(c1);
assertTrue(c2);
assertTrue(c3);
}
@Nested
class WithArchivedRepository {
@BeforeEach
void setRepositoryArchived() {
store.create("1"); // store for test must not be empty
when(archivedCheck.isArchived(repository.getId())).thenReturn(true);
createBlobStore();
}
@Test
void shouldNotClear() {
assertThrows(StoreReadOnlyException.class, () -> store.clear());
}
@Test
void shouldNotRemove() {
assertThrows(StoreReadOnlyException.class, () -> store.remove("1"));
}
}
private String read(Blob blob) throws IOException
{
InputStream input = blob.getInputStream();
byte[] bytes = ByteStreams.toByteArray(input);
input.close();
return new String(bytes);
}
private void write(Blob blob, String content) throws IOException
{
OutputStream output = blob.getOutputStream();
output.write(content.getBytes());
output.close();
blob.commit();
}
protected BlobStoreFactory createBlobStoreFactory()
{
return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), archivedCheck);
}
}

View File

@@ -154,7 +154,7 @@ public class JAXBConfigurationEntryStoreTest
@Override
protected ConfigurationEntryStoreFactory createConfigurationStoreFactory()
{
return new JAXBConfigurationEntryStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator());
return new JAXBConfigurationEntryStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), null);
}
/**

View File

@@ -26,9 +26,13 @@ package sonia.scm.store;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link JAXBConfigurationStore}.
@@ -37,10 +41,12 @@ import static org.junit.Assert.assertNotNull;
*/
public class JAXBConfigurationStoreTest extends StoreTestBase {
private final RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
@Override
protected ConfigurationStoreFactory createStoreFactory()
{
return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver);
return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver, archivedCheck);
}
@@ -48,10 +54,11 @@ public class JAXBConfigurationStoreTest extends StoreTestBase {
@SuppressWarnings("unchecked")
public void shouldStoreAndLoadInRepository()
{
Repository repository = new Repository("id", "git", "ns", "n");
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(new Repository("id", "git", "ns", "n"))
.forRepository(repository)
.build();
store.set(new StoreObject("value"));
@@ -60,4 +67,20 @@ public class JAXBConfigurationStoreTest extends StoreTestBase {
assertNotNull(storeObject);
assertEquals("value", storeObject.getValue());
}
@Test
@SuppressWarnings("unchecked")
public void shouldNotWriteArchivedRepository()
{
Repository repository = new Repository("id", "git", "ns", "n");
when(archivedCheck.isArchived("id")).thenReturn(true);
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
assertThrows(RuntimeException.class, () -> store.set(new StoreObject("value")));
}
}

View File

@@ -28,10 +28,13 @@ package sonia.scm.store;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.security.UUIDKeyGenerator;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
*
@@ -39,16 +42,12 @@ import static org.junit.Assert.assertNotNull;
*/
public class JAXBDataStoreTest extends DataStoreTestBase {
/**
* Method description
*
*
* @return
*/
private final RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
@Override
protected DataStoreFactory createDataStoreFactory()
{
return new JAXBDataStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator());
return new JAXBDataStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), archivedCheck);
}
@Override
@@ -77,4 +76,11 @@ public class JAXBDataStoreTest extends DataStoreTestBase {
assertNotNull(storeObject);
assertEquals("abc_value", storeObject.getValue());
}
@Test(expected = StoreReadOnlyException.class)
public void shouldNotStoreForReadOnlyRepository()
{
when(archivedCheck.isArchived(repository.getId())).thenReturn(true);
getDataStore(StoreObject.class, repository).put("abc", new StoreObject("abc_value"));
}
}

View File

@@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.SCMContextProvider;
import sonia.scm.Stage;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.JAXBConfigurationEntryStoreFactory;
import sonia.scm.update.RepositoryV1PropertyReader;
@@ -38,6 +39,8 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.mockito.Mockito.mock;
class XmlV1PropertyDAOTest {
@@ -108,7 +111,8 @@ class XmlV1PropertyDAOTest {
Files.createDirectories(configPath);
Path propFile = configPath.resolve("repository-properties-v1.xml");
Files.write(propFile, PROPERTIES.getBytes());
XmlV1PropertyDAO dao = new XmlV1PropertyDAO(new JAXBConfigurationEntryStoreFactory(new SimpleContextProvider(temp), null, new SimpleKeyGenerator()));
RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
XmlV1PropertyDAO dao = new XmlV1PropertyDAO(new JAXBConfigurationEntryStoreFactory(new SimpleContextProvider(temp), null, new SimpleKeyGenerator(), archivedCheck));
dao.getProperties(new RepositoryV1PropertyReader())
.forEachEntry((key, prop) -> {

View File

@@ -59,6 +59,12 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.sdorra</groupId>
<artifactId>shiro-unit</artifactId>

View File

@@ -32,11 +32,12 @@ import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.LifecycleUtils;
import org.apache.shiro.util.ThreadState;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.RepositoryDAO;
@@ -44,17 +45,14 @@ import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.util.IOUtil;
import sonia.scm.util.MockUtil;
import static org.junit.Assert.*;
import static org.mockito.Mockito.mock;
//~--- JDK imports ------------------------------------------------------------
import java.io.File;
import java.io.IOException;
import java.util.UUID;
import java.util.logging.Logger;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
/**
*
* @author Sebastian Sdorra
@@ -73,6 +71,7 @@ public class AbstractTestBase
protected RepositoryDAO repositoryDAO = mock(RepositoryDAO.class);
protected RepositoryLocationResolver repositoryLocationResolver;
@BeforeEach
@Before
public void setUpTest() throws Exception
{
@@ -90,6 +89,7 @@ public class AbstractTestBase
* Method description
*
*/
@AfterAll
@AfterClass
public static void tearDownShiro()
{
@@ -162,6 +162,7 @@ public class AbstractTestBase
*
* @throws Exception
*/
@AfterEach
@After
public void tearDownTest() throws Exception
{

View File

@@ -1,264 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.store;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.io.ByteStreams;
import org.junit.Before;
import org.junit.Test;
import sonia.scm.AbstractTestBase;
import sonia.scm.repository.RepositoryTestData;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
*/
public abstract class BlobStoreTestBase extends AbstractTestBase
{
protected abstract BlobStoreFactory createBlobStoreFactory();
/**
* Method description
*
*/
@Before
public void createBlobStore()
{
store = createBlobStoreFactory()
.withName("test")
.forRepository(RepositoryTestData.createHeartOfGold())
.build();
store.clear();
}
/**
* Method description
*
*/
@Test
public void testClear()
{
store.create("1");
store.create("2");
store.create("3");
assertNotNull(store.get("1"));
assertNotNull(store.get("2"));
assertNotNull(store.get("3"));
store.clear();
assertNull(store.get("1"));
assertNull(store.get("2"));
assertNull(store.get("3"));
}
/**
* Method description
*
*
* @throws IOException
*/
@Test
public void testContent() throws IOException
{
Blob blob = store.create();
write(blob, "Hello");
assertEquals("Hello", read(blob));
blob = store.get(blob.getId());
assertEquals("Hello", read(blob));
write(blob, "Other Text");
assertEquals("Other Text", read(blob));
blob = store.get(blob.getId());
assertEquals("Other Text", read(blob));
}
/**
* Method description
*
*/
@Test(expected = EntryAlreadyExistsStoreException.class)
public void testCreateAlreadyExistingEntry()
{
assertNotNull(store.create("1"));
store.create("1");
}
/**
* Method description
*
*/
@Test
public void testCreateWithId()
{
Blob blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
/**
* Method description
*
*/
@Test
public void testCreateWithoutId()
{
Blob blob = store.create();
assertNotNull(blob);
String id = blob.getId();
assertNotNull(id);
blob = store.get(id);
assertNotNull(blob);
}
/**
* Method description
*
*/
@Test
public void testGet()
{
Blob blob = store.get("1");
assertNull(blob);
blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
/**
* Method description
*
*/
@Test
public void testGetAll()
{
store.create("1");
store.create("2");
store.create("3");
List<Blob> all = store.getAll();
assertNotNull(all);
assertFalse(all.isEmpty());
assertEquals(3, all.size());
boolean c1 = false;
boolean c2 = false;
boolean c3 = false;
for (Blob b : all)
{
if ("1".equals(b.getId()))
{
c1 = true;
}
else if ("2".equals(b.getId()))
{
c2 = true;
}
else if ("3".equals(b.getId()))
{
c3 = true;
}
}
assertTrue(c1);
assertTrue(c2);
assertTrue(c3);
}
/**
* Method description
*
*
* @param blob
*
* @return
*
* @throws IOException
*/
private String read(Blob blob) throws IOException
{
InputStream input = blob.getInputStream();
byte[] bytes = ByteStreams.toByteArray(input);
input.close();
return new String(bytes);
}
/**
* Method description
*
*
* @param blob
* @param content
*
* @throws IOException
*/
private void write(Blob blob, String content) throws IOException
{
OutputStream output = blob.getOutputStream();
output.write(content.getBytes());
output.close();
blob.commit();
}
//~--- fields ---------------------------------------------------------------
/** Field description */
private BlobStore store;
}

View File

@@ -50505,6 +50505,162 @@ exports[`Storyshots Popover Link 1`] = `
</div>
`;
exports[`Storyshots RepositoryEntry Archived 1`] = `
<div
className="RepositoryEntrystories__Spacing-toppdg-0 iIzVNZ box box-link-shadow"
>
<a
className="overlay-column"
href="/repo/hitchhiker/heartOfGold"
onClick={[Function]}
/>
<article
className="CardColumn__NoEventWrapper-sc-1w6lsih-0 eUWboI media"
>
<figure
className="CardColumn__AvatarWrapper-sc-1w6lsih-1 lhzEPm media-left"
>
<p
className="image is-64x64"
>
<img
alt="Logo"
src="test-file-stub"
/>
</p>
</figure>
<div
className="CardColumn__FlexFullHeight-sc-1w6lsih-2 hWRPir media-content text-box is-flex"
>
<div
className="is-flex"
>
<div
className="CardColumn__ContentLeft-sc-1w6lsih-4 iRVRBC content"
>
<p
className="shorten-text is-marginless"
>
<strong>
heartOfGold
</strong>
<i
className="fas fa-archive has-text-grey-light"
/>
<span
className="RepositoryEntry__Smaller-sc-6jys82-0 lpzPr"
>
repository.archived
</span>
</p>
<p
className="shorten-text"
>
The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive
</p>
</div>
</div>
<div
className="CardColumn__FooterWrapper-sc-1w6lsih-3 hzknmV level is-flex"
>
<div
className="CardColumn__RightMarginDiv-sc-1w6lsih-6 bnJfDV level-left is-hidden-mobile"
>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
className="CardColumn__InheritFlexShrinkDiv-sc-1w6lsih-7 kdhCxo level-right is-block is-mobile is-marginless shorten-text"
>
<small
className="level-item"
>
<time
className="DateElement-sc-1schp8c-0 IMGpa"
title="2020-03-23 09:26:01"
>
3 days ago
</time>
</small>
</div>
</div>
</div>
</article>
</div>
`;
exports[`Storyshots RepositoryEntry Avatar EP 1`] = `
<div
className="RepositoryEntrystories__Spacing-toppdg-0 iIzVNZ box box-link-shadow"
@@ -50545,6 +50701,7 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = `
<strong>
heartOfGold
</strong>
</p>
<p
className="shorten-text"
@@ -50693,6 +50850,7 @@ exports[`Storyshots RepositoryEntry Before Title EP 1`] = `
<strong>
heartOfGold
</strong>
</p>
<p
className="shorten-text"
@@ -50838,6 +50996,7 @@ exports[`Storyshots RepositoryEntry Default 1`] = `
<strong>
heartOfGold
</strong>
</p>
<p
className="shorten-text"
@@ -50983,6 +51142,7 @@ exports[`Storyshots RepositoryEntry Quick Link EP 1`] = `
<strong>
heartOfGold
</strong>
</p>
<p
className="shorten-text"

View File

@@ -74,9 +74,11 @@ const QuickLink = (
</a>
);
const archivedRepository = { ...repository, archived: true };
storiesOf("RepositoryEntry", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator(storyFn => <Container>{storyFn()}</Container>)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator((storyFn) => <Container>{storyFn()}</Container>)
.add("Default", () => {
return <RepositoryEntry repository={repository} baseDate={baseDate} />;
})
@@ -94,4 +96,9 @@ storiesOf("RepositoryEntry", module)
const binder = new Binder("title");
bindQuickLink(binder, QuickLink);
return withBinder(binder, repository);
})
.add("Archived", () => {
const binder = new Binder("title");
bindAvatar(binder, Git);
return withBinder(binder, archivedRepository);
});

View File

@@ -28,6 +28,7 @@ import RepositoryEntryLink from "./RepositoryEntryLink";
import RepositoryAvatar from "./RepositoryAvatar";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { withTranslation, WithTranslation } from "react-i18next";
import styled from "styled-components";
type DateProp = Date | string;
@@ -38,6 +39,18 @@ type Props = WithTranslation & {
baseDate?: DateProp;
};
const ArchiveTag = styled.span`
margin-left: 0.2rem;
background-color: #9a9a9a;
padding: 0.25rem;
border-radius: 5px;
color: white;
overflow: visible;
pointer-events: all;
font-weight: bold;
font-size: 0.7rem;
`;
class RepositoryEntry extends React.Component<Props> {
createLink = (repository: Repository) => {
return `/repo/${repository.namespace}/${repository.name}`;
@@ -131,10 +144,14 @@ class RepositoryEntry extends React.Component<Props> {
};
createTitle = () => {
const { repository } = this.props;
const { repository, t } = this.props;
const archivedFlag = repository.archived && (
<ArchiveTag title={t("archive.tooltip")}>{t("repository.archived")}</ArchiveTag>
);
return (
<>
<ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} /> <strong>{repository.name}</strong>
<ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} />
<strong>{repository.name}</strong> {archivedFlag}
</>
);
};

View File

@@ -32,6 +32,7 @@ export type Repository = {
description?: string;
creationDate?: string;
lastModified?: string;
archived?: boolean;
_links: Links;
};

View File

@@ -6,7 +6,8 @@
"contact": "Kontakt",
"description": "Beschreibung",
"creationDate": "Erstellt",
"lastModified": "Zuletzt bearbeitet"
"lastModified": "Zuletzt bearbeitet",
"archived": "archiviert"
},
"validation": {
"namespace-invalid": "Der Namespace des Repository ist ungültig",
@@ -237,7 +238,7 @@
"submitCreate": "Speichern",
"submitImport": "Importieren",
"initializeRepository": "Repository initiieren",
"dangerZone": "Umbenennen und Löschen",
"dangerZone": "Umbenennen, Archivieren und Löschen",
"createButton": "Neues Repository erstellen",
"importButton": "Repository importieren"
},
@@ -351,6 +352,31 @@
}
}
},
"archiveRepo": {
"button": "Repository archivieren",
"subtitle": "Repository archivieren",
"description": "Archivierte Repositories können nicht mehr verändert werden.",
"confirmAlert": {
"title": "Repository archivieren",
"message": "Soll das Repository wirklich archiviert werden?",
"submit": "Ja",
"cancel": "Nein"
}
},
"unarchiveRepo": {
"button": "Archivierung zurücknehmen",
"subtitle": "Archivierung zurücknehmen",
"description": "Wenn das Repository nicht mehr archiviert ist, kann es wieder verändert werden.",
"confirmAlert": {
"title": "Archivierung zurücknehmen",
"message": "Soll die Archivierung des Repositorys wirklich rückgängig gemacht werden?",
"submit": "Ja",
"cancel": "Nein"
}
},
"archive": {
"tooltip": "Nur lesender Zugriff möglich. Das Archiv kann nicht verändert werden."
},
"diff": {
"jumpToSource": "Zur Quelldatei springen",
"jumpToTarget": "Zur vorherigen Version der Datei springen",

View File

@@ -6,7 +6,8 @@
"contact": "Contact",
"description": "Description",
"creationDate": "Creation Date",
"lastModified": "Last Modified"
"lastModified": "Last Modified",
"archived": "archived"
},
"validation": {
"namespace-invalid": "The repository namespace is invalid",
@@ -238,7 +239,7 @@
"submitCreate": "Save",
"submitImport": "Import",
"initializeRepository": "Initialize repository",
"dangerZone": "Rename and delete",
"dangerZone": "Rename, archive and delete",
"createButton": "Create Repository",
"importButton": "Import repository"
},
@@ -352,6 +353,31 @@
}
}
},
"archiveRepo": {
"button": "Archive Repository",
"subtitle": "Archive this repository",
"description": "An archived repository can no longer be modified.",
"confirmAlert": {
"title": "Archive Repository",
"message": "Shall the repository really be archived?",
"submit": "Yes",
"cancel": "No"
}
},
"unarchiveRepo": {
"button": "Remove Archive Mark",
"subtitle": "Remove Archive Mark",
"description": "When the archive mark is removed, this repository can be modified again.",
"confirmAlert": {
"title": "Remove Archive Mark",
"message": "Shall the archive mark really be removed?",
"submit": "Yes",
"cancel": "No"
}
},
"archive": {
"tooltip": "Read only. The archive cannot be changed."
},
"diff": {
"changes": {
"add": "added",

View File

@@ -0,0 +1,123 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { Button, ConfirmAlert, ErrorNotification, Level } from "@scm-manager/ui-components";
import { archiveRepo, getModifyRepoFailure, isModifyRepoPending } from "../modules/repos";
type Props = {
loading: boolean;
error: Error;
repository: Repository;
confirmDialog?: boolean;
archiveRepo: (p1: Repository, p2: () => void) => void;
};
const ArchiveRepo: FC<Props> = ({ confirmDialog = true, repository, archiveRepo, loading, error }: Props) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const archived = () => {
window.location.reload();
};
const archiveRepoCallback = () => {
archiveRepo(repository, archived);
};
const confirmArchive = () => {
setShowConfirmAlert(true);
};
const isArchiveable = () => {
return repository._links.archive;
};
const action = confirmDialog ? confirmArchive : archiveRepoCallback;
if (!isArchiveable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("archiveRepo.confirmAlert.title")}
message={t("archiveRepo.confirmAlert.message")}
buttons={[
{
className: "is-outlined",
label: t("archiveRepo.confirmAlert.submit"),
onClick: () => archiveRepoCallback(),
},
{
label: t("archiveRepo.confirmAlert.cancel"),
onClick: () => null,
},
]}
close={() => setShowConfirmAlert(false)}
/>
);
}
return (
<>
<ErrorNotification error={error} />
<Level
left={
<p>
<strong>{t("archiveRepo.subtitle")}</strong>
<br />
{t("archiveRepo.description")}
</p>
}
right={
<Button color="warning" icon="archive" label={t("archiveRepo.button")} action={action} loading={loading} />
}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
loading,
error,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
archiveRepo: (repo: Repository, callback: () => void) => {
dispatch(archiveRepo(repo, callback));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ArchiveRepo);

View File

@@ -29,6 +29,8 @@ import DeleteRepo from "./DeleteRepo";
import styled from "styled-components";
import { Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import ArchiveRepo from "./ArchiveRepo";
import UnarchiveRepo from "./UnarchiveRepo";
type Props = {
repository: Repository;
@@ -67,6 +69,14 @@ const RepositoryDangerZone: FC<Props> = ({ repository, indexLinks }) => {
// @ts-ignore
dangerZone.push(<DeleteRepo repository={repository} />);
}
if (repository?._links?.archive) {
// @ts-ignore
dangerZone.push(<ArchiveRepo repository={repository} />);
}
if (repository?._links?.unarchive) {
// @ts-ignore
dangerZone.push(<UnarchiveRepo repository={repository} />);
}
if (dangerZone.length === 0) {
return null;

View File

@@ -30,6 +30,8 @@ import { Changeset, Repository } from "@scm-manager/ui-types";
import {
CustomQueryFlexWrappedColumns,
ErrorPage,
FileControlFactory,
JumpToFileButton,
Loading,
NavLink,
Page,
@@ -37,7 +39,9 @@ import {
SecondaryNavigation,
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation
SubNavigation,
Tooltip,
urls,
} from "@scm-manager/ui-components";
import { fetchRepoByName, getFetchRepoFailure, getRepository, isFetchRepoPending } from "../modules/repos";
import RepositoryDetails from "../components/RepositoryDetails";
@@ -53,10 +57,9 @@ import { getLinks, getRepositoriesLink } from "../../modules/indexResource";
import CodeOverview from "../codeSection/containers/CodeOverview";
import ChangesetView from "./ChangesetView";
import SourceExtensions from "../sources/containers/SourceExtensions";
import { FileControlFactory, JumpToFileButton } from "@scm-manager/ui-components";
import TagsOverview from "../tags/container/TagsOverview";
import TagRoot from "../tags/container/TagRoot";
import { urls } from "@scm-manager/ui-components";
import styled from "styled-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -72,6 +75,15 @@ type Props = RouteComponentProps &
fetchRepoByName: (link: string, namespace: string, name: string) => void;
};
const ArchiveTag = styled.span`
margin-left: 0.2rem;
background-color: #9a9a9a;
padding: 0.4rem;
border-radius: 5px;
color: white;
font-weight: bold;
`;
class RepositoryRoot extends React.Component<Props> {
componentDidMount() {
const { fetchRepoByName, namespace, name, repoLink } = this.props;
@@ -141,7 +153,7 @@ class RepositoryRoot extends React.Component<Props> {
const extensionProps = {
repository,
url,
indexLinks
indexLinks,
};
const redirectUrlFactory = binder.getExtension("repository.redirect", this.props);
@@ -152,15 +164,16 @@ class RepositoryRoot extends React.Component<Props> {
redirectedUrl = url + "/info";
}
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = (changeset) => (file) => {
const baseUrl = `${url}/code/sources`;
const sourceLink = file.newPath && {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: t("diff.jumpToSource")
label: t("diff.jumpToSource"),
};
const targetLink = file.oldPath && changeset._embedded?.parents?.length === 1 && {
const targetLink = file.oldPath &&
changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: t("diff.jumpToTarget")
label: t("diff.jumpToTarget"),
};
const links = [];
@@ -186,6 +199,12 @@ class RepositoryRoot extends React.Component<Props> {
return links ? links.map(({ url, label }) => <JumpToFileButton tooltip={label} link={url} />) : null;
};
const archivedFlag = repository.archived && (
<Tooltip message={t("archive.tooltip")}>
<ArchiveTag className="is-size-6">{t("repository.archived")}</ArchiveTag>
</Tooltip>
);
const titleComponent = (
<>
<Link to={`/repos/${repository.namespace}/`} className={"has-text-dark"}>
@@ -200,7 +219,12 @@ class RepositoryRoot extends React.Component<Props> {
<Page
title={titleComponent}
documentTitle={`${repository.namespace}/${repository.name}`}
afterTitle={<ExtensionPoint name={"repository.afterTitle"} props={{ repository }} />}
afterTitle={
<>
<ExtensionPoint name={"repository.afterTitle"} props={{ repository }} />
{archivedFlag}
</>
}
>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
@@ -336,7 +360,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
loading,
error,
repoLink,
indexLinks
indexLinks,
};
};
@@ -344,7 +368,7 @@ const mapDispatchToProps = (dispatch: any) => {
return {
fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepoByName(link, namespace, name));
}
},
};
};

View File

@@ -0,0 +1,123 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { Button, ConfirmAlert, ErrorNotification, Level } from "@scm-manager/ui-components";
import { getModifyRepoFailure, isModifyRepoPending, unarchiveRepo } from "../modules/repos";
type Props = {
loading: boolean;
error: Error;
repository: Repository;
confirmDialog?: boolean;
unarchiveRepo: (p1: Repository, p2: () => void) => void;
};
const UnarchiveRepo: FC<Props> = ({ confirmDialog = true, repository, unarchiveRepo, loading, error }: Props) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const unarchived = () => {
window.location.reload();
};
const unarchiveRepoCallback = () => {
unarchiveRepo(repository, unarchived);
};
const confirmUnarchive = () => {
setShowConfirmAlert(true);
};
const isUnarchiveable = () => {
return repository._links.unarchive;
};
const action = confirmDialog ? confirmUnarchive : unarchiveRepoCallback;
if (!isUnarchiveable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("unarchiveRepo.confirmAlert.title")}
message={t("unarchiveRepo.confirmAlert.message")}
buttons={[
{
className: "is-outlined",
label: t("unarchiveRepo.confirmAlert.submit"),
onClick: () => unarchiveRepoCallback(),
},
{
label: t("unarchiveRepo.confirmAlert.cancel"),
onClick: () => null,
},
]}
close={() => setShowConfirmAlert(false)}
/>
);
}
return (
<>
<ErrorNotification error={error} />
<Level
left={
<p>
<strong>{t("unarchiveRepo.subtitle")}</strong>
<br />
{t("unarchiveRepo.description")}
</p>
}
right={
<Button color="warning" icon="box-open" label={t("unarchiveRepo.button")} action={action} loading={loading} />
}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository;
const loading = isModifyRepoPending(state, namespace, name);
const error = getModifyRepoFailure(state, namespace, name);
return {
loading,
error,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
unarchiveRepo: (repo: Repository, callback: () => void) => {
dispatch(unarchiveRepo(repo, callback));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(UnarchiveRepo);

View File

@@ -397,6 +397,42 @@ export function deleteRepoFailure(repository: Repository, error: Error): Action
};
}
// archive
export function archiveRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.post((repository._links.archive as Link).href)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyRepoFailure(repository, err));
});
};
}
export function unarchiveRepo(repository: Repository, callback?: () => void) {
return function(dispatch: any) {
dispatch(modifyRepoPending(repository));
return apiClient
.post((repository._links.unarchive as Link).href)
.then(() => {
dispatch(modifyRepoSuccess(repository));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyRepoFailure(repository, err));
});
};
}
export function fetchNamespace(link: string, namespaceName: string) {
return function(dispatch: any) {
dispatch(fetchNamespacePending(namespaceName));

View File

@@ -57,6 +57,7 @@ public class RepositoryDto extends HalRepresentation implements CreateRepository
private String name;
@NotEmpty
private String type;
private boolean archived;
RepositoryDto(Links links, Embedded embedded) {
super(links, embedded);

View File

@@ -215,10 +215,60 @@ public class RepositoryResource {
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response rename(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryRenameDto renameDto) {
public void rename(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryRenameDto renameDto) {
Repository repository = loadBy(namespace, name).get();
manager.rename(repository, renameDto.getNamespace(), renameDto.getName());
return Response.status(204).build();
}
/**
* Marks the given repository as "archived".
*
* @param namespace the namespace of the repository to be marked
* @param name the name of the repository to be marked
*/
@POST
@Path("archive")
@Operation(summary = "Mark repository as \"archived\"", description = "Marks the repository as \"archived\".", tags = "Repository")
@ApiResponse(responseCode = "204", description = "update success")
@ApiResponse(responseCode = "400", description = "invalid request, e.g. when the repository already is marked as archived")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository:archive\" privilege")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified namespace and name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public void archive(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = loadBy(namespace, name).get();
manager.archive(repository);
}
/**
* Marks the given repository as not "archived".
*
* @param namespace the namespace of the repository to remove the mark from
* @param name the name of the repository to remove the mark from
*/
@POST
@Path("unarchive")
@Operation(summary = "Mark repository as \"not archived\"", description = "Removes the \"archived\" mark from the repository.", tags = "Repository")
@ApiResponse(responseCode = "204", description = "update success")
@ApiResponse(responseCode = "400", description = "invalid request, e.g. when the repository is not marked as archived")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository:archive\" privilege")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified namespace and name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public void unarchive(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = loadBy(namespace, name).get();
manager.unarchive(repository);
}
private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) {

View File

@@ -79,6 +79,13 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
if (RepositoryPermissions.modify(repository).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.repository().update(repository.getNamespace(), repository.getName())));
}
if (RepositoryPermissions.archive().isPermitted(repository)) {
if (repository.isArchived()) {
linksBuilder.single(link("unarchive", resourceLinks.repository().unarchive(repository.getNamespace(), repository.getName())));
} else {
linksBuilder.single(link("archive", resourceLinks.repository().archive(repository.getNamespace(), repository.getName())));
}
}
if (RepositoryPermissions.rename(repository).isPermitted()) {
if (isRenameNamespacePossible()) {
linksBuilder.single(link("renameWithNamespace", resourceLinks.repository().rename(repository.getNamespace(), repository.getName())));

View File

@@ -370,6 +370,13 @@ class ResourceLinks {
String importFromBundle(String type) {
return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFromBundle").parameters(type).href();
}
String archive(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("archive").parameters().href();
}
String unarchive(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("unarchive").parameters().href();
}
}
RepositoryCollectionLinks repositoryCollection() {

View File

@@ -27,6 +27,7 @@ package sonia.scm.lifecycle.modules;
import com.google.inject.AbstractModule;
import com.google.inject.TypeLiteral;
import com.google.inject.throwingproviders.ThrowingProviderBinder;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext;
@@ -36,6 +37,8 @@ import sonia.scm.io.FileSystem;
import sonia.scm.lifecycle.DefaultRestarter;
import sonia.scm.lifecycle.Restarter;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.repository.EventDrivenRepositoryArchiveCheck;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.xml.MetadataStore;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver;
@@ -93,6 +96,7 @@ public class BootstrapModule extends AbstractModule {
bind(CipherHandler.class).toInstance(CipherUtil.getInstance().getCipherHandler());
// bind core
bind(RepositoryArchivedCheck.class, EventDrivenRepositoryArchiveCheck.class);
bind(ConfigurationStoreFactory.class, JAXBConfigurationStoreFactory.class);
bind(ConfigurationEntryStoreFactory.class, JAXBConfigurationEntryStoreFactory.class);
bind(DataStoreFactory.class, JAXBDataStoreFactory.class);

View File

@@ -72,6 +72,7 @@ import sonia.scm.repository.HealthCheckContextListener;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.repository.NamespaceStrategyProvider;
import sonia.scm.repository.PermissionProvider;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryManager;
@@ -92,6 +93,7 @@ import sonia.scm.security.ConfigurableLoginAttemptHandler;
import sonia.scm.security.DefaultAccessTokenCookieIssuer;
import sonia.scm.security.DefaultSecuritySystem;
import sonia.scm.security.LoginAttemptHandler;
import sonia.scm.security.RepositoryPermissionProvider;
import sonia.scm.security.SecuritySystem;
import sonia.scm.template.MustacheTemplateEngine;
import sonia.scm.template.TemplateEngine;
@@ -247,6 +249,8 @@ class ScmServletModule extends ServletModule {
// bind url helper
bind(RootURL.class).to(DefaultRootURL.class);
bind(PermissionProvider.class).to(RepositoryPermissionProvider.class);
}
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -24,7 +24,6 @@
package sonia.scm.repository;
import com.github.sdorra.ssp.PermissionActionCheck;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
@@ -294,6 +293,35 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
return changedRepository;
}
@Override
public void archive(Repository repository) {
setArchived(repository, true);
}
@Override
public void unarchive(Repository repository) {
setArchived(repository, false);
}
private void setArchived(Repository repository, boolean archived) {
Repository originalRepository = repositoryDAO.get(repository.getNamespaceAndName());
if (archived == originalRepository.isArchived()) {
throw new NoChangesMadeException(repository);
}
Repository changedRepository = originalRepository.clone();
changedRepository.setArchived(archived);
managerDaoAdapter.modify(
changedRepository,
RepositoryPermissions::archive,
notModified -> {
},
notModified -> fireEvent(HandlerEventType.MODIFY, changedRepository, originalRepository));
}
private boolean hasNamespaceOrNameNotChanged(Repository repository, String newNamespace, String newName) {
return repository.getName().equals(newName)
&& repository.getNamespace().equals(newNamespace);
@@ -303,12 +331,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
public Collection<Repository> getAll(Predicate<Repository> filter, Comparator<Repository> comparator) {
List<Repository> repositories = Lists.newArrayList();
PermissionActionCheck<Repository> check = RepositoryPermissions.read();
for (Repository repository : repositoryDAO.getAll()) {
if (handlerMap.containsKey(repository.getType())
&& filter.test(repository)
&& check.isPermitted(repository)) {
&& RepositoryPermissions.read().isPermitted(repository)) {
Repository r = repository.clone();
repositories.add(r);
@@ -331,14 +357,12 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
@Override
public Collection<Repository> getAll(Comparator<Repository> comparator,
int start, int limit) {
final PermissionActionCheck<Repository> check =
RepositoryPermissions.read();
return Util.createSubCollection(repositoryDAO.getAll(), comparator,
new CollectionAppender<Repository>() {
@Override
public void append(Collection<Repository> collection, Repository item) {
if (check.isPermitted(item)) {
if (RepositoryPermissions.read().isPermitted(item)) {
collection.add(item.clone());
}
}

View File

@@ -72,10 +72,8 @@ public final class HealthChecker {
public void checkAll() {
logger.debug("check health of all repositories");
PermissionActionCheck<Repository> check = RepositoryPermissions.healthCheck();
for (Repository repository : repositoryManager.getAll()) {
if (check.isPermitted(repository)) {
if (RepositoryPermissions.healthCheck().isPermitted(repository)) {
try {
check(repository);
} catch (NotFoundException ex) {

View File

@@ -25,6 +25,7 @@
package sonia.scm.security;
import com.google.inject.Inject;
import sonia.scm.repository.PermissionProvider;
import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleDAO;
@@ -32,7 +33,7 @@ import java.util.AbstractList;
import java.util.Collection;
import java.util.List ;
public class RepositoryPermissionProvider {
public class RepositoryPermissionProvider implements PermissionProvider {
private final SystemRepositoryPermissionProvider systemRepositoryPermissionProvider;
private final RepositoryRoleDAO repositoryRoleDAO;
@@ -47,6 +48,10 @@ public class RepositoryPermissionProvider {
return systemRepositoryPermissionProvider.availableVerbs();
}
public Collection<String> readOnlyVerbs() {
return systemRepositoryPermissionProvider.readOnlyVerbs();
}
public Collection<RepositoryRole> availableRoles() {
List<RepositoryRole> availableSystemRoles = systemRepositoryPermissionProvider.availableRoles();
List<RepositoryRole> customRoles = repositoryRoleDAO.getAll();

View File

@@ -25,6 +25,7 @@
package sonia.scm.security;
import com.google.inject.Inject;
import lombok.EqualsAndHashCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.PluginLoader;
@@ -34,8 +35,10 @@ import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlValue;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
@@ -54,25 +57,32 @@ public class SystemRepositoryPermissionProvider {
private static final Logger logger = LoggerFactory.getLogger(SystemRepositoryPermissionProvider.class);
private static final String REPOSITORY_PERMISSION_DESCRIPTOR = "META-INF/scm/repository-permissions.xml";
private final List<String> availableVerbs;
private final List<String> readOnlyVerbs;
private final List<RepositoryRole> availableRoles;
@Inject
public SystemRepositoryPermissionProvider(PluginLoader pluginLoader) {
AvailableRepositoryPermissions availablePermissions = readAvailablePermissions(pluginLoader);
this.availableVerbs = removeDuplicates(availablePermissions.availableVerbs);
this.availableRoles = removeDuplicates(availablePermissions.availableRoles.stream().map(r -> new RepositoryRole(r.name, r.verbs.verbs, "system")).collect(toList()));
this.readOnlyVerbs = removeDuplicates(availablePermissions.readOnlyVerbs);
this.availableRoles = removeDuplicates(availablePermissions.availableRoles.stream().map(r -> new RepositoryRole(r.name, r.verbs.verbs.stream().map(verb -> verb.value).collect(toList()), "system")).collect(toList()));
}
public List<String> availableVerbs() {
return availableVerbs;
}
public List<String> readOnlyVerbs() {
return readOnlyVerbs;
}
public List<RepositoryRole> availableRoles() {
return availableRoles;
}
private static AvailableRepositoryPermissions readAvailablePermissions(PluginLoader pluginLoader) {
Collection<String> availableVerbs = new ArrayList<>();
Collection<String> readOnlyVerbs = new ArrayList<>();
Collection<RoleDescriptor> availableRoles = new ArrayList<>();
try {
@@ -89,7 +99,8 @@ public class SystemRepositoryPermissionProvider {
logger.debug("read repository permission descriptor from {}", descriptorUrl);
RepositoryPermissionsRoot repositoryPermissionsRoot = parsePermissionDescriptor(context, descriptorUrl);
availableVerbs.addAll(repositoryPermissionsRoot.verbs.verbs);
repositoryPermissionsRoot.verbs.verbs.forEach(verb -> availableVerbs.add(verb.value));
repositoryPermissionsRoot.verbs.verbs.stream().filter(verb -> verb.readOnly).map(verb -> verb.value).forEach(readOnlyVerbs::add);
mergeRolesInto(availableRoles, repositoryPermissionsRoot.roles.roles);
}
} catch (IOException ex) {
@@ -99,7 +110,7 @@ public class SystemRepositoryPermissionProvider {
"could not create jaxb context to read permission descriptors", ex);
}
return new AvailableRepositoryPermissions(availableVerbs, availableRoles);
return new AvailableRepositoryPermissions(availableVerbs, readOnlyVerbs, availableRoles);
}
private static void mergeRolesInto(Collection<RoleDescriptor> targetRoles, List<RoleDescriptor> additionalRoles) {
@@ -138,10 +149,12 @@ public class SystemRepositoryPermissionProvider {
private static class AvailableRepositoryPermissions {
private final Collection<String> availableVerbs;
private final Collection<String> readOnlyVerbs;
private final Collection<RoleDescriptor> availableRoles;
private AvailableRepositoryPermissions(Collection<String> availableVerbs, Collection<RoleDescriptor> availableRoles) {
private AvailableRepositoryPermissions(Collection<String> availableVerbs, Collection<String> readOnlyVerbs, Collection<RoleDescriptor> availableRoles) {
this.availableVerbs = unmodifiableCollection(availableVerbs);
this.readOnlyVerbs = unmodifiableCollection(readOnlyVerbs);
this.availableRoles = unmodifiableCollection(availableRoles);
}
}
@@ -156,7 +169,18 @@ public class SystemRepositoryPermissionProvider {
@XmlRootElement(name = "verbs")
private static class VerbListDescriptor {
@XmlElement(name = "verb")
private Set<String> verbs = new LinkedHashSet<>();
private Set<Verb> verbs = new LinkedHashSet<>();
}
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "verb")
@EqualsAndHashCode
private static class Verb {
@XmlValue
private String value;
@XmlAttribute(name = "read-only")
@EqualsAndHashCode.Exclude
private boolean readOnly;
}
@XmlRootElement(name = "roles")

View File

@@ -175,6 +175,7 @@ public class XmlRepositoryV1UpdateStep implements CoreUpdateStep {
v1Repository.getContact(),
v1Repository.getDescription(),
createPermissions(v1Repository));
repository.setArchived(v1Repository.isArchived());
LOG.info("creating new repository {} from old repository {} in directory {}", repository, v1Repository.getName(), newPath);
repositoryDao.add(repository, newPath);
propertyStore.put(v1Repository.getId(), v1Repository.getProperties());

View File

@@ -44,6 +44,9 @@
<permission>
<value>repository:create</value>
</permission>
<permission>
<value>repository:archive:*</value>
</permission>
<permission>
<value>namespace:permissionRead</value>
</permission>

View File

@@ -25,14 +25,15 @@
-->
<repository-permissions>
<verbs>
<verb>read</verb>
<verb read-only="true">read</verb>
<verb>modify</verb>
<verb>delete</verb>
<verb>rename</verb>
<verb>pull</verb>
<verb read-only="true">pull</verb>
<verb>push</verb>
<verb>permissionRead</verb>
<verb read-only="true">permissionRead</verb>
<verb>permissionWrite</verb>
<verb read-only="true">archive</verb>
<verb>*</verb>
</verbs>
<roles>

View File

@@ -41,6 +41,12 @@
"create": {
"displayName": "Repositories erstellen",
"description": "Darf Repositories erstellen."
},
"archive": {
"*": {
"displayName": "Repositories archivieren",
"description": "Darf Repositories als \"archiviert\" und somit als schreibgeschützt markieren."
}
}
},
"user": {
@@ -139,6 +145,10 @@
"displayName": "Berechtigungen modifizieren",
"description": "Darf die Berechtigungen des Repository bearbeiten."
},
"archive": {
"displayName": "Repository archivieren",
"description": "Darf das Repository als \"archiviert\" und somit als schreibgeschützt markieren."
},
"*": {
"displayName": "Alle Repository Rechte",
"description": "Darf im Repository Kontext alles ausführen. Dies beinhaltet alle Repository Berechtigungen."
@@ -217,7 +227,7 @@
},
"4iRct4avG1": {
"displayName": "Die Revisionen haben keinen gemeinsamen Ursprung",
"description": "Die Historie der Revisionen hat keinen gemeinsamen Urspung und kann somit auch nicht gegen einen solchen verglichen werden."
"description": "Die Historie der Revisionen hat keinen gemeinsamen Ursprung und kann somit auch nicht gegen einen solchen verglichen werden."
},
"65RdZ5atX1": {
"displayName": "Fehler beim Löschen von Plugin-Dateien",
@@ -241,7 +251,7 @@
},
"4GRrgkSC01": {
"displayName": "Unerwartetes Merge-Ergebnis",
"description": "Der Merge hatte ein unerwartetes Ergebis, das nicht automatisiert behandelt werden konnte. Nähere Details sind im Log zu finden. Führen Sie den Merge ggf. manuell durch."
"description": "Der Merge hatte ein unerwartetes Ergebnis, das nicht automatisiert behandelt werden konnte. Nähere Details sind im Log zu finden. Führen Sie den Merge ggf. manuell durch."
},
"6mRuFxaWM1": {
"displayName": "Falsche Checksumme",
@@ -256,12 +266,12 @@
"description": "Ein fehlerhaft heruntergeladenes Plugin konnte nicht gelöscht werden. Bitte prüfen Sie die Server Logs und löschen die Datei manuell."
},
"5GS6lwvWF1": {
"displayName": "Abhänigkeit konnte nicht gefunden werden",
"description": "Eine der Abhänigkeiten des Plugins konnte nicht gefunden werden. Bitte prüfen Sie die Logs für weitere Informationen."
"displayName": "Abhängigkeit konnte nicht gefunden werden",
"description": "Eine der Abhängigkeiten des Plugins konnte nicht gefunden werden. Bitte prüfen Sie die Logs für weitere Informationen."
},
"E5S6niWwi1": {
"displayName": "Version einer Abhänigkeit zu niedrig",
"description": "Die Version einer Abhänigkeit des Plugin ist zu niedrig. Bitte prüfen Sie die Logs für weitere Informationen."
"displayName": "Version einer Abhängigkeit zu niedrig",
"description": "Die Version einer Abhängigkeit des Plugin ist zu niedrig. Bitte prüfen Sie die Logs für weitere Informationen."
},
"4RS6niPRX1": {
"displayName": "Plugin information stimmen nicht überein",
@@ -269,7 +279,7 @@
},
"2qRyyaVcJ1": {
"displayName": "Ungültig formatiertes Element",
"description": "Die Eingabe beinhaltete unfültige Formate. Bitte prüfen Sie die Server Logs für genauere Informationen."
"description": "Die Eingabe beinhaltete ungültige Formate. Bitte prüfen Sie die Server Logs für genauere Informationen."
},
"3tS0mjSoo1": {
"displayName": "Fehler bei der Erstellung eines Arbeitsverzeichnisses",
@@ -281,7 +291,7 @@
},
"BxS5wX2v71": {
"displayName": "Inkorrekter Schlüssel",
"description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel."
"description": "Der bereitgestellte Schlüssel ist kein korrekt formatierter öffentlicher Schlüssel."
},
"FVS9JY1T21": {
"displayName": "Fehler bei der Anfrage",
@@ -290,6 +300,14 @@
"D6SHRfqQw1": {
"displayName": "Repository Import fehlgeschlagen",
"description": "Das Repository konnte nicht importiert werden. Möglicherweise wurden die Zugangsdaten (Benutzername/Passwort) nicht gesetzt oder sind fehlerhaft. Bitte prüfen Sie Ihre Eingaben."
},
"3FSIYtBJw1": {
"displayName": "Ein Datensatz konnte nicht gespeichert werden",
"description": "Ein Datensatz konnte nicht gespeichert werden, da der entsprechende Speicher als schreibgeschützt markiert wurde. Weitere Hinweise finden sich im Log."
},
"3hSIlptme1": {
"displayName": "Repository ist archiviert",
"description": "Das Repository ist als \"archiviert\" markiert und darf nicht modifiziert werden."
}
},
"namespaceStrategies": {

View File

@@ -41,6 +41,12 @@
"create": {
"displayName": "Create repositories",
"description": "May create repositories"
},
"archive": {
"*": {
"displayName": "Archive repositories",
"description": "May mark repositories as \"archived\" and therefore unmodifiable"
}
}
},
"user": {
@@ -139,6 +145,10 @@
"displayName": "modify permissions",
"description": "May modify the permissions of the repository"
},
"archive": {
"displayName": "archive repository",
"description": "May mark the repository as \"archived\" and therefore unmodifiable"
},
"*": {
"displayName": "own repository",
"description": "May change everything for the repository (includes all other permissions)"
@@ -290,6 +300,14 @@
"D6SHRfqQw1": {
"displayName": "Repository import failed",
"description": "The repository could not be imported. It's likely that either the credentials (username/password) are wrong or missing. Please check your inputs."
},
"3FSIYtBJw1": {
"displayName": "An entity could not be stored",
"description": "An entity could not be stored, because the corresponding store was set to read only. Please see the log for more information."
},
"3hSIlptme1": {
"displayName": "Repository is archived",
"description": "The repository is marked as \"archived\" and therefore must noch be modified."
}
},
"namespaceStrategies": {

View File

@@ -673,6 +673,43 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
verify(ubc).unbundle(any(File.class));
}
@Test
public void shouldMarkRepositoryAsArchived() throws Exception {
String namespace = "space";
String name = "repo";
Repository repository = mockRepository(namespace, name);
when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository);
MockHttpRequest request = MockHttpRequest
.post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/archive")
.content(new byte[]{});
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_NO_CONTENT, response.getStatus());
verify(repositoryManager).archive(repository);
}
@Test
public void shouldRemoveArchiveMarkFromRepository() throws Exception {
String namespace = "space";
String name = "repo";
Repository repository = mockRepository(namespace, name);
repository.setArchived(true);
when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository);
MockHttpRequest request = MockHttpRequest
.post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/unarchive")
.content(new byte[]{});
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(SC_NO_CONTENT, response.getStatus());
verify(repositoryManager).unarchive(repository);
}
private PageResult<Repository> createSingletonPageResult(Repository repository) {
return new PageResult<>(singletonList(repository), 0);
}

View File

@@ -266,6 +266,24 @@ public class RepositoryToRepositoryDtoMapperTest {
assertEquals("http://1", dto.getLinks().getLinkBy("id").get().getHref());
}
@Test
public void shouldCreateArchiveLink() {
RepositoryDto dto = mapper.map(createTestRepository());
assertEquals(
"http://example.com/base/v2/repositories/testspace/test/archive",
dto.getLinks().getLinkBy("archive").get().getHref());
}
@Test
public void shouldCreateUnArchiveLink() {
Repository repository = createTestRepository();
repository.setArchived(true);
RepositoryDto dto = mapper.map(repository);
assertEquals(
"http://example.com/base/v2/repositories/testspace/test/unarchive",
dto.getLinks().getLinkBy("unarchive").get().getHref());
}
private ScmProtocol mockProtocol(String type, String protocol) {
return new MockScmProtocol(type, protocol);
}

View File

@@ -78,13 +78,16 @@ import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
//~--- JDK imports ------------------------------------------------------------
@@ -101,6 +104,8 @@ import static org.mockito.Mockito.when;
)
public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
private RepositoryDAO repositoryDAO;
{
ThreadContext.unbindSubject();
}
@@ -457,6 +462,77 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
.contains("default_namespace");
}
@Test
public void shouldMarkRepositoryAsArchived() {
Repository repository = createTestRepository();
RepositoryManager repoManager = (RepositoryManager) manager;
repoManager.archive(repository);
verify(repositoryDAO).modify(argThat(Repository::isArchived));
}
@Test
@SubjectAware(username = "dent")
public void shouldNotMarkRepositoryAsArchivedWithoutPermission() {
Repository repository = RepositoryTestData.createHeartOfGold();
when(repositoryDAO.get(repository.getNamespaceAndName())).thenReturn(repository);
when(repositoryDAO.get(repository.getId())).thenReturn(repository);
RepositoryManager repoManager = (RepositoryManager) manager;
assertThrows(UnauthorizedException.class, () -> repoManager.archive(repository));
verify(repositoryDAO, never()).modify(any());
}
@Test
public void shouldNotMarkRepositoryAsArchivedTwice() {
Repository repository = RepositoryTestData.createHeartOfGold();
repository.setArchived(true);
createRepository(repository);
RepositoryManager repoManager = (RepositoryManager) manager;
assertThrows(NoChangesMadeException.class, () -> repoManager.archive(repository));
verify(repositoryDAO, never()).modify(any());
}
@Test
public void shouldRemoveArchiveMarkFromRepository() {
Repository repository = RepositoryTestData.createHeartOfGold();
repository.setArchived(true);
createRepository(repository);
RepositoryManager repoManager = (RepositoryManager) manager;
repoManager.unarchive(repository);
verify(repositoryDAO).modify(argThat(r -> !r.isArchived()));
}
@Test
@SubjectAware(username = "dent")
public void shouldNotRemoveArchiveMarkFromRepositoryWithoutPermission() {
Repository repository = RepositoryTestData.createHeartOfGold();
when(repositoryDAO.get(repository.getNamespaceAndName())).thenReturn(repository);
when(repositoryDAO.get(repository.getId())).thenReturn(repository);
repository.setArchived(true);
RepositoryManager repoManager = (RepositoryManager) manager;
assertThrows(UnauthorizedException.class, () -> repoManager.unarchive(repository));
verify(repositoryDAO, never()).modify(any());
}
@Test
public void shouldNotRemoveArchiveMarkFromNotArchivedRepository() {
Repository repository = createTestRepository();
RepositoryManager repoManager = (RepositoryManager) manager;
assertThrows(NoChangesMadeException.class, () -> repoManager.unarchive(repository));
verify(repositoryDAO, never()).modify(any());
}
//~--- methods --------------------------------------------------------------
@Override
@@ -466,7 +542,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
private DefaultRepositoryManager createRepositoryManager(KeyGenerator keyGenerator) {
Set<RepositoryHandler> handlerSet = new HashSet<>();
RepositoryDAO repositoryDAO = createRepositoryDaoMock();
repositoryDAO = createRepositoryDaoMock();
mock(ConfigurationStoreFactory.class);
handlerSet.add(createRepositoryHandler("dummy", "Dummy"));
handlerSet.add(createRepositoryHandler("git", "Git"));

View File

@@ -68,7 +68,7 @@ public class DefaultSecuritySystemTest extends AbstractTestBase
public void createSecuritySystem()
{
jaxbConfigurationEntryStoreFactory =
spy(new JAXBConfigurationEntryStoreFactory(contextProvider , repositoryLocationResolver, new UUIDKeyGenerator() ) {});
spy(new JAXBConfigurationEntryStoreFactory(contextProvider , repositoryLocationResolver, new UUIDKeyGenerator(), null) {});
pluginLoader = mock(PluginLoader.class);
when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class));

View File

@@ -35,6 +35,7 @@ import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collection;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
@@ -55,7 +56,7 @@ class SystemRepositoryPermissionProviderTest {
.filter(field -> field.getName().startsWith("ACTION_"))
.filter(field -> !field.getName().equals("ACTION_HEALTHCHECK"))
.map(this::getString)
.filter(verb -> !"create".equals(verb))
.filter(verb -> !asList("create", "archive").contains(verb))
.toArray(String[]::new);
}

View File

@@ -53,7 +53,7 @@ class DefaultMigrationStrategyDAOTest {
@BeforeEach
void initStore(@TempDir Path tempDir) {
when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile());
storeFactory = new JAXBConfigurationStoreFactory(contextProvider, null);
storeFactory = new JAXBConfigurationStoreFactory(contextProvider, null, null);
}
@Test

View File

@@ -52,7 +52,7 @@ class V1RepositoryFileSystem {
* <id>c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f</id>
* <name>some/more/directories/than/one</name>
* <public>false</public>
* <archived>false</archived>
* <archived>true</archived>
* <type>git</type>
* </repository>
* <repository>

View File

@@ -136,7 +136,19 @@ class XmlRepositoryV1UpdateStepTest {
.get()
.hasFieldOrPropertyWithValue("type", "git")
.hasFieldOrPropertyWithValue("contact", "arthur@dent.uk")
.hasFieldOrPropertyWithValue("description", "A repository with two folders.");
.hasFieldOrPropertyWithValue("description", "A repository with two folders.")
.hasFieldOrPropertyWithValue("archived", false);
}
@Test
void shouldMapArchivedAttribute() throws JAXBException {
updateStep.doUpdate();
Optional<Repository> repository = findByNamespace("namespace-c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f");
assertThat(repository)
.get()
.hasFieldOrPropertyWithValue("archived", true);
}
@Test

View File

@@ -127,6 +127,6 @@ public class DefaultUserManagerTest extends UserManagerTestBase {
//~--- methods --------------------------------------------------------------
private XmlUserDAO createXmlUserDAO() {
return new XmlUserDAO(new JAXBConfigurationStoreFactory(contextProvider, locationResolver));
return new XmlUserDAO(new JAXBConfigurationStoreFactory(contextProvider, locationResolver, null));
}
}