Merge branch 'develop' into bugfix/rest-download

This commit is contained in:
Konstantin Schaper
2020-11-02 13:28:44 +01:00
committed by GitHub
64 changed files with 1415 additions and 558 deletions

View File

@@ -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))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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)

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -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".

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -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 dont 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)

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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<ExternalUserConverter> 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<ExternalUserConverter> 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);
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<User> 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<User> 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() {

View File

@@ -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<TreeEntry> parents = new Stack<>();
Deque<TreeEntry> 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<String, SubRepository> 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() {

View File

@@ -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<FileObject> children = result.getFile().getChildren();
List<String> subrepos = subRepositoriesOnly(children);
assertThat(subrepos).containsExactly(
"anonymous-access",
"hasselhoffme",
"recipes",
"scm-redmine-plugin"
);
List<String> directories = directoriesOnly(children);
assertThat(directories).containsExactly(
"dir",
"plugins"
);
List<String> 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<FileObject> children = result.getFile().getChildren();
FileObject fileObject = children.stream().filter(f -> "plugins".equals(f.getPath())).findFirst().get();
assertThat(fileObject.getChildren()).hasSize(3);
List<String> subrepos = subRepositoriesOnly(fileObject.getChildren());
assertThat(subrepos)
.containsExactly(
"plugins/scm-branchwp-plugin",
"plugins/scm-jira-plugin",
"plugins/statistic-plugin"
);
}
@Nonnull
private List<String> filesOnly(Collection<FileObject> children) {
return children.stream().filter(f -> !f.isDirectory()).map(FileObject::getPath).collect(Collectors.toList());
}
@Nonnull
private List<String> directoriesOnly(Collection<FileObject> children) {
return children.stream()
.filter(FileObject::isDirectory)
.filter(f -> f.getSubRepository() == null)
.map(FileObject::getPath)
.collect(Collectors.toList());
}
@Nonnull
private List<String> subRepositoriesOnly(Collection<FileObject> 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";
}
}

View File

@@ -90,6 +90,15 @@
</plugins>
</build>
<properties>
<!--
SCMSvnDiffGenerator is a copy of an internal SVNKit class, with small changes for SCM-Manger.
If we refactor the class, it could become very hard to merge it with upstream.
So we do not want a duplication report for this class.
-->
<sonar.cpd.exclusions>**/SCMSvnDiffGenerator.java</sonar.cpd.exclusions>
</properties>
<repositories>
<repository>

View File

@@ -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 {

View File

@@ -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<String, String> properties) throws SVNException {
for (Map.Entry<String, String> 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
);
}
}

View File

@@ -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<Props, State> {
@@ -57,7 +58,7 @@ class PasswordConfirmation extends React.Component<Props, State> {
}
render() {
const { t } = this.props;
const { t, onReturnPressed } = this.props;
return (
<div className="columns is-multiline">
<div className="column is-half">
@@ -78,6 +79,7 @@ class PasswordConfirmation extends React.Component<Props, State> {
value={this.state ? this.state.confirmedPassword : ""}
validationError={this.state.passwordConfirmationFailed}
errorMessage={t("password.passwordConfirmFailed")}
onReturnPressed={onReturnPressed}
/>
</div>
</div>

View File

@@ -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",

View File

@@ -45,6 +45,7 @@ export type Config = {
pluginUrl: string;
loginAttemptLimitTimeout: number;
enabledXsrfProtection: boolean;
enabledUserConverter: boolean;
namespaceStrategy: string;
loginInfoUrl: string;
releaseFeedUrl: string;

View File

@@ -39,5 +39,6 @@ export type User = {
type?: string;
creationDate?: string;
lastModified?: string;
external: boolean;
_links: Links;
};

View File

@@ -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."
}

View File

@@ -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.",

View File

@@ -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."
}

View File

@@ -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.",

View File

@@ -72,6 +72,7 @@ class ConfigForm extends React.Component<Props, State> {
pluginUrl: "",
loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true,
enabledUserConverter: false,
namespaceStrategy: "",
loginInfoUrl: "",
_links: {}
@@ -146,6 +147,7 @@ class ConfigForm extends React.Component<Props, State> {
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}

View File

@@ -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<Props> {
releaseFeedUrl,
mailDomainName,
enabledXsrfProtection,
enabledUserConverter,
anonymousMode,
namespaceStrategy,
hasUpdatePermission,
@@ -140,6 +142,18 @@ class GeneralSettings extends React.Component<Props> {
helpText={t("help.releaseFeedUrlHelpText")}
/>
</div>
<div className="column is-half">
<Checkbox
label={t("general-settings.enabled-user-converter")}
onChange={this.handleEnabledUserConverterChange}
checked={enabledUserConverter}
title={t("general-settings.enabled-user-converter")}
disabled={!hasUpdatePermission}
helpText={t("help.enabledUserConverterHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.mail-domain-name")}
@@ -163,6 +177,9 @@ class GeneralSettings extends React.Component<Props> {
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");
};

View File

@@ -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<Props> = ({ user, fetchUser }) => {
const [t] = useTranslation("users");
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [password, setPassword] = useState("");
const [passwordValid, setPasswordValid] = useState(false);
const [error, setError] = useState<Error | undefined>();
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 (
<Button
label={t("userForm.button.convertToInternal")}
action={() => setShowPasswordModal(true)}
icon="exchange-alt"
className="is-pulled-right"
/>
);
} else {
return <Button label={t("userForm.button.convertToExternal")} action={() => toExternal()} icon="exchange-alt" />;
}
};
const onReturnPressed = () => {
if (password && passwordValid) {
toInternal();
}
};
const passwordChangeField = (
<PasswordConfirmation passwordChanged={changePassword} onReturnPressed={onReturnPressed} />
);
const passwordModal = (
<Modal
body={passwordChangeField}
closeFunction={() => setShowPasswordModal(false)}
active={showPasswordModal}
title={t("userForm.modal.passwordRequired")}
footer={
<SubmitButton
action={() => password && passwordValid && toInternal()}
disabled={!passwordValid}
scrollToTop={false}
label={t("userForm.modal.convertToInternal")}
/>
}
/>
);
return (
<div>
{showPasswordModal && passwordModal}
{error && <ErrorNotification error={error} />}
<div className="columns is-multiline">
<ExternalDescription className="column is-half">{getUserExternalDescription()}</ExternalDescription>
<div className="column is-half">
<Level right={getConvertButton()} />
</div>
</div>
</div>
);
};
export default UserConverter;

View File

@@ -23,9 +23,10 @@
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
import { Link, User } from "@scm-manager/ui-types";
import {
Checkbox,
ErrorNotification,
InputField,
Level,
PasswordConfirmation,
@@ -47,6 +48,7 @@ type State = {
nameValidationError: boolean;
displayNameValidationError: boolean;
passwordValid: boolean;
error?: Error;
};
class UserForm extends React.Component<Props, State> {
@@ -60,6 +62,7 @@ class UserForm extends React.Component<Props, State> {
mail: "",
password: "",
active: true,
external: false,
_links: {}
},
mailValidationError: false,
@@ -80,14 +83,10 @@ class UserForm extends React.Component<Props, State> {
}
}
isFalsy(value) {
return !value;
}
createUserComponentsAreInvalid = () => {
const user = this.state.user;
if (!this.props.user) {
return this.state.nameValidationError || this.isFalsy(user.name) || !this.state.passwordValid;
return this.state.nameValidationError || !user.name || (!user.external && !this.state.passwordValid);
} else {
return false;
}
@@ -99,37 +98,40 @@ class UserForm extends React.Component<Props, State> {
return (
this.props.user.displayName === user.displayName &&
this.props.user.mail === user.mail &&
this.props.user.active === user.active
this.props.user.active === user.active &&
this.props.user.external === user.external
);
} else {
return false;
}
};
isValid = () => {
const user = this.state.user;
return !(
isInvalid = () => {
const { user } = this.state;
return (
this.createUserComponentsAreInvalid() ||
this.editUserComponentsAreUnchanged() ||
this.state.mailValidationError ||
this.state.displayNameValidationError ||
this.isFalsy(user.displayName)
this.state.nameValidationError ||
!user.displayName
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
if (!this.isInvalid()) {
this.props.submitForm(this.state.user);
}
};
render() {
const { loading, t } = this.props;
const user = this.state.user;
const { user, error } = this.state;
const passwordChangeField = <PasswordConfirmation passwordChanged={this.handlePasswordChange} />;
let nameField = null;
let passwordChangeField = null;
let subtitle = null;
if (!this.props.user) {
// create new user
@@ -145,8 +147,6 @@ class UserForm extends React.Component<Props, State> {
/>
</div>
);
passwordChangeField = <PasswordConfirmation passwordChanged={this.handlePasswordChange} />;
} else {
// edit existing user
subtitle = <Subtitle subtitle={t("userForm.subtitle")} />;
@@ -179,18 +179,37 @@ class UserForm extends React.Component<Props, State> {
/>
</div>
</div>
{passwordChangeField}
<div className="columns">
<div className="column">
<Checkbox
label={t("user.active")}
onChange={this.handleActiveChange}
checked={user ? user.active : false}
helpText={t("help.activeHelpText")}
/>
</div>
</div>
<Level right={<SubmitButton disabled={!this.isValid()} loading={loading} label={t("userForm.button")} />} />
{!this.props.user && (
<>
<div className="columns">
<div className="column">
<Checkbox
label={t("user.externalFlag")}
onChange={this.handleExternalChange}
checked={user.external}
helpText={t("help.externalFlagHelpText")}
/>
</div>
</div>
</>
)}
{!user.external && (
<>
{!this.props.user && passwordChangeField}
<div className="columns">
<div className="column">
<Checkbox
label={t("user.active")}
onChange={this.handleActiveChange}
checked={user ? user.active : false}
helpText={t("help.activeHelpText")}
/>
</div>
</div>
</>
)}
{error && <ErrorNotification error={error} />}
<Level right={<SubmitButton disabled={this.isInvalid()} loading={loading} label={t("userForm.button.submit")} />} />
</form>
</>
);
@@ -232,7 +251,7 @@ class UserForm extends React.Component<Props, State> {
...this.state.user,
password
},
passwordValid: !this.isFalsy(password) && passwordValid
passwordValid: !!password && passwordValid
});
};
@@ -244,6 +263,15 @@ class UserForm extends React.Component<Props, State> {
}
});
};
handleExternalChange = (external: boolean) => {
this.setState({
user: {
...this.state.user,
external
}
});
};
}
export default withTranslation("users")(UserForm);

View File

@@ -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;
});
}

View File

@@ -42,7 +42,8 @@ class ChangePasswordNavLink extends React.Component<Props> {
}
hasPermissionToSetPassword = () => {
return this.props.user._links.password;
const { user } = this.props;
return user._links.password;
};
}

View File

@@ -62,8 +62,10 @@ class Details extends React.Component<Props> {
</td>
</tr>
<tr>
<th>{t("user.type")}</th>
<td>{user.type}</td>
<th>{t("user.externalFlag")}</th>
<td>
<Checkbox checked={!!user.external} />
</td>
</tr>
<tr>
<th>{t("user.creationDate")}</th>

View File

@@ -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<Props> {
return (
<div>
<ErrorNotification error={error} />
<UserForm submitForm={user => this.modifyUser(user)} user={user} loading={loading} />
<UserForm submitForm={this.modifyUser} user={user} loading={loading} />
<hr />
<UserConverter user={user} fetchUser={this.props.fetchUser} />
<DeleteUser user={user} />
</div>
);
@@ -87,6 +97,9 @@ const mapDispatchToProps = (dispatch: any) => {
},
modifyUserReset: (user: User) => {
dispatch(modifyUserReset(user));
},
fetchUser: (user: User) => {
dispatch(fetchUserByLink(user));
}
};
};

View File

@@ -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

View File

@@ -46,8 +46,6 @@ public class ManagerDaoAdapter<T extends ModelObject> {
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);

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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.
*
* <strong>Note:</strong> 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.
*
* <strong>Note:</strong> 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;

View File

@@ -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<User, UserDto> {
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()) {

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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<User> 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<User> 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<User> createSingletonPageResult(int overallCount) {
return new PageResult<>(singletonList(createDummyUser("Neo")), overallCount);
}

View File

@@ -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

View File

@@ -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)));

View File

@@ -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();

View File

@@ -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<User> userCaptor = ArgumentCaptor.forClass(User.class);

View File

@@ -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();
}
}