From dd8f84e7c4e947aaf58110af6f557d72dbd668c8 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 19 Nov 2019 13:50:57 +0100 Subject: [PATCH] implement repository public flag migration to repositoryPermissions for _anonymous user --- .../repository/PublicFlagUpdateStep.java | 97 ++++++++++++++ .../scm/update/repository/V1Repository.java | 2 + .../update/repository/V1RepositoryHelper.java | 51 ++++++++ .../repository/XmlRepositoryV1UpdateStep.java | 52 ++------ .../repository/PublicFlagUpdateStepTest.java | 118 ++++++++++++++++++ .../scm/update/repository/scm-home.v1.zip | Bin 13593 -> 14520 bytes 6 files changed, 277 insertions(+), 43 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/update/repository/PublicFlagUpdateStep.java create mode 100644 scm-webapp/src/main/java/sonia/scm/update/repository/V1RepositoryHelper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/update/repository/PublicFlagUpdateStepTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/PublicFlagUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/repository/PublicFlagUpdateStep.java new file mode 100644 index 0000000000..b2e3c17980 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/PublicFlagUpdateStep.java @@ -0,0 +1,97 @@ +package sonia.scm.update.repository; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContext; +import sonia.scm.SCMContextProvider; +import sonia.scm.migration.UpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.xml.XmlRepositoryDAO; +import sonia.scm.user.User; +import sonia.scm.user.xml.XmlUserDAO; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; + +import static sonia.scm.version.Version.parse; + +@Extension +public class PublicFlagUpdateStep implements UpdateStep { + + private static final Logger LOG = LoggerFactory.getLogger(PublicFlagUpdateStep.class); + + private static final String V1_REPOSITORY_BACKUP_FILENAME = "repositories.xml.v1.backup"; + + private final SCMContextProvider contextProvider; + private final XmlUserDAO userDAO; + private final XmlRepositoryDAO repositoryDAO; + + @Inject + public PublicFlagUpdateStep(SCMContextProvider contextProvider, XmlUserDAO userDAO, XmlRepositoryDAO repositoryDAO) { + this.contextProvider = contextProvider; + this.userDAO = userDAO; + this.repositoryDAO = repositoryDAO; + } + + @Override + public void doUpdate() throws JAXBException { + createNewAnonymousUserIfNotExists(); + deleteOldAnonymousUserIfAvailable(); + + JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryHelper.V1RepositoryDatabase.class); + LOG.info("Migrating public flags of repositories as RepositoryRolePermission 'READ' for user '_anonymous'"); + V1RepositoryHelper.readV1Database(jaxbContext, contextProvider, V1_REPOSITORY_BACKUP_FILENAME).ifPresent( + this::addRepositoryReadPermissionForAnonymousUser + ); + } + + @Override + public Version getTargetVersion() { + return parse("2.0.3"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.repository.xml"; + } + + private void addRepositoryReadPermissionForAnonymousUser(V1RepositoryHelper.V1RepositoryDatabase v1RepositoryDatabase) { + User v2AnonymousUser = userDAO.get(SCMContext.USER_ANONYMOUS); + v1RepositoryDatabase.repositoryList.repositories + .stream() + .filter(V1Repository::isPublic) + .forEach(v1Repository -> { + Repository v2Repository = repositoryDAO.get(v1Repository.getId()); + LOG.info(String.format("Add RepositoryRole 'READ' to _anonymous user for repository: %s - %s/%s", v2Repository.getId(), v2Repository.getNamespace(), v2Repository.getName())); + v2Repository.addPermission(new RepositoryPermission(v2AnonymousUser.getId(), "READ", false)); + repositoryDAO.modify(v2Repository); + }); + } + + private void createNewAnonymousUserIfNotExists() { + if (!userExists(SCMContext.USER_ANONYMOUS)) { + LOG.info("Create new _anonymous user"); + userDAO.add(SCMContext.ANONYMOUS); + } + } + + private void deleteOldAnonymousUserIfAvailable() { + String oldAnonymous = "anonymous"; + if (userExists(oldAnonymous)) { + User anonymousUser = userDAO.get(oldAnonymous); + LOG.info("Delete obsolete anonymous user"); + userDAO.delete(anonymousUser); + } + } + + private boolean userExists(String username) { + return userDAO + .getAll() + .stream() + .anyMatch(user -> user.getName().equals(username)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java index 4ce823bd33..8b389e467b 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java @@ -4,6 +4,7 @@ import sonia.scm.update.V1Properties; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import java.util.List; @@ -16,6 +17,7 @@ public class V1Repository { private String description; private String id; private String name; + @XmlElement(name="public") private boolean isPublic; private boolean archived; private String type; diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/V1RepositoryHelper.java b/scm-webapp/src/main/java/sonia/scm/update/repository/V1RepositoryHelper.java new file mode 100644 index 0000000000..bba89b541f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/V1RepositoryHelper.java @@ -0,0 +1,51 @@ +package sonia.scm.update.repository; + +import sonia.scm.SCMContextProvider; +import sonia.scm.store.StoreConstants; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.io.File; +import java.nio.file.Paths; +import java.util.List; +import java.util.Optional; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +class V1RepositoryHelper { + + static File resolveV1File(SCMContextProvider contextProvider, String filename) { + return contextProvider + .resolve( + Paths.get(StoreConstants.CONFIG_DIRECTORY_NAME).resolve(filename) + ).toFile(); + } + + static Optional readV1Database(JAXBContext jaxbContext, SCMContextProvider contextProvider, String filename) throws JAXBException { + Object unmarshal = jaxbContext.createUnmarshaller().unmarshal(resolveV1File(contextProvider,filename)); + if (unmarshal instanceof V1RepositoryHelper.V1RepositoryDatabase) { + return of((V1RepositoryHelper.V1RepositoryDatabase) unmarshal); + } else { + return empty(); + } + } + + static class RepositoryList { + @XmlElement(name = "repository") + List repositories; + } + + @XmlRootElement(name = "repository-db") + @XmlAccessorType(XmlAccessType.FIELD) + static class V1RepositoryDatabase { + long creationTime; + Long lastModified; + @XmlElement(name = "repositories") + RepositoryList repositoryList; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java index a2f7656498..69354c294a 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java @@ -19,24 +19,17 @@ import sonia.scm.version.Version; import javax.inject.Inject; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlRootElement; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import static java.util.Collections.emptyList; -import static java.util.Optional.empty; -import static java.util.Optional.of; import static sonia.scm.update.V1PropertyReader.REPOSITORY_PROPERTY_READER; +import static sonia.scm.update.repository.V1RepositoryHelper.resolveV1File; import static sonia.scm.version.Version.parse; /** @@ -59,6 +52,8 @@ import static sonia.scm.version.Version.parse; @Extension public class XmlRepositoryV1UpdateStep implements CoreUpdateStep { + private final String V1_REPOSITORY_FILENAME = "repositories" + StoreConstants.FILE_EXTENSION; + private static Logger LOG = LoggerFactory.getLogger(XmlRepositoryV1UpdateStep.class); private final SCMContextProvider contextProvider; @@ -97,12 +92,12 @@ public class XmlRepositoryV1UpdateStep implements CoreUpdateStep { @Override public void doUpdate() throws JAXBException { - if (!resolveV1File().exists()) { + if (!resolveV1File(contextProvider, V1_REPOSITORY_FILENAME).exists()) { LOG.info("no v1 repositories database file found"); return; } - JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryDatabase.class); - readV1Database(jaxbContext).ifPresent( + JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryHelper.V1RepositoryDatabase.class); + V1RepositoryHelper.readV1Database(jaxbContext, contextProvider, V1_REPOSITORY_FILENAME).ifPresent( v1Database -> { v1Database.repositoryList.repositories.forEach(this::readMigrationEntry); v1Database.repositoryList.repositories.forEach(this::update); @@ -112,13 +107,13 @@ public class XmlRepositoryV1UpdateStep implements CoreUpdateStep { } public List getRepositoriesWithoutMigrationStrategies() { - if (!resolveV1File().exists()) { + if (!resolveV1File(contextProvider, V1_REPOSITORY_FILENAME).exists()) { LOG.info("no v1 repositories database file found"); return emptyList(); } try { - JAXBContext jaxbContext = JAXBContext.newInstance(XmlRepositoryV1UpdateStep.V1RepositoryDatabase.class); - return readV1Database(jaxbContext) + JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryHelper.V1RepositoryDatabase.class); + return V1RepositoryHelper.readV1Database(jaxbContext, contextProvider, V1_REPOSITORY_FILENAME) .map(v1Database -> v1Database.repositoryList.repositories.stream()) .orElse(Stream.empty()) .filter(v1Repository -> !this.findMigrationStrategy(v1Repository).isPresent()) @@ -196,33 +191,4 @@ public class XmlRepositoryV1UpdateStep implements CoreUpdateStep { return new RepositoryPermission(v1Permission.getName(), v1Permission.getType(), v1Permission.isGroupPermission()); } - private Optional readV1Database(JAXBContext jaxbContext) throws JAXBException { - Object unmarshal = jaxbContext.createUnmarshaller().unmarshal(resolveV1File()); - if (unmarshal instanceof V1RepositoryDatabase) { - return of((V1RepositoryDatabase) unmarshal); - } else { - return empty(); - } - } - - private File resolveV1File() { - return contextProvider - .resolve( - Paths.get(StoreConstants.CONFIG_DIRECTORY_NAME).resolve("repositories" + StoreConstants.FILE_EXTENSION) - ).toFile(); - } - - private static class RepositoryList { - @XmlElement(name = "repository") - private List repositories; - } - - @XmlRootElement(name = "repository-db") - @XmlAccessorType(XmlAccessType.FIELD) - private static class V1RepositoryDatabase { - private long creationTime; - private Long lastModified; - @XmlElement(name = "repositories") - private RepositoryList repositoryList; - } } diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/PublicFlagUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/PublicFlagUpdateStepTest.java new file mode 100644 index 0000000000..68edc760c3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/PublicFlagUpdateStepTest.java @@ -0,0 +1,118 @@ +package sonia.scm.update.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContext; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryRolePermissions; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.xml.XmlRepositoryDAO; +import sonia.scm.update.UpdateStepTestUtil; +import sonia.scm.user.User; +import sonia.scm.user.xml.XmlUserDAO; + +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junitpioneer.jupiter.TempDirectory.TempDir; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) +class PublicFlagUpdateStepTest { + + @Mock + XmlUserDAO userDAO; + @Mock + XmlRepositoryDAO repositoryDAO; + @Captor + ArgumentCaptor repositoryCaptor; + + private UpdateStepTestUtil testUtil; + private PublicFlagUpdateStep updateStep; + private Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); + + @BeforeEach + void mockScmHome(@TempDir Path tempDir) throws IOException { + testUtil = new UpdateStepTestUtil(tempDir); + updateStep = new PublicFlagUpdateStep(testUtil.getContextProvider(), userDAO, repositoryDAO); + + //prepare backup xml + V1RepositoryFileSystem.createV1Home(tempDir); + Files.move(tempDir.resolve("config").resolve("repositories.xml"), tempDir.resolve("config").resolve("repositories.xml.v1.backup")); + when(repositoryDAO.get((String) any())).thenReturn(REPOSITORY); + } + + @Test + void shouldDeleteOldAnonymousUserIfExists() throws JAXBException { + when(userDAO.getAll()).thenReturn(Collections.singleton(new User("anonymous"))); + User anonymous = new User("anonymous"); + doReturn(anonymous).when(userDAO).get("anonymous"); + doReturn(SCMContext.ANONYMOUS).when(userDAO).get(SCMContext.USER_ANONYMOUS); + + updateStep.doUpdate(); + + verify(userDAO).delete(anonymous); + } + + @Test + void shouldNotTryToDeleteOldAnonymousUserIfNotExists() throws JAXBException { + when(userDAO.getAll()).thenReturn(Collections.emptyList()); + doReturn(SCMContext.ANONYMOUS).when(userDAO).get(SCMContext.USER_ANONYMOUS); + + updateStep.doUpdate(); + + verify(userDAO, never()).delete(any()); + } + + @Test + void shouldCreateNewAnonymousUserIfNotExists() throws JAXBException { + doReturn(SCMContext.ANONYMOUS).when(userDAO).get(SCMContext.USER_ANONYMOUS); + when(userDAO.getAll()).thenReturn(Collections.singleton(new User("trillian"))); + + updateStep.doUpdate(); + + verify(userDAO).add(SCMContext.ANONYMOUS); + } + + @Test + void shouldNotCreateNewAnonymousUserIfAlreadyExists() throws JAXBException { + doReturn(SCMContext.ANONYMOUS).when(userDAO).get(SCMContext.USER_ANONYMOUS); + when(userDAO.getAll()).thenReturn(Collections.singleton(new User("_anonymous"))); + + updateStep.doUpdate(); + + verify(userDAO, never()).add(SCMContext.ANONYMOUS); + } + + @Test + void shouldMigratePublicFlagToAnonymousRepositoryPermission() throws JAXBException { + when(userDAO.getAll()).thenReturn(Collections.emptyList()); + when(userDAO.get("_anonymous")).thenReturn(SCMContext.ANONYMOUS); + + updateStep.doUpdate(); + + verify(repositoryDAO, times(2)).modify(repositoryCaptor.capture()); + + RepositoryPermission migratedRepositoryPermission = repositoryCaptor.getValue().getPermissions().iterator().next(); + assertThat(migratedRepositoryPermission.getName()).isEqualTo(SCMContext.USER_ANONYMOUS); + assertThat(migratedRepositoryPermission.getRole()).isEqualTo("READ"); + assertThat(migratedRepositoryPermission.isGroupPermission()).isFalse(); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/update/repository/scm-home.v1.zip b/scm-webapp/src/test/resources/sonia/scm/update/repository/scm-home.v1.zip index 5d936db5e264ac6182855e34a5aae82871c2c3fd..4b21785f20ee59325f4b6208385143a4abe44e1d 100644 GIT binary patch literal 14520 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 Pv0rnsyXu93Fc|*>fMK>$ literal 13593 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