diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da5aabb3a..55c2eafdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- Automatic user converter for external users ([#1380](https://github.com/scm-manager/scm-manager/pull/1380)) + ### 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)) - 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)) ## [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/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/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/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/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/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/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-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/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/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-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/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/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-ui/ui-components/src/forms/PasswordConfirmation.tsx b/scm-ui/ui-components/src/forms/PasswordConfirmation.tsx index 2db9a773fc..8d4468be4f 100644 --- a/scm-ui/ui-components/src/forms/PasswordConfirmation.tsx +++ b/scm-ui/ui-components/src/forms/PasswordConfirmation.tsx @@ -34,6 +34,7 @@ type State = { type Props = WithTranslation & { passwordChanged: (p1: string, p2: boolean) => 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-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/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/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/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/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/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/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(); + } +}