diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ae6ebf28..acd792468c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Tracing api ([#1393](https://github.com/scm-manager/scm-manager/pull/#1393)) +- Automatic user converter for external users ([#1380](https://github.com/scm-manager/scm-manager/pull/1380)) +- Create _authenticated group on setup ([#1396](https://github.com/scm-manager/scm-manager/pull/1396)) +- The name of the initial git branch can be configured and is set to `main` by default ([#1399](https://github.com/scm-manager/scm-manager/pull/1399)) + +### Fixed +- Internal server error for git sub modules without tree object ([#1397](https://github.com/scm-manager/scm-manager/pull/1397)) +- Do not expose subversion commit with id 0 ([#1395](https://github.com/scm-manager/scm-manager/pull/1395)) +- Disable cloning repositories via ssh for anonymous users ([#1403](https://github.com/scm-manager/scm-manager/pull/1403)) +- Support anonymous file download through rest api for non-browser clients (e.g. curl or postman) when anonymous mode is set to protocol-only ([#1402](https://github.com/scm-manager/scm-manager/pull/1402)) +- SVN diff with property changes ([#1400](https://github.com/scm-manager/scm-manager/pull/1400)) +- Branches link in repository overview ([#1404](https://github.com/scm-manager/scm-manager/pull/1404)) ## [2.8.0] - 2020-10-27 ### Added - Generation of email addresses for users, where none is configured ([#1370](https://github.com/scm-manager/scm-manager/pull/1370)) -- Plugins can now expose ui components to be shared with other plugins ([#1382](https://github.com/scm-manager/scm-manager/pull/1382)) - Source code fullscreen view ([#1376](https://github.com/scm-manager/scm-manager/pull/1376)) +- Plugins can now expose ui components to be shared with other plugins ([#1382](https://github.com/scm-manager/scm-manager/pull/1382)) ### Changed - Reduce logging of ApiTokenRealm ([#1385](https://github.com/scm-manager/scm-manager/pull/1385)) diff --git a/Jenkinsfile b/Jenkinsfile index c6dc70b8d7..9acca06822 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -204,7 +204,7 @@ String developmentBranch String mainBranch Maven setupMavenBuild() { - MavenWrapperInDocker mvn = new MavenWrapperInDocker(this, "scmmanager/java-build:11.0.8_10") + MavenWrapperInDocker mvn = new MavenWrapperInDocker(this, "scmmanager/java-build:11.0.9_11.1") mvn.enableDockerHost = true // disable logging durring the build diff --git a/build/Dockerfile b/build/Dockerfile index 6ef5ff1cfb..b5b2585c2b 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -22,7 +22,7 @@ # SOFTWARE. # -FROM adoptopenjdk/openjdk11:x86_64-debian-jdk-11.0.8_10 +FROM adoptopenjdk/openjdk11:x86_64-debian-jdk-11.0.9_11.1 ENV DOCKER_VERSION=19.03.8 \ DOCKER_CHANNEL=stable \ diff --git a/build/Makefile b/build/Makefile index 180b0db68d..8aa8cefc1d 100644 --- a/build/Makefile +++ b/build/Makefile @@ -1,4 +1,4 @@ -VERSION:=11.0.8_10 +VERSION:=11.0.9_11.1 .PHONY:build build: diff --git a/docs/de/user/admin/assets/administration-settings-general.png b/docs/de/user/admin/assets/administration-settings-general.png index 340f987f68..bdf3fa828b 100644 Binary files a/docs/de/user/admin/assets/administration-settings-general.png and b/docs/de/user/admin/assets/administration-settings-general.png differ diff --git a/docs/de/user/admin/assets/administration-settings-git.png b/docs/de/user/admin/assets/administration-settings-git.png new file mode 100644 index 0000000000..71ae9a6feb Binary files /dev/null and b/docs/de/user/admin/assets/administration-settings-git.png differ diff --git a/docs/de/user/admin/git.md b/docs/de/user/admin/git.md new file mode 100644 index 0000000000..e80864fcd3 --- /dev/null +++ b/docs/de/user/admin/git.md @@ -0,0 +1,23 @@ +--- +title: Administration +subtitle: Git +--- +Unter dem Eintrag Git können die folgenden Git-spezifischen Einstellungen vorgenommen werden: + +- GC Cron Ausdruck + + Wenn hier ein Wert gesetzt wird, führt der SCM-Manager zu den + [entsprechenden Zeiten](https://de.wikipedia.org/wiki/Cron) + eine "Git Garbage Collection" aus. + +- Deaktiviere "Non Fast-Forward" + + Wenn dieses aktiviert ist, werden "forcierte" Pushs abgelehnt, wenn diese keine "fast forwards" sind. + +- Default Branch + + Der hier gesetzte Branch Name wird bei der Initialisierung von neuen Repositories genutzt. + Bitte beachten Sie, dass dieser Name aufgrund von Git-Spezifika nicht bei leeren Repositories genutzt + werden kann (hier wird immer der Git-interne Default Name genutzt, derzeit also `master`). + +![Administration-Plugins-Installed](assets/administration-settings-git.png) diff --git a/docs/de/user/admin/index.md b/docs/de/user/admin/index.md index 3f88a4c402..fd4e75c3f4 100644 --- a/docs/de/user/admin/index.md +++ b/docs/de/user/admin/index.md @@ -8,6 +8,7 @@ Im Bereich Administration kann die SCM-Manager Instanz administriert werden. Von * [Plugins](plugins/) * [Berechtigungsrollen](roles/) * [Einstellungen](settings/) +* [Git](git/) ### Information diff --git a/docs/de/user/admin/settings.md b/docs/de/user/admin/settings.md index 82643a35bb..f89180e4b5 100644 --- a/docs/de/user/admin/settings.md +++ b/docs/de/user/admin/settings.md @@ -32,6 +32,15 @@ Ist der anonyme Zugriff nur für Protokoll aktiviert, können die REST API und d Beispiel: Falls der anonyme Zugriff aktiviert ist und der "_anonymous"-Benutzer volle Zugriffsrechte auf ein bestimmtes Git-Repository hat, kann jeder über eine Kommandozeile mit den klassischen Git-Befehlen ohne Zugangsdaten auf dieses Repository zugreifen. Zugriffe über SSH werden aktuell nicht unterstützt. +#### Release Feed Url +Die URL des RSS Release Feed des SCM-Managers. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer. + +#### User converter +Ist der Benutzer Konverter aktiviert, werden alle internen Benutzer beim Einloggen über ein externes System automatisch zu externen Benutzern konvertiert. Nach dem Konvertieren können sich die Benutzer nicht mehr mit dem lokalen SCM-Manager Passwort einloggen, sondern nur noch über das Fremdsystem. + +#### Fallback E-Mail Domain Name +Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut. + #### Anmeldeversuche Es lässt sich konfigurieren wie häufig sich ein Benutzer falsch anmelden darf, bevor dessen Benutzerkonto gesperrt wird. Der Zähler für fehlerhafte Anmeldeversuche wird nach einem erfolgreichen Login zurückgesetzt. Man kann dieses Feature abschalten, indem man "-1" in die Konfiguration einträgt. diff --git a/docs/de/user/user/assets/create-user.png b/docs/de/user/user/assets/create-user.png deleted file mode 100644 index 3c6b09fa75..0000000000 Binary files a/docs/de/user/user/assets/create-user.png and /dev/null differ diff --git a/docs/de/user/user/assets/user-create.png b/docs/de/user/user/assets/user-create.png new file mode 100644 index 0000000000..a7d625c430 Binary files /dev/null and b/docs/de/user/user/assets/user-create.png differ diff --git a/docs/de/user/user/assets/user-information.png b/docs/de/user/user/assets/user-information.png index 563c1e91c7..f5573c10e8 100644 Binary files a/docs/de/user/user/assets/user-information.png and b/docs/de/user/user/assets/user-information.png differ diff --git a/docs/de/user/user/assets/user-password-modal.png b/docs/de/user/user/assets/user-password-modal.png new file mode 100644 index 0000000000..95502c28a9 Binary files /dev/null and b/docs/de/user/user/assets/user-password-modal.png differ diff --git a/docs/de/user/user/assets/user-settings-general.png b/docs/de/user/user/assets/user-settings-general.png index f7badcc23a..9fe75a3a89 100644 Binary files a/docs/de/user/user/assets/user-settings-general.png and b/docs/de/user/user/assets/user-settings-general.png differ diff --git a/docs/de/user/user/index.md b/docs/de/user/user/index.md index 64461330bd..d20309ed0a 100644 --- a/docs/de/user/user/index.md +++ b/docs/de/user/user/index.md @@ -16,13 +16,12 @@ Auf der Benutzer Übersichtsseite wird eine Liste der existierenden Benutzer ang ### Benutzer erstellen Mithilfe des "Benutzer erstellen"-Formulars können neue Benutzer im SCM-Manager angelegt werden. Neue Benutzer haben noch keine Berechtigungen und sollten direkt nach dem Anlegen konfiguriert werden. -![Benutzer erstellen](assets/create-user.png) +![Benutzer erstellen](assets/user-create.png) ### Benutzer Detailseite Die Detailseite eines Benutzers zeigt die Informationen zu diesem an. Über den "Aktiv"-Marker sieht man, ob dies ein aktivierter Benutzer des SCM-Managers ist. Wird ein Benutzer auf inaktiv gesetzt, kann er sich nicht mehr am SCM-Manager anmelden. - -Der Typ eines Benutzers gibt an, aus welcher Quelle dieser Benutzer stammt. Der Typ "XML" aus dem Beispiel gibt an, dass dieser Benutzer im SCM-Manager erstellt wurde. Daneben kann es aber auch externe Benutzer geben, die beispielweise mithilfe des LDAP-Plugins aus einer LDAP-Instanz angebunden wurden. +Die Checkbox `Extern` zeigt an, ob es sich um einen internen Benutzer handelt oder der Benutzer von einem Fremdsystem verwaltet wird. ![Benutzer Informationen](assets/user-information.png) diff --git a/docs/de/user/user/settings.md b/docs/de/user/user/settings.md index d09de0b98e..677533bbf6 100644 --- a/docs/de/user/user/settings.md +++ b/docs/de/user/user/settings.md @@ -3,7 +3,11 @@ title: Benutzer subtitle: Einstellungen --- ### Generell -In den generellen Einstellungen des Benutzers können der Anzeigename, die E-Mail-Adresse und der Aktivitätsstatus des Kontos editiert werden. +In den generellen Einstellungen des Benutzers können der Anzeigename, die E-Mail-Adresse, der "Extern"-Status und der Aktivitätsstatus des Kontos editiert werden. + +Wird ein interner Benutzer zu einem externen Benutzer konvertiert, wird das SCM-Manager Passwort des Benutzers entfernt. Soll ein externer Benutzer zu einem internen Benutzer umgewandelt werden, wird nach einem neuen Passwort für diesen Benutzer gefragt. + +![User Password Modal](assets/user-password-modal.png) Über die Schaltfläche unten kann der Benutzer auch komplett gelöscht werden. Dieser Vorgang kann nicht rückgängig gemacht werden. diff --git a/docs/en/user/admin/assets/administration-settings-general.png b/docs/en/user/admin/assets/administration-settings-general.png index 84267d8d07..0026223639 100644 Binary files a/docs/en/user/admin/assets/administration-settings-general.png and b/docs/en/user/admin/assets/administration-settings-general.png differ diff --git a/docs/en/user/admin/assets/administration-settings-git.png b/docs/en/user/admin/assets/administration-settings-git.png new file mode 100644 index 0000000000..31ed35138f Binary files /dev/null and b/docs/en/user/admin/assets/administration-settings-git.png differ diff --git a/docs/en/user/admin/git.md b/docs/en/user/admin/git.md new file mode 100644 index 0000000000..625bca93b8 --- /dev/null +++ b/docs/en/user/admin/git.md @@ -0,0 +1,22 @@ +--- +title: Administration +subtitle: Git +--- +In the git section there are the following git specific settings: + +- GC Cron Expression + + If this is set, SCM-Manager will execute a git garbage collection matching the given + [cron expression](https://en.wikipedia.org/wiki/Cron#CRON_expression). + +- Disable Non Fast-Forward + + Activate this to reject forced pushes that are not fast forwards. + +- Default Branch + + The branch name configured here will be used for the initialization of new repositories. + Please mind, that due to git internals this cannot work for empty repositories (here git + will always use its internal default branch, so at the time being `master`). + +![Administration-Plugins-Installed](assets/administration-settings-git.png) diff --git a/docs/en/user/admin/index.md b/docs/en/user/admin/index.md index d2e77554d1..af1b9fd116 100644 --- a/docs/en/user/admin/index.md +++ b/docs/en/user/admin/index.md @@ -7,6 +7,7 @@ The SCM-Manager instance can be administered in the Administration area. From he * [Plugins](plugins/) * [Permission Roles](roles/) * [Settings](settings/) +* [Git](git/) ### Information On the information page in the administration area you can find the version of your SCM-Manager instance and helpful links to get in touch with the SCM-Manager support team. If there is a newer version for SCM-Manager, it will be shown with the link to the download section on the official SCM-Manager homepage. diff --git a/docs/en/user/admin/settings.md b/docs/en/user/admin/settings.md index 96a606bad0..527716f113 100644 --- a/docs/en/user/admin/settings.md +++ b/docs/en/user/admin/settings.md @@ -32,6 +32,15 @@ If the anonymous mode is protocol only you may access the SCM-Manager via the RE Example: If anonymous access is enabled and the "_anonymous" user has full access on a certain Git repository, everybody can access this repository via command line and the classic Git commands without any login credentials. Access via SSH is not supported at this time. +#### Release Feed Url +The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank. + +#### User converter +Internal users will automatically be converted to external on their first login using an external system. After conversion the users may only log in using the external system. + +#### Fallback Mail Domain Name +This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise. + #### Login Attempt Limit It can be configured how many failed login attempts a user can have before the account gets disabled. The counter for failed login attempts is reset after a successful login. This feature can be deactivated by setting the value "-1". diff --git a/docs/en/user/user/assets/create-user.png b/docs/en/user/user/assets/create-user.png deleted file mode 100644 index e84bccea58..0000000000 Binary files a/docs/en/user/user/assets/create-user.png and /dev/null differ diff --git a/docs/en/user/user/assets/user-create.png b/docs/en/user/user/assets/user-create.png new file mode 100644 index 0000000000..eedee01634 Binary files /dev/null and b/docs/en/user/user/assets/user-create.png differ diff --git a/docs/en/user/user/assets/user-information.png b/docs/en/user/user/assets/user-information.png index 842aa35d54..f5573c10e8 100644 Binary files a/docs/en/user/user/assets/user-information.png and b/docs/en/user/user/assets/user-information.png differ diff --git a/docs/en/user/user/assets/user-password-modal.png b/docs/en/user/user/assets/user-password-modal.png new file mode 100644 index 0000000000..f842fdf2c2 Binary files /dev/null and b/docs/en/user/user/assets/user-password-modal.png differ diff --git a/docs/en/user/user/assets/user-settings-general.png b/docs/en/user/user/assets/user-settings-general.png index 3c939b505d..4312fa0e8e 100644 Binary files a/docs/en/user/user/assets/user-settings-general.png and b/docs/en/user/user/assets/user-settings-general.png differ diff --git a/docs/en/user/user/index.md b/docs/en/user/user/index.md index bf6223391b..927a656c5a 100644 --- a/docs/en/user/user/index.md +++ b/docs/en/user/user/index.md @@ -14,11 +14,11 @@ The user overview shows a list of all existing users. A page with details about ### Create User The "Create User" form can be used to create new users in SCM-Manager. New users don’t have any permissions and should therefore be configured right after they were created. -![Create User](assets/create-user.png) +![Create User](assets/user-create.png) ### User Details Page The user details page shows the information about the user. -The active box shows whether the user is able to use SCM-Manager. The type XML from the shown example indicates that the user was created in SCM-Manager. Users can also be created through external sources, for example based on the information from a LDAP instance that is connected through the LDAP plugin. +The active box shows whether the user is able to use SCM-Manager. The external box shows if it is an internal user or whether it is managed by an external system. ![User-Information](assets/user-information.png) diff --git a/docs/en/user/user/settings.md b/docs/en/user/user/settings.md index 328fe414af..9b97bad2c8 100644 --- a/docs/en/user/user/settings.md +++ b/docs/en/user/user/settings.md @@ -3,7 +3,11 @@ title: User subtitle: Settings --- ### General -In the general settings the display name, e-mail address and active status of an account can be edited. +In the general settings the display name, e-mail address, external flag and active status of an account can be edited. + +If a user is converted from internal to external the password is going to be removed. When switching an external user to an internal one, a password must be set using the password modal dialogue. + +![User Password Modal](assets/user-password-modal.png) On the bottom is also a button to delete the user. The deletion is irreversible. diff --git a/pom.xml b/pom.xml index 2e3c7d8840..806595bc8e 100644 --- a/pom.xml +++ b/pom.xml @@ -915,7 +915,7 @@ 2.1.1 4.5.8.Final 1.19.4 - 2.11.2 + 2.11.3 4.2.3 2.3.3 6.1.5.Final diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 6f1414b8e0..dfec682665 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -189,6 +189,14 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "xsrf-protection") private boolean enabledXsrfProtection = true; + /** + * Enables user converter. + * + * @since 2.9.0 + */ + @XmlElement(name = "user-converter") + private boolean enabledUserConverter = false; + @XmlElement(name = "namespace-strategy") private String namespaceStrategy = "UsernameNamespaceStrategy"; @@ -238,6 +246,7 @@ public class ScmConfiguration implements Configuration { this.loginInfoUrl = other.loginInfoUrl; this.releaseFeedUrl = other.releaseFeedUrl; this.mailDomainName = other.mailDomainName; + this.enabledUserConverter = other.enabledUserConverter; } /** @@ -387,6 +396,17 @@ public class ScmConfiguration implements Configuration { return enabledXsrfProtection; } + /** + * Returns {@code true} if the user converter is enabled. + * + * @return {@code true} if the user converter is enabled + * The user converter automatically converts an internal user to external on their first login using an external system like ldap + * @since 2.9.0 + */ + public boolean isEnabledUserConverter() { + return enabledUserConverter; + } + public boolean isEnableProxy() { return enableProxy; } @@ -554,6 +574,16 @@ public class ScmConfiguration implements Configuration { this.enabledXsrfProtection = enabledXsrfProtection; } + /** + * Set {@code true} to enable user converter. + * + * @param enabledUserConverter {@code true} to enable user converter + * @since 2.9.0 + */ + public void setEnabledUserConverter(boolean enabledUserConverter) { + this.enabledUserConverter = enabledUserConverter; + } + public void setNamespaceStrategy(String namespaceStrategy) { this.namespaceStrategy = namespaceStrategy; } diff --git a/scm-core/src/main/java/sonia/scm/repository/Branch.java b/scm-core/src/main/java/sonia/scm/repository/Branch.java index a1d500078c..bdcbc66a82 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Branch.java +++ b/scm-core/src/main/java/sonia/scm/repository/Branch.java @@ -21,18 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import sonia.scm.Validateable; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; +import java.util.regex.Pattern; //~--- JDK imports ------------------------------------------------------------ @@ -44,9 +46,14 @@ import java.io.Serializable; */ @XmlRootElement(name = "branch") @XmlAccessorType(XmlAccessType.FIELD) -public final class Branch implements Serializable +public final class Branch implements Serializable, Validateable { + private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>"; + private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/."; + public static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?"; + public static final Pattern VALID_BRANCH_NAME_PATTERN = Pattern.compile(VALID_BRANCH_NAMES); + /** Field description */ private static final long serialVersionUID = -4602244691711222413L; @@ -83,6 +90,11 @@ public final class Branch implements Serializable //~--- methods -------------------------------------------------------------- + @Override + public boolean isValid() { + return VALID_BRANCH_NAME_PATTERN.matcher(name).matches(); + } + /** * {@inheritDoc} * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index aa2a41782d..a6b4265239 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -34,6 +34,7 @@ import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.spi.RepositoryServiceProvider; import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.security.Authentications; import sonia.scm.user.EMail; import javax.annotation.Nullable; @@ -453,7 +454,8 @@ public final class RepositoryService implements Closeable { public Stream getSupportedProtocols() { return protocolProviders.stream() .filter(protocolProvider -> protocolProvider.getType().equals(getRepository().getType())) - .map(this::createProviderInstanceForRepository); + .map(this::createProviderInstanceForRepository) + .filter(protocol -> !Authentications.isAuthenticatedSubjectAnonymous() || protocol.isAnonymousEnabled()); } @SuppressWarnings({"rawtypes", "java:S3740"}) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocol.java b/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocol.java index 135191d5eb..ffdab2fc11 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocol.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocol.java @@ -40,4 +40,11 @@ public interface ScmProtocol { * The URL to access the repository providing this protocol. */ String getUrl(); + + /** + * Whether the protocol can be used as an anonymous user. + */ + default boolean isAnonymousEnabled() { + return true; + } } diff --git a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java index ec4966068f..46746b4781 100644 --- a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java @@ -21,10 +21,11 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.inject.Inject; +import lombok.extern.slf4j.Slf4j; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.subject.SimplePrincipalCollection; @@ -33,10 +34,14 @@ import sonia.scm.NotFoundException; import sonia.scm.group.Group; import sonia.scm.group.GroupManager; import sonia.scm.plugin.Extension; +import sonia.scm.user.ExternalUserConverter; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.security.AdministrationContext; +import java.util.Collections; +import java.util.Set; + /** * Helper class for syncing realms. The class should simplify the creation of realms, which are syncing authenticated * users with the local database. @@ -44,34 +49,49 @@ import sonia.scm.web.security.AdministrationContext; * @author Sebastian Sdorra * @since 2.0.0 */ +@Slf4j @Extension public final class SyncingRealmHelper { private final AdministrationContext ctx; private final UserManager userManager; private final GroupManager groupManager; + private final Set externalUserConverters; /** * Constructs a new SyncingRealmHelper. * - * @param ctx administration context - * @param userManager user manager - * @param groupManager group manager + * @param ctx administration context + * @param userManager user manager + * @param groupManager group manager + * @param externalUserConverters global scm configuration */ @Inject - public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager) { + public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager, Set externalUserConverters) { this.ctx = ctx; this.userManager = userManager; this.groupManager = groupManager; + this.externalUserConverters = externalUserConverters; + } + + /** + * Constructs a new SyncingRealmHelper. + * + * @param ctx administration context + * @param userManager user manager + * @param groupManager group manager + * @deprecated Use {@link #SyncingRealmHelper(AdministrationContext, UserManager, GroupManager, Set)} instead. + */ + @Deprecated + public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager) { + this(ctx, userManager, groupManager, Collections.emptySet()); } /** * Create {@link AuthenticationInfo} from user and groups. * - * * @param realm name of the realm - * @param user authenticated user - * + * @param user authenticated user * @return authentication info */ public AuthenticationInfo createAuthenticationInfo(String realm, User user) { @@ -91,17 +111,9 @@ public final class SyncingRealmHelper { public void store(final Group group) { ctx.runAsAdmin(() -> { if (groupManager.get(group.getId()) != null) { - try { - groupManager.modify(group); - } catch (NotFoundException e) { - throw new IllegalStateException("got NotFoundException though group " + group.getName() + " could be loaded", e); - } + modifyGroup(group); } else { - try { - groupManager.create(group); - } catch (AlreadyExistsException e) { - throw new IllegalStateException("got AlreadyExistsException though group " + group.getName() + " could not be loaded", e); - } + createNewGroup(group); } }); } @@ -114,19 +126,54 @@ public final class SyncingRealmHelper { public void store(final User user) { ctx.runAsAdmin(() -> { if (userManager.contains(user.getName())) { - try { - userManager.modify(user); - } catch (NotFoundException e) { - throw new IllegalStateException("got NotFoundException though user " + user.getName() + " could be loaded", e); - } + modifyUser(user); } else { - try { - userManager.create(user); - } catch (AlreadyExistsException e) { - throw new IllegalStateException("got AlreadyExistsException though user " + user.getName() + " could not be loaded", e); - - } + createNewUser(user); } }); } + + private void createNewUser(User user) { + try { + User clone = user.clone(); + // New user created by syncing realm helper is always external + clone.setExternal(true); + userManager.create(clone); + } catch (AlreadyExistsException e) { + throw new IllegalStateException("got AlreadyExistsException though user " + user.getName() + " could not be loaded", e); + + } + } + + private void modifyUser(User user) { + User clone = user.clone(); + if (!externalUserConverters.isEmpty()) { + log.debug("execute available user converters"); + for (ExternalUserConverter converter : externalUserConverters) { + clone = converter.convert(clone); + } + } + + try { + userManager.modify(clone); + } catch (NotFoundException e) { + throw new IllegalStateException("got NotFoundException though user " + clone.getName() + " could be loaded", e); + } + } + + private void createNewGroup(Group group) { + try { + groupManager.create(group); + } catch (AlreadyExistsException e) { + throw new IllegalStateException("got AlreadyExistsException though group " + group.getName() + " could not be loaded", e); + } + } + + private void modifyGroup(Group group) { + try { + groupManager.modify(group); + } catch (NotFoundException e) { + throw new IllegalStateException("got NotFoundException though group " + group.getName() + " could be loaded", e); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/user/ExternalUserConverter.java b/scm-core/src/main/java/sonia/scm/user/ExternalUserConverter.java new file mode 100644 index 0000000000..5fa5579b88 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/user/ExternalUserConverter.java @@ -0,0 +1,43 @@ +/* + * 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.user; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * The external user converter can be used to modify users + * which are provided by external systems before creation in SCM-Manager. + * The implementations will be called in the {@link sonia.scm.security.SyncingRealmHelper} + * @since 2.9.0 + */ +@ExtensionPoint +public interface ExternalUserConverter { + + /** + * Returns the converted user. + * @return converted user + */ + User convert(User user); +} diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 3379041100..51af6f7b6d 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -24,12 +24,14 @@ package sonia.scm.user; -//~--- non-JDK imports -------------------------------------------------------- - import com.github.sdorra.ssp.PermissionObject; import com.github.sdorra.ssp.StaticPermissions; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import sonia.scm.BasicPropertiesAware; import sonia.scm.ModelObject; import sonia.scm.ReducedModelObject; @@ -41,12 +43,6 @@ import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; import java.security.Principal; -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ @StaticPermissions( value = "user", globalPermissions = {"create", "list", "autocomplete"}, @@ -55,57 +51,42 @@ import java.security.Principal; ) @XmlRootElement(name = "users") @XmlAccessorType(XmlAccessType.FIELD) -public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject, ReducedModelObject -{ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject, ReducedModelObject { - /** Field description */ private static final long serialVersionUID = -3089541936726329663L; - //~--- constructors --------------------------------------------------------- + private boolean active = true; + private boolean external; + private Long creationDate; + private String displayName; + private Long lastModified; + private String mail; + private String name; + private String password; /** - * Constructs ... - * + * The user type is replaced by {@link #external} flag + * @deprecated Use {@link #external} instead. */ - public User() {} + @Deprecated + private String type; - /** - * Constructs ... - * - * - * @param name - */ - public User(String name) - { + public User(String name) { this.name = name; this.displayName = name; } - /** - * Constructs ... - * - * - * @param name - * @param displayName - * @param mail - */ - public User(String name, String displayName, String mail) - { + public User(String name, String displayName, String mail) { this.name = name; this.displayName = displayName; this.mail = mail; } - /** - * Constructs ... - * - * - * @param name - * @param displayName - * @param mail - */ - public User(String name, String displayName, String mail, String password, String type, boolean active) - { + public User(String name, String displayName, String mail, String password, String type, boolean active) { this.name = name; this.displayName = displayName; this.mail = mail; @@ -114,90 +95,57 @@ public class User extends BasicPropertiesAware implements Principal, ModelObject this.active = active; } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @return - * - */ @Override - public User clone() - { - User user = null; + public User clone() { + User user; - try - { + try { user = (User) super.clone(); - } - catch (CloneNotSupportedException ex) - { + } catch (CloneNotSupportedException ex) { throw new RuntimeException(ex); } return user; } - /** - * Method description - * - * - * @param user - * - * @return - */ - public boolean copyProperties(User user) - { + public boolean copyProperties(User user) { return copyProperties(user, true); } - /** - * Method description - * - * - * @param user - * @param copyPassword - * - * @return - */ - public boolean copyProperties(User user, boolean copyPassword) - { + public boolean copyProperties(User user, boolean copyPassword) { boolean result = false; - if (user.isActive() != active) - { + if (user.isActive() != active) { result = true; user.setActive(active); } - if (Util.isNotEquals(user.getDisplayName(), displayName)) - { + if (user.isExternal() != external) { + result = true; + user.setExternal(external); + } + + if (Util.isNotEquals(user.getDisplayName(), displayName)) { result = true; user.setDisplayName(displayName); } - if (Util.isNotEquals(user.getMail(), mail)) - { + if (Util.isNotEquals(user.getMail(), mail)) { result = true; user.setMail(mail); } - if (Util.isNotEquals(user.getName(), name)) - { + if (Util.isNotEquals(user.getName(), name)) { result = true; user.setName(name); } - if (copyPassword && Util.isNotEquals(user.getPassword(), password)) - { + if (copyPassword && Util.isNotEquals(user.getPassword(), password)) { result = true; user.setPassword(password); } - if (Util.isNotEquals(user.getType(), type)) - { + if (Util.isNotEquals(user.getType(), type)) { result = true; user.setType(type); } @@ -205,316 +153,65 @@ public class User extends BasicPropertiesAware implements Principal, ModelObject return result; } - /** - * {@inheritDoc} - * - * - * @param obj - * - * @return - */ @Override - public boolean equals(Object obj) - { - if (obj == null) - { + public boolean equals(Object obj) { + if (obj == null) { return false; } - if (getClass() != obj.getClass()) - { + if (getClass() != obj.getClass()) { return false; } final User other = (User) obj; return Objects.equal(name, other.name) - && Objects.equal(displayName, other.displayName) - && Objects.equal(mail, other.mail) - && Objects.equal(type, other.type) - && Objects.equal(active, other.active) - && Objects.equal(password, other.password) - && Objects.equal(creationDate, other.creationDate) - && Objects.equal(lastModified, other.lastModified) - && Objects.equal(properties, other.properties); + && Objects.equal(displayName, other.displayName) + && Objects.equal(mail, other.mail) + && Objects.equal(external, other.external) + && Objects.equal(active, other.active) + && Objects.equal(password, other.password) + && Objects.equal(creationDate, other.creationDate) + && Objects.equal(lastModified, other.lastModified) + && Objects.equal(properties, other.properties); } - /** - * {@inheritDoc} - * - * - * @return - */ @Override - public int hashCode() - { - return Objects.hashCode(name, displayName, mail, type, password, - active, creationDate, lastModified, properties); + public int hashCode() { + return Objects.hashCode(name, displayName, mail, password, + active, external, creationDate, lastModified, properties); } - /** - * {@inheritDoc} - * - * - * @return - */ @Override - public String toString() - { + public String toString() { String pwd = (password != null) - ? "(is set)" - : "(not set)"; + ? "(is set)" + : "(not set)"; //J- return MoreObjects.toStringHelper(this) - .add("name", name) - .add("displayName",displayName) - .add("mail", mail) - .add("password", pwd) - .add("type", type) - .add("active", active) - .add("creationDate", creationDate) - .add("lastModified", lastModified) - .add("properties", properties) - .toString(); + .add("name", name) + .add("displayName", displayName) + .add("mail", mail) + .add("password", pwd) + .add("type", type) + .add("active", active) + .add("external", external) + .add("creationDate", creationDate) + .add("lastModified", lastModified) + .add("properties", properties) + .toString(); //J+ } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public Long getCreationDate() - { - return creationDate; - } - - /** - * Method description - * - * - * @return - */ - public String getDisplayName() - { - return displayName; - } - - /** - * Method description - * - * - * @return - */ @Override - public String getId() - { - return name; - } - - /** - * Method description - * - * - * @return - */ - @Override - public Long getLastModified() - { - return lastModified; - } - - /** - * Method description - * - * - * @return - */ - public String getMail() - { - return mail; - } - - /** - * Method description - * - * - * @return - */ - @Override - public String getName() - { - return name; - } - - /** - * Method description - * - * - * @return - */ - public String getPassword() - { - return password; - } - - /** - * Method description - * - * - * @return - */ - @Override - public String getType() - { - return type; - } - - /** - * Returns false if the user is deactivated. - * - * - * @return false if the user is deactivated - * @since 1.16 - */ - public boolean isActive() - { - return active; - } - - /** - * Method description - * - * - * @return - */ - @Override - public boolean isValid() - { + public boolean isValid() { return ValidationUtil.isNameValid(name) && Util.isNotEmpty(displayName) - && Util.isNotEmpty(type) - && ((Util.isEmpty(mail)) || ValidationUtil.isMailAddressValid(mail)); + && ((Util.isEmpty(mail)) || ValidationUtil.isMailAddressValid(mail)); } - //~--- set methods ---------------------------------------------------------- - - /** - * Activate or deactive this user. - * - * - * @param active false to deactivate the user. - * @since 1.6 - */ - public void setActive(boolean active) - { - this.active = active; + @Override + public String getId() { + return name; } - - /** - * Method description - * - * - * @param creationDate - */ - public void setCreationDate(Long creationDate) - { - this.creationDate = creationDate; - } - - /** - * Method description - * - * - * @param displayName - */ - public void setDisplayName(String displayName) - { - this.displayName = displayName; - } - - /** - * Method description - * - * - * @param lastModified - */ - public void setLastModified(Long lastModified) - { - this.lastModified = lastModified; - } - - /** - * Method description - * - * - * @param mail - */ - public void setMail(String mail) - { - this.mail = mail; - } - - /** - * Method description - * - * - * - * @param name - */ - public void setName(String name) - { - this.name = name; - } - - /** - * Method description - * - * - * @param password - */ - public void setPassword(String password) - { - this.password = password; - } - - /** - * Method description - * - * - * @param type - */ - public void setType(String type) - { - this.type = type; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private boolean active = true; - - /** Field description */ - private Long creationDate; - - /** Field description */ - private String displayName; - - /** Field description */ - private Long lastModified; - - /** Field description */ - private String mail; - - /** Field description */ - private String name; - - /** Field description */ - private String password; - - /** Field description */ - private String type; } diff --git a/scm-core/src/main/java/sonia/scm/web/UserAgent.java b/scm-core/src/main/java/sonia/scm/web/UserAgent.java index 9cee5f9c1e..35e4060a91 100644 --- a/scm-core/src/main/java/sonia/scm/web/UserAgent.java +++ b/scm-core/src/main/java/sonia/scm/web/UserAgent.java @@ -21,23 +21,19 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.web; -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Charsets; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import static com.google.common.base.Preconditions.checkNotNull; -//~--- JDK imports ------------------------------------------------------------ - /** - * The software agent that is acting on behalf of a user. The user agent + * The software agent that is acting on behalf of a user. The user agent * represents a browser or one of the repository client (svn, git or hg). * * @author Sebastian Sdorra @@ -49,17 +45,16 @@ public final class UserAgent /** * Constructs a new user agent * - * - * @param name - * @param browser + * @param name * @param basicAuthenticationCharset + * @param browser */ - private UserAgent(String name, boolean browser, - Charset basicAuthenticationCharset) + private UserAgent(String name, Charset basicAuthenticationCharset, boolean browser, boolean scmClient) { this.name = checkNotNull(name); - this.browser = browser; this.basicAuthenticationCharset = checkNotNull(basicAuthenticationCharset); + this.browser = browser; + this.scmClient = scmClient; } //~--- methods -------------------------------------------------------------- @@ -71,8 +66,30 @@ public final class UserAgent * @param name name of the UserAgent * * @return builder for UserAgent + * + * @deprecated Use {@link #browser(String)}, {@link #scmClient(String)} or {@link #other(String)} instead */ + @Deprecated public static Builder builder(String name) + { + return other(name); + } + + public static Builder browser(String name) + { + final Builder builder = new Builder(name); + builder.browser = true; + return builder; + } + + public static Builder scmClient(String name) + { + final Builder builder = new Builder(name); + builder.scmClient = true; + return builder; + } + + public static Builder other(String name) { return new Builder(name); } @@ -97,7 +114,7 @@ public final class UserAgent return Objects.equal(name, other.name) && Objects.equal(browser, other.browser) - && Objects.equal(basicAuthenticationCharset, basicAuthenticationCharset); + && Objects.equal(basicAuthenticationCharset, other.basicAuthenticationCharset); } /** @@ -127,7 +144,7 @@ public final class UserAgent //~--- get methods ---------------------------------------------------------- /** - * Returns the {@link Charset}, which is used to decode the basic + * Returns the {@link Charset}, which is used to decode the basic * authentication header. * * @return {@link Charset} for basic authentication @@ -152,13 +169,23 @@ public final class UserAgent * Returns {@code true} if UserAgent is a browser. * * - * @return {@code true} if UserAgent is a browser + * @return {@code true} if UserAgent is a browser */ public boolean isBrowser() { return browser; } + /** + * Returns {@code true} if UserAgent is an scm client (e.g. git, svn or hg). + * + * + * @return {@code true} if UserAgent is an scm client + */ + public boolean isScmClient() { + return scmClient; + } + //~--- inner classes -------------------------------------------------------- /** @@ -204,7 +231,10 @@ public final class UserAgent * @param browser {@code true} for a browser * * @return {@code this} + * + * @deprecated Use {@link #browser(String)} instead */ + @Deprecated public Builder browser(boolean browser) { this.browser = browser; @@ -215,12 +245,11 @@ public final class UserAgent /** * Builds the {@link UserAgent}. * - * * @return new {@link UserAgent} */ public UserAgent build() { - return new UserAgent(name, browser, basicAuthenticationCharset); + return new UserAgent(name, basicAuthenticationCharset, browser, scmClient); } //~--- fields ------------------------------------------------------------- @@ -229,10 +258,13 @@ public final class UserAgent private final String name; /** indicator for browsers */ - private boolean browser = true; + private boolean browser = false; + + /** indicator for browsers */ + private boolean scmClient = false; /** basic authentication charset */ - private Charset basicAuthenticationCharset = Charsets.ISO_8859_1; + private Charset basicAuthenticationCharset = StandardCharsets.ISO_8859_1; } @@ -244,6 +276,9 @@ public final class UserAgent /** indicator for browsers */ private final boolean browser; + /** indicator for scm clients (e.g. git, hg, svn) */ + private final boolean scmClient; + /** name of UserAgent */ private final String name; } diff --git a/scm-core/src/main/java/sonia/scm/web/UserAgentParser.java b/scm-core/src/main/java/sonia/scm/web/UserAgentParser.java index 423234f756..307e09fdca 100644 --- a/scm-core/src/main/java/sonia/scm/web/UserAgentParser.java +++ b/scm-core/src/main/java/sonia/scm/web/UserAgentParser.java @@ -62,7 +62,7 @@ public final class UserAgentParser /** unknown UserAgent */ @VisibleForTesting - static final UserAgent UNKNOWN = UserAgent.builder("UNKNOWN").build(); + static final UserAgent UNKNOWN = UserAgent.other("UNKNOWN").build(); /** logger */ private static final Logger logger = diff --git a/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java b/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java index 8394e3c582..c702cc64de 100644 --- a/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java @@ -24,7 +24,15 @@ package sonia.scm.repository.api; -import org.junit.Test; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.Repository; import sonia.scm.repository.spi.HttpScmProtocol; @@ -42,16 +50,32 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.util.IterableUtil.sizeOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -public class RepositoryServiceTest { +@ExtendWith(MockitoExtension.class) +class RepositoryServiceTest { private final RepositoryServiceProvider provider = mock(RepositoryServiceProvider.class); private final Repository repository = new Repository("", "git", "space", "repo"); private final EMail eMail = new EMail(new ScmConfiguration()); + @Mock + private Subject subject; + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @Test - public void shouldReturnMatchingProtocolsFromProvider() { + void shouldReturnMatchingProtocolsFromProvider() { + when(subject.getPrincipal()).thenReturn("Hitchhiker"); RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail); Stream supportedProtocols = repositoryService.getSupportedProtocols(); @@ -59,7 +83,17 @@ public class RepositoryServiceTest { } @Test - public void shouldFindKnownProtocol() { + void shouldFilterOutNonAnonymousEnabledProtocolsForAnonymousUser() { + when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS); + RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Stream.of(new DummyScmProtocolProvider(), new DummyScmProtocolProvider(false)).collect(Collectors.toSet()), null, eMail); + Stream supportedProtocols = repositoryService.getSupportedProtocols(); + + assertThat(sizeOf(supportedProtocols.collect(Collectors.toList()))).isEqualTo(1); + } + + @Test + void shouldFindKnownProtocol() { + when(subject.getPrincipal()).thenReturn("Hitchhiker"); RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail); HttpScmProtocol protocol = repositoryService.getProtocol(HttpScmProtocol.class); @@ -68,23 +102,44 @@ public class RepositoryServiceTest { } @Test - public void shouldFailForUnknownProtocol() { + void shouldFailForUnknownProtocol() { + when(subject.getPrincipal()).thenReturn("Hitchhiker"); RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail); assertThrows(IllegalArgumentException.class, () -> repositoryService.getProtocol(UnknownScmProtocol.class)); } private static class DummyHttpProtocol extends HttpScmProtocol { - public DummyHttpProtocol(Repository repository) { + + private final boolean anonymousEnabled; + + public DummyHttpProtocol(Repository repository, boolean anonymousEnabled) { super(repository, ""); + this.anonymousEnabled = anonymousEnabled; } @Override public void serve(HttpServletRequest request, HttpServletResponse response, Repository repository, ServletConfig config) { } + + @Override + public boolean isAnonymousEnabled() { + return anonymousEnabled; + } } private static class DummyScmProtocolProvider implements ScmProtocolProvider { + + private final boolean anonymousEnabled; + + public DummyScmProtocolProvider() { + this(true); + } + + public DummyScmProtocolProvider(boolean anonymousEnabled) { + this.anonymousEnabled = anonymousEnabled; + } + @Override public String getType() { return "git"; @@ -92,9 +147,10 @@ public class RepositoryServiceTest { @Override public ScmProtocol get(Repository repository) { - return new DummyHttpProtocol(repository); + return new DummyHttpProtocol(repository, anonymousEnabled); } } - private interface UnknownScmProtocol extends ScmProtocol {} + private interface UnknownScmProtocol extends ScmProtocol { + } } diff --git a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java index 7dca9c6ada..81bf6d1a09 100644 --- a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java @@ -27,15 +27,18 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.authc.AuthenticationInfo; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.AlreadyExistsException; import sonia.scm.group.Group; import sonia.scm.group.GroupManager; +import sonia.scm.user.ExternalUserConverter; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.security.AdministrationContext; @@ -46,7 +49,9 @@ import java.io.IOException; import static org.hamcrest.Matchers.hasItem; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -68,8 +73,13 @@ public class SyncingRealmHelperTest { @Mock private UserManager userManager; + @Mock + private ExternalUserConverter converter; + private SyncingRealmHelper helper; + private SyncingRealmHelper helperWithConverters; + /** * Mock {@link AdministrationContext} and create object under test. */ @@ -94,6 +104,7 @@ public class SyncingRealmHelperTest { }; helper = new SyncingRealmHelper(ctx, userManager, groupManager); + helperWithConverters = new SyncingRealmHelper(ctx, userManager, groupManager, ImmutableSet.of(converter)); } /** @@ -140,10 +151,15 @@ public class SyncingRealmHelperTest { */ @Test public void testStoreUserCreate() { + ArgumentCaptor userArgumentCaptor = ArgumentCaptor.forClass(User.class); User user = new User("tricia"); helper.store(user); - verify(userManager, times(1)).create(user); + verify(userManager, times(1)).create(userArgumentCaptor.capture()); + + User value = userArgumentCaptor.getValue(); + assertEquals(user.getDisplayName(), value.getDisplayName()); + assertEquals(user.getName(), value.getName()); } /** @@ -151,9 +167,10 @@ public class SyncingRealmHelperTest { */ @Test(expected = IllegalStateException.class) public void testStoreUserFailure() { + ArgumentCaptor userArgumentCaptor = ArgumentCaptor.forClass(User.class); User user = new User("tricia"); - doThrow(AlreadyExistsException.class).when(userManager).create(user); + doThrow(AlreadyExistsException.class).when(userManager).create(userArgumentCaptor.capture()); helper.store(user); } @@ -170,6 +187,23 @@ public class SyncingRealmHelperTest { verify(userManager, times(1)).modify(user); } + /** + * Tests {@link SyncingRealmHelper#store(User)} with an existing user. + */ + @Test + public void testConvertUser(){ + User zaphod = new User("zaphod"); + when(converter.convert(any())).thenReturn(zaphod); + when(userManager.contains("tricia")).thenReturn(Boolean.TRUE); + + User user = new User("tricia"); + + helperWithConverters.store(user); + + verify(converter).convert(user); + verify(userManager, times(1)).modify(zaphod); + } + @Test public void builderShouldSetValues() { diff --git a/scm-core/src/test/java/sonia/scm/web/UserAgentParserTest.java b/scm-core/src/test/java/sonia/scm/web/UserAgentParserTest.java index dd19414fe7..33ba090373 100644 --- a/scm-core/src/test/java/sonia/scm/web/UserAgentParserTest.java +++ b/scm-core/src/test/java/sonia/scm/web/UserAgentParserTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- @@ -89,7 +89,7 @@ public class UserAgentParserTest UserAgent ua = parser.parse(UA_1); assertEquals(Charsets.ISO_8859_1, ua.getBasicAuthenticationCharset()); - assertTrue(ua.isBrowser()); + assertFalse(ua.isBrowser()); } /** @@ -99,11 +99,11 @@ public class UserAgentParserTest @Test public void testParse() { - UserAgent ua = UserAgent.builder("UA1").build(); + UserAgent ua = UserAgent.other("UA1").build(); when(provider1.parseUserAgent(UA_1)).thenReturn(ua); - UserAgent ua2 = UserAgent.builder("UA2").build(); + UserAgent ua2 = UserAgent.other("UA2").build(); when(provider2.parseUserAgent(UA_2)).thenReturn(ua2); @@ -120,7 +120,7 @@ public class UserAgentParserTest { when(request.getHeader(HttpUtil.HEADER_USERAGENT)).thenReturn(UA_2); - UserAgent ua = UserAgent.builder("UA2").build(); + UserAgent ua = UserAgent.other("UA2").build(); when(provider1.parseUserAgent(UA_2)).thenReturn(ua); assertEquals(ua, parser.parse(request)); @@ -144,7 +144,7 @@ public class UserAgentParserTest @Test public void testParseWithCache() { - UserAgent ua = UserAgent.builder("UA").build(); + UserAgent ua = UserAgent.other("UA").build(); when(cache.get(UA_1)).thenReturn(ua); assertEquals(ua, parser.parse(UA_1)); diff --git a/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java index 2c49458547..a3b9318e5a 100644 --- a/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java +++ b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java @@ -69,8 +69,8 @@ class HttpProtocolServletAuthenticationFilterBaseTest { @Mock private FilterChain filterChain; - private UserAgent nonBrowser = UserAgent.builder("i'm not a browser").browser(false).build(); - private UserAgent browser = UserAgent.builder("i am a browser").browser(true).build(); + private UserAgent nonBrowser = UserAgent.other("i'm not a browser").build(); + private UserAgent browser = UserAgent.browser("i am a browser").build(); @BeforeEach void setUpObjectUnderTest() { diff --git a/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java index 8a115a2392..c6f263fed9 100644 --- a/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.it; import com.google.common.base.Charsets; @@ -185,7 +185,7 @@ public class GitNonFastForwardITCase { } private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) { - String config = String.format("{'disabled': false, 'gcExpression': null, 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed) + String config = String.format("{'disabled': false, 'gcExpression': null, 'defaultBranch': 'main', 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed) .replace('\'', '"'); given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX) diff --git a/scm-packaging/docker/Dockerfile b/scm-packaging/docker/Dockerfile index be60fa9fbe..9c0451a38e 100644 --- a/scm-packaging/docker/Dockerfile +++ b/scm-packaging/docker/Dockerfile @@ -22,7 +22,7 @@ # SOFTWARE. # -FROM adoptopenjdk/openjdk11:jdk-11.0.8_10-alpine-slim +FROM adoptopenjdk/openjdk11:jdk-11.0.9_11.1-alpine-slim ENV SCM_HOME=/var/lib/scm ENV CACHE_DIR=/var/cache/scm/work diff --git a/scm-packaging/windows/pom.xml b/scm-packaging/windows/pom.xml index 4c2a25d2ff..9cd0dd1984 100644 --- a/scm-packaging/windows/pom.xml +++ b/scm-packaging/windows/pom.xml @@ -71,11 +71,11 @@ wget - https://github.com/winsw/winsw/releases/download/v2.9.0/WinSW.NETCore31.x64.exe + https://github.com/winsw/winsw/releases/download/v2.10.3/WinSW.NETCore31.x86.exe false scm-server.exe ${project.build.directory}/windows - 59d29a41652cfc9a564c9c05d77976391833a6fb686bce941ad89f8f8dff120b + d6ad842e104bfb200bca06d6724e3e1fb19d013fa62fa49a21298d2ee9b044b7 diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java index 640fadc8e8..893de773da 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; @@ -29,6 +29,12 @@ import de.otto.edison.hal.Links; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Pattern; + +import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES; @NoArgsConstructor @Getter @@ -41,6 +47,11 @@ public class GitConfigDto extends HalRepresentation { private boolean nonFastForwardDisallowed; + @NotEmpty + @Length(min = 1, max = 100) + @Pattern(regexp = VALID_BRANCH_NAMES) + private String defaultBranch; + @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package protected HalRepresentation add(Links links) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java index 17f76913c5..71d97f27be 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.OpenAPIDefinition; @@ -38,6 +38,7 @@ import sonia.scm.web.VndMediaType; import javax.inject.Inject; import javax.inject.Provider; +import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.PUT; @@ -126,7 +127,7 @@ public class GitConfigResource { mediaType = VndMediaType.ERROR_TYPE, schema = @Schema(implementation = ErrorDto.class) )) - public Response update(GitConfigDto configDto) { + public Response update(@Valid GitConfigDto configDto) { GitConfig config = dtoToConfigMapper.map(configDto); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java index facdcccbbf..f699093f73 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- JDK imports ------------------------------------------------------------ @@ -33,7 +33,6 @@ import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlTransient; /** - * * @author Sebastian Sdorra */ @XmlRootElement(name = "config") @@ -49,6 +48,9 @@ public class GitConfig extends RepositoryConfig { @XmlElement(name = "disallow-non-fast-forward") private boolean nonFastForwardDisallowed; + @XmlElement(name = "default-branch") + private String defaultBranch = "main"; + public String getGcExpression() { return gcExpression; } @@ -65,6 +67,14 @@ public class GitConfig extends RepositoryConfig { this.nonFastForwardDisallowed = nonFastForwardDisallowed; } + public String getDefaultBranch() { + return defaultBranch; + } + + public void setDefaultBranch(String defaultBranch) { + this.defaultBranch = defaultBranch; + } + @Override @XmlTransient // Only for permission checks, don't serialize to XML public String getId() { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index 97115ca159..75b96d16f2 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -55,16 +55,18 @@ import sonia.scm.store.BlobStore; import sonia.scm.util.Util; import sonia.scm.web.lfs.LfsBlobStoreFactory; +import javax.annotation.Nullable; import javax.inject.Inject; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Stack; import java.util.function.Consumer; import static java.util.Optional.empty; @@ -141,12 +143,12 @@ public class GitBrowseCommand extends AbstractGitCommand if (Util.isEmpty(request.getRevision())) { return getDefaultBranch(repo); } else { - ObjectId revId = GitUtil.getRevisionId(repo, request.getRevision()); - if (revId == null) { + ObjectId revisionId = GitUtil.getRevisionId(repo, request.getRevision()); + if (revisionId == null) { logger.error("could not find revision {}", request.getRevision()); throw notFound(entity("Revision", request.getRevision()).in(this.repository)); } - return revId; + return revisionId; } } @@ -212,7 +214,9 @@ public class GitBrowseCommand extends AbstractGitCommand private FileObject getEntry() throws IOException { try (RevWalk revWalk = new RevWalk(repo); TreeWalk treeWalk = new TreeWalk(repo)) { - logger.debug("load repository browser for revision {}", revId.name()); + if (logger.isDebugEnabled()) { // method call in logger call + logger.debug("load repository browser for revision {}", revId.name()); + } if (!isRootRequest()) { treeWalk.setFilter(PathFilter.create(request.getPath())); @@ -275,7 +279,7 @@ public class GitBrowseCommand extends AbstractGitCommand } private void createTree(TreeEntry parent, TreeWalk treeWalk) throws IOException { - Stack parents = new Stack<>(); + Deque parents = new ArrayDeque<>(); parents.push(parent); while (treeWalk.next()) { final String currentPath = treeWalk.getPathString(); @@ -283,11 +287,15 @@ public class GitBrowseCommand extends AbstractGitCommand parents.pop(); } TreeEntry currentParent = parents.peek(); - TreeEntry treeEntry = new TreeEntry(repo, treeWalk); - currentParent.addChild(treeEntry); - if (request.isRecursive() && treeEntry.getType() == TreeType.DIRECTORY) { - treeWalk.enterSubtree(); - parents.push(treeEntry); + TreeEntry treeEntry = createTreeEntry(repo, treeWalk); + if (treeEntry != null) { + currentParent.addChild(treeEntry); + if (request.isRecursive() && treeEntry.getType() == TreeType.DIRECTORY) { + treeWalk.enterSubtree(); + parents.push(treeEntry); + } + } else { + logger.warn("failed to find tree entry for {}", currentPath); } } } @@ -304,7 +312,12 @@ public class GitBrowseCommand extends AbstractGitCommand currentDepth++; if (currentDepth >= limit) { - return createFileObject(new TreeEntry(repo, treeWalk)); + TreeEntry treeEntry = createTreeEntry(repo, treeWalk); + if (treeEntry != null) { + return createFileObject(treeEntry); + } else { + logger.warn("could not find tree entry at {}", name); + } } else { treeWalk.enterSubtree(); } @@ -328,8 +341,12 @@ public class GitBrowseCommand extends AbstractGitCommand } } - private SubRepository getSubRepository(String path) - throws IOException { + @Nullable + private SubRepository getSubRepository(String path) throws IOException { + if (request.isDisableSubRepositoryDetection()) { + return null; + } + Map subRepositories = subrepositoryCache.get(revId); if (subRepositories == null) { @@ -447,6 +464,23 @@ public class GitBrowseCommand extends AbstractGitCommand FILE, DIRECTORY, SUB_REPOSITORY } + @Nullable + TreeEntry createTreeEntry(org.eclipse.jgit.lib.Repository repo, TreeWalk treeWalk) throws IOException { + String pathString = treeWalk.getPathString(); + ObjectId objectId = treeWalk.getObjectId(0); + SubRepository subRepository = getSubRepository(pathString); + if (subRepository != null) { + return new TreeEntry(pathString, treeWalk.getNameString(), objectId, subRepository); + } else if (repo.getObjectDatabase().has(objectId)) { + TreeType type = TreeType.FILE; + if (repo.open(objectId).getType() == Constants.OBJ_TREE) { + type = TreeType.DIRECTORY; + } + return new TreeEntry(pathString, treeWalk.getNameString(), objectId, type); + } + return null; + } + private class TreeEntry { private final String pathString; @@ -466,21 +500,20 @@ public class GitBrowseCommand extends AbstractGitCommand type = TreeType.DIRECTORY; } - TreeEntry(org.eclipse.jgit.lib.Repository repo, TreeWalk treeWalk) throws IOException { - this.pathString = treeWalk.getPathString(); - this.nameString = treeWalk.getNameString(); - this.objectId = treeWalk.getObjectId(0); + TreeEntry(String pathString, String nameString, ObjectId objectId, SubRepository subRepository) { + this.pathString = pathString; + this.nameString = nameString; + this.objectId = objectId; + this.type = TreeType.SUB_REPOSITORY; + this.subRepository = subRepository; + } - if (!request.isDisableSubRepositoryDetection() && GitBrowseCommand.this.getSubRepository(pathString) != null) { - subRepository = GitBrowseCommand.this.getSubRepository(pathString); - type = TreeType.SUB_REPOSITORY; - } else if (repo.open(objectId).getType() == Constants.OBJ_TREE) { - subRepository = null; - type = TreeType.DIRECTORY; - } else { - subRepository = null; - type = TreeType.FILE; - } + TreeEntry(String pathString, String nameString, ObjectId objectId, TreeType type) { + this.pathString = pathString; + this.nameString = nameString; + this.objectId = objectId; + this.type = type; + this.subRepository = null; } String getPathString() { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java index c7f0327312..4246dd4ff3 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java @@ -27,6 +27,7 @@ package sonia.scm.repository.spi; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; @@ -49,20 +50,12 @@ public class GitContext implements Closeable, RepositoryProvider private static final Logger logger = LoggerFactory.getLogger(GitContext.class); - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param directory - * @param repository - */ - public GitContext(File directory, Repository repository, GitRepositoryConfigStoreProvider storeProvider) + public GitContext(File directory, Repository repository, GitRepositoryConfigStoreProvider storeProvider, GitConfig config) { this.directory = directory; this.repository = repository; this.storeProvider = storeProvider; + this.config = config; } //~--- methods -------------------------------------------------------------- @@ -126,12 +119,17 @@ public class GitContext implements Closeable, RepositoryProvider storeProvider.get(repository).set(newConfig); } + GitConfig getGlobalConfig() { + return config; + } + //~--- fields --------------------------------------------------------------- /** Field description */ private final File directory; private final Repository repository; private final GitRepositoryConfigStoreProvider storeProvider; + private final GitConfig config; /** Field description */ private org.eclipse.jgit.lib.Repository gitRepository; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java index 04bb37bed0..214eb090b8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java @@ -42,7 +42,7 @@ class GitContextFactory { } GitContext create(Repository repository) { - return new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider); + return new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider, handler.getConfig()); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java index 01271d0fc3..ba6df0c1e8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java @@ -30,14 +30,16 @@ import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.attributes.FilterCommandRegistry; import org.eclipse.jgit.revwalk.RevCommit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import sonia.scm.ConcurrentModificationException; +import sonia.scm.ContextEntry; import sonia.scm.NoChangesMadeException; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; +import sonia.scm.store.ConfigurationStore; import sonia.scm.web.lfs.LfsBlobStoreFactory; import javax.inject.Inject; @@ -49,21 +51,22 @@ import java.util.concurrent.locks.Lock; public class GitModifyCommand extends AbstractGitCommand implements ModifyCommand { - private static final Logger LOG = LoggerFactory.getLogger(GitModifyCommand.class); private static final Striped REGISTER_LOCKS = Striped.lock(5); private final GitWorkingCopyFactory workingCopyFactory; private final LfsBlobStoreFactory lfsBlobStoreFactory; + private final GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider; @Inject - GitModifyCommand(GitContext context, GitRepositoryHandler repositoryHandler, LfsBlobStoreFactory lfsBlobStoreFactory) { - this(context, repositoryHandler.getWorkingCopyFactory(), lfsBlobStoreFactory); + GitModifyCommand(GitContext context, GitRepositoryHandler repositoryHandler, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { + this(context, repositoryHandler.getWorkingCopyFactory(), lfsBlobStoreFactory, gitRepositoryConfigStoreProvider); } - GitModifyCommand(GitContext context, GitWorkingCopyFactory workingCopyFactory, LfsBlobStoreFactory lfsBlobStoreFactory) { + GitModifyCommand(GitContext context, GitWorkingCopyFactory workingCopyFactory, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { super(context); this.workingCopyFactory = workingCopyFactory; this.lfsBlobStoreFactory = lfsBlobStoreFactory; + this.gitRepositoryConfigStoreProvider = gitRepositoryConfigStoreProvider; } @Override @@ -85,19 +88,49 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman @Override String run() throws IOException { getClone().getRepository().getFullBranch(); + + boolean initialCommit = getClone().getRepository().getRefDatabase().getRefs().isEmpty(); + if (!StringUtils.isEmpty(request.getExpectedRevision()) && !request.getExpectedRevision().equals(getCurrentRevision().getName())) { - throw new ConcurrentModificationException("branch", request.getBranch() == null ? "default" : request.getBranch()); + throw new ConcurrentModificationException(ContextEntry.ContextBuilder.entity("Branch", request.getBranch() == null ? "default" : request.getBranch()).in(repository).build()); } for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { r.execute(this); } failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())); Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSign()); + + if (initialCommit) { + handleBranchForInitialCommit(); + } + push(); return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name(); } + private void handleBranchForInitialCommit() { + String branch = StringUtils.isNotBlank(request.getBranch()) ? request.getBranch() : context.getGlobalConfig().getDefaultBranch(); + if (StringUtils.isNotBlank(branch)) { + try { + getClone().checkout().setName(branch).setCreateBranch(true).call(); + setBranchInConfig(branch); + } catch (GitAPIException e) { + throw new InternalRepositoryException(repository, "could not create default branch for initial commit", e); + } + } + } + + private void setBranchInConfig(String branch) { + ConfigurationStore store = gitRepositoryConfigStoreProvider + .get(repository); + GitRepositoryConfig gitRepositoryConfig = store + .getOptional() + .orElse(new GitRepositoryConfig()); + gitRepositoryConfig.setDefaultBranch(branch); + store.set(gitRepositoryConfig); + } + @Override public void addFileToScm(String name, Path file) { addToGitWithLfsSupport(name, file); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java index 11019b288c..346a2776b1 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java @@ -67,7 +67,11 @@ class GitWorkingCopyInitializer { Ref head = clone.exactRef(Constants.HEAD); if (head == null || !head.isSymbolic() || (initialBranch != null && !head.getTarget().getName().endsWith(initialBranch))) { - throw notFound(entity("Branch", initialBranch).in(context.getRepository())); + if (clone.getRefDatabase().getRefs().isEmpty()) { + LOG.warn("could not initialize empty clone with given branch {}; this has to be handled later on", initialBranch); + } else { + throw notFound(entity("Branch", initialBranch).in(context.getRepository())); + } } return new ParentAndClone<>(null, clone, target); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitUserAgentProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitUserAgentProvider.java index 4bb2a74442..e93c5a3ecc 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitUserAgentProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitUserAgentProvider.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- @@ -40,36 +40,32 @@ import sonia.scm.plugin.Extension; */ @Extension public class GitUserAgentProvider implements UserAgentProvider { - + private static final String PREFIX_JGIT = "jgit/"; @VisibleForTesting - static final UserAgent JGIT = UserAgent.builder("JGit") - .browser(false) + static final UserAgent JGIT = UserAgent.scmClient("JGit") .basicAuthenticationCharset(Charsets.UTF_8) .build(); - + private static final String PREFIX_REGULAR = "git/"; - + @VisibleForTesting - static final UserAgent GIT = UserAgent.builder("Git") - .browser(false) + static final UserAgent GIT = UserAgent.scmClient("Git") .basicAuthenticationCharset(Charsets.UTF_8) .build(); - + private static final String PREFIX_LFS = "git-lfs/"; @VisibleForTesting - static final UserAgent GIT_LFS = UserAgent.builder("Git Lfs") - .browser(false) + static final UserAgent GIT_LFS = UserAgent.scmClient("Git Lfs") .basicAuthenticationCharset(Charsets.UTF_8) .build(); private static final String SUFFIX_MSYSGIT = "msysgit"; - + @VisibleForTesting - static final UserAgent MSYSGIT = UserAgent.builder("msysGit") - .browser(false) + static final UserAgent MSYSGIT = UserAgent.scmClient("msysGit") .basicAuthenticationCharset(Charsets.UTF_8) .build(); @@ -80,7 +76,7 @@ public class GitUserAgentProvider implements UserAgentProvider { @Override public UserAgent parseUserAgent(String userAgentString) { String lowerUserAgent = toLower(userAgentString); - + if (isJGit(lowerUserAgent)) { return JGIT; } else if (isMsysGit(lowerUserAgent)) { @@ -93,23 +89,23 @@ public class GitUserAgentProvider implements UserAgentProvider { return null; } } - + private String toLower(String value) { return Strings.nullToEmpty(value).toLowerCase(Locale.ENGLISH); } - + private boolean isJGit(String userAgent) { return userAgent.startsWith(PREFIX_JGIT); } - + private boolean isMsysGit(String userAgent) { return userAgent.startsWith(PREFIX_REGULAR) && userAgent.contains(SUFFIX_MSYSGIT); } - + private boolean isGitLFS(String userAgent) { return userAgent.startsWith(PREFIX_LFS); } - + private boolean isGit(String userAgent) { return userAgent.startsWith(PREFIX_REGULAR); } diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx index 036c7f3f40..cec3f1b85f 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx +++ b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx @@ -24,12 +24,13 @@ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { Links } from "@scm-manager/ui-types"; -import { InputField, Checkbox } from "@scm-manager/ui-components"; +import { InputField, Checkbox, validation as validator } from "@scm-manager/ui-components"; type Configuration = { repositoryDirectory?: string; gcExpression?: string; nonFastForwardDisallowed: boolean; + defaultBranch: string; _links: Links; }; @@ -68,8 +69,21 @@ class GitConfigurationForm extends React.Component { ); }; + onDefaultBranchChange = (value: string) => { + this.setState( + { + defaultBranch: value + }, + () => this.props.onConfigurationChange(this.state, this.isValidDefaultBranch()) + ); + }; + + isValidDefaultBranch = () => { + return validator.isBranchValid(this.state.defaultBranch); + }; + render() { - const { gcExpression, nonFastForwardDisallowed } = this.state; + const { gcExpression, nonFastForwardDisallowed, defaultBranch } = this.state; const { readOnly, t } = this.props; return ( @@ -90,6 +104,16 @@ class GitConfigurationForm extends React.Component { onChange={this.onNonFastForwardDisallowed} disabled={readOnly} /> + ); } diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 7553856e57..ad452fd559 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -24,6 +24,9 @@ "gcExpressionHelpText": "Benutze Quartz Cron Ausdrücke (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK), um git GC regelmäßig auszuführen.", "nonFastForwardDisallowed": "Deaktiviere \"Non Fast-Forward\"", "nonFastForwardDisallowedHelpText": "Git Pushes ablehnen, die nicht \"fast-forward\" sind, wie \"--force\".", + "defaultBranch": "Default Branch", + "defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).", + "defaultBranchValidationError": "Dies ist kein valider Branchname", "disabled": "Deaktiviert", "disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin", "submit": "Speichern" diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index 23ab1ffce0..f894d1dbf6 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -24,6 +24,9 @@ "gcExpressionHelpText": "Use Quartz Cron Expressions (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK) to run git gc in intervals.", "nonFastForwardDisallowed": "Disallow Non Fast-Forward", "nonFastForwardDisallowedHelpText": "Reject git pushes which are non fast-forward such as --force.", + "defaultBranch": "Default Branch", + "defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).", + "defaultBranchValidationError": "This is not a valid branch name", "disabled": "Disabled", "disabledHelpText": "Enable or disable the Git plugin", "submit": "Submit" diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java index ab4c71b7fc..e3041dd447 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; @@ -261,7 +261,7 @@ public class GitConfigResourceTest { private MockHttpResponse put() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.put("/" + GitConfigResource.GIT_CONFIG_PATH_V2) .contentType(GitVndMediaType.GIT_CONFIG) - .content("{\"disabled\":true}".getBytes()); + .content("{\"disabled\":true, \"defaultBranch\":\"main\"}".getBytes()); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java index d593a58678..2c2ee26f80 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java @@ -21,24 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- -import org.eclipse.jgit.transport.ScmTransportProtocol; -import org.eclipse.jgit.transport.Transport; import org.junit.After; -import org.junit.Before; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryConfig; -import sonia.scm.repository.PreProcessorUtil; -import sonia.scm.repository.api.HookContextFactory; import sonia.scm.store.InMemoryConfigurationStoreFactory; -import static com.google.inject.util.Providers.of; -import static org.mockito.Mockito.mock; - /** * * @author Sebastian Sdorra @@ -69,7 +62,7 @@ public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase { if (context == null) { - context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create())); + context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), new GitConfig()); } return context; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java new file mode 100644 index 0000000000..4d73bef0f2 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java @@ -0,0 +1,129 @@ +/* + * 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.spi; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.repository.BrowserResult; +import sonia.scm.repository.FileObject; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.repository.spi.SyncAsyncExecutors.synchronousExecutor; + +@RunWith(MockitoJUnitRunner.class) +public class GitBrowseCommand_BrokenSubmoduleTest extends AbstractGitCommandTestBase { + + @Mock + private LfsBlobStoreFactory lfsBlobStoreFactory; + + private GitBrowseCommand command; + + @Before + public void createCommand() { + command = new GitBrowseCommand(createContext(), lfsBlobStoreFactory, synchronousExecutor()); + } + + @Test + public void testBrowse() throws IOException { + BrowserResult result = command.getBrowserResult(new BrowseCommandRequest()); + Collection children = result.getFile().getChildren(); + + List subrepos = subRepositoriesOnly(children); + assertThat(subrepos).containsExactly( + "anonymous-access", + "hasselhoffme", + "recipes", + "scm-redmine-plugin" + ); + + List directories = directoriesOnly(children); + assertThat(directories).containsExactly( + "dir", + "plugins" + ); + + List files = filesOnly(children); + assertThat(files) + .containsExactly( + ".gitmodules", + "README.md", + "test.txt" + ); + } + + @Test + public void testBrowseRecursive() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setRecursive(true); + BrowserResult result = command.getBrowserResult(request); + Collection children = result.getFile().getChildren(); + FileObject fileObject = children.stream().filter(f -> "plugins".equals(f.getPath())).findFirst().get(); + assertThat(fileObject.getChildren()).hasSize(3); + List subrepos = subRepositoriesOnly(fileObject.getChildren()); + assertThat(subrepos) + .containsExactly( + "plugins/scm-branchwp-plugin", + "plugins/scm-jira-plugin", + "plugins/statistic-plugin" + ); + } + + @Nonnull + private List filesOnly(Collection children) { + return children.stream().filter(f -> !f.isDirectory()).map(FileObject::getPath).collect(Collectors.toList()); + } + + @Nonnull + private List directoriesOnly(Collection children) { + return children.stream() + .filter(FileObject::isDirectory) + .filter(f -> f.getSubRepository() == null) + .map(FileObject::getPath) + .collect(Collectors.toList()); + } + + @Nonnull + private List subRepositoriesOnly(Collection children) { + return children.stream() + .filter(f -> f.getSubRepository() != null) + .map(FileObject::getPath) + .collect(Collectors.toList()); + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-broken-submodule-repo.zip"; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java index 5cfab8de24..270c548120 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java @@ -32,7 +32,7 @@ import org.junit.Ignore; import org.junit.Test; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; -import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitTestHelper; import sonia.scm.store.InMemoryConfigurationStoreFactory; @@ -99,7 +99,7 @@ public class GitIncomingCommandTest commit(outgoing, "added a"); - GitPullCommand pull = new GitPullCommand(handler, new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()))); + GitPullCommand pull = new GitPullCommand(handler, new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()), new GitConfig())); PullCommandRequest req = new PullCommandRequest(); req.setRemoteRepository(outgoingRepository); pull.pull(req); @@ -177,7 +177,7 @@ public class GitIncomingCommandTest private GitIncomingCommand createCommand() { return new GitIncomingCommand( - new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), + new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()), new GitConfig()), handler, GitTestHelper.createConverterFactory() ); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java index 1657191d21..28ddd0a234 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java @@ -27,6 +27,7 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Before; import org.junit.Test; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.Modifications; import java.io.File; @@ -42,8 +43,8 @@ public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { @Before public void init() { - incomingModificationsCommand = new GitModificationsCommand(new GitContext(incomingDirectory, incomingRepository, null)); - outgoingModificationsCommand = new GitModificationsCommand(new GitContext(outgoingDirectory, outgoingRepository, null)); + incomingModificationsCommand = new GitModificationsCommand(new GitContext(incomingDirectory, incomingRepository, null, new GitConfig())); + outgoingModificationsCommand = new GitModificationsCommand(new GitContext(outgoingDirectory, outgoingRepository, null, new GitConfig())); } @Test @@ -106,11 +107,11 @@ public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { } void pushOutgoingAndPullIncoming() throws IOException { - GitPushCommand cmd = new GitPushCommand(handler, new GitContext(outgoingDirectory, outgoingRepository, null)); + GitPushCommand cmd = new GitPushCommand(handler, new GitContext(outgoingDirectory, outgoingRepository, null, new GitConfig())); PushCommandRequest request = new PushCommandRequest(); request.setRemoteRepository(incomingRepository); cmd.push(request); - GitPullCommand pullCommand = new GitPullCommand(handler, new GitContext(incomingDirectory, incomingRepository, null)); + GitPullCommand pullCommand = new GitPullCommand(handler, new GitContext(incomingDirectory, incomingRepository, null, new GitConfig())); PullCommandRequest pullRequest = new PullCommandRequest(); pullRequest.setRemoteRepository(incomingRepository); pullCommand.pull(pullRequest); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java index 473356d8bf..e4e09c5bd5 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java @@ -24,61 +24,26 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.ShiroRule; -import com.github.sdorra.shiro.SubjectAware; import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.CanceledException; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.errors.CorruptObjectException; -import org.eclipse.jgit.lib.CommitBuilder; -import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.lib.ObjectReader; -import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.transport.CredentialsProvider; -import org.eclipse.jgit.treewalk.CanonicalTreeParser; -import org.junit.BeforeClass; -import org.junit.Rule; import org.junit.Test; -import org.junit.jupiter.api.BeforeEach; -import org.junit.rules.TemporaryFolder; import sonia.scm.AlreadyExistsException; import sonia.scm.BadRequestException; import sonia.scm.ConcurrentModificationException; import sonia.scm.NotFoundException; import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Person; -import sonia.scm.repository.work.NoneCachingWorkingCopyPool; -import sonia.scm.repository.work.WorkdirProvider; -import sonia.scm.security.PublicKey; -import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.File; import java.io.IOException; import java.nio.file.Files; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") -public class GitModifyCommandTest extends AbstractGitCommandTestBase { - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); - @Rule - public ShiroRule shiro = new ShiroRule(); - - private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); - - @BeforeClass - public static void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); - } +public class GitModifyCommandTest extends GitModifyCommandTestBase { @Test public void shouldCreateCommit() throws IOException, GitAPIException { @@ -362,30 +327,4 @@ public class GitModifyCommandTest extends AbstractGitCommandTestBase { assertThat(lastCommit.getRawGpgSignature()).isNullOrEmpty(); } } - - private void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { - try (Git git = new Git(createContext().open())) { - RevCommit lastCommit = getLastCommit(git); - try (RevWalk walk = new RevWalk(git.getRepository())) { - RevCommit commit = walk.parseCommit(lastCommit); - ObjectId treeId = commit.getTree().getId(); - try (ObjectReader reader = git.getRepository().newObjectReader()) { - assertions.checkAssertions(new CanonicalTreeParser(null, reader, treeId)); - } - } - } - } - - private RevCommit getLastCommit(Git git) throws GitAPIException { - return git.log().setMaxCount(1).call().iterator().next(); - } - - private GitModifyCommand createCommand() { - return new GitModifyCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())), lfsBlobStoreFactory); - } - - @FunctionalInterface - private interface TreeAssertions { - void checkAssertions(CanonicalTreeParser treeParser) throws CorruptObjectException; - } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java new file mode 100644 index 0000000000..a890a83255 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java @@ -0,0 +1,97 @@ +/* + * 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.spi; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.errors.CorruptObjectException; +import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import sonia.scm.repository.GitTestHelper; +import sonia.scm.repository.work.NoneCachingWorkingCopyPool; +import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import java.io.IOException; + +import static org.mockito.Mockito.mock; +import static sonia.scm.repository.spi.GitRepositoryConfigStoreProviderTestUtil.createGitRepositoryConfigStoreProvider; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") +class GitModifyCommandTestBase extends AbstractGitCommandTestBase { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Rule + public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); + @Rule + public ShiroRule shiro = new ShiroRule(); + + final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + + @BeforeClass + public static void setSigner() { + GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + } + + RevCommit getLastCommit(Git git) throws GitAPIException, IOException { + return git.log().setMaxCount(1).call().iterator().next(); + } + + GitModifyCommand createCommand() { + return new GitModifyCommand( + createContext(), + new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())), + lfsBlobStoreFactory, + createGitRepositoryConfigStoreProvider()); + } + + void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + try (RevWalk walk = new RevWalk(git.getRepository())) { + RevCommit commit = walk.parseCommit(lastCommit); + ObjectId treeId = commit.getTree().getId(); + try (ObjectReader reader = git.getRepository().newObjectReader()) { + assertions.checkAssertions(new CanonicalTreeParser(null, reader, treeId)); + } + } + } + } + + @FunctionalInterface + interface TreeAssertions { + void checkAssertions(CanonicalTreeParser treeParser) throws CorruptObjectException; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java index 6709505c90..955d16965b 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java @@ -24,22 +24,15 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.ShiroRule; -import com.github.sdorra.shiro.SubjectAware; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.AfterClass; import org.junit.BeforeClass; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; import sonia.scm.repository.Person; -import sonia.scm.repository.work.NoneCachingWorkingCopyPool; -import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.store.Blob; import sonia.scm.store.BlobStore; -import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.ByteArrayOutputStream; import java.io.File; @@ -51,17 +44,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") -public class GitModifyCommand_LFSTest extends AbstractGitCommandTestBase { - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); - @Rule - public ShiroRule shiro = new ShiroRule(); - - private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); +public class GitModifyCommand_LFSTest extends GitModifyCommandTestBase { @BeforeClass public static void registerFilter() { @@ -126,14 +109,6 @@ public class GitModifyCommand_LFSTest extends AbstractGitCommandTestBase { return command.execute(request); } - private RevCommit getLastCommit(Git git) throws GitAPIException { - return git.log().setMaxCount(1).call().iterator().next(); - } - - private GitModifyCommand createCommand() { - return new GitModifyCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())), lfsBlobStoreFactory); - } - @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-lfs-test.zip"; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java index 5898845a74..7877f1a686 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java @@ -24,42 +24,21 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.ShiroRule; -import com.github.sdorra.shiro.SubjectAware; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.errors.CorruptObjectException; -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.treewalk.CanonicalTreeParser; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; import sonia.scm.repository.Person; -import sonia.scm.repository.work.NoneCachingWorkingCopyPool; -import sonia.scm.repository.work.WorkdirProvider; -import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") -public class GitModifyCommand_withEmptyRepositoryTest extends AbstractGitCommandTestBase { - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); - @Rule - public ShiroRule shiro = new ShiroRule(); - - private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); +public class GitModifyCommand_withEmptyRepositoryTest extends GitModifyCommandTestBase { @Test public void shouldCreateNewFileInEmptyRepository() throws IOException, GitAPIException { @@ -79,34 +58,65 @@ public class GitModifyCommand_withEmptyRepositoryTest extends AbstractGitCommand assertInTree(assertions); } + @Test + public void shouldCreateCommitOnMasterByDefault() throws IOException, GitAPIException { + createContext().getGlobalConfig().setDefaultBranch(""); + + executeModifyCommand(); + + try (Git git = new Git(createContext().open())) { + List branches = git.branchList().call(); + assertThat(branches).extracting("name").containsExactly("refs/heads/master"); + } + } + + @Test + public void shouldCreateCommitWithConfiguredDefaultBranch() throws IOException, GitAPIException { + createContext().getGlobalConfig().setDefaultBranch("main"); + + executeModifyCommand(); + + try (Git git = new Git(createContext().open())) { + List branches = git.branchList().call(); + assertThat(branches).extracting("name").containsExactly("refs/heads/main"); + } + } + + @Test + public void shouldCreateCommitWithBranchFromRequestIfPresent() throws IOException, GitAPIException { + createContext().getGlobalConfig().setDefaultBranch("main"); + + ModifyCommandRequest request = createRequest(); + request.setBranch("different"); + createCommand().execute(request); + + try (Git git = new Git(createContext().open())) { + List branches = git.branchList().call(); + assertThat(branches).extracting("name").containsExactly("refs/heads/different"); + } + } + @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-empty-repo.zip"; } - private void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { - try (Git git = new Git(createContext().open())) { - RevCommit lastCommit = getLastCommit(git); - try (RevWalk walk = new RevWalk(git.getRepository())) { - RevCommit commit = walk.parseCommit(lastCommit); - ObjectId treeId = commit.getTree().getId(); - try (ObjectReader reader = git.getRepository().newObjectReader()) { - assertions.checkAssertions(new CanonicalTreeParser(null, reader, treeId)); - } - } - } + @Override + RevCommit getLastCommit(Git git) throws GitAPIException, IOException { + return git.log().setMaxCount(1).all().call().iterator().next(); } - private RevCommit getLastCommit(Git git) throws GitAPIException { - return git.log().setMaxCount(1).call().iterator().next(); + private void executeModifyCommand() throws IOException { + createCommand().execute(createRequest()); } - private GitModifyCommand createCommand() { - return new GitModifyCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())), lfsBlobStoreFactory); - } + private ModifyCommandRequest createRequest() throws IOException { + File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile(); - @FunctionalInterface - private interface TreeAssertions { - void checkAssertions(CanonicalTreeParser treeParser) throws CorruptObjectException; + ModifyCommandRequest request = new ModifyCommandRequest(); + request.setCommitMessage("initial commit"); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false)); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + return request; } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java index 22ccb94e1c..5609a222f9 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java @@ -31,7 +31,7 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; -import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitTestHelper; import sonia.scm.store.InMemoryConfigurationStoreFactory; @@ -99,7 +99,7 @@ public class GitOutgoingCommandTest extends AbstractRemoteCommandTestBase commit(outgoing, "added a"); GitPushCommand push = new GitPushCommand(handler, - new GitContext(outgoingDirectory, outgoingRepository, null) + new GitContext(outgoingDirectory, outgoingRepository, null, new GitConfig()) ); PushCommandRequest req = new PushCommandRequest(); @@ -154,7 +154,7 @@ public class GitOutgoingCommandTest extends AbstractRemoteCommandTestBase private GitOutgoingCommand createCommand() { return new GitOutgoingCommand( - new GitContext(outgoingDirectory, outgoingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), + new GitContext(outgoingDirectory, outgoingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()), new GitConfig()), handler, GitTestHelper.createConverterFactory() ); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java index 1c5796a44f..6deb3afe53 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java @@ -29,6 +29,7 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; +import sonia.scm.repository.GitConfig; import sonia.scm.repository.api.PushResponse; import java.io.IOException; @@ -89,6 +90,6 @@ public class GitPushCommandTest extends AbstractRemoteCommandTestBase */ private GitPushCommand createCommand() { - return new GitPushCommand(handler, new GitContext(outgoingDirectory, outgoingRepository, null)); + return new GitPushCommand(handler, new GitContext(outgoingDirectory, outgoingRepository, null, new GitConfig())); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryConfigStoreProviderTestUtil.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryConfigStoreProviderTestUtil.java new file mode 100644 index 0000000000..196b5258de --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryConfigStoreProviderTestUtil.java @@ -0,0 +1,47 @@ +/* + * 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.spi; + +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.Repository; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.InMemoryConfigurationStore; + +import java.util.HashMap; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitRepositoryConfigStoreProviderTestUtil { + + static GitRepositoryConfigStoreProvider createGitRepositoryConfigStoreProvider() { + GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider = mock(GitRepositoryConfigStoreProvider.class); + HashMap> storeMap = new HashMap<>(); + when(gitRepositoryConfigStoreProvider.get(any())).thenAnswer(invocation -> storeMap.computeIfAbsent(invocation.getArgument(0, Repository.class).getId(), id -> new InMemoryConfigurationStore<>())); + return gitRepositoryConfigStoreProvider; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-broken-submodule-repo.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-broken-submodule-repo.zip new file mode 100644 index 0000000000..e123c32400 Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-broken-submodule-repo.zip differ diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUserAgentProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUserAgentProvider.java index 82dc543230..109c80a09a 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUserAgentProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUserAgentProvider.java @@ -45,8 +45,7 @@ public class HgUserAgentProvider implements UserAgentProvider /** mercurial seems to use system encoding */ @VisibleForTesting - static UserAgent HG = UserAgent.builder("Mercurial").browser( - false).basicAuthenticationCharset( + static UserAgent HG = UserAgent.scmClient("Mercurial").basicAuthenticationCharset( Charset.defaultCharset()).build(); /** Field description */ diff --git a/scm-plugins/scm-svn-plugin/pom.xml b/scm-plugins/scm-svn-plugin/pom.xml index e78a8221bb..254fa6730d 100644 --- a/scm-plugins/scm-svn-plugin/pom.xml +++ b/scm-plugins/scm-svn-plugin/pom.xml @@ -90,6 +90,15 @@ + + + **/SCMSvnDiffGenerator.java + + diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnUtil.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnUtil.java index 26a851d1e2..7a8dd5e98d 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnUtil.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnUtil.java @@ -198,7 +198,7 @@ public final class SvnUtil Person.toPerson(entry.getAuthor()), entry.getMessage()); - if (revision > 0) + if (revision > 1) { changeset.getParents().add(String.valueOf(revision - 1)); } @@ -232,6 +232,7 @@ public final class SvnUtil * * @return */ + @SuppressWarnings("java:S1149") // we can not use StringBuild SVNXMLUtil requires StringBuffer public static String createErrorBody(SVNErrorCode errorCode) { StringBuffer xmlBuffer = new StringBuffer(); diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SCMSvnDiffGenerator.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SCMSvnDiffGenerator.java index a353ea489f..81e40023f6 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SCMSvnDiffGenerator.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SCMSvnDiffGenerator.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.spi; import de.regnis.q.sequence.line.diff.QDiffGenerator; @@ -56,6 +56,7 @@ import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.RandomAccessFile; +import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; import java.util.ArrayList; @@ -344,16 +345,17 @@ public class SCMSvnDiffGenerator implements ISvnDiffGenerator { String label1 = getLabel(newTargetString1, revision1); String label2 = getLabel(newTargetString2, revision2); - boolean shouldStopDisplaying = displayHeader(outputStream, displayPath, false, fallbackToAbsolutePath, SvnDiffCallback.OperationKind.Modified); visitedPaths.add(displayPath); if (useGitFormat) { displayGitDiffHeader(outputStream, SvnDiffCallback.OperationKind.Modified, getRelativeToRootPath(target, originalTarget1), getRelativeToRootPath(target, originalTarget2), null); - } - if (shouldStopDisplaying) { - return; + } else { + boolean shouldStopDisplaying = displayHeader(outputStream, displayPath, false, fallbackToAbsolutePath, SvnDiffCallback.OperationKind.Modified); + if (shouldStopDisplaying) { + return; + } } // if (useGitFormat) { @@ -374,9 +376,74 @@ public class SCMSvnDiffGenerator implements ISvnDiffGenerator { } } - displayPropertyChangesOn(useGitFormat ? getRelativeToRootPath(target, originalTarget1) : displayPath, outputStream); + if (useGitFormat) { + displayGitPropDiffValues(outputStream, propChanges, originalProps); + } else { + displayPropertyChangesOn(displayPath, outputStream); + displayPropDiffValues(outputStream, propChanges, originalProps); + } + } + + private void displayGitPropDiffValues(OutputStream outputStream, SVNProperties diff, SVNProperties baseProps) throws SVNException { + for (Iterator changedPropNames = diff.nameSet().iterator(); changedPropNames.hasNext(); ) { + String name = (String) changedPropNames.next(); + SVNPropertyValue originalValue = baseProps != null ? baseProps.getSVNPropertyValue(name) : null; + SVNPropertyValue newValue = diff.getSVNPropertyValue(name); + + try { + byte[] originalValueBytes = getPropertyAsBytes(originalValue, getEncoding(), true); + byte[] newValueBytes = getPropertyAsBytes(newValue, getEncoding(), true); + + if (originalValueBytes == null) { + originalValueBytes = new byte[0]; + } else { + originalValueBytes = maybeAppendEOL(originalValueBytes); + } + + boolean newValueHadEol = newValueBytes != null && newValueBytes.length > 0 && + (newValueBytes[newValueBytes.length - 1] == SVNProperty.EOL_CR_BYTES[0] || + newValueBytes[newValueBytes.length - 1] == SVNProperty.EOL_LF_BYTES[0]); + + if (newValueBytes == null) { + newValueBytes = new byte[0]; + } else { + newValueBytes = maybeAppendEOL(newValueBytes); + } + + QDiffUniGenerator.setup(); + Map properties = new SVNHashMap(); + + properties.put(QDiffGeneratorFactory.IGNORE_EOL_PROPERTY, Boolean.valueOf(getDiffOptions().isIgnoreEOLStyle())); + properties.put(QDiffGeneratorFactory.EOL_PROPERTY, new String(getEOL())); + properties.put(QDiffGeneratorFactory.HUNK_DELIMITER, "@@"); + if (getDiffOptions().isIgnoreAllWhitespace()) { + properties.put(QDiffGeneratorFactory.IGNORE_SPACE_PROPERTY, QDiffGeneratorFactory.IGNORE_ALL_SPACE); + } else if (getDiffOptions().isIgnoreAmountOfWhitespace()) { + properties.put(QDiffGeneratorFactory.IGNORE_SPACE_PROPERTY, QDiffGeneratorFactory.IGNORE_SPACE_CHANGE); + } + + QDiffGenerator generator = new QDiffUniGenerator(properties, ""); + StringWriter writer = new StringWriter(); + QDiffManager.generateTextDiff(new ByteArrayInputStream(originalValueBytes), new ByteArrayInputStream(newValueBytes), + null, writer, generator); + writer.flush(); + + String lines[] = writer.toString().split("\\r?\\n"); + displayString(outputStream, lines[0] + "\n"); + displayString(outputStream, " # property " + name + " has changed\n"); + for (int i=1; i< lines.length; i++) { + displayString(outputStream, lines[i] + "\n"); + } + + if (!newValueHadEol) { + displayString(outputStream, "\\ No newline at end of property"); + displayEOL(outputStream); + } + } catch (IOException e) { + wrapException(e); + } + } - displayPropDiffValues(outputStream, propChanges, originalProps); } private void throwBadRelativePathException(String displayPath, String relativeToPath) throws SVNException { @@ -1123,6 +1190,10 @@ public class SCMSvnDiffGenerator implements ISvnDiffGenerator { } private byte[] getPropertyAsBytes(SVNPropertyValue value, String encoding) { + return getPropertyAsBytes(value, encoding, false); + } + + private byte[] getPropertyAsBytes(SVNPropertyValue value, String encoding, boolean replaceBinary) { if (value == null) { return null; } @@ -1133,7 +1204,11 @@ public class SCMSvnDiffGenerator implements ISvnDiffGenerator { return value.getString().getBytes(); } } - return value.getBytes(); + if (replaceBinary) { + return String.format("Binary value (%s bytes)", value.getBytes().length).getBytes(); + } else { + return value.getBytes(); + } } private void displayMergeInfoDiff(OutputStream outputStream, String oldValue, String newValue) throws SVNException, IOException { diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnLogCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnLogCommand.java index 35f5ec1fec..d613f9284d 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnLogCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnLogCommand.java @@ -26,6 +26,7 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.Lists; import org.slf4j.Logger; @@ -76,6 +77,7 @@ public class SvnLogCommand extends AbstractSvnCommand implements LogCommand try { long revisioNumber = parseRevision(revision, repository); + Preconditions.checkArgument(revisioNumber > 0, "revision must be greater than zero: %d", revisioNumber); SVNRepository repo = open(); Collection entries = repo.log(null, null, revisioNumber, revisioNumber, true, true); diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnUserAgentProvider.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnUserAgentProvider.java index b5eb5c32e1..45b4e651ff 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnUserAgentProvider.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnUserAgentProvider.java @@ -49,13 +49,13 @@ public final class SvnUserAgentProvider implements UserAgentProvider /** TortoiseSVN */ @VisibleForTesting static final UserAgent TORTOISE_SVN = - UserAgent.builder("TortoiseSVN").browser(false) + UserAgent.scmClient("TortoiseSVN") .basicAuthenticationCharset(Charsets.UTF_8).build(); /** Subversion cli client */ @VisibleForTesting static final UserAgent SVN = - UserAgent.builder("Subversion").browser(false) + UserAgent.scmClient("Subversion") .basicAuthenticationCharset(Charsets.UTF_8).build(); //~--- methods -------------------------------------------------------------- diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnDiffCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnDiffCommandTest.java new file mode 100644 index 0000000000..e7a46b18be --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnDiffCommandTest.java @@ -0,0 +1,225 @@ +/* + * 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.spi; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.tmatesoft.svn.core.SVNDepth; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNPropertyValue; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNRevision; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.DiffFormat; + +import javax.annotation.Nonnull; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SvnDiffCommandTest { + + // the smallest gif of the world + private static final String GIF = "R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + + private final SVNClientManager client = SVNClientManager.newInstance(); + + private File repository; + private File workingCopy; + + @BeforeEach + void setUpDirectories(@TempDir Path directory) { + repository = directory.resolve("repository").toFile(); + workingCopy = directory.resolve("working-copy").toFile(); + } + + @Test + void shouldCreateGitCompatibleDiffForSinglePropChanges() throws SVNException, IOException { + createRepository(); + commitProperty("scm:awesome", "shit"); + + String diff = gitDiff("1"); + + assertThat(diff).isEqualToIgnoringNewLines(String.join("\n", + "diff --git a/ b/", + "--- a/", + "+++ b/", + "@@ -0,0 +1 @@", + " # property scm:awesome has changed", + "+shit", + "\\ No newline at end of property" + )); + } + + @Test + void shouldCreateGitCompatibleDiffForPropChanges() throws SVNException, IOException { + createRepository(); + commitProperties(ImmutableMap.of("one", "eins", "two", "zwei", "three", "drei")); + + String diff = gitDiff("1"); + + assertThat(diff).isEqualToIgnoringNewLines(String.join("\n", + "diff --git a/ b/", + "--- a/", + "+++ b/", + "@@ -0,0 +1 @@", + " # property one has changed", + "+eins", + "\\ No newline at end of property", + "@@ -0,0 +1 @@", + " # property two has changed", + "+zwei", + "\\ No newline at end of property", + "@@ -0,0 +1 @@", + " # property three has changed", + "+drei", + "\\ No newline at end of property" + )); + } + + @Test + void shouldCreateGitCompatibleDiffForModifiedProp() throws SVNException, IOException { + createRepository(); + commitProperty("scm:spaceship", "Razor Crest"); + commitProperty("scm:spaceship", "Heart Of Gold"); + + String diff = gitDiff("2"); + + assertThat(diff).isEqualToIgnoringNewLines(String.join("\n", + "diff --git a/ b/", + "--- a/", + "+++ b/", + "@@ -1 +1 @@", + " # property scm:spaceship has changed", + "-Razor Crest", + "+Heart Of Gold", + "\\ No newline at end of property" + )); + } + + @Test + void shouldCreateGitCompatibleDiffForBinaryProps() throws SVNException, IOException { + createRepository(); + + byte[] gif = Base64.getDecoder().decode(GIF); + commitProperty("scm:gif", gif); + + String diff = gitDiff("1"); + + assertThat(diff).isEqualToIgnoringNewLines(String.join("\n", + "diff --git a/ b/", + "--- a/", + "+++ b/", + "@@ -0,0 +1 @@", + " # property scm:gif has changed", + "+Binary value (43 bytes)", + "\\ No newline at end of property" + )); + } + + @Nonnull + private String gitDiff(String revision) throws IOException { + SvnDiffCommand command = createCommand(); + DiffCommandRequest request = new DiffCommandRequest(); + request.setFormat(DiffFormat.GIT); + request.setRevision(revision); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + command.getDiffResult(request).accept(baos); + return baos.toString(); + } + + private SvnDiffCommand createCommand() { + return new SvnDiffCommand(new SvnContext(RepositoryTestData.createHeartOfGold(), repository)); + } + + private void commitProperty(String name, String value) throws SVNException { + setProperty(name, SVNPropertyValue.create(value)); + commit("set property " + name + " = " + value); + } + + private void commitProperty(String name, byte[] value) throws SVNException { + commitProperty(name, SVNPropertyValue.create(name, value)); + } + + private void commitProperty(String name, SVNPropertyValue value) throws SVNException { + setProperty(name, value); + commit("set property " + name + " = " + value); + } + + private void commit(String message) throws SVNException { + client.getCommitClient().doCommit( + new File[]{workingCopy}, + false, + message, + null, + null, + false, + false, + SVNDepth.UNKNOWN + ); + } + + private void setProperty(String name, SVNPropertyValue value) throws SVNException { + client.getWCClient().doSetProperty( + workingCopy, + name, + value, + true, + SVNDepth.UNKNOWN, + null, + null + ); + } + + private void commitProperties(Map properties) throws SVNException { + for (Map.Entry e : properties.entrySet()) { + setProperty(e.getKey(), SVNPropertyValue.create(e.getValue())); + } + commit("set " + properties.size() + " properties"); + } + + private void createRepository() throws SVNException { + SVNURL url = SVNRepositoryFactory.createLocalRepository(repository, true, false); + client.getUpdateClient().doCheckout( + url, + workingCopy, + SVNRevision.HEAD, + SVNRevision.HEAD, + SVNDepth.INFINITY, + true + ); + } + +} diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java index cab001573b..b758d7828c 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java @@ -32,10 +32,9 @@ import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.Modifications; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import java.util.stream.StreamSupport; + +import static org.junit.Assert.*; //~--- JDK imports ------------------------------------------------------------ @@ -56,11 +55,36 @@ public class SvnLogCommandTest extends AbstractSvnCommandTestBase assertEquals(5, result.getChangesets().size()); } + @Test + public void shouldNotReturnChangesetWithIdZero() { + ChangesetPagingResult result = createCommand().getChangesets(new LogCommandRequest()); + boolean found = StreamSupport.stream(result.spliterator(), false).anyMatch(c -> "0".equals(c.getId())); + assertFalse(found); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowIllegalArgumentExceptionForChangesetZero() { + createCommand().getChangeset("0", new LogCommandRequest()); + } + + @Test + public void shouldNotReturnChangesetZeroAsParent() { + Changeset changeset = createCommand().getChangeset("1", new LogCommandRequest()); + assertTrue(changeset.getParents().isEmpty()); + } + + @Test + public void shouldAppendParentChangeset() { + Changeset changeset = createCommand().getChangeset("2", new LogCommandRequest()); + assertEquals(1, changeset.getParents().size()); + assertEquals("1", changeset.getParents().get(0)); + } + @Test public void testShouldStartWithRevisionOne() { ChangesetPagingResult result = createCommand().getChangesets(new LogCommandRequest()); Changeset first = Iterables.getLast(result); - assertEquals(first.getId(), "1"); + assertEquals("1", first.getId()); } @Test diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 7c552c9c62..f2fd0d93d8 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -50436,7 +50436,7 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = ` > void; passwordValidator?: (p: string) => boolean; + onReturnPressed?: () => void; }; class PasswordConfirmation extends React.Component { @@ -57,7 +58,7 @@ class PasswordConfirmation extends React.Component { } render() { - const { t } = this.props; + const { t, onReturnPressed } = this.props; return (
@@ -78,6 +79,7 @@ class PasswordConfirmation extends React.Component { value={this.state ? this.state.confirmedPassword : ""} validationError={this.state.passwordConfirmationFailed} errorMessage={t("password.passwordConfirmFailed")} + onReturnPressed={onReturnPressed} />
diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx index 80722eecfb..5ad24bd763 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx @@ -44,7 +44,7 @@ class RepositoryEntry extends React.Component { renderBranchesLink = (repository: Repository, repositoryLink: string) => { if (repository._links["branches"]) { - return ; + return ; } return null; }; diff --git a/scm-ui/ui-components/src/validation.ts b/scm-ui/ui-components/src/validation.ts index fcdb1a6dfd..9e2b372dcb 100644 --- a/scm-ui/ui-components/src/validation.ts +++ b/scm-ui/ui-components/src/validation.ts @@ -28,6 +28,12 @@ export const isNameValid = (name: string) => { return nameRegex.test(name); }; +export const branchRegex = /^[\w-,;\]{}@&+=$#`|<>]([\w-,;\]{}@&+=$#`|<>/.]*[\w-,;\]{}@&+=$#`|<>])?$/; + +export const isBranchValid = (name: string) => { + return branchRegex.test(name); +}; + const mailRegex = /^[ -~]+@[A-Za-z0-9][\w\-.]*\.[A-Za-z0-9][A-Za-z0-9-]+$/; export const isMailValid = (mail: string) => { diff --git a/scm-ui/ui-scripts/package.json b/scm-ui/ui-scripts/package.json index d106b6da46..c91b7be07a 100644 --- a/scm-ui/ui-scripts/package.json +++ b/scm-ui/ui-scripts/package.json @@ -14,7 +14,7 @@ "babel-loader": "^8.0.6", "css-loader": "^3.2.0", "file-loader": "^4.2.0", - "mini-css-extract-plugin": "^0.11.0", + "mini-css-extract-plugin": "^0.12.0", "mustache": "^3.1.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "react-refresh": "^0.8.0", diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index 92b678e491..f12358aff7 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -45,6 +45,7 @@ export type Config = { pluginUrl: string; loginAttemptLimitTimeout: number; enabledXsrfProtection: boolean; + enabledUserConverter: boolean; namespaceStrategy: string; loginInfoUrl: string; releaseFeedUrl: string; diff --git a/scm-ui/ui-types/src/User.ts b/scm-ui/ui-types/src/User.ts index 29009b5d0d..fd2314144b 100644 --- a/scm-ui/ui-types/src/User.ts +++ b/scm-ui/ui-types/src/User.ts @@ -39,5 +39,6 @@ export type User = { type?: string; creationDate?: string; lastModified?: string; + external: boolean; _links: Links; }; diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 24830f2f4d..04cf9ff4c1 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -49,6 +49,7 @@ "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback E-Mail Domain Name", "enabled-xsrf-protection": "XSRF Protection aktivieren", + "enabled-user-converter": "Benutzer Konverter aktivieren", "namespace-strategy": "Namespace Strategie", "login-info-url": "Login Info URL" }, @@ -81,6 +82,7 @@ "proxyUserHelpText": "Der Benutzername für die Proxy Server Anmeldung.", "proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.", "enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.", + "enabledUserConverterHelpText": "Benutzer Konverter aktivieren. Interne Benutzer werden beim Einloggen über ein Fremdsystem zu externen Benutzern konvertiert.", "nameSpaceStrategyHelpText": "Strategie für Namespaces.", "loginInfoUrlHelpText": "URL zu der Login Information (Plugin und Feature Tipps auf der Login Seite). Um die Login Information zu deaktivieren, kann das Feld leer gelassen werden." } diff --git a/scm-ui/ui-webapp/public/locales/de/users.json b/scm-ui/ui-webapp/public/locales/de/users.json index 0daa4b5c9a..80d251e6b3 100644 --- a/scm-ui/ui-webapp/public/locales/de/users.json +++ b/scm-ui/ui-webapp/public/locales/de/users.json @@ -6,6 +6,7 @@ "password": "Passwort", "active": "Aktiv", "inactive": "Inaktiv", + "externalFlag": "Extern", "type": "Typ", "creationDate": "Erstellt", "lastModified": "Zuletzt bearbeitet" @@ -20,7 +21,8 @@ "displayNameHelpText": "Anzeigename des Benutzers", "mailHelpText": "E-Mail Adresse des Benutzers", "adminHelpText": "Ein Administrator kann Repositories, Gruppen und Benutzer erstellen, bearbeiten und löschen.", - "activeHelpText": "Aktivierung oder Deaktivierung eines Benutzers" + "activeHelpText": "Aktivierung oder Deaktivierung eines Benutzers", + "externalFlagHelpText": "Der Benutzer wird über ein Fremdsystem verwaltet." }, "users": { "title": "Benutzer", @@ -61,7 +63,17 @@ }, "userForm": { "subtitle": "Benutzer bearbeiten", - "button": "Speichern" + "userIsInternal": "Der Benutzer wird intern vom SCM-Manager verwaltet", + "userIsExternal": "Der Benutzer wird von einem externen System verwaltet", + "button": { + "submit": "Speichern", + "convertToExternal": "Zu externem Benutzer konvertieren", + "convertToInternal": "Zu internem Benutzer konvertieren" + }, + "modal": { + "passwordRequired": "Neues Passwort für internen Benutzer setzen", + "convertToInternal": "Zu internem Benutzer konvertieren" + } }, "publicKey": { "noStoredKeys": "Es wurden keine Schlüssel gefunden.", diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 6cb06ecb25..9b332a2013 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -49,6 +49,7 @@ "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback Mail Domain Name", "enabled-xsrf-protection": "Enabled XSRF Protection", + "enabled-user-converter": "Enabled User Converter", "namespace-strategy": "Namespace Strategy", "login-info-url": "Login Info URL" }, @@ -81,6 +82,7 @@ "proxyUserHelpText": "The username for the proxy server authentication.", "proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.", "enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.", + "enabledUserConverterHelpText": "Enable User Converter. Internal users will automatically be converted to external on their first login using an external system.", "nameSpaceStrategyHelpText": "The namespace strategy.", "loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page). If this is omitted, no login information will be displayed." } diff --git a/scm-ui/ui-webapp/public/locales/en/users.json b/scm-ui/ui-webapp/public/locales/en/users.json index 8effc6c589..627e2b80aa 100644 --- a/scm-ui/ui-webapp/public/locales/en/users.json +++ b/scm-ui/ui-webapp/public/locales/en/users.json @@ -6,6 +6,7 @@ "password": "Password", "active": "Active", "inactive": "Inactive", + "externalFlag": "External", "type": "Type", "creationDate": "Creation Date", "lastModified": "Last Modified" @@ -20,7 +21,8 @@ "displayNameHelpText": "Display name of the user.", "mailHelpText": "Email address of the user.", "adminHelpText": "An administrator is able to create, modify and delete repositories, groups and users.", - "activeHelpText": "Activate or deactivate the user." + "activeHelpText": "Activate or deactivate the user.", + "externalFlagHelpText": "This user is managed by an external system." }, "users": { "title": "Users", @@ -61,7 +63,17 @@ }, "userForm": { "subtitle": "Edit User", - "button": "Submit" + "userIsInternal": "This user is managed internally by SCM-Manager", + "userIsExternal": "This user is managed by an external system", + "button": { + "submit": "Submit", + "convertToExternal": "Convert user to external", + "convertToInternal": "Convert user to internal" + }, + "modal": { + "passwordRequired": "Set new password for internal user", + "convertToInternal": "Convert to internal" + } }, "publicKey": { "noStoredKeys": "No keys found.", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index c284df3180..cb7fe0a14b 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -72,6 +72,7 @@ class ConfigForm extends React.Component { pluginUrl: "", loginAttemptLimitTimeout: 0, enabledXsrfProtection: true, + enabledUserConverter: false, namespaceStrategy: "", loginInfoUrl: "", _links: {} @@ -146,6 +147,7 @@ class ConfigForm extends React.Component { releaseFeedUrl={config.releaseFeedUrl} mailDomainName={config.mailDomainName} enabledXsrfProtection={config.enabledXsrfProtection} + enabledUserConverter={config.enabledUserConverter} namespaceStrategy={config.namespaceStrategy} onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)} hasUpdatePermission={configUpdatePermission} diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index e9ed50a3b5..41a62298e0 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -38,6 +38,7 @@ type Props = WithTranslation & { releaseFeedUrl: string; mailDomainName: string; enabledXsrfProtection: boolean; + enabledUserConverter: boolean; namespaceStrategy: string; namespaceStrategies?: NamespaceStrategies; onChange: (p1: boolean, p2: any, p3: string) => void; @@ -54,6 +55,7 @@ class GeneralSettings extends React.Component { releaseFeedUrl, mailDomainName, enabledXsrfProtection, + enabledUserConverter, anonymousMode, namespaceStrategy, hasUpdatePermission, @@ -140,6 +142,18 @@ class GeneralSettings extends React.Component { helpText={t("help.releaseFeedUrlHelpText")} /> +
+ +
+ +
{ handleEnabledXsrfProtectionChange = (value: boolean) => { this.props.onChange(true, value, "enabledXsrfProtection"); }; + handleEnabledUserConverterChange = (value: boolean) => { + this.props.onChange(true, value, "enabledUserConverter"); + }; handleAnonymousMode = (value: string) => { this.props.onChange(true, value, "anonymousMode"); }; diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchForm.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchForm.tsx index 35d2a42bd9..e4d9864c1f 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchForm.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchForm.tsx @@ -124,15 +124,13 @@ class BranchForm extends React.Component { handleSourceChange = (source: string) => { this.setState({ - ...this.state, source }); }; handleNameChange = (name: string) => { this.setState({ - nameValidationError: !validator.isNameValid(name), - ...this.state, + nameValidationError: !validator.isBranchValid(name), name }); }; diff --git a/scm-ui/ui-webapp/src/users/components/UserConverter.tsx b/scm-ui/ui-webapp/src/users/components/UserConverter.tsx new file mode 100644 index 0000000000..4b2f0f0fa7 --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/UserConverter.tsx @@ -0,0 +1,137 @@ +/* + * 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 { + Button, + Modal, + PasswordConfirmation, + SubmitButton, + ErrorNotification, + Level +} from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { Link, User } from "@scm-manager/ui-types"; +import { convertToExternal, convertToInternal } from "./convertUser"; +import styled from "styled-components"; + +const ExternalDescription = styled.div` + display: flex; + align-items: center; + font-weight: 400; +`; + +type Props = { + user: User; + fetchUser: (user: User) => void; +}; + +const UserConverter: FC = ({ user, fetchUser }) => { + const [t] = useTranslation("users"); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [password, setPassword] = useState(""); + const [passwordValid, setPasswordValid] = useState(false); + const [error, setError] = useState(); + + const toInternal = () => { + convertToInternal((user._links.convertToInternal as Link).href, password) + .then(() => fetchUser(user)) + .then(() => setShowPasswordModal(false)) + .catch(setError); + }; + + const toExternal = () => { + convertToExternal((user._links.convertToExternal as Link).href) + .then(() => fetchUser(user)) + .catch(setError); + }; + + const changePassword = (password: string, valid: boolean) => { + setPassword(password); + setPasswordValid(valid); + }; + + const getUserExternalDescription = () => { + if (user.external) { + return t("userForm.userIsExternal"); + } else { + return t("userForm.userIsInternal"); + } + }; + + const getConvertButton = () => { + if (user.external) { + return ( +
); - - passwordChangeField = ; } else { // edit existing user subtitle = ; @@ -179,18 +179,37 @@ class UserForm extends React.Component { />
- {passwordChangeField} -
-
- -
-
- } /> + {!this.props.user && ( + <> +
+
+ +
+
+ + )} + {!user.external && ( + <> + {!this.props.user && passwordChangeField} +
+
+ +
+
+ + )} + {error && } + } /> ); @@ -232,7 +251,7 @@ class UserForm extends React.Component { ...this.state.user, password }, - passwordValid: !this.isFalsy(password) && passwordValid + passwordValid: !!password && passwordValid }); }; @@ -244,6 +263,15 @@ class UserForm extends React.Component { } }); }; + + handleExternalChange = (external: boolean) => { + this.setState({ + user: { + ...this.state.user, + external + } + }); + }; } export default withTranslation("users")(UserForm); diff --git a/scm-ui/ui-webapp/src/users/components/convertUser.ts b/scm-ui/ui-webapp/src/users/components/convertUser.ts new file mode 100644 index 0000000000..a37c8879c9 --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/convertUser.ts @@ -0,0 +1,46 @@ +/* + * 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 { apiClient } from "@scm-manager/ui-components"; +import { CONTENT_TYPE_USER } from "../modules/users"; + +export function convertToInternal(url: string, newPassword: string) { + return apiClient + .put( + url, + { + newPassword + }, + CONTENT_TYPE_USER + ) + .then(response => { + return response; + }); +} + +export function convertToExternal(url: string) { + return apiClient.put(url, {}, CONTENT_TYPE_USER).then(response => { + return response; + }); +} diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx b/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx index 978c78b833..194bf71c18 100644 --- a/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx +++ b/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx @@ -42,7 +42,8 @@ class ChangePasswordNavLink extends React.Component { } hasPermissionToSetPassword = () => { - return this.props.user._links.password; + const { user } = this.props; + return user._links.password; }; } diff --git a/scm-ui/ui-webapp/src/users/components/table/Details.tsx b/scm-ui/ui-webapp/src/users/components/table/Details.tsx index 18df0cdf49..8b94e36ff2 100644 --- a/scm-ui/ui-webapp/src/users/components/table/Details.tsx +++ b/scm-ui/ui-webapp/src/users/components/table/Details.tsx @@ -62,8 +62,10 @@ class Details extends React.Component { - {t("user.type")} - {user.type} + {t("user.externalFlag")} + + + {t("user.creationDate")} diff --git a/scm-ui/ui-webapp/src/users/containers/EditUser.tsx b/scm-ui/ui-webapp/src/users/containers/EditUser.tsx index 895ced6337..b863d2f1c9 100644 --- a/scm-ui/ui-webapp/src/users/containers/EditUser.tsx +++ b/scm-ui/ui-webapp/src/users/containers/EditUser.tsx @@ -27,10 +27,17 @@ import { withRouter } from "react-router-dom"; import UserForm from "../components/UserForm"; import DeleteUser from "./DeleteUser"; import { User } from "@scm-manager/ui-types"; -import { getModifyUserFailure, isModifyUserPending, modifyUser, modifyUserReset } from "../modules/users"; +import { + fetchUserByLink, + getModifyUserFailure, + isModifyUserPending, + modifyUser, + modifyUserReset +} from "../modules/users"; import { History } from "history"; import { ErrorNotification } from "@scm-manager/ui-components"; import { compose } from "redux"; +import UserConverter from "../components/UserConverter"; type Props = { loading: boolean; @@ -39,6 +46,7 @@ type Props = { // dispatch functions modifyUser: (user: User, callback?: () => void) => void; modifyUserReset: (p: User) => void; + fetchUser: (user: User) => void; // context objects user: User; @@ -64,7 +72,9 @@ class EditUser extends React.Component { return (
- this.modifyUser(user)} user={user} loading={loading} /> + +
+
); @@ -87,6 +97,9 @@ const mapDispatchToProps = (dispatch: any) => { }, modifyUserReset: (user: User) => { dispatch(modifyUserReset(user)); + }, + fetchUser: (user: User) => { + dispatch(fetchUserByLink(user)); } }; }; diff --git a/scm-ui/ui-webapp/src/users/modules/users.ts b/scm-ui/ui-webapp/src/users/modules/users.ts index 28e0e59fe2..1648090cc1 100644 --- a/scm-ui/ui-webapp/src/users/modules/users.ts +++ b/scm-ui/ui-webapp/src/users/modules/users.ts @@ -56,7 +56,7 @@ export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`; export const DELETE_USER_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`; export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`; -const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2"; +export const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2"; // TODO i18n for error messages diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index ce26d72a9d..5874113e55 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -210,7 +210,7 @@ org.hibernate.validator hibernate-validator - 6.1.5.Final + 6.1.6.Final diff --git a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java index c846b166b2..692dad457e 100644 --- a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java +++ b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java @@ -46,8 +46,6 @@ public class ManagerDaoAdapter { if (notModified != null) { permissionCheck.apply(notModified).check(); - doThrow().violation("type must not be changed").when(!notModified.getType().equals(object.getType())); - AssertUtil.assertIsValid(object); beforeUpdate.handle(notModified); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java index db144c18b2..4825291c1b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java @@ -35,16 +35,14 @@ import org.hibernate.validator.constraints.Length; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; +import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES; + @Getter @Setter @NoArgsConstructor @SuppressWarnings("java:S2160") // we do not need this for dto public class BranchDto extends HalRepresentation { - private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>"; - private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/."; - static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?"; - @NotEmpty @Length(min = 1, max = 100) @Pattern(regexp = VALID_BRANCH_NAMES) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRequestDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRequestDto.java index c81aaf18c7..f56de8c38b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRequestDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRequestDto.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import lombok.Getter; @@ -31,7 +31,7 @@ import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; -import static sonia.scm.api.v2.resources.BranchDto.VALID_BRANCH_NAMES; +import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES; @Getter @Setter diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index f7025f141f..2b7249c7e7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -56,6 +56,7 @@ public class ConfigDto extends HalRepresentation { private String pluginUrl; private long loginAttemptLimitTimeout; private boolean enabledXsrfProtection; + private boolean enabledUserConverter; private String namespaceStrategy; private String loginInfoUrl; private String releaseFeedUrl; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java index ff3914654d..f51be4ae11 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -102,7 +102,7 @@ public class MeDtoFactory extends HalAppenderMapper { if (UserPermissions.changePublicKeys(user).isPermitted()) { linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName()))); } - if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { + if (!user.isExternal() && UserPermissions.changePassword(user).isPermitted()) { linksBuilder.single(link("password", resourceLinks.me().passwordChange())); } if (UserPermissions.changeApiKeys(user).isPermitted()) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 684978b0ab..c826ee8e5e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -123,6 +123,14 @@ class ResourceLinks { return userLinkBuilder.method("getUserResource").parameters(name).method("overwritePassword").parameters().href(); } + public String toExternal(String name) { + return userLinkBuilder.method("getUserResource").parameters(name).method("toExternal").parameters().href(); + } + + public String toInternal(String name) { + return userLinkBuilder.method("getUserResource").parameters(name).method("toInternal").parameters().href(); + } + public String publicKeys(String name) { return publicKeyLinkBuilder.method("findAll").parameters(name).href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java index 027cc59621..b1eaa1d7a6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java @@ -41,6 +41,7 @@ import java.time.Instant; @NoArgsConstructor @Getter @Setter public class UserDto extends HalRepresentation { private boolean active; + private boolean external; private Instant creationDate; @NotEmpty private String displayName; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index ba5e027bf4..679224cde8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -186,6 +186,71 @@ public class UserResource { return Response.noContent().build(); } + /** + * This Endpoint is for Admin user to convert external user to internal. + * The oldPassword property of the DTO is not needed here. it will be ignored. + * The oldPassword property is needed in the MeResources when the actual user change the own password. + * + * Note: This method requires "user:modify" privilege to modify the password of other users. + * + * @param name name of the user to be modified + * @param passwordOverwrite change password object to modify password. the old password is here not required + */ + @PUT + @Path("convert-to-internal") + @Consumes(VndMediaType.USER) + @Operation(summary = "Converts an external user to internal", description = "Converts an external user to an internal one and set the new password.", tags = "User") + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "400", description = "invalid body, e.g. the new password is missing") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege") + @ApiResponse( + responseCode = "404", + description = "not found, no user with the specified id/name available", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "500", description = "internal server error") + public Response toInternal(@PathParam("id") String name, @Valid PasswordOverwriteDto passwordOverwrite) { + UserDto dto = userToDtoMapper.map(userManager.get(name)); + dto.setExternal(false); + adapter.update(name, existing -> dtoToUserMapper.map(dto, existing.getPassword())); + userManager.overwritePassword(name, passwordService.encryptPassword(passwordOverwrite.getNewPassword())); + return Response.noContent().build(); + } + + /** + * This Endpoint is for Admin user to convert internal user to external. + * + * Note: This method requires "user:modify" privilege to modify the password of other users. + * + * @param name name of the user to be modified + */ + @PUT + @Path("convert-to-external") + @Consumes(VndMediaType.USER) + @Operation(summary = "Converts an internal user to external", description = "Converts an internal user to an external one and removes the local password.", tags = "User") + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "400", description = "invalid body, e.g. the new password is missing") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege") + @ApiResponse( + responseCode = "404", + description = "not found, no user with the specified id/name available", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "500", description = "internal server error") + public Response toExternal(@PathParam("id") String name) { + userManager.overwritePassword(name, null); + UserDto dto = userToDtoMapper.map(userManager.get(name)); + dto.setExternal(true); + adapter.update(name, existing -> dtoToUserMapper.map(dto, existing.getPassword())); + return Response.noContent().build(); + } + @Path("permissions") public UserPermissionResource permissions() { return userPermissionResource; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index 761de187f1..97aca8c1fb 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.Embedded; @@ -66,8 +66,11 @@ public abstract class UserToUserDtoMapper extends BaseMapper { if (UserPermissions.modify(user).isPermitted()) { linksBuilder.single(link("update", resourceLinks.user().update(user.getName()))); linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName()))); - if (userManager.isTypeDefault(user)) { + if (user.isExternal()) { + linksBuilder.single(link("convertToInternal", resourceLinks.user().toInternal(user.getName()))); + } else { linksBuilder.single(link("password", resourceLinks.user().passwordChange(user.getName()))); + linksBuilder.single(link("convertToExternal", resourceLinks.user().toExternal(user.getName()))); } } if (PermissionPermissions.read().isPermitted()) { diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java index af95c09aa8..1f47f03686 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java @@ -30,6 +30,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; +import sonia.scm.group.Group; +import sonia.scm.group.GroupManager; import sonia.scm.plugin.Extension; import sonia.scm.security.AnonymousMode; import sonia.scm.security.PermissionAssigner; @@ -44,6 +46,8 @@ import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import java.util.Collections; +import static sonia.scm.group.GroupCollector.AUTHENTICATED; + @Extension public class SetupContextListener implements ServletContextListener { @@ -75,13 +79,18 @@ public class SetupContextListener implements ServletContextListener { private final PasswordService passwordService; private final PermissionAssigner permissionAssigner; private final ScmConfiguration scmConfiguration; + private final GroupManager groupManager; + + @VisibleForTesting + static final String AUTHENTICATED_GROUP_DESCRIPTION = "Includes all authenticated users"; @Inject - public SetupAction(UserManager userManager, PasswordService passwordService, PermissionAssigner permissionAssigner, ScmConfiguration scmConfiguration) { + public SetupAction(UserManager userManager, PasswordService passwordService, PermissionAssigner permissionAssigner, ScmConfiguration scmConfiguration, GroupManager groupManager) { this.userManager = userManager; this.passwordService = passwordService; this.permissionAssigner = permissionAssigner; this.scmConfiguration = scmConfiguration; + this.groupManager = groupManager; } @Override @@ -92,6 +101,10 @@ public class SetupContextListener implements ServletContextListener { if (anonymousUserRequiredButNotExists()) { userManager.create(SCMContext.ANONYMOUS); } + + if (authenticatedGroupDoesNotExists()) { + createAuthenticatedGroup(); + } } private boolean anonymousUserRequiredButNotExists() { @@ -115,5 +128,16 @@ public class SetupContextListener implements ServletContextListener { PermissionDescriptor descriptor = new PermissionDescriptor("*"); permissionAssigner.setPermissionsForUser("scmadmin", Collections.singleton(descriptor)); } + + private boolean authenticatedGroupDoesNotExists() { + return groupManager.get(AUTHENTICATED) == null; + } + + private void createAuthenticatedGroup() { + Group authenticated = new Group("xml", AUTHENTICATED); + authenticated.setDescription(AUTHENTICATED_GROUP_DESCRIPTION); + authenticated.setExternal(true); + groupManager.create(authenticated); + } } } diff --git a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java index b6bb822e5a..00b0216818 100644 --- a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java +++ b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java @@ -389,8 +389,8 @@ public class DefaultUserManager extends AbstractUserManager if (user == null) { throw new NotFoundException(User.class, userId); } - if (!isTypeDefault(user) || isAnonymousUser(user)) { - throw new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("PasswordChange", "-").in(User.class, user.getName()), user.getType()); + if (isAnonymousUser(user) || user.isExternal()) { + throw new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("PasswordChange", "-").in(User.class, user.getName()), "external"); } user.setPassword(newPassword); this.modify(user); diff --git a/scm-webapp/src/main/java/sonia/scm/user/InternalToExternalUserConverter.java b/scm-webapp/src/main/java/sonia/scm/user/InternalToExternalUserConverter.java new file mode 100644 index 0000000000..b87e33bf78 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/InternalToExternalUserConverter.java @@ -0,0 +1,56 @@ +/* + * 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.user; + +import lombok.extern.slf4j.Slf4j; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; + +@Slf4j +@Extension +public class InternalToExternalUserConverter implements ExternalUserConverter{ + + private final ScmConfiguration scmConfiguration; + + @Inject + public InternalToExternalUserConverter(ScmConfiguration scmConfiguration) { + this.scmConfiguration = scmConfiguration; + } + + public User convert(User user) { + if (shouldConvertUser(user)) { + log.info("Convert internal user {} to external", user.getId()); + user.setExternal(true); + user.setPassword(null); + } + return user; + } + + private boolean shouldConvertUser(User user) { + return !user.isExternal() && scmConfiguration.isEnabledUserConverter(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/BrowserUserAgentProvider.java b/scm-webapp/src/main/java/sonia/scm/web/BrowserUserAgentProvider.java index 60650ff927..e9725b41d7 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/BrowserUserAgentProvider.java +++ b/scm-webapp/src/main/java/sonia/scm/web/BrowserUserAgentProvider.java @@ -41,7 +41,7 @@ public class BrowserUserAgentProvider implements UserAgentProvider /** Field description */ @VisibleForTesting - static final UserAgent CHROME = UserAgent.builder( + static final UserAgent CHROME = UserAgent.browser( "Chrome").basicAuthenticationCharset( Charsets.UTF_8).build(); @@ -50,21 +50,21 @@ public class BrowserUserAgentProvider implements UserAgentProvider /** Field description */ @VisibleForTesting - static final UserAgent FIREFOX = UserAgent.builder("Firefox").build(); + static final UserAgent FIREFOX = UserAgent.browser("Firefox").build(); /** Field description */ private static final String FIREFOX_PATTERN = "firefox"; /** Field description */ @VisibleForTesting - static final UserAgent MSIE = UserAgent.builder("Internet Explorer").build(); + static final UserAgent MSIE = UserAgent.browser("Internet Explorer").build(); /** Field description */ private static final String MSIE_PATTERN = "msie"; /** Field description */ @VisibleForTesting // todo check charset - static final UserAgent SAFARI = UserAgent.builder("Safari").build(); + static final UserAgent SAFARI = UserAgent.browser("Safari").build(); /** Field description */ private static final String OPERA_PATTERN = "opera"; @@ -74,7 +74,7 @@ public class BrowserUserAgentProvider implements UserAgentProvider /** Field description */ @VisibleForTesting // todo check charset - static final UserAgent OPERA = UserAgent.builder( + static final UserAgent OPERA = UserAgent.browser( "Opera").basicAuthenticationCharset( Charsets.UTF_8).build(); diff --git a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java index cb48c329c2..84f4f75191 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java @@ -74,10 +74,7 @@ public class HttpProtocolServlet extends HttpServlet { @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { UserAgent userAgent = userAgentParser.parse(request); - if (userAgent.isBrowser()) { - log.trace("dispatch browser request for user agent {}", userAgent); - dispatcher.dispatch(request, response, request.getRequestURI()); - } else { + if (userAgent.isScmClient()) { String pathInfo = request.getPathInfo(); Optional namespaceAndName = pathExtractor.fromUri(pathInfo); if (namespaceAndName.isPresent()) { @@ -86,6 +83,9 @@ public class HttpProtocolServlet extends HttpServlet { log.debug("namespace and name not found in request path {}", pathInfo); response.setStatus(HttpStatus.SC_BAD_REQUEST); } + } else { + log.trace("dispatch non-scm-client request for user agent {}", userAgent); + dispatcher.dispatch(request, response, request.getRequestURI()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java index 57e9b3b031..7f11520f33 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import org.junit.jupiter.params.ParameterizedTest; @@ -29,6 +29,7 @@ import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES; class BranchDtoTest { @@ -54,10 +55,11 @@ class BranchDtoTest { "val{d", "val{}d", "val|kill", - "val}" + "val}", + "va/li/d" }) void shouldAcceptValidBranchName(String branchName) { - assertTrue(branchName.matches(BranchDto.VALID_BRANCH_NAMES)); + assertTrue(branchName.matches(VALID_BRANCH_NAMES)); } @ParameterizedTest @@ -70,6 +72,6 @@ class BranchDtoTest { "val id" }) void shouldRejectInvalidBranchName(String branchName) { - assertFalse(branchName.matches(BranchDto.VALID_BRANCH_NAMES)); + assertFalse(branchName.matches(VALID_BRANCH_NAMES)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java index f938c7f655..632841b78f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java @@ -34,6 +34,7 @@ import sonia.scm.security.AnonymousMode; import java.util.Arrays; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.MockitoAnnotations.initMocks; @@ -42,9 +43,7 @@ public class ConfigDtoToScmConfigurationMapperTest { @InjectMocks private ConfigDtoToScmConfigurationMapperImpl mapper; - private String[] expectedUsers = {"trillian", "arthur"}; - private String[] expectedGroups = {"admin", "plebs"}; - private String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedExcludes = {"ex", "clude"}; @Before public void init() { @@ -73,6 +72,7 @@ public class ConfigDtoToScmConfigurationMapperTest { assertEquals("https://plug.ins", config.getPluginUrl()); assertEquals(40, config.getLoginAttemptLimitTimeout()); assertTrue(config.isEnabledXsrfProtection()); + assertFalse(config.isEnabledUserConverter()); assertEquals("username", config.getNamespaceStrategy()); assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); assertEquals("hitchhiker.mail", config.getMailDomainName()); @@ -115,6 +115,7 @@ public class ConfigDtoToScmConfigurationMapperTest { configDto.setNamespaceStrategy("username"); configDto.setLoginInfoUrl("https://scm-manager.org/login-info"); configDto.setMailDomainName("hitchhiker.mail"); + configDto.setEnabledUserConverter(false); return configDto; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java index 953f4638ad..e25576f2a3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java @@ -43,6 +43,7 @@ import sonia.scm.ContextEntry; import sonia.scm.group.GroupCollector; import sonia.scm.security.ApiKey; import sonia.scm.security.ApiKeyService; +import sonia.scm.user.EMail; import sonia.scm.user.InvalidPasswordException; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -96,6 +97,9 @@ public class MeResourceTest { @Mock private ApiKeyService apiKeyService; + @Mock + private EMail eMail; + @InjectMocks private MeDtoFactory meDtoFactory; @InjectMocks diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 1609bc0065..f6bdd4b4eb 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -28,6 +28,7 @@ import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.io.Resources; import com.google.inject.util.Providers; +import com.sun.mail.iap.Argument; import org.apache.shiro.authc.credential.PasswordService; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; @@ -58,10 +59,12 @@ import java.util.Collection; import java.util.function.Predicate; import static java.util.Collections.singletonList; +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.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -457,6 +460,43 @@ public class UserRootResourceTest { assertEquals("other:*", captor.getValue().iterator().next().getValue()); } + @Test + public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException { + when(passwordService.encryptPassword(anyString())).thenReturn("abc"); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + MockHttpRequest request = MockHttpRequest + .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal") + .contentType(VndMediaType.USER) + .content("{\"newPassword\":\"trillian\"}".getBytes()); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(passwordService).encryptPassword("trillian"); + verify(userManager).overwritePassword("Neo", "abc"); + verify(userManager).modify(userCaptor.capture()); + + User user = userCaptor.getValue(); + assertThat(user.isExternal()).isFalse(); + } + + @Test + public void shouldConvertUserToExternalAndRemoveLocalPassword() throws URISyntaxException { + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + MockHttpRequest request = MockHttpRequest + .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external") + .contentType(VndMediaType.USER); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(userManager).overwritePassword("Neo", null); + verify(userManager).modify(userCaptor.capture()); + + User user = userCaptor.getValue(); + assertThat(user.isExternal()).isTrue(); + } + private PageResult createSingletonPageResult(int overallCount) { return new PageResult<>(singletonList(createDummyUser("Neo")), overallCount); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index 92fa381c4a..3e82c92cbe 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -90,25 +90,15 @@ public class UserToUserDtoMapperTest { } @Test - public void shouldGetPasswordLinkForAdmin() { + public void shouldGetInternalUserLinks() { User user = createDefaultUser(); + user.setExternal(false); when(subject.isPermitted("user:modify:abc")).thenReturn(true); - when(userManager.isTypeDefault(eq(user))).thenReturn(true); UserDto userDto = mapper.map(user); assertEquals("expected password link with modify permission", expectedBaseUri.resolve("abc/password").toString(), userDto.getLinks().getLinkBy("password").get().getHref()); - } - - @Test - public void shouldGetPasswordLinkOnlyForDefaultUserType() { - User user = createDefaultUser(); - when(subject.isPermitted("user:modify:abc")).thenReturn(true); - when(userManager.isTypeDefault(eq(user))).thenReturn(false); - - UserDto userDto = mapper.map(user); - - assertFalse("expected no password link", userDto.getLinks().getLinkBy("password").isPresent()); + assertEquals("expected convert to external link with modify permission", expectedBaseUri.resolve("abc/convert-to-external").toString(), userDto.getLinks().getLinkBy("convertToExternal").get().getHref()); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java index fab7b789ad..e6984ff537 100644 --- a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java @@ -37,6 +37,8 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; +import sonia.scm.group.Group; +import sonia.scm.group.GroupManager; import sonia.scm.security.AnonymousMode; import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionDescriptor; @@ -56,6 +58,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static sonia.scm.group.GroupCollector.AUTHENTICATED; +import static sonia.scm.lifecycle.SetupContextListener.SetupAction.AUTHENTICATED_GROUP_DESCRIPTION; @ExtendWith(MockitoExtension.class) class SetupContextListenerTest { @@ -75,6 +79,9 @@ class SetupContextListenerTest { @Mock ScmConfiguration scmConfiguration; + @Mock + private GroupManager groupManager; + @Mock private PermissionAssigner permissionAssigner; @@ -96,6 +103,7 @@ class SetupContextListenerTest { @Test void shouldCreateAdminAccountIfNoUserExistsAndAssignPermissions() { + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); when(passwordService.encryptPassword("scmadmin")).thenReturn("secret"); setupContextListener.contextInitialized(null); @@ -108,6 +116,7 @@ class SetupContextListenerTest { void shouldCreateAdminAccountIfOnlyAnonymousUserExistsAndAssignPermissions() { when(userManager.getAll()).thenReturn(Lists.newArrayList(SCMContext.ANONYMOUS)); when(userManager.contains(SCMContext.USER_ANONYMOUS)).thenReturn(true); + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); when(passwordService.encryptPassword("scmadmin")).thenReturn("secret"); setupContextListener.contextInitialized(null); @@ -135,6 +144,7 @@ class SetupContextListenerTest { void shouldDoNothingOnSecondStart() { List users = Lists.newArrayList(UserTestData.createTrillian()); when(userManager.getAll()).thenReturn(users); + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); setupContextListener.contextInitialized(null); @@ -146,6 +156,7 @@ class SetupContextListenerTest { void shouldCreateAnonymousUserIfRequired() { List users = Lists.newArrayList(UserTestData.createTrillian()); when(userManager.getAll()).thenReturn(users); + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); when(scmConfiguration.getAnonymousMode()).thenReturn(AnonymousMode.FULL); setupContextListener.contextInitialized(null); @@ -157,6 +168,7 @@ class SetupContextListenerTest { void shouldNotCreateAnonymousUserIfNotRequired() { List users = Lists.newArrayList(UserTestData.createTrillian()); when(userManager.getAll()).thenReturn(users); + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); setupContextListener.contextInitialized(null); @@ -167,6 +179,7 @@ class SetupContextListenerTest { void shouldNotCreateAnonymousUserIfAlreadyExists() { List users = Lists.newArrayList(SCMContext.ANONYMOUS); when(userManager.getAll()).thenReturn(users); + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); when(scmConfiguration.getAnonymousMode()).thenReturn(AnonymousMode.FULL); setupContextListener.contextInitialized(null); @@ -174,6 +187,28 @@ class SetupContextListenerTest { verify(userManager, times(1)).create(SCMContext.ANONYMOUS); } + @Test + void shouldCreateAuthenticatedGroupIfMissing() { + when(groupManager.get(AUTHENTICATED)).thenReturn(null); + + setupContextListener.contextInitialized(null); + + Group authenticated = createAuthenticatedGroup(); + authenticated.setDescription(AUTHENTICATED_GROUP_DESCRIPTION); + authenticated.setExternal(true); + + verify(groupManager, times(1)).create(authenticated); + } + + @Test + void shouldNotCreateAuthenticatedGroupIfAlreadyExists() { + when(groupManager.get(AUTHENTICATED)).thenReturn(createAuthenticatedGroup()); + + setupContextListener.contextInitialized(null); + + verify(groupManager, never()).create(any()); + } + private void verifyAdminPermissionsAssigned() { ArgumentCaptor usernameCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor> permissionCaptor = ArgumentCaptor.forClass(Collection.class); @@ -192,4 +227,7 @@ class SetupContextListenerTest { assertThat(user.getPassword()).isEqualTo("secret"); } + private Group createAuthenticatedGroup() { + return new Group("xml", AUTHENTICATED); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java index ddc7587213..4f296b96ca 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java @@ -148,12 +148,6 @@ class DefaultRepositoryRoleManagerTest { verify(dao).modify(role); } - @Test - void shouldNotModifyRole_whenTypeChanged() { - assertThrows(ScmConstraintViolationException.class, () -> manager.modify(new RepositoryRole(CUSTOM_ROLE_NAME, singletonList("changed"), null))); - verify(dao, never()).modify(any()); - } - @Test void shouldNotModifyRole_whenRoleDoesNotExists() { assertThrows(NotFoundException.class, () -> manager.modify(new RepositoryRole("noSuchRole", singletonList("changed"), null))); diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java index 4e25b8e869..40234781d4 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java @@ -102,7 +102,7 @@ class JwtAccessTokenBuilderTest { void testBuild() { JwtAccessToken token = factory.create().subject("dent") .issuer("https://www.scm-manager.org") - .expiresIn(5, TimeUnit.SECONDS) + .expiresIn(1, TimeUnit.MINUTES) .custom("a", "b") .scope(Scope.valueOf("repo:*")) .build(); diff --git a/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java b/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java index d1a11444e8..8d26c94237 100644 --- a/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java @@ -21,66 +21,52 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.user; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.user; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; -import com.google.common.collect.Lists; - import org.assertj.core.api.Assertions; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; - import org.mockito.ArgumentCaptor; import sonia.scm.NotFoundException; import sonia.scm.store.JAXBConfigurationStoreFactory; import sonia.scm.user.xml.XmlUserDAO; -import static org.mockito.Mockito.*; - -//~--- JDK imports ------------------------------------------------------------ - -import java.util.Collections; -import java.util.List; -import org.junit.Rule; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** - * * @author Sebastian Sdorra */ @SubjectAware( - username = "trillian", - password = "secret", - configuration = "classpath:sonia/scm/repository/shiro.ini" + username = "trillian", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" ) -public class DefaultUserManagerTest extends UserManagerTestBase -{ +public class DefaultUserManagerTest extends UserManagerTestBase { @Rule public ShiroRule shiro = new ShiroRule(); - - private UserDAO userDAO ; - private User trillian; + private UserDAO userDAO; /** * Method description * - * * @return */ @Override - public UserManager createManager() - { + public UserManager createManager() { return new DefaultUserManager(createXmlUserDAO()); } @Before public void initDao() { - trillian = UserTestData.createTrillian(); + User trillian = UserTestData.createTrillian(); trillian.setPassword("oldEncrypted"); userDAO = mock(UserDAO.class); @@ -108,15 +94,6 @@ public class DefaultUserManagerTest extends UserManagerTestBase Assertions.assertThat(userCaptor.getValue().getPassword()).isEqualTo("newEncrypted"); } - @Test(expected = ChangePasswordNotAllowedException.class) - public void shouldFailOverwritePasswordForWrongType() { - trillian.setType("wrongType"); - - UserManager userManager = new DefaultUserManager(userDAO); - - userManager.overwritePassword("trillian", "---"); - } - @Test(expected = NotFoundException.class) public void shouldFailOverwritePasswordForMissingUser() { UserManager userManager = new DefaultUserManager(userDAO); @@ -124,6 +101,16 @@ public class DefaultUserManagerTest extends UserManagerTestBase userManager.overwritePassword("notExisting", "---"); } + @Test(expected = ChangePasswordNotAllowedException.class) + public void shouldFailOverwritePasswordForExternalUser() { + User trillian = new User("trillian"); + trillian.setExternal(true); + when(userDAO.get("trillian")).thenReturn(trillian); + UserManager userManager = new DefaultUserManager(userDAO); + + userManager.overwritePassword("trillian", "---"); + } + @Test public void shouldSucceedOverwritePassword() { ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); diff --git a/scm-webapp/src/test/java/sonia/scm/user/InternalToExternalUserConverterTest.java b/scm-webapp/src/test/java/sonia/scm/user/InternalToExternalUserConverterTest.java new file mode 100644 index 0000000000..e4f2ad76e2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/user/InternalToExternalUserConverterTest.java @@ -0,0 +1,79 @@ +/* + * 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.user; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.config.ScmConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InternalToExternalUserConverterTest { + + @Mock + ScmConfiguration scmConfiguration; + + @InjectMocks + InternalToExternalUserConverter converter; + + @Test + void shouldNotConvertExternalUser() { + User external = new User(); + external.setExternal(true); + + User user = converter.convert(external); + + assertThat(user).isSameAs(external); + } + + @Test + void shouldNotConvertIfConfigDisabled() { + when(scmConfiguration.isEnabledUserConverter()).thenReturn(false); + User external = new User(); + external.setExternal(false); + + User user = converter.convert(external); + + assertThat(user).isSameAs(external); + } + + @Test + void shouldReturnConvertedUser() { + when(scmConfiguration.isEnabledUserConverter()).thenReturn(true); + User internal = new User(); + internal.setExternal(false); + + User external = converter.convert(internal); + + assertThat(external).isInstanceOf(User.class); + assertThat(external.isExternal()).isTrue(); + assertThat(external.getPassword()).isNull(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java index 7e01e0b1d4..9dac395df9 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java @@ -91,15 +91,12 @@ class HttpProtocolServletTest { @BeforeEach void prepareMocks() { when(userAgentParser.parse(request)).thenReturn(userAgent); - when(userAgent.isBrowser()).thenReturn(true); + when(userAgent.isScmClient()).thenReturn(false); when(request.getRequestURI()).thenReturn("uri"); } @Test void shouldDispatchBrowserRequests() throws ServletException, IOException { - when(userAgent.isBrowser()).thenReturn(true); - when(request.getRequestURI()).thenReturn("uri"); - servlet.service(request, response); verify(dispatcher).dispatch(request, response, "uri"); @@ -113,7 +110,7 @@ class HttpProtocolServletTest { @BeforeEach void prepareMocks() { when(userAgentParser.parse(request)).thenReturn(userAgent); - when(userAgent.isBrowser()).thenReturn(false); + when(userAgent.isScmClient()).thenReturn(true); } @Test