From e1a2d27256b4fdb86de6179c1c538147dbb0a164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 1 Nov 2021 16:54:58 +0100 Subject: [PATCH] Implement file lock for git (#1838) Adds a "file lock" command that can be used to mark files as locked by a specific user. This command is implemented for git using a store to keep the locks. Additionally, the Git LFS locking API is implemented. To display locks, the scm-manager/scm-file-lock-plugin can be used. Co-authored-by: Eduard Heimbuch --- gradle/changelog/file_lock_for_git_lfs.yaml | 2 + .../sonia/scm/repository/api/Command.java | 7 +- .../sonia/scm/repository/api/FileLock.java | 77 + .../api/FileLockCommandBuilder.java | 160 ++ .../repository/api/FileLockedException.java | 78 + .../scm/repository/api/LockCommandResult.java | 44 + .../scm/repository/api/RepositoryService.java | 13 + .../repository/api/UnlockCommandResult.java | 44 + .../scm/repository/spi/FileLockCommand.java | 72 + .../repository/spi/LockCommandRequest.java | 38 + .../spi/LockStatusCommandRequest.java | 32 + .../spi/RepositoryServiceProvider.java | 10 +- .../repository/spi/UnlockCommandRequest.java | 39 + .../java/sonia/scm/web/ScmClientDetector.java | 47 + .../repository/spi/FileLockPreCommitHook.java | 118 + .../repository/spi/GitFileLockCommand.java | 76 + .../spi/GitFileLockStoreFactory.java | 250 ++ .../spi/GitRepositoryServiceProvider.java | 8 +- .../sonia/scm/web/GitLfsLockApiDetector.java | 41 + .../scm/web/LfsLockingProtocolServlet.java | 505 ++++ .../java/sonia/scm/web/ScmGitServlet.java | 30 + .../web/lfs/servlet/LfsServletFactory.java | 17 +- .../spi/FileLockPreCommitHookTest.java | 151 ++ .../spi/GitFileLockCommandTest.java | 145 + .../spi/GitFileLockStoreFactoryTest.java | 232 ++ .../scm/web/CapturingServletOutputStream.java | 71 + .../scm/web/GitPermissionFilterTest.java | 33 - .../web/LfsLockingProtocolServletTest.java | 535 ++++ .../java/sonia/scm/web/WireProtocolTest.java | 32 - .../scm/web/BufferedServletInputStream.java | 59 + scm-ui/ui-components/src/Icon.tsx | 57 +- .../src/__snapshots__/storyshots.test.ts.snap | 2406 +++++++++++++---- scm-ui/ui-components/src/index.ts | 1 + scm-ui/ui-extensions/src/extensionPoints.ts | 1 + scm-ui/ui-types/src/Sources.ts | 10 +- .../src/repos/sources/components/FileTree.tsx | 5 +- .../repos/sources/components/FileTreeLeaf.tsx | 8 +- .../components/content/DownloadViewer.tsx | 11 +- .../repos/sources/containers/SourcesView.tsx | 2 +- .../api/rest/FileLockedExceptionMapper.java | 40 + .../scm/web/protocol/HttpProtocolServlet.java | 18 +- .../main/resources/locales/de/plugins.json | 4 + .../main/resources/locales/en/plugins.json | 4 + .../web/protocol/HttpProtocolServletTest.java | 224 +- 44 files changed, 4970 insertions(+), 787 deletions(-) create mode 100644 gradle/changelog/file_lock_for_git_lfs.yaml create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/FileLock.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/FileLockCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/FileLockedException.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/LockCommandResult.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/UnlockCommandResult.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/FileLockCommand.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/LockCommandRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/LockStatusCommandRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/UnlockCommandRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileLockPreCommitHook.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockStoreFactory.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/LfsLockingProtocolServlet.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/FileLockPreCommitHookTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockCommandTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockStoreFactoryTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/CapturingServletOutputStream.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/LfsLockingProtocolServletTest.java create mode 100644 scm-test/src/main/java/sonia/scm/web/BufferedServletInputStream.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/rest/FileLockedExceptionMapper.java diff --git a/gradle/changelog/file_lock_for_git_lfs.yaml b/gradle/changelog/file_lock_for_git_lfs.yaml new file mode 100644 index 0000000000..e36be90693 --- /dev/null +++ b/gradle/changelog/file_lock_for_git_lfs.yaml @@ -0,0 +1,2 @@ +- type: added + descripion: File lock implementation for git (lfs) ([#1838](https://github.com/scm-manager/scm-manager/pull/1838)) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index 18acd0ab65..1d4e87fd59 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -77,5 +77,10 @@ public enum Command /** * @since 2.19.0 */ - MIRROR; + MIRROR, + + /** + * @since 2.26.0 + */ + FILE_LOCK } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/FileLock.java b/scm-core/src/main/java/sonia/scm/repository/api/FileLock.java new file mode 100644 index 0000000000..5e2fff8d81 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/FileLock.java @@ -0,0 +1,77 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import java.io.Serializable; +import java.time.Instant; + +/** + * Detailes of a file lock. + * + * @since 2.26.0 + */ +public class FileLock implements Serializable { + private static final long serialVersionUID = 1902345795392347027L; + + private final String path; + private final String id; + private final String userId; + private final Instant timestamp; + + public FileLock(String path, String id, String userId, Instant timestamp) { + this.path = path; + this.id = id; + this.userId = userId; + this.timestamp = timestamp; + } + + /** + * The path of the locked file. + */ + public String getPath() { + return path; + } + + /** + * The id of the lock. + */ + public String getId() { + return id; + } + + /** + * The id of the user that created the lock. + */ + public String getUserId() { + return userId; + } + + /** + * The time the lock was created. + */ + public Instant getTimestamp() { + return timestamp; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/FileLockCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/FileLockCommandBuilder.java new file mode 100644 index 0000000000..fe38c02fe8 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/FileLockCommandBuilder.java @@ -0,0 +1,160 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.spi.FileLockCommand; +import sonia.scm.repository.spi.LockCommandRequest; +import sonia.scm.repository.spi.LockStatusCommandRequest; +import sonia.scm.repository.spi.UnlockCommandRequest; + +import java.util.Collection; +import java.util.Optional; + +/** + * Can lock and unlock files and check lock states. Locked files can only be modified by the user holding the lock. + * + * @since 2.26.0 + */ +public final class FileLockCommandBuilder { + + private final FileLockCommand fileLockCommand; + private final Repository repository; + + public FileLockCommandBuilder(FileLockCommand fileLockCommand, Repository repository) { + this.fileLockCommand = fileLockCommand; + this.repository = repository; + } + + /** + * Creates builder to lock the given file. + * + * @param file The file to lock. + * @return Builder for lock creation. + */ + public InnerLockCommandBuilder lock(String file) { + RepositoryPermissions.push(repository).check(); + return new InnerLockCommandBuilder(file); + } + + /** + * Creates builder to unlock the given file. + * + * @param file The file to unlock. + * @return Builder to unlock a file. + */ + public InnerUnlockCommandBuilder unlock(String file) { + RepositoryPermissions.push(repository).check(); + return new InnerUnlockCommandBuilder(file); + } + + /** + * Retrieves the lock for a file, if it is locked. + * + * @param file The file to get the lock for. + * @return {@link Optional} with the lock, if the file is locked, + * or {@link Optional#empty()}, if the file is not locked + */ + public Optional status(String file) { + LockStatusCommandRequest lockStatusCommandRequest = new LockStatusCommandRequest(); + lockStatusCommandRequest.setFile(file); + return fileLockCommand.status(lockStatusCommandRequest); + } + + /** + * Retrieves all locks for the repository. + * + * @return Collection of all locks for the repository. + */ + public Collection getAll() { + return fileLockCommand.getAll(); + } + + public class InnerLockCommandBuilder { + private final String file; + + public InnerLockCommandBuilder(String file) { + this.file = file; + } + + /** + * Creates the lock. + * + * @return The result of the lock creation. + * @throws FileLockedException if the file is already locked. + */ + public LockCommandResult execute() { + LockCommandRequest lockCommandRequest = new LockCommandRequest(); + lockCommandRequest.setFile(file); + return fileLockCommand.lock(lockCommandRequest); + } + } + + public class InnerUnlockCommandBuilder { + private final String file; + private boolean force; + + public InnerUnlockCommandBuilder(String file) { + this.file = file; + } + + /** + * Set the command to force unlock. Shortcur for force(true). + * + * @return This builder instance. + * @see #force(boolean) + */ + public InnerUnlockCommandBuilder force() { + return force(true); + } + + /** + * Set whether to force unlock or not. A lock from a different user can only + * be removed with force set to true. + * + * @param force Whether to force unlock or not. + * @return This builder instance. + */ + public InnerUnlockCommandBuilder force(boolean force) { + this.force = force; + return this; + } + + /** + * Remove the lock. + * + * @return The result of the lock removal. + * @throws FileLockedException if the file is locked by another user and {@link #force(boolean)} has not been + * set to true. + */ + public UnlockCommandResult execute() { + UnlockCommandRequest unlockCommandRequest = new UnlockCommandRequest(); + unlockCommandRequest.setFile(file); + unlockCommandRequest.setForce(force); + return fileLockCommand.unlock(unlockCommandRequest); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/FileLockedException.java b/scm-core/src/main/java/sonia/scm/repository/api/FileLockedException.java new file mode 100644 index 0000000000..432a78c87f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/FileLockedException.java @@ -0,0 +1,78 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import sonia.scm.ExceptionWithContext; +import sonia.scm.repository.NamespaceAndName; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +/** + * Exception thrown whenever a locked file should be modified or locked/unlocked by a user that does not hold the lock. + * + * @since 2.26.0 + */ +public class FileLockedException extends ExceptionWithContext { + + private static final String CODE = "3mSmwOtOd1"; + + private final FileLock conflictingLock; + + /** + * Creates the exception. + * + * @param namespaceAndName The namespace and name of the repository. + * @param lock The lock causing this exception. + */ + public FileLockedException(NamespaceAndName namespaceAndName, FileLock lock) { + this(namespaceAndName, lock, ""); + } + + /** + * Creates the exception with an additional message. + * + * @param namespaceAndName The namespace and name of the repository. + * @param lock The lock causing this exception. + * @param additionalMessage An additional message that will be appended to the default message. + */ + public FileLockedException(NamespaceAndName namespaceAndName, FileLock lock, String additionalMessage) { + super( + entity("File Lock", lock.getPath()).in(namespaceAndName).build(), + ("File " + lock.getPath() + " locked by " + lock.getUserId() + ". " + additionalMessage).trim()); + this.conflictingLock = lock; + } + + @Override + public String getCode() { + return CODE; + } + + /** + * The lock that caused this exception. + */ + public FileLock getConflictingLock() { + return conflictingLock; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/LockCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/LockCommandResult.java new file mode 100644 index 0000000000..101aa3ded7 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/LockCommandResult.java @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import lombok.AllArgsConstructor; + +/** + * Result of a lock command. + * + * @since 2.26.0 + */ +@AllArgsConstructor +public class LockCommandResult { + private final boolean successful; + + /** + * If true, the lock has been set successfully. + */ + public boolean isSuccessful() { + return successful; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index b64e4998c7..4c369c1a4a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -477,6 +477,19 @@ public final class RepositoryService implements Closeable { return new MirrorCommandBuilder(provider.getMirrorCommand(), repository); } + /** + * Lock and unlock files. + * + * @return instance of {@link FileLockCommandBuilder} + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + * @since 2.26.0 + */ + public FileLockCommandBuilder getLockCommand() { + LOG.debug("create lock command for repository {}", repository); + return new FileLockCommandBuilder(provider.getFileLockCommand(), repository); + } + /** * Returns true if the command is supported by the repository service. * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/UnlockCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/UnlockCommandResult.java new file mode 100644 index 0000000000..3f7d7e6d70 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/UnlockCommandResult.java @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import lombok.AllArgsConstructor; + +/** + * Result of a unlock command. + * + * @since 2.26.0 + */ +@AllArgsConstructor +public class UnlockCommandResult { + private boolean successful; + + /** + * If true, the lock has been removed successfully. + */ + public boolean isSuccessful() { + return successful; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/FileLockCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/FileLockCommand.java new file mode 100644 index 0000000000..12838b9283 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/FileLockCommand.java @@ -0,0 +1,72 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.LockCommandResult; +import sonia.scm.repository.api.UnlockCommandResult; + +import java.util.Collection; +import java.util.Optional; + +/** + * Interface for lock implementations. + * + * @since 2.26.0 + */ +public interface FileLockCommand { + + /** + * Locks a given file. + * + * @param request The details of the lock creation. + * @return The result of the lock creation. + * @throws sonia.scm.repository.api.FileLockedException if the file is already locked. + */ + LockCommandResult lock(LockCommandRequest request); + + /** + * Unlocks a given file. + * + * @param request The details of the lock removal. + * @return The result of the lock removal. + * @throws sonia.scm.repository.api.FileLockedException if the file is locked and the lock cannot be removed. + */ + UnlockCommandResult unlock(UnlockCommandRequest request); + + /** + * Returns the lock of a file, if it exists. + * + * @param request Details of the status request. + * @return {@link Optional} with the lock, if the file is locked, + * or {@link Optional#empty()}, if the file is not locked + */ + Optional status(LockStatusCommandRequest request); + + /** + * Returns all locks for the repository. + */ + Collection getAll(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/LockCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/LockCommandRequest.java new file mode 100644 index 0000000000..3949bf3954 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/LockCommandRequest.java @@ -0,0 +1,38 @@ +/* + * 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 lombok.Data; + +/** + * Request used to lock a file. + * + * @since 2.26.0 + */ +@Data +public final class LockCommandRequest { + + private String file; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/LockStatusCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/LockStatusCommandRequest.java new file mode 100644 index 0000000000..08e48fd10f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/LockStatusCommandRequest.java @@ -0,0 +1,32 @@ +/* + * 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 lombok.Data; + +@Data +public final class LockStatusCommandRequest { + private String file; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index b4d9939ace..287bd2cb33 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -229,10 +229,9 @@ public abstract class RepositoryServiceProvider implements Closeable * * @return */ - @SuppressWarnings("unchecked") public Set getSupportedFeatures() { - return Collections.EMPTY_SET; + return Collections.emptySet(); } /** @@ -305,4 +304,11 @@ public abstract class RepositoryServiceProvider implements Closeable public MirrorCommand getMirrorCommand() { throw new CommandNotSupportedException(Command.MIRROR); } + + /** + * @since 2.26.0 + */ + public FileLockCommand getFileLockCommand() { + throw new CommandNotSupportedException(Command.FILE_LOCK); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/UnlockCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/UnlockCommandRequest.java new file mode 100644 index 0000000000..28fe31cf3f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/UnlockCommandRequest.java @@ -0,0 +1,39 @@ +/* + * 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 lombok.Data; + +/** + * Request to unlock a file. + * + * @since 2.26.0 + */ +@Data +public final class UnlockCommandRequest { + + private String file; + private boolean force; +} diff --git a/scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java b/scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java new file mode 100644 index 0000000000..5937e19437 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.web; + +import sonia.scm.plugin.ExtensionPoint; + +import javax.servlet.http.HttpServletRequest; + +/** + * This can be used to determine, whether a web request should be handled as a scm client request. + * + * @since 2.26.0 + */ +@ExtensionPoint +public interface ScmClientDetector { + + /** + * Checks whether the given request and/or the userAgent imply a request from a scm client. + * + * @param request The request to check. + * @param userAgent The {@link UserAgent} for the request. + * @return true if the given request was sent by an scm client. + */ + boolean isScmClient(HttpServletRequest request, UserAgent userAgent); +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileLockPreCommitHook.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileLockPreCommitHook.java new file mode 100644 index 0000000000..4a99b8eb7a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileLockPreCommitHook.java @@ -0,0 +1,118 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.github.legman.Subscribe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.EagerSingleton; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Modifications; +import sonia.scm.repository.PreReceiveRepositoryHookEvent; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; + +import javax.inject.Inject; +import java.io.IOException; + +@Extension +@EagerSingleton +public class FileLockPreCommitHook { + + private static final Logger LOG = LoggerFactory.getLogger(FileLockPreCommitHook.class); + + private final GitFileLockStoreFactory fileLockStoreFactory; + private final RepositoryServiceFactory serviceFactory; + + @Inject + public FileLockPreCommitHook(GitFileLockStoreFactory fileLockStoreFactory, RepositoryServiceFactory serviceFactory) { + this.fileLockStoreFactory = fileLockStoreFactory; + this.serviceFactory = serviceFactory; + } + + @Subscribe(async = false) + public void checkForLocks(PreReceiveRepositoryHookEvent event) { + Repository repository = event.getRepository(); + LOG.trace("checking for locks during push in repository {}", repository); + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = fileLockStoreFactory.create(repository); + if (!gitFileLockStore.hasLocks()) { + LOG.trace("no locks found in repository {}", repository); + return; + } + try (RepositoryService service = serviceFactory.create(repository)) { + checkPaths(event, gitFileLockStore, service); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "could not check locks", e); + } + } + + private void checkPaths(PreReceiveRepositoryHookEvent event, GitFileLockStoreFactory.GitFileLockStore gitFileLockStore, RepositoryService service) throws IOException { + new Checker(gitFileLockStore, service) + .checkPaths(event.getContext().getChangesetProvider().getChangesets()); + } + + private static class Checker { + + private final GitFileLockStoreFactory.GitFileLockStore fileLockStore; + private final RepositoryService service; + + private Checker(GitFileLockStoreFactory.GitFileLockStore fileLockStore, RepositoryService service) { + this.fileLockStore = fileLockStore; + this.service = service; + } + + private void checkPaths(Iterable changesets) throws IOException { + for (Changeset c : changesets) { + checkPaths(c); + } + } + + private void checkPaths(Changeset changeset) throws IOException { + LOG.trace("checking changeset {}", changeset.getId()); + Modifications modifications = service.getModificationsCommand() + .revision(changeset.getId()) + .getModifications(); + + if (modifications != null) { + checkPaths(modifications); + } else { + LOG.trace("no modifications for the changeset {} found", changeset.getId()); + } + } + + private void checkPaths(Modifications modifications) { + check(modifications.getEffectedPaths()); + } + + private void check(Iterable modifiedPaths) { + for (String path : modifiedPaths) { + fileLockStore.assertModifiable(path); + } + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockCommand.java new file mode 100644 index 0000000000..cbcc4b835e --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockCommand.java @@ -0,0 +1,76 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.LockCommandResult; +import sonia.scm.repository.api.UnlockCommandResult; +import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore; + +import javax.inject.Inject; +import java.util.Collection; +import java.util.Optional; + +public class GitFileLockCommand implements FileLockCommand { + + private final GitContext context; + private final GitFileLockStoreFactory lockStoreFactory; + + @Inject + public GitFileLockCommand(GitContext context, GitFileLockStoreFactory lockStoreFactory) { + this.context = context; + this.lockStoreFactory = lockStoreFactory; + } + + @Override + public LockCommandResult lock(LockCommandRequest request) { + GitFileLockStore lockStore = getLockStore(); + lockStore.put(request.getFile()); + return new LockCommandResult(true); + } + + @Override + public UnlockCommandResult unlock(UnlockCommandRequest request) { + GitFileLockStore lockStore = getLockStore(); + lockStore.remove(request.getFile(), request.isForce()); + return new UnlockCommandResult(true); + } + + @Override + public Optional status(LockStatusCommandRequest request) { + GitFileLockStore lockStore = getLockStore(); + return lockStore.getLock(request.getFile()); + } + + @Override + public Collection getAll() { + GitFileLockStore lockStore = getLockStore(); + return lockStore.getAll(); + } + + private GitFileLockStore getLockStore() { + return lockStoreFactory.create(context.getRepository()); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockStoreFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockStoreFactory.java new file mode 100644 index 0000000000..c3583a76cc --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFileLockStoreFactory.java @@ -0,0 +1,250 @@ +/* + * 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 lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.shiro.SecurityUtils; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.FileLockedException; +import sonia.scm.security.KeyGenerator; +import sonia.scm.store.DataStore; +import sonia.scm.store.DataStoreFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlTransient; +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.Supplier; + +import static java.util.Collections.emptyList; +import static java.util.Optional.empty; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.toList; + +@Singleton +public final class GitFileLockStoreFactory { + + private static final String STORE_ID = "locks"; + + private final DataStoreFactory dataStoreFactory; + private final KeyGenerator keyGenerator; + private final Clock clock; + private final Supplier currentUser; + + @Inject + public GitFileLockStoreFactory(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator) { + this(dataStoreFactory, + keyGenerator, + Clock.systemDefaultZone(), + () -> SecurityUtils.getSubject().getPrincipal().toString()); + } + + GitFileLockStoreFactory(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator, Clock clock, Supplier currentUser) { + this.dataStoreFactory = dataStoreFactory; + this.keyGenerator = keyGenerator; + this.clock = clock; + this.currentUser = currentUser; + } + + public GitFileLockStore create(Repository repository) { + return new GitFileLockStore(repository); + } + + public final class GitFileLockStore { + + private final Repository repository; + private final DataStore store; + + public GitFileLockStore(Repository repository) { + this.repository = repository; + this.store = + dataStoreFactory + .withType(StoreEntry.class) + .withName("file-locks") + .forRepository(repository) + .build(); + } + + public boolean hasLocks() { + return !readEntry().isEmpty(); + } + + public FileLock put(String file) { + StoreEntry storeEntry = readEntry(); + Optional existingLock = storeEntry.get(file); + if (existingLock.isPresent() && !existingLock.get().getUserId().equals(currentUser.get())) { + throw createLockException(existingLock.get()); + } + FileLock newLock = new FileLock(file, keyGenerator.createKey(), currentUser.get(), Instant.now(clock)); + storeEntry.add(newLock); + store(storeEntry); + return newLock; + } + + public Optional remove(String file, boolean force) { + StoreEntry storeEntry = readEntry(); + Optional existingFileLock = storeEntry.get(file); + if (existingFileLock.isPresent()) { + if (!force && !currentUser.get().equals(existingFileLock.get().getUserId())) { + throw createLockException(existingFileLock.get()); + } + storeEntry.remove(file); + store(storeEntry); + } + return existingFileLock; + } + + public Optional removeById(String id, boolean force) { + StoreEntry storeEntry = readEntry(); + return storeEntry.getById(id).flatMap(lock -> remove(lock.getPath(), force)); + } + + public Optional getLock(String file) { + return readEntry().get(file); + } + + public void assertModifiable(String file) { + getLock(file) + .filter(lock -> !lock.getUserId().equals(currentUser.get())) + .ifPresent(lock -> { + throw createLockException(lock); + }); + } + + public Optional getById(String id) { + return readEntry().getById(id); + } + + public Collection getAll() { + return readEntry().getAll(); + } + + private StoreEntry readEntry() { + return store.getOptional(STORE_ID).orElse(new StoreEntry()); + } + + private void store(StoreEntry storeEntry) { + store.put(STORE_ID, storeEntry); + } + + private FileLockedException createLockException(FileLock lock) { + return new FileLockedException(repository.getNamespaceAndName(), lock, "Lock or unlock with git lfs lock/unlock ."); + } + } + + @XmlRootElement(name = "file-locks") + @XmlAccessorType(XmlAccessType.PROPERTY) + private static class StoreEntry { + private Map files = new TreeMap<>(); + @XmlTransient + private final Map ids = new HashMap<>(); + + public void setFiles(Map files) { + this.files = files; + files.values().forEach( + lock -> ids.put(lock.getId(), lock) + ); + } + + public Map getFiles() { + return files; + } + + Optional get(String file) { + if (files == null) { + return empty(); + } + return ofNullable(files.get(file)).map(StoredFileLock::toFileLock); + } + + Optional getById(String id) { + if (files == null) { + return empty(); + } + return ofNullable(ids.get(id)).map(StoredFileLock::toFileLock); + } + + void add(FileLock lock) { + if (files == null) { + files = new TreeMap<>(); + } + StoredFileLock newLock = new StoredFileLock(lock); + files.put(lock.getPath(), newLock); + ids.put(lock.getId(), newLock); + } + + void remove(String file) { + if (files == null) { + return; + } + StoredFileLock existingLock = files.remove(file); + if (existingLock != null) { + ids.remove(existingLock.getId()); + } + } + + Collection getAll() { + if (files == null) { + return emptyList(); + } + return files.values().stream().map(StoredFileLock::toFileLock).collect(toList()); + } + + boolean isEmpty() { + return files == null || files.isEmpty(); + } + } + + @Data + @NoArgsConstructor + private static class StoredFileLock { + private String id; + private String path; + private String userId; + private long timestamp; + + StoredFileLock(FileLock fileLock) { + this.id = fileLock.getId(); + this.path = fileLock.getPath(); + this.userId = fileLock.getUserId(); + this.timestamp = fileLock.getTimestamp().getEpochSecond(); + } + + FileLock toFileLock() { + return new FileLock(path, id, userId, Instant.ofEpochSecond(timestamp)); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index a3613a3bf6..ba4bd19b1f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -57,7 +57,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { Command.MODIFY, Command.BUNDLE, Command.UNBUNDLE, - Command.MIRROR + Command.MIRROR, + Command.FILE_LOCK ); protected static final Set FEATURES = EnumSet.of( @@ -180,6 +181,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { return commandInjector.getInstance(GitMirrorCommand.class); } + @Override + public FileLockCommand getFileLockCommand() { + return commandInjector.getInstance(GitFileLockCommand.class); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java new file mode 100644 index 0000000000..dbd4d43bce --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java @@ -0,0 +1,41 @@ +/* + * 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.web; + +import sonia.scm.plugin.Extension; + +import javax.servlet.http.HttpServletRequest; + +@Extension +public class GitLfsLockApiDetector implements ScmClientDetector { + + public static final String LOCK_APPLICATION_TYPE = "application/vnd.git-lfs+json"; + + @Override + public boolean isScmClient(HttpServletRequest request, UserAgent userAgent) { + return LOCK_APPLICATION_TYPE.equals(request.getHeader("Content-Type")) + || LOCK_APPLICATION_TYPE.equals(request.getHeader("Accept")); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/LfsLockingProtocolServlet.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/LfsLockingProtocolServlet.java new file mode 100644 index 0000000000..bbc815a486 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/LfsLockingProtocolServlet.java @@ -0,0 +1,505 @@ +/* + * 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.web; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.sdorra.ssp.PermissionCheck; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.Value; +import org.apache.shiro.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.TransactionId; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.FileLockedException; +import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore; +import sonia.scm.user.DisplayUser; +import sonia.scm.user.UserDisplayManager; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.Integer.parseInt; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.util.Collections.emptyList; +import static java.util.Optional.empty; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; +import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; +import static javax.servlet.http.HttpServletResponse.SC_CREATED; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_OK; + +public class LfsLockingProtocolServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(LfsLockingProtocolServlet.class); + private static final Pattern GET_PATH_PATTERN = Pattern.compile(".*\\.git/info/lfs/locks"); + private static final Pattern POST_PATH_PATTERN = Pattern.compile(".*\\.git/info/lfs/locks(?:/(verify|(\\w+)/unlock))?"); + + private static final int DEFAULT_LIMIT = 1000; + private static final int LOWER_LIMIT = 10; + + private final Repository repository; + private final GitFileLockStore lockStore; + private final UserDisplayManager userDisplayManager; + private final ObjectMapper objectMapper; + private final int defaultLimit; + private final int lowerLimit; + + public LfsLockingProtocolServlet(Repository repository, GitFileLockStore lockStore, UserDisplayManager userDisplayManager, ObjectMapper objectMapper) { + this(repository, lockStore, userDisplayManager, objectMapper, DEFAULT_LIMIT, LOWER_LIMIT); + } + + LfsLockingProtocolServlet(Repository repository, GitFileLockStore lockStore, UserDisplayManager userDisplayManager, ObjectMapper objectMapper, int defaultLimit, int lowerLimit) { + this.repository = repository; + this.lockStore = lockStore; + this.userDisplayManager = userDisplayManager; + this.objectMapper = objectMapper; + this.defaultLimit = defaultLimit; + this.lowerLimit = lowerLimit; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + LOG.trace("processing GET request"); + new Handler(req, resp).handleGet(); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + LOG.trace("processing POST request"); + new Handler(req, resp).handlePost(); + } + + private class Handler { + private final HttpServletRequest req; + private final HttpServletResponse resp; + + public Handler(HttpServletRequest req, HttpServletResponse resp) { + this.req = req; + this.resp = resp; + } + + private void handleGet() { + if (getRequestValidator().verifyRequest()) { + if (!isNullOrEmpty(req.getParameter("path"))) { + handleSinglePathRequest(); + } else if (!isNullOrEmpty(req.getParameter("id"))) { + handleSingleIdRequest(); + } else { + handleGetAllRequest(); + } + } + } + + private void handlePost() { + PostRequestValidator validator = postRequestValidator(); + if (validator.verifyRequest()) { + if (validator.isLockRequest()) { + handleLockRequest(); + } else if (validator.isVerifyRequest()) { + handleVerifyRequest(); + } else { + handleUnlockRequest(validator.getLockId()); + } + } + } + + private void handleSinglePathRequest() { + LOG.trace("request limited to path: {}", req.getParameter("path")); + sendResult(SC_OK, new LocksListDto(lockStore.getLock(req.getParameter("path")))); + } + + private void handleSingleIdRequest() { + String id = req.getParameter("id"); + LOG.trace("request limited to id: {}", id); + sendResult(SC_OK, new LocksListDto(lockStore.getById(id))); + } + + private void handleGetAllRequest() { + int limit = getLimit(); + int cursor = getCursor(); + if (limit < 0 || cursor < 0) { + return; + } + Collection allLocks = lockStore.getAll(); + Stream resultLocks = limit(allLocks, limit, cursor); + LocksListDto result = new LocksListDto(resultLocks, computeNextCursor(limit, cursor, allLocks)); + LOG.trace("created list result with {} locks and next cursor {}", result.getLocks().size(), result.getNextCursor()); + sendResult(SC_OK, result); + } + + private String computeNextCursor(int limit, int cursor, Collection allLocks) { + return allLocks.size() > cursor + limit ? Integer.toString(cursor + limit) : null; + } + + private Stream limit(Collection allLocks, int limit, int cursor) { + return allLocks.stream().skip(cursor).limit(limit); + } + + private int getLimit() { + String limitString = req.getParameter("limit"); + if (isNullOrEmpty(limitString)) { + LOG.trace("using default limit {}", defaultLimit); + return defaultLimit; + } + try { + return getEffectiveLimit(parseInt(limitString)); + } catch (NumberFormatException e) { + LOG.trace("illegal limit parameter '{}'", limitString); + sendError(SC_BAD_REQUEST, "Illegal limit parameter"); + return -1; + } + } + + private int getEffectiveLimit(int limit) { + int effectiveLimit = max(lowerLimit, min(defaultLimit, limit)); + LOG.trace("using limit {}", effectiveLimit); + return effectiveLimit; + } + + private int getCursor() { + String cursor = req.getParameter("cursor"); + return getCursor(cursor); + } + + private int getCursor(String cursor) { + if (isNullOrEmpty(cursor)) { + return 0; + } + try { + int effectiveCursor = parseInt(cursor); + LOG.trace("starting at position {}", effectiveCursor); + return effectiveCursor; + } catch (NumberFormatException e) { + LOG.trace("illegal cursor parameter '{}'", cursor); + sendError(SC_BAD_REQUEST, "Illegal cursor parameter"); + return -1; + } + } + + private void handleLockRequest() { + LOG.trace("processing lock request"); + readObject(LockCreateDto.class).ifPresent( + lockCreate -> { + if (isNullOrEmpty(lockCreate.path)) { + sendError(SC_BAD_REQUEST, "Illegal input"); + } else { + try { + FileLock createdLock = lockStore.put(lockCreate.getPath()); + sendResult(SC_CREATED, new SingleLockDto(createdLock)); + } catch (FileLockedException e) { + FileLock conflictingLock = e.getConflictingLock(); + sendError(SC_CONFLICT, new ConflictDto("already created lock", conflictingLock)); + } + } + } + ); + } + + private void handleVerifyRequest() { + LOG.trace("processing verify request"); + readObject(VerifyDto.class).ifPresent( + verify -> { + Collection allLocks = lockStore.getAll(); + int cursor = getCursor(verify.getCursor()); + int limit = getEffectiveLimit(verify.getLimit()); + if (limit < 0 || cursor < 0) { + return; + } + Stream resultLocks = limit(allLocks, limit, cursor); + VerifyResultDto result = new VerifyResultDto(resultLocks, computeNextCursor(limit, cursor, allLocks)); + LOG.trace("created list result with {} 'our' locks, {} 'their' locks, and next cursor {}", result.getOurs().size(), result.getTheirs().size(), result.getNextCursor()); + sendResult(SC_OK, result); + } + ); + } + + private void handleUnlockRequest(String lockId) { + LOG.trace("processing unlock request"); + readObject(UnlockDto.class).ifPresent( + unlock -> { + try { + Optional deletedLock = lockStore.removeById(lockId, unlock.isForce()); + if (deletedLock.isPresent()) { + sendResult(SC_OK, new SingleLockDto(deletedLock.get())); + } else { + sendError(SC_NOT_FOUND, "No such lock"); + } + } catch (FileLockedException e) { + FileLock conflictingLock = e.getConflictingLock(); + sendError(SC_FORBIDDEN, "locked by " + conflictingLock.getUserId()); + } + } + ); + } + + private Optional readObject(Class resultType) { + try { + return ofNullable(objectMapper.readValue(req.getInputStream(), resultType)); + } catch (IOException e) { + LOG.info("got exception reading input", e); + sendError(SC_BAD_REQUEST, "Could not read input"); + return empty(); + } + } + + private GetRequestValidator getRequestValidator() { + return new GetRequestValidator(); + } + + private PostRequestValidator postRequestValidator() { + return new PostRequestValidator(); + } + + private abstract class RequestValidator { + + boolean verifyRequest() { + return verifyPath() && verifyPermission(); + } + + private boolean verifyPermission() { + if (!getPermission().isPermitted()) { + sendError(HttpServletResponse.SC_FORBIDDEN, "You must have push access to create a lock"); + return false; + } + return true; + } + + abstract PermissionCheck getPermission(); + + private boolean verifyPath() { + if (!isPathValid(req.getPathInfo())) { + LOG.trace("got illegal path {}", req.getPathInfo()); + sendError(HttpServletResponse.SC_BAD_REQUEST, "wrong URL for locks api"); + return false; + } + return true; + } + + abstract boolean isPathValid(String path); + } + + private class GetRequestValidator extends RequestValidator { + + @Override + PermissionCheck getPermission() { + return RepositoryPermissions.pull(repository); + } + + @Override + boolean isPathValid(String path) { + return GET_PATH_PATTERN.matcher(path).matches(); + } + } + + private class PostRequestValidator extends RequestValidator { + + private Matcher matcher; + + @Override + PermissionCheck getPermission() { + return RepositoryPermissions.push(repository); + } + + @Override + boolean isPathValid(String path) { + matcher = POST_PATH_PATTERN.matcher(path); + return matcher.matches(); + } + + boolean isLockRequest() { + return matcher.group(1) == null; + } + + boolean isVerifyRequest() { + String subPath = matcher.group(1); + return subPath.equals("verify"); + } + + public String getLockId() { + return matcher.group(2); + } + } + + private void sendResult(int statusCode, Object result) { + LOG.trace("Completing with status code {}", statusCode); + resp.setStatus(statusCode); + resp.setContentType("application/vnd.git-lfs+json"); + try { + objectMapper.writeValue(resp.getOutputStream(), result); + } catch (IOException e) { + LOG.warn("Failed to send result to client", e); + } + } + + private void sendError(int statusCode, String message) { + LOG.trace("Sending error message '{}'", message); + sendError(statusCode, new ErrorMessageDto(message)); + } + + private void sendError(int statusCode, Object error) { + LOG.trace("Completing with error, status code {}", statusCode); + resp.setStatus(statusCode); + try { + objectMapper.writeValue(resp.getOutputStream(), error); + } catch (IOException e) { + LOG.warn("Failed to send error to client", e); + } + } + } + + @Value + private class ConflictDto extends ErrorMessageDto { + private LockDto lock; + + public ConflictDto(String message, FileLock lock) { + super(message); + this.lock = new LockDto(lock); + } + } + + @Getter + @AllArgsConstructor + private class ErrorMessageDto { + private String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String requestId; + + public ErrorMessageDto(String message) { + this.message = message; + this.requestId = TransactionId.get().orElse(null); + } + } + + @Data + static class LockCreateDto { + private String path; + } + + @Data + static class VerifyDto { + private String cursor; + private int limit = Integer.MAX_VALUE; + } + + @Data + static class UnlockDto { + private boolean force; + } + + @Value + private class VerifyResultDto { + private Collection ours; + private Collection theirs; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("next_cursor") + private String nextCursor; + + VerifyResultDto(Stream locks, String nextCursor) { + String userId = SecurityUtils.getSubject().getPrincipals().oneByType(String.class); + Map> groupedLocks = locks.map(LockDto::new).collect(groupingBy(lock -> userId.equals(lock.getUserId()))); + ours = groupedLocks.getOrDefault(true, emptyList()); + theirs = groupedLocks.getOrDefault(false, emptyList()); + this.nextCursor = nextCursor; + } + } + + @Getter + private class OwnerDto { + private String name; + + OwnerDto(String userId) { + this.name = userDisplayManager.get(userId).map(DisplayUser::getDisplayName).orElse(userId); + } + } + + @Getter + private class SingleLockDto { + private LockDto lock; + + SingleLockDto(FileLock lock) { + this.lock = new LockDto(lock); + } + } + + @Getter + private class LockDto { + private String id; + private String path; + @JsonProperty("locked_at") + private String lockedAt; + private OwnerDto owner; + @JsonIgnore + private String userId; + + LockDto(FileLock lock) { + this.id = lock.getId(); + this.path = lock.getPath(); + this.lockedAt = lock.getTimestamp().toString(); + this.owner = new OwnerDto(lock.getUserId()); + this.userId = lock.getUserId(); + } + } + + @Getter + private class LocksListDto { + private List locks; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("next_cursor") + private String nextCursor; + + LocksListDto(Optional locks) { + this.locks = locks.map(LockDto::new).map(Collections::singletonList).orElse(emptyList()); + } + + LocksListDto(Stream locks, String nextCursor) { + this.locks = locks.map(LockDto::new).collect(toList()); + this.nextCursor = nextCursor; + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java index 034e8fa84d..b520baa8b5 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java @@ -40,6 +40,7 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.MediaType; import java.io.IOException; import java.util.regex.Pattern; @@ -65,6 +66,7 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet /** the logger for ScmGitServlet */ private static final Logger logger = getLogger(ScmGitServlet.class); + public static final MediaType LFS_LOCKING_MEDIA_TYPE = MediaType.valueOf("application/vnd.git-lfs+json"); //~--- constructors --------------------------------------------------------- @@ -109,6 +111,10 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet HttpServlet servlet = lfsServletFactory.createFileLfsServletFor(repository, request); logger.trace("handle lfs file transfer request"); handleGitLfsRequest(servlet, request, response, repository); + } else if (isLfsLockingAPIRequest(request)) { + HttpServlet servlet = lfsServletFactory.createLockServletFor(repository); + logger.trace("handle lfs lock request"); + handleGitLfsLockingRequest(servlet, request, response, repository); } else if (isRegularGitAPIRequest(request)) { logger.trace("handle regular git request"); // continue with the regular git Backend @@ -119,10 +125,34 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet } } + private void handleGitLfsLockingRequest(HttpServlet servlet, HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException { + if (repositoryRequestListenerUtil.callListeners(request, response, repository)) { + servlet.service(request, response); + } else if (logger.isDebugEnabled()) { + logger.debug("request aborted by repository request listener"); + } + } + private boolean isRegularGitAPIRequest(HttpServletRequest request) { return REGEX_GITHTTPBACKEND.matcher(HttpUtil.getStrippedURI(request)).matches(); } + private boolean isLfsLockingAPIRequest(HttpServletRequest request) { + return isLfsLockingMediaType(request, "Content-Type") + || isLfsLockingMediaType(request, "Accept"); + } + + private boolean isLfsLockingMediaType(HttpServletRequest request, String header) { + try { + MediaType requestMediaType = MediaType.valueOf(request.getHeader(header)); + return !requestMediaType.isWildcardType() + && !requestMediaType.isWildcardSubtype() + && LFS_LOCKING_MEDIA_TYPE.isCompatible(requestMediaType); + } catch (IllegalArgumentException e) { + return false; + } + } + private void handleGitLfsRequest(HttpServlet servlet, HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException { if (repositoryRequestListenerUtil.callListeners(request, response, repository)) { servlet.service(request, response); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java index 6d95251825..541e935899 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java @@ -24,6 +24,7 @@ package sonia.scm.web.lfs.servlet; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; import org.eclipse.jgit.lfs.server.LargeFileRepository; import org.eclipse.jgit.lfs.server.LfsProtocolServlet; @@ -31,8 +32,11 @@ import org.eclipse.jgit.lfs.server.fs.FileLfsServlet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.Repository; +import sonia.scm.repository.spi.GitFileLockStoreFactory; import sonia.scm.store.BlobStore; +import sonia.scm.user.UserDisplayManager; import sonia.scm.util.HttpUtil; +import sonia.scm.web.LfsLockingProtocolServlet; import sonia.scm.web.lfs.LfsAccessTokenFactory; import sonia.scm.web.lfs.LfsBlobStoreFactory; import sonia.scm.web.lfs.ScmBlobLfsRepository; @@ -56,11 +60,17 @@ public class LfsServletFactory { private final LfsBlobStoreFactory lfsBlobStoreFactory; private final LfsAccessTokenFactory tokenFactory; + private final GitFileLockStoreFactory lockStoreFactory; + private final UserDisplayManager userDisplayManager; + private final ObjectMapper objectMapper; @Inject - public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory) { + public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory, GitFileLockStoreFactory lockStoreFactory, UserDisplayManager userDisplayManager, ObjectMapper objectMapper) { this.lfsBlobStoreFactory = lfsBlobStoreFactory; this.tokenFactory = tokenFactory; + this.lockStoreFactory = lockStoreFactory; + this.userDisplayManager = userDisplayManager; + this.objectMapper = objectMapper; } /** @@ -91,6 +101,11 @@ public class LfsServletFactory { return new ScmFileTransferServlet(lfsBlobStoreFactory.getLfsBlobStore(repository)); } + public LfsLockingProtocolServlet createLockServletFor(Repository repository) { + LOG.trace("create lfs lock servlet for repository {}", repository); + return new LfsLockingProtocolServlet(repository, lockStoreFactory.create(repository), userDisplayManager, objectMapper); + } + /** * Build the complete URI, under which the File Transfer API for this repository will be will be reachable. * diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/FileLockPreCommitHookTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/FileLockPreCommitHookTest.java new file mode 100644 index 0000000000..a90451a5eb --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/FileLockPreCommitHookTest.java @@ -0,0 +1,151 @@ +/* + * 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.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.Modifications; +import sonia.scm.repository.Modified; +import sonia.scm.repository.PreReceiveRepositoryHookEvent; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.HookChangesetBuilder; +import sonia.scm.repository.api.HookContext; +import sonia.scm.repository.api.ModificationsCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; + +import java.io.IOException; + +import static java.util.Arrays.asList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; + +@ExtendWith(MockitoExtension.class) +class FileLockPreCommitHookTest { + + private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); + + @Mock + private GitFileLockStoreFactory fileLockStoreFactory; + @Mock + private GitFileLockStoreFactory.GitFileLockStore fileLockStore; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService service; + + @InjectMocks + private FileLockPreCommitHook hook; + + @Mock + private HookContext context; + + @BeforeEach + void initLockStore() { + when(fileLockStoreFactory.create(REPOSITORY)) + .thenReturn(fileLockStore); + } + + @Test + void shouldIgnoreRepositoriesWithoutLockSupport() { + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + + hook.checkForLocks(event); + + verify(serviceFactory, never()).create(any(Repository.class)); + } + + @Nested + class WithLocks { + + @Mock + private HookChangesetBuilder changesetBuilder; + @Mock(answer = Answers.RETURNS_SELF) + private ModificationsCommandBuilder modificationsCommand; + + private String currentChangesetId; + + @BeforeEach + void initService() { + when(serviceFactory.create(REPOSITORY)) + .thenReturn(service); + when(service.getModificationsCommand()) + .thenReturn(modificationsCommand); + when(context.getChangesetProvider()) + .thenReturn(changesetBuilder); + } + + @BeforeEach + void initLocks() { + when(fileLockStore.hasLocks()).thenReturn(true); + } + + @BeforeEach + void initModifications() throws IOException { + when(modificationsCommand.revision(anyString())) + .thenAnswer(invocation -> { + currentChangesetId = invocation.getArgument(0, String.class); + return modificationsCommand; + }); + when(modificationsCommand.getModifications()) + .thenAnswer(invocation -> + new Modifications( + currentChangesetId, + new Modified("path-1-" + currentChangesetId), + new Modified("path-2-" + currentChangesetId) + )); + } + + @Test + void shouldCheckAllPathsForLocks() { + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + when(changesetBuilder.getChangesets()) + .thenReturn(asList( + new Changeset("1", null, null), + new Changeset("2", null, null) + )); + + hook.checkForLocks(event); + + verify(fileLockStore).assertModifiable("path-1-1"); + verify(fileLockStore).assertModifiable("path-2-1"); + verify(fileLockStore).assertModifiable("path-1-2"); + verify(fileLockStore).assertModifiable("path-2-2"); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockCommandTest.java new file mode 100644 index 0000000000..2639ea93bc --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockCommandTest.java @@ -0,0 +1,145 @@ +/* + * 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.assertj.core.api.AbstractObjectAssert; +import org.junit.jupiter.api.BeforeEach; +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.repository.Repository; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.LockCommandResult; +import sonia.scm.repository.api.UnlockCommandResult; +import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Optional; + +import static java.util.Optional.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitFileLockCommandTest { + + private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); + private static final Instant NOW = Instant.ofEpochSecond(-562031958); + + @Mock + private GitContext context; + @Mock + private GitFileLockStoreFactory lockStoreFactory; + @Mock + private GitFileLockStore lockStore; + + @InjectMocks + private GitFileLockCommand lockCommand; + + @BeforeEach + void initContext() { + when(context.getRepository()).thenReturn(REPOSITORY); + } + + @BeforeEach + void initStoreFactory() { + when(lockStoreFactory.create(REPOSITORY)).thenReturn(lockStore); + } + + @Test + void shouldSetLockOnLockRequest() { + LockCommandRequest request = new LockCommandRequest(); + request.setFile("some/file.txt"); + + LockCommandResult lock = lockCommand.lock(request); + + assertThat(lock.isSuccessful()).isTrue(); + verify(lockStore).put("some/file.txt"); + } + + @Test + void shouldUnlockOnUnlockRequest() { + UnlockCommandRequest request = new UnlockCommandRequest(); + request.setFile("some/file.txt"); + + UnlockCommandResult lock = lockCommand.unlock(request); + + assertThat(lock.isSuccessful()).isTrue(); + verify(lockStore).remove("some/file.txt", false); + } + + @Test + void shouldUnlockWithForceOnUnlockRequestWithForce() { + UnlockCommandRequest request = new UnlockCommandRequest(); + request.setFile("some/file.txt"); + request.setForce(true); + + UnlockCommandResult lock = lockCommand.unlock(request); + + assertThat(lock.isSuccessful()).isTrue(); + verify(lockStore).remove("some/file.txt", true); + } + + @Test + void shouldGetStatus() { + when(lockStore.getLock("some/file.txt")) + .thenReturn(of(new FileLock("some/file.txt", "42", "dent", NOW))); + + LockStatusCommandRequest request = new LockStatusCommandRequest(); + request.setFile("some/file.txt"); + + Optional status = lockCommand.status(request); + + AbstractObjectAssert statusAssert = assertThat(status).get(); + statusAssert + .extracting("id") + .isEqualTo("42"); + statusAssert + .extracting("path") + .isEqualTo("some/file.txt"); + statusAssert + .extracting("userId") + .isEqualTo("dent"); + statusAssert + .extracting("timestamp") + .isEqualTo(NOW); + } + + @Test + void shouldGetAll() { + ArrayList existingLocks = new ArrayList<>(); + when(lockStore.getAll()).thenReturn(existingLocks); + + Collection all = lockCommand.getAll(); + + assertThat(all).isSameAs(existingLocks); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockStoreFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockStoreFactoryTest.java new file mode 100644 index 0000000000..3f2a309a7b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitFileLockStoreFactoryTest.java @@ -0,0 +1,232 @@ +/* + * 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.assertj.core.api.AbstractObjectAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.FileLockedException; +import sonia.scm.store.DataStoreFactory; +import sonia.scm.store.InMemoryByteDataStoreFactory; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitFileLockStoreFactoryTest { + + private static final Instant NOW = Instant.ofEpochSecond(-562031958); + + private final DataStoreFactory dataStoreFactory = new InMemoryByteDataStoreFactory(); + private final Clock clock = mock(Clock.class); + private String currentUser = "dent"; + private int nextId = 0; + private final GitFileLockStoreFactory gitFileLockStoreFactory = + new GitFileLockStoreFactory(dataStoreFactory, () -> "id-" + (nextId++), clock, () -> currentUser); + + private final Repository repository = RepositoryTestData.createHeartOfGold(); + + @BeforeEach + void setClock() { + when(clock.instant()).thenReturn(NOW); + } + + @Test + void shouldHaveNoLockOnStartup() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .isEmpty(); + assertThat(gitFileLockStore.getAll()) + .isEmpty(); + assertThat(gitFileLockStore.hasLocks()) + .isFalse(); + } + + @Test + void shouldNotFailOnRemovingNonExistingLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + gitFileLockStore.remove("some/file.txt", false); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .isEmpty(); + } + + @Test + void shouldStoreAndRetrieveLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + FileLock createdLock = gitFileLockStore.put("some/file.txt"); + + Optional retrievedLock = gitFileLockStore.getLock("some/file.txt"); + + AbstractObjectAssert lockAssert = assertThat(retrievedLock) + .get(); + lockAssert + .extracting("userId") + .isEqualTo("dent"); + lockAssert + .extracting("id") + .isEqualTo("id-0"); + lockAssert + .extracting("timestamp") + .isEqualTo(NOW); + lockAssert + .usingRecursiveComparison() + .isEqualTo(createdLock); + + assertThat(gitFileLockStore.getAll()) + .extracting("userId") + .containsExactly("dent"); + } + + @Test + void shouldRetrieveLockById() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + FileLock createdLock = gitFileLockStore.put("some/file.txt"); + + Optional retrievedLock = gitFileLockStore.getById(createdLock.getId()); + + assertThat(retrievedLock) + .get() + .usingRecursiveComparison() + .isEqualTo(createdLock); + } + + @Test + void shouldHaveLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + gitFileLockStore.put("some/file.txt"); + + assertThat(gitFileLockStore.hasLocks()) + .isTrue(); + } + + @Test + void shouldBeModifiableWithoutLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + gitFileLockStore.assertModifiable("some/file.txt"); + } + + @Test + void shouldBeModifiableWithOwnLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + gitFileLockStore.put("some/file.txt"); + + gitFileLockStore.assertModifiable("some/file.txt"); + } + + @Test + void shouldRemoveLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + FileLock createdLock = gitFileLockStore.put("some/file.txt"); + + gitFileLockStore.remove("some/file.txt", false); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .isEmpty(); + assertThat(gitFileLockStore.getById(createdLock.getId())) + .isEmpty(); + } + + @Test + void shouldRemoveLockById() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + FileLock createdLock = gitFileLockStore.put("some/file.txt"); + + gitFileLockStore.removeById(createdLock.getId(), false); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .isEmpty(); + assertThat(gitFileLockStore.getById(createdLock.getId())) + .isEmpty(); + } + + @Nested + class WithExistingLockFromOtherUser { + + @BeforeEach + void setLock() { + currentUser = "trillian"; + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + gitFileLockStore.put("some/file.txt"); + currentUser = "dent"; + } + + @Test + void shouldNotRemoveExistingLockWithoutForce() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + assertThrows(FileLockedException.class, () -> gitFileLockStore.remove("some/file.txt", false)); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .get() + .extracting("userId") + .isEqualTo("trillian"); + } + + @Test + void shouldRemoveExistingLockWithForce() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + gitFileLockStore.remove("some/file.txt", true); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .isEmpty(); + } + + @Test + void shouldNotBeModifiableWithOwnLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + assertThrows(FileLockedException.class, () -> gitFileLockStore.assertModifiable("some/file.txt")); + } + + @Test + void shouldNotOverrideExistingLock() { + GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository); + + assertThrows(FileLockedException.class, () -> gitFileLockStore.put("some/file.txt")); + + assertThat(gitFileLockStore.getLock("some/file.txt")) + .get() + .extracting("userId") + .isEqualTo("trillian"); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/CapturingServletOutputStream.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/CapturingServletOutputStream.java new file mode 100644 index 0000000000..e8ed7fe855 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/CapturingServletOutputStream.java @@ -0,0 +1,71 @@ +/* + * 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.web; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class CapturingServletOutputStream extends ServletOutputStream { + + private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + @Override + public void write(int b) throws IOException { + baos.write(b); + } + + @Override + public void close() throws IOException { + baos.close(); + } + + @Override + public String toString() { + return baos.toString(); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + } + + public JsonNode getContentAsJson() { + try { + return new ObjectMapper().readTree(toString()); + } catch (JsonProcessingException e) { + throw new RuntimeException("could not unmarshal json content", e); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java index 486cffde8c..1039eee074 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java @@ -32,11 +32,8 @@ import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.spi.ScmProviderHttpServlet; import sonia.scm.util.HttpUtil; -import javax.servlet.ServletOutputStream; -import javax.servlet.WriteListener; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import java.io.ByteArrayOutputStream; import java.io.IOException; import static org.hamcrest.Matchers.containsString; @@ -133,34 +130,4 @@ public class GitPermissionFilterTest { return request; } - private static class CapturingServletOutputStream extends ServletOutputStream { - - private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - @Override - public void write(int b) throws IOException { - baos.write(b); - } - - @Override - public void close() throws IOException { - baos.close(); - } - - @Override - public String toString() { - return baos.toString(); - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setWriteListener(WriteListener writeListener) { - - } - } - } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/LfsLockingProtocolServletTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/LfsLockingProtocolServletTest.java new file mode 100644 index 0000000000..3defb4ba91 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/LfsLockingProtocolServletTest.java @@ -0,0 +1,535 @@ +/* + * 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.web; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.assertj.core.api.Condition; +import org.github.sdorra.jse.ShiroExtension; +import org.github.sdorra.jse.SubjectAware; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.FileLock; +import sonia.scm.repository.api.FileLockedException; +import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore; +import sonia.scm.user.DisplayUser; +import sonia.scm.user.User; +import sonia.scm.user.UserDisplayManager; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; + +import static java.time.temporal.ChronoUnit.DAYS; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(ShiroExtension.class) +class LfsLockingProtocolServletTest { + + private static final Repository REPOSITORY = new Repository("23", "git", "hitchhiker", "hog"); + private static final Instant NOW = Instant.ofEpochSecond(-562031958); + + @Mock + private GitFileLockStore lockStore; + @Mock + private UserDisplayManager userDisplayManager; + + private LfsLockingProtocolServlet servlet; + + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + private final CapturingServletOutputStream responseStream = new CapturingServletOutputStream(); + + @BeforeEach + void setUpServlet() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true); + servlet = new LfsLockingProtocolServlet(REPOSITORY, lockStore, userDisplayManager, mapper, 3, 2); + lenient().when(response.getOutputStream()).thenReturn(responseStream); + } + + @BeforeEach + void setUpUserDisplayManager() { + lenient().when( userDisplayManager.get("dent")) + .thenReturn(of(DisplayUser.from(new User("dent", "Arthur Dent", "irrelevant")))); + lenient().when(userDisplayManager.get("trillian")) + .thenReturn(of(DisplayUser.from(new User("trillian", "Tricia McMillan", "irrelevant")))); + } + + @Nested + class WithValidLocksPath { + + @BeforeEach + void mockValidPath() { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks"); + } + + @Test + void shouldNotBeAuthorizedToReadLocks() { + servlet.doGet(request, response); + + verify(response).setStatus(403); + verify(lockStore, never()).getAll(); + } + + @Nested + @SubjectAware(value = "trillian", permissions = "repository:read,pull:23") + class WithReadPermission { + + @Test + void shouldGetEmptyArrayForNoFileLocks() { + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks).isEmpty(); + } + + @Test + void shouldGetAllExistingFileLocks() { + when(lockStore.getAll()) + .thenReturn( + asList( + new FileLock("some/file", "42", "dent", NOW), + new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS)) + )); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks.get(0)) + .is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z")); + assertThat(locks.get(1)) + .is(lockNodeWith("1337", "other/file", "Tricia McMillan", "1952-04-22T00:00:42Z")); + } + + @Test + void shouldGetExistingLockByPath() { + when(request.getParameter("path")).thenReturn("some/file"); + when(lockStore.getLock("some/file")) + .thenReturn(of(new FileLock("some/file", "42", "dent", NOW))); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks.get(0)) + .is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldGetEmptyListForNotExistingLockByPath() { + when(request.getParameter("path")).thenReturn("some/file"); + when(lockStore.getLock("some/file")) + .thenReturn(empty()); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks).isEmpty(); + } + + @Test + void shouldGetExistingLockById() { + when(request.getParameter("path")).thenReturn(null); + when(request.getParameter("id")).thenReturn("42"); + when(lockStore.getById("42")) + .thenReturn(of(new FileLock("some/file", "42", "dent", NOW))); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks.get(0)) + .is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldGetEmptyListForNotExistingLockById() { + when(request.getParameter("path")).thenReturn(null); + when(request.getParameter("id")).thenReturn("42"); + when(lockStore.getById("42")) + .thenReturn(empty()); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks).isEmpty(); + } + + @Test + void shouldUseUserIdIfUserIsUnknown() { + when(lockStore.getAll()) + .thenReturn( + singletonList( + new FileLock("some/file", "42", "marvin", NOW) + )); + + servlet.doGet(request, response); + + JsonNode locks = responseStream.getContentAsJson().get("locks"); + assertThat(locks.get(0)) + .is(lockNodeWith("42", "some/file", "marvin", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldNotBeAuthorizedToCreateNewLock() { + servlet.doPost(request, response); + + verify(response).setStatus(403); + verify(lockStore, never()).put(any()); + } + + @Nested + class WithLimiting { + + @BeforeEach + void mockManyResults() { + when(lockStore.getAll()) + .thenReturn( + asList( + new FileLock("empty/file", "2", "zaphod", NOW), + new FileLock("some/file", "23", "dent", NOW), + new FileLock("any/file", "42", "marvin", NOW), + new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS)) + )); + } + + @Test + void shouldLimitFileLocksByDefault() { + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode locks = contentAsJson.get("locks"); + assertThat(locks).hasSize(3); + assertThat(locks.get(0).get("id").asText()).isEqualTo("2"); + assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("3"); + } + + @Test + void shouldUseLimitFromRequest() { + lenient().doReturn("2").when(request).getParameter("limit"); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode locks = contentAsJson.get("locks"); + assertThat(locks).hasSize(2); + assertThat(locks.get(0).get("id").asText()).isEqualTo("2"); + assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("2"); + } + + @Test + void shouldUseCursorFromRequest() { + lenient().doReturn("3").when(request).getParameter("cursor"); + + servlet.doGet(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode locks = contentAsJson.get("locks"); + assertThat(locks).hasSize(1); + assertThat(locks.get(0).get("id").asText()).isEqualTo("1337"); + assertThat(contentAsJson.get("next_cursor")).isNull(); + } + } + } + + @Nested + @SubjectAware(value = "trillian", permissions = "repository:read,write,pull,push:23") + class WithWritePermission { + + @Test + void shouldCreateNewLock() throws IOException { + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" + + " \"path\": \"some/file.txt\"\n" + + "}")); + when(lockStore.put("some/file.txt")) + .thenReturn(new FileLock("some/file.txt", "42", "Tricia", NOW)); + + servlet.doPost(request, response); + + verify(response).setStatus(201); + assertThat(responseStream.getContentAsJson().get("lock")) + .is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldIgnoreUnknownAttributed() throws IOException { + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" + + " \"path\": \"some/file.txt\",\n" + + " \"unknown\": \"attribute\"\n" + + "}")); + when(lockStore.put("some/file.txt")) + .thenReturn(new FileLock("some/file.txt", "42", "Tricia", NOW)); + + servlet.doPost(request, response); + + verify(response).setStatus(201); + assertThat(responseStream.getContentAsJson().get("lock")) + .is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldHandleInvalidInput() throws IOException { + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" + + " \"invalidAttribute\": \"some value\"\n" + + "}")); + + servlet.doPost(request, response); + + verify(response).setStatus(400); + verify(lockStore, never()).put(any()); + } + + @Test + void shouldFailToCreateExistingLock() throws IOException { + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" + + " \"path\": \"some/file.txt\"\n" + + "}")); + when(lockStore.put("some/file.txt")) + .thenThrow(new FileLockedException(REPOSITORY.getNamespaceAndName(), new FileLock("some/file.txt", "42", "Tricia", NOW))); + + servlet.doPost(request, response); + + verify(response).setStatus(409); + JsonNode contentAsJson = responseStream.getContentAsJson(); + assertThat(contentAsJson.get("lock")) + .is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z")); + assertThat(contentAsJson.get("message")).isNotNull(); + } + + @Test + void shouldGetVerifyResult() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}")); + when(lockStore.getAll()) + .thenReturn( + asList( + new FileLock("some/file", "42", "dent", NOW), + new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS)) + )); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode ourLocks = responseStream.getContentAsJson().get("ours"); + assertThat(ourLocks.get(0)) + .is(lockNodeWith("1337", "other/file", "Tricia McMillan", "1952-04-22T00:00:42Z")); + JsonNode theirLocks = responseStream.getContentAsJson().get("theirs"); + assertThat(theirLocks.get(0)) + .is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z")); + } + + @Test + void shouldGetVerifyResultForNoFileLocks() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}")); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode ourLocks = responseStream.getContentAsJson().get("ours"); + assertThat(ourLocks).isEmpty(); + JsonNode theirLocks = responseStream.getContentAsJson().get("theirs"); + assertThat(theirLocks).isEmpty(); + } + + @Nested + class VerifyWithLimiting { + + @BeforeEach + void mockManyResults() { + when(lockStore.getAll()) + .thenReturn( + asList( + new FileLock("empty/file", "2", "zaphod", NOW), + new FileLock("some/file", "23", "dent", NOW), + new FileLock("any/file", "42", "marvin", NOW), + new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS)) + )); + } + + @Test + void shouldLimitVerifyByDefault() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}")); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode ourLocks = contentAsJson.get("ours"); + assertThat(ourLocks).isEmpty(); + JsonNode theirLocks = contentAsJson.get("theirs"); + assertThat(theirLocks).hasSize(3); + assertThat(theirLocks.get(0).get("id").asText()).isEqualTo("2"); + assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("3"); + } + + @Test + void shouldUseLimitFromRequest() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"limit\":2}")); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode ourLocks = contentAsJson.get("ours"); + assertThat(ourLocks).isEmpty(); + JsonNode theirLocks = contentAsJson.get("theirs"); + assertThat(theirLocks).hasSize(2); + assertThat(theirLocks.get(0).get("id").asText()).isEqualTo("2"); + assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("2"); + } + + @Test + void shouldUseCursorFromRequest() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"cursor\":\"3\"}")); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode contentAsJson = responseStream.getContentAsJson(); + JsonNode ourLocks = contentAsJson.get("ours"); + assertThat(ourLocks).hasSize(1); + assertThat(ourLocks.get(0).get("id").asText()).isEqualTo("1337"); + JsonNode theirLocks = contentAsJson.get("theirs"); + assertThat(theirLocks).isEmpty(); + assertThat(contentAsJson.get("next_cursor")).isNull(); + } + } + + @Test + void shouldDeleteExistingFileLock() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}")); + FileLock expectedLock = new FileLock("some/file.txt", "42", "trillian", NOW); + when(lockStore.removeById("42", false)) + .thenReturn(of(expectedLock)); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode deletedLock = responseStream.getContentAsJson().get("lock"); + assertThat(deletedLock).is(lockNodeWith(expectedLock, "Tricia McMillan")); + } + + @Test + void shouldFailToDeleteFileLockByAnotherUser() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}")); + when(lockStore.removeById("42", false)) + .thenThrow(new FileLockedException(REPOSITORY.getNamespaceAndName(), new FileLock("some/file.txt", "42", "dent", NOW))); + + servlet.doPost(request, response); + + verify(response).setStatus(403); + } + + @Test + void shouldDeleteExistingLockWithForceFlag() throws IOException { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock"); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"force\":true}")); + FileLock expectedLock = new FileLock("some/file.txt", "42", "dent", NOW); + when(lockStore.removeById("42", true)) + .thenReturn(of(expectedLock)); + + servlet.doPost(request, response); + + verify(response).setStatus(200); + JsonNode deletedLock = responseStream.getContentAsJson().get("lock"); + assertThat(deletedLock).is(lockNodeWith(expectedLock, "Arthur Dent")); + } + } + } + + @Test + void shouldFailForIllegalPath() { + when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/other"); + + servlet.doGet(request, response); + + verify(response).setStatus(400); + } + + private Condition> lockNodeWith(FileLock lock, String expectedName) { + return new Condition>() { + @Override + public boolean matches(Iterable value) { + JsonNode node = (JsonNode) value; + assertThat(node.get("id").asText()).isEqualTo(lock.getId()); + assertThat(node.get("path").asText()).isEqualTo(lock.getPath()); + assertThat(node.get("owner").get("name").asText()).isEqualTo(expectedName); + assertThat(node.get("locked_at").asText()).isEqualTo(lock.getTimestamp().toString()); + return true; + } + }; + } + + private Condition> lockNodeWith(String expectedId, String expectedPath, String expectedName, String expectedTimestamp) { + return new Condition>() { + @Override + public boolean matches(Iterable value) { + JsonNode node = (JsonNode) value; + assertThat(node.get("id").asText()).isEqualTo(expectedId); + assertThat(node.get("path").asText()).isEqualTo(expectedPath); + assertThat(node.get("owner").get("name").asText()).isEqualTo(expectedName); + assertThat(node.get("locked_at").asText()).isEqualTo(expectedTimestamp); + return true; + } + }; + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java index 29bd1b41a2..92449ce875 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java @@ -24,17 +24,13 @@ package sonia.scm.web; -import com.google.common.base.Charsets; import com.google.common.collect.Lists; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import javax.servlet.ReadListener; -import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Collections; import java.util.List; @@ -169,32 +165,4 @@ public class WireProtocolTest { assertTrue(commands.contains(expected)); } - private static class BufferedServletInputStream extends ServletInputStream { - - private ByteArrayInputStream input; - - BufferedServletInputStream(String content) { - this.input = new ByteArrayInputStream(content.getBytes(Charsets.US_ASCII)); - } - - @Override - public int read() { - return input.read(); - } - - @Override - public boolean isFinished() { - return false; - } - - @Override - public boolean isReady() { - return false; - } - - @Override - public void setReadListener(ReadListener readListener) { - } - } - } diff --git a/scm-test/src/main/java/sonia/scm/web/BufferedServletInputStream.java b/scm-test/src/main/java/sonia/scm/web/BufferedServletInputStream.java new file mode 100644 index 0000000000..8f8909b907 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/web/BufferedServletInputStream.java @@ -0,0 +1,59 @@ +/* + * 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.web; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +public class BufferedServletInputStream extends ServletInputStream { + + private ByteArrayInputStream input; + + BufferedServletInputStream(String content) { + this.input = new ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public int read() { + return input.read(); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + // not necessary for tests + } +} diff --git a/scm-ui/ui-components/src/Icon.tsx b/scm-ui/ui-components/src/Icon.tsx index 81493cf2f2..bf9db2e6c0 100644 --- a/scm-ui/ui-components/src/Icon.tsx +++ b/scm-ui/ui-components/src/Icon.tsx @@ -21,44 +21,43 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; +import React, { FC } from "react"; import classNames from "classnames"; import { createAttributesForTesting } from "./devBuild"; type Props = { title?: string; - iconStyle: string; + iconStyle?: string; name: string; - color: string; + color?: string; className?: string; onClick?: (event: React.MouseEvent) => void; + onEnter?: (event: React.KeyboardEvent) => void; testId?: string; + tabIndex?: number; }; -export default class Icon extends React.Component { - static defaultProps = { - iconStyle: "fas", - color: "grey-light" - }; +const Icon: FC = ({ + iconStyle = "fas", + color = "grey-light", + title, + name, + className, + onClick, + testId, + tabIndex = -1, + onEnter, +}) => { + return ( + event.key === "Enter" && onEnter && onEnter(event)} + title={title} + className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)} + tabIndex={tabIndex} + {...createAttributesForTesting(testId)} + /> + ); +}; - render() { - const { title, iconStyle, name, color, className, onClick, testId } = this.props; - if (title) { - return ( - - ); - } - return ( - - ); - } -} +export default Icon; diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 497cd49332..433f29ac2d 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -1679,6 +1679,8 @@ exports[`Storyshots BreadCrumb Default 1`] = ` > @@ -1744,8 +1746,10 @@ exports[`Storyshots BreadCrumb Default 1`] = ` data-tooltip="breadcrumb.copyPermalink" > @@ -1779,6 +1783,8 @@ exports[`Storyshots BreadCrumb Long path 1`] = ` > @@ -1894,8 +1900,10 @@ exports[`Storyshots BreadCrumb Long path 1`] = ` data-tooltip="breadcrumb.copyPermalink" > @@ -1928,7 +1936,9 @@ exports[`Storyshots BreadCrumb With prefix button 1`] = ` href="#link" > @@ -1940,6 +1950,8 @@ exports[`Storyshots BreadCrumb With prefix button 1`] = ` > @@ -2005,8 +2017,10 @@ exports[`Storyshots BreadCrumb With prefix button 1`] = ` data-tooltip="breadcrumb.copyPermalink" > @@ -2034,7 +2048,9 @@ exports[`Storyshots Buttons|AddButton Default 1`] = ` className="icon is-medium" > @@ -2377,7 +2393,9 @@ exports[`Storyshots Buttons|DeleteButton Default 1`] = ` className="icon is-medium" > @@ -2483,7 +2501,9 @@ exports[`Storyshots CardColumn Default 1`] = ` className="media-left mt-3 ml-4" >
@@ -2921,7 +2947,9 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = ` className="icon is-medium" > @@ -3059,7 +3087,9 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = ` className="icon is-medium" > @@ -3080,7 +3110,9 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = ` className="icon is-medium" > @@ -3176,7 +3208,9 @@ exports[`Storyshots Changesets Default 1`] = ` className="icon is-medium" > @@ -3197,7 +3231,9 @@ exports[`Storyshots Changesets Default 1`] = ` className="icon is-medium" > @@ -3303,7 +3339,9 @@ exports[`Storyshots Changesets Replacements 1`] = ` className="icon is-medium" > @@ -3324,7 +3362,9 @@ exports[`Storyshots Changesets Replacements 1`] = ` className="icon is-medium" > @@ -3432,7 +3472,9 @@ exports[`Storyshots Changesets With Committer 1`] = ` className="icon is-medium" > @@ -3453,7 +3495,9 @@ exports[`Storyshots Changesets With Committer 1`] = ` className="icon is-medium" > @@ -3570,7 +3614,9 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = ` className="icon is-medium" > @@ -3591,7 +3637,9 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = ` className="icon is-medium" > @@ -3700,7 +3748,9 @@ exports[`Storyshots Changesets With avatar 1`] = ` className="icon is-medium" > @@ -3721,7 +3771,9 @@ exports[`Storyshots Changesets With avatar 1`] = ` className="icon is-medium" > @@ -3795,7 +3847,9 @@ exports[`Storyshots Changesets With contactless signature 1`] = ` onMouseOver={[Function]} >
@@ -3825,7 +3879,9 @@ exports[`Storyshots Changesets With contactless signature 1`] = ` className="icon is-medium" > @@ -3846,7 +3902,9 @@ exports[`Storyshots Changesets With contactless signature 1`] = ` className="icon is-medium" > @@ -3920,7 +3978,9 @@ exports[`Storyshots Changesets With invalid signature 1`] = ` onMouseOver={[Function]} >
@@ -3950,7 +4010,9 @@ exports[`Storyshots Changesets With invalid signature 1`] = ` className="icon is-medium" >
@@ -3971,7 +4033,9 @@ exports[`Storyshots Changesets With invalid signature 1`] = ` className="icon is-medium" > @@ -4080,7 +4144,9 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = ` className="icon is-medium" > @@ -4101,7 +4167,9 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = ` className="icon is-medium" > @@ -4175,7 +4243,9 @@ exports[`Storyshots Changesets With multiple signatures and invalid status 1`] = onMouseOver={[Function]} > @@ -4205,7 +4275,9 @@ exports[`Storyshots Changesets With multiple signatures and invalid status 1`] = className="icon is-medium" > @@ -4226,7 +4298,9 @@ exports[`Storyshots Changesets With multiple signatures and invalid status 1`] = className="icon is-medium" > @@ -4300,7 +4374,9 @@ exports[`Storyshots Changesets With multiple signatures and not found status 1`] onMouseOver={[Function]} > @@ -4330,7 +4406,9 @@ exports[`Storyshots Changesets With multiple signatures and not found status 1`] className="icon is-medium" > @@ -4351,7 +4429,9 @@ exports[`Storyshots Changesets With multiple signatures and not found status 1`] className="icon is-medium" > @@ -4425,7 +4505,9 @@ exports[`Storyshots Changesets With multiple signatures and valid status 1`] = ` onMouseOver={[Function]} > @@ -4455,7 +4537,9 @@ exports[`Storyshots Changesets With multiple signatures and valid status 1`] = ` className="icon is-medium" > @@ -4476,7 +4560,9 @@ exports[`Storyshots Changesets With multiple signatures and valid status 1`] = ` className="icon is-medium" > @@ -4550,7 +4636,9 @@ exports[`Storyshots Changesets With unknown signature 1`] = ` onMouseOver={[Function]} > @@ -4580,7 +4668,9 @@ exports[`Storyshots Changesets With unknown signature 1`] = ` className="icon is-medium" > @@ -4601,7 +4691,9 @@ exports[`Storyshots Changesets With unknown signature 1`] = ` className="icon is-medium" > @@ -4675,7 +4767,9 @@ exports[`Storyshots Changesets With unowned signature 1`] = ` onMouseOver={[Function]} > @@ -4705,7 +4799,9 @@ exports[`Storyshots Changesets With unowned signature 1`] = ` className="icon is-medium" > @@ -4726,7 +4822,9 @@ exports[`Storyshots Changesets With unowned signature 1`] = ` className="icon is-medium" > @@ -4800,7 +4898,9 @@ exports[`Storyshots Changesets With valid signature 1`] = ` onMouseOver={[Function]} > @@ -4830,7 +4930,9 @@ exports[`Storyshots Changesets With valid signature 1`] = ` className="icon is-medium" > @@ -4851,7 +4953,9 @@ exports[`Storyshots Changesets With valid signature 1`] = ` className="icon is-medium" > @@ -4966,7 +5070,9 @@ exports[`Storyshots Diff Binaries 1`] = ` title="Main.java" > @@ -9764,7 +9886,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -9790,7 +9914,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` title="src/main/js/ChangeNotification.tsx" > @@ -9877,7 +10005,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -9903,7 +10033,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` title="src/main/resources/locales/de/plugins.json" > @@ -9990,7 +10124,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -10016,7 +10152,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` title="src/main/resources/locales/en/plugins.json" > @@ -10103,7 +10243,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -10129,7 +10271,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` title="src/test/java/com/cloudogu/scm/review/events/ClientTest.java" > @@ -10216,7 +10362,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -10242,7 +10390,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` title="Main.java" > @@ -10329,7 +10481,9 @@ exports[`Storyshots Diff Collapsed 1`] = ` onClick={[Function]} > @@ -10364,7 +10518,9 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` title="src/main/java/com/cloudogu/scm/review/events/EventListener.java" > @@ -55580,7 +55872,9 @@ exports[`Storyshots Forms|AddKeyValueEntryToTableField Disabled 1`] = ` className="icon is-medium" > @@ -55891,7 +56185,9 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = ` data-tooltip="This is a classic help text." > @@ -55920,7 +56216,9 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = ` data-tooltip="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." > @@ -56054,7 +56352,9 @@ exports[`Storyshots Forms|FileInput Default 1`] = ` data-tooltip="Select your most loved file" > @@ -56646,7 +56946,9 @@ exports[`Storyshots Forms|Radio With HelpText 1`] = ` data-tooltip="This is a classic help text." > @@ -56670,7 +56972,9 @@ exports[`Storyshots Forms|Radio With HelpText 1`] = ` data-tooltip="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." > @@ -57332,7 +57636,9 @@ exports[`Storyshots GroupEntry Default 1`] = ` className="GroupEntry__Avatar-sc-1f902yu-2 iFzhlu mr-4" >
@@ -57409,7 +57717,9 @@ exports[`Storyshots GroupEntry With long texts 1`] = ` className="GroupEntry__Avatar-sc-1f902yu-2 iFzhlu mr-4" >
@@ -57473,7 +57785,9 @@ exports[`Storyshots Help Default 1`] = ` data-tooltip="This is a help message" >
@@ -57497,7 +57811,9 @@ shapes field strong disaster parties Russell’s ancestors infinite colour imaginative generator sweep." >
@@ -57515,7 +57831,9 @@ shapes field strong disaster parties Russell’s ancestors infinite colour imaginative generator sweep." >
@@ -57528,50 +57846,74 @@ exports[`Storyshots Icon Colors 1`] = ` > @@ -57582,18 +57924,26 @@ exports[`Storyshots Icon Default 1`] = ` className="Iconstories__Wrapper-sc-1g657fe-0 jVnSlu" > @@ -57605,14 +57955,20 @@ exports[`Storyshots Icon Icon styles 1`] = ` > @@ -57624,18 +57980,26 @@ exports[`Storyshots Icon More options 1`] = ` > @@ -57647,38 +58011,56 @@ exports[`Storyshots Icon Sizing 1`] = ` > @@ -58327,8 +58709,10 @@ exports[`Storyshots MarkdownView Code without Lang 1`] = ` data-tooltip="sources.content.copyPermalink" >
@@ -60768,8 +61188,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -60796,8 +61218,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -60817,8 +61241,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -60948,8 +61374,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61053,8 +61481,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61126,8 +61556,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61147,8 +61579,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61296,8 +61730,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61317,8 +61753,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61409,8 +61847,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61482,8 +61922,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61503,8 +61945,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61607,8 +62051,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61628,8 +62074,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61759,8 +62207,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61877,8 +62327,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -61989,8 +62441,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62010,8 +62464,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62167,8 +62623,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62290,8 +62748,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62363,8 +62823,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62384,8 +62846,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62450,8 +62914,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62490,8 +62956,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62597,8 +63065,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62618,8 +63088,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62673,8 +63145,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62694,8 +63168,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62833,8 +63309,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62873,8 +63351,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62945,8 +63425,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -62966,8 +63448,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63026,8 +63510,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63047,8 +63533,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63139,8 +63627,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63224,8 +63714,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63245,8 +63737,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63284,8 +63778,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63305,8 +63801,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63388,8 +63886,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63513,8 +64013,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63534,8 +64036,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63600,8 +64104,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63653,8 +64159,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63726,8 +64234,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63747,8 +64257,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63794,8 +64306,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63815,8 +64329,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63868,8 +64384,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63915,8 +64433,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63936,8 +64456,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -63983,8 +64505,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64004,8 +64528,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64044,8 +64570,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64117,8 +64645,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64138,8 +64668,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64185,8 +64717,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64206,8 +64740,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64285,8 +64821,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64325,8 +64863,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64476,8 +65016,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64497,8 +65039,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" >
@@ -64589,8 +65133,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -64667,8 +65213,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -64688,8 +65236,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -64728,8 +65278,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -64905,8 +65457,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -64926,8 +65480,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65044,8 +65600,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65130,8 +65688,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65151,8 +65711,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65191,8 +65753,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65231,8 +65795,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65291,8 +65857,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65312,8 +65880,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65378,8 +65948,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65436,8 +66008,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65561,8 +66135,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65582,8 +66158,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65657,8 +66235,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65731,8 +66311,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65884,8 +66466,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65905,8 +66489,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -65986,8 +66572,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66007,8 +66595,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66099,8 +66689,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66326,8 +66918,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66347,8 +66941,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66426,8 +67022,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66499,8 +67097,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66520,8 +67120,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66603,8 +67205,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66707,8 +67311,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66814,8 +67420,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66835,8 +67443,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66888,8 +67498,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -66941,8 +67553,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67001,8 +67615,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67022,8 +67638,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67127,8 +67745,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67182,8 +67802,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67227,8 +67849,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67316,8 +67940,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67337,8 +67963,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67394,8 +68022,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67441,8 +68071,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67488,8 +68120,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67532,8 +68166,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67553,8 +68189,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67595,8 +68233,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67671,8 +68311,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67716,8 +68358,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67737,8 +68381,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67781,8 +68427,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67802,8 +68450,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67859,8 +68509,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67896,8 +68548,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67965,8 +68619,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -67986,8 +68642,10 @@ and this project adheres to data-tooltip="sources.content.copyPermalink" > @@ -68178,8 +68836,10 @@ exports[`Storyshots MarkdownView Inline Xml 1`] = ` data-tooltip="sources.content.copyPermalink" > @@ -72991,6 +73755,8 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = ` @@ -73087,6 +73853,8 @@ exports[`Storyshots RepositoryEntry Before Title EP 1`] = ` @@ -73180,6 +73948,8 @@ exports[`Storyshots RepositoryEntry Default 1`] = ` @@ -73284,6 +74054,8 @@ exports[`Storyshots RepositoryEntry Exporting 1`] = ` @@ -73389,6 +74161,8 @@ exports[`Storyshots RepositoryEntry HealthCheck Failure 1`] = ` @@ -73503,6 +74277,8 @@ exports[`Storyshots RepositoryEntry MultiRepositoryTags 1`] = ` @@ -73608,6 +74384,8 @@ exports[`Storyshots RepositoryEntry RepositoryFlag EP 1`] = ` @@ -73701,6 +74479,8 @@ exports[`Storyshots RepositoryEntry With long texts 1`] = ` @@ -73727,50 +74507,66 @@ Array [ className="m-6" > So this is it,   said Arthur,   We are going to die. ,
Yes,   said Ford,   except... no! Wait a minute!
, ] @@ -73841,8 +74637,10 @@ exports[`Storyshots SyntaxHighlighter Go 1`] = ` data-tooltip="sources.content.copyPermalink" > Last Name Id Name @@ -88143,7 +89383,9 @@ exports[`Storyshots Table|Table TextColumn 1`] = ` > Id Name Description diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index 23db2f91a3..591503ed02 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -44,6 +44,7 @@ export { validation, repositories }; export { default as DateFromNow } from "./DateFromNow"; export { default as DateShort } from "./DateShort"; +export { default as useDateFormatter } from "./useDateFormatter"; export { default as Duration } from "./Duration"; export { default as ErrorNotification } from "./ErrorNotification"; export { default as ErrorPage } from "./ErrorPage"; diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index 971eeeeec2..9e5c2105b0 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -97,6 +97,7 @@ export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition< >; export type ReposSourcesTreeRowProps = { + repository: Repository; file: File; }; diff --git a/scm-ui/ui-types/src/Sources.ts b/scm-ui/ui-types/src/Sources.ts index 986c896e2d..081c582cfe 100644 --- a/scm-ui/ui-types/src/Sources.ts +++ b/scm-ui/ui-types/src/Sources.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { HalRepresentation, Links } from "./hal"; +import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal"; export type SubRepository = { repositoryUrl: string; @@ -30,7 +30,9 @@ export type SubRepository = { revision: string; }; -export type File = { +export type File = HalRepresentationWithEmbedded<{ + children?: File[]; +}> & { name: string; path: string; directory: boolean; @@ -42,10 +44,6 @@ export type File = { partialResult?: boolean; computationAborted?: boolean; truncated?: boolean; - _links: Links; - _embedded?: { - children?: File[] | null; - }; }; export type Paths = HalRepresentation & { diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx index 87e2367315..989952c943 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx @@ -25,12 +25,11 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; -import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { File, Repository } from "@scm-manager/ui-types"; import FileTreeLeaf from "./FileTreeLeaf"; import TruncatedNotification from "./TruncatedNotification"; import { isRootPath } from "../utils/files"; -import { extensionPoints } from "@scm-manager/ui-extensions"; type Props = { repository: Repository; @@ -102,7 +101,7 @@ const FileTree: FC = ({ repository, directory, baseUrl, revision, fetchNe {files.map((file: File) => ( - + ))} diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx index 6793289d34..dfc747a1e3 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx @@ -26,12 +26,13 @@ import { WithTranslation, withTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; -import { File } from "@scm-manager/ui-types"; -import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components"; +import { File, Repository } from "@scm-manager/ui-types"; +import { DateFromNow, FileSize, Icon, Tooltip } from "@scm-manager/ui-components"; import FileIcon from "./FileIcon"; import FileLink from "./content/FileLink"; type Props = WithTranslation & { + repository: Repository; file: File; baseUrl: string; }; @@ -91,12 +92,13 @@ class FileTreeLeaf extends React.Component { }; render() { - const { file } = this.props; + const { repository, file } = this.props; const renderFileSize = (file: File) => ; const renderCommitDate = (file: File) => ; const extProps: extensionPoints.ReposSourcesTreeRowProps = { + repository, file, }; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx index 757a44af7a..9691aeb7e1 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx @@ -23,19 +23,24 @@ */ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; -import { File } from "@scm-manager/ui-types"; +import { File, Link, Repository } from "@scm-manager/ui-types"; import { DownloadButton } from "@scm-manager/ui-components"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = WithTranslation & { + repository: Repository; file: File; }; class DownloadViewer extends React.Component { render() { - const { t, file } = this.props; + const { t, repository, file } = this.props; + return (
- + + +
); } diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx index 71fec326c3..fb2e41ad78 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx @@ -80,7 +80,7 @@ const SourcesView: FC = ({ file, repository, revision }) => { basePath, }} > - + ); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/FileLockedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/FileLockedExceptionMapper.java new file mode 100644 index 0000000000..e3629190b4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/FileLockedExceptionMapper.java @@ -0,0 +1,40 @@ +/* + * 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.api.rest; + +import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; +import sonia.scm.repository.api.FileLockedException; + +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class FileLockedExceptionMapper extends ContextualExceptionMapper { + @Inject + public FileLockedExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { + super(FileLockedException.class, Response.Status.CONFLICT, mapper); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java index c46f05a360..7462d27541 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java @@ -40,6 +40,7 @@ import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.spi.HttpScmProtocol; import sonia.scm.security.Authentications; import sonia.scm.util.HttpUtil; +import sonia.scm.web.ScmClientDetector; import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgentParser; @@ -49,6 +50,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Optional; +import java.util.Set; @Singleton @WebElement(value = HttpProtocolServlet.PATTERN) @@ -63,21 +65,27 @@ public class HttpProtocolServlet extends HttpServlet { private final NamespaceAndNameFromPathExtractor pathExtractor; private final PushStateDispatcher dispatcher; private final UserAgentParser userAgentParser; - + private final Set scmClientDetectors; @Inject - public HttpProtocolServlet(ScmConfiguration configuration, RepositoryServiceFactory serviceFactory, NamespaceAndNameFromPathExtractor pathExtractor, PushStateDispatcher dispatcher, UserAgentParser userAgentParser) { + public HttpProtocolServlet(ScmConfiguration configuration, + RepositoryServiceFactory serviceFactory, + NamespaceAndNameFromPathExtractor pathExtractor, + PushStateDispatcher dispatcher, + UserAgentParser userAgentParser, + Set scmClientDetectors) { this.configuration = configuration; this.serviceFactory = serviceFactory; this.pathExtractor = pathExtractor; this.dispatcher = dispatcher; this.userAgentParser = userAgentParser; + this.scmClientDetectors = scmClientDetectors; } @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { UserAgent userAgent = userAgentParser.parse(request); - if (userAgent.isScmClient()) { + if (isScmClient(userAgent, request)) { String pathInfo = request.getPathInfo(); Optional namespaceAndName = pathExtractor.fromUri(pathInfo); if (namespaceAndName.isPresent()) { @@ -92,6 +100,10 @@ public class HttpProtocolServlet extends HttpServlet { } } + private boolean isScmClient(UserAgent userAgent, HttpServletRequest request) { + return userAgent.isScmClient() || scmClientDetectors.stream().anyMatch(detector -> detector.isScmClient(request, userAgent)); + } + private void service(HttpServletRequest req, HttpServletResponse resp, NamespaceAndName namespaceAndName) throws IOException, ServletException { try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { req.setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repositoryService.getRepository()); diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 0f38348bf7..d81d36882e 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -421,6 +421,10 @@ "5FSV2kreE1": { "summary": "'svn verify' fehlgeschlagen", "description": "Die Prüfung 'svn verify' ist für das Repository fehlgeschlagen." + }, + "3mSmwOtOd1": { + "displayName": "Datei gesperrt", + "description": "Die Datei oder ihre Sperre kann nicht bearbeitet werden, da eine andere Sperre existiert. Die andere Sperre kann ggf. durch eine 'forcierte' Aktion umgangen werden." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 039630e15a..401ff2bf7d 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -362,6 +362,10 @@ "CISPvega31": { "displayName": "Illegal repository type for import", "description": "The import is not possible for the given repository type." + }, + "3mSmwOtOd1": { + "displayName": "File locked", + "description": "The file or its lock cannot be modified, because another lock exists. This other lock may be ignored by using a 'forced' action." } }, "healthChecksFailures": { diff --git a/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java index 734e8fcc52..b437475ff5 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/protocol/HttpProtocolServletTest.java @@ -32,7 +32,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; @@ -47,6 +46,7 @@ import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.spi.HttpScmProtocol; import sonia.scm.util.HttpUtil; +import sonia.scm.web.ScmClientDetector; import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgentParser; @@ -55,7 +55,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Optional; +import java.util.Set; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,7 +82,6 @@ class HttpProtocolServletTest { @Mock private ScmConfiguration configuration; - @InjectMocks private HttpProtocolServlet servlet; @Mock @@ -97,108 +100,72 @@ class HttpProtocolServletTest { private HttpScmProtocol protocol; @Nested - class Browser { + class WithoutAdditionalScmClientDetector { @BeforeEach - void prepareMocks() { - when(userAgentParser.parse(request)).thenReturn(userAgent); - when(userAgent.isScmClient()).thenReturn(false); - when(request.getRequestURI()).thenReturn("uri"); - } - - @Test - void shouldDispatchBrowserRequests() throws ServletException, IOException { - servlet.service(request, response); - - verify(dispatcher).dispatch(request, response, "uri"); - } - - } - - @Nested - class ScmClient { - - @BeforeEach - void prepareMocks() { - when(userAgentParser.parse(request)).thenReturn(userAgent); - when(userAgent.isScmClient()).thenReturn(true); - } - - @Test - void shouldHandleBadPaths() throws IOException, ServletException { - when(request.getPathInfo()).thenReturn("/illegal"); - - servlet.service(request, response); - - verify(response).setStatus(400); - } - - @Test - void shouldHandleNotExistingRepository() throws IOException, ServletException { - when(request.getPathInfo()).thenReturn("/not/exists"); - - NamespaceAndName repo = new NamespaceAndName("not", "exists"); - when(extractor.fromUri("/not/exists")).thenReturn(Optional.of(repo)); - when(serviceFactory.create(repo)).thenThrow(new NotFoundException("Test", "a")); - - servlet.service(request, response); - - verify(response).setStatus(404); - } - - @Test - void shouldDelegateToProvider() throws IOException, ServletException { - NamespaceAndName repo = new NamespaceAndName("space", "name"); - when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo)); - when(serviceFactory.create(repo)).thenReturn(repositoryService); - - when(request.getPathInfo()).thenReturn("/space/name"); - Repository repository = RepositoryTestData.createHeartOfGold(); - when(repositoryService.getRepository()).thenReturn(repository); - when(repositoryService.getProtocol(HttpScmProtocol.class)).thenReturn(protocol); - - servlet.service(request, response); - - verify(request).setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repository); - verify(protocol).serve(request, response, null); - verify(repositoryService).close(); + void initServlet() { + servlet = new HttpProtocolServlet( + configuration, + serviceFactory, + extractor, + dispatcher, + userAgentParser, + emptySet() + ); } @Nested - class WithSubject { - - @Mock - private Subject subject; + class Browser { @BeforeEach - void setUpSubject() { - ThreadContext.bind(subject); - } - - @AfterEach - void tearDownSubject() { - ThreadContext.unbindSubject(); + void prepareMocks() { + when(userAgentParser.parse(request)).thenReturn(userAgent); + when(userAgent.isScmClient()).thenReturn(false); + when(request.getRequestURI()).thenReturn("uri"); } @Test - void shouldSendUnauthorizedWithCustomRealmDescription() throws IOException, ServletException { - when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS); - when(configuration.getRealmDescription()).thenReturn("Hitchhikers finest"); + void shouldDispatchBrowserRequests() throws ServletException, IOException { + servlet.service(request, response); - callServiceWithAuthorizationException(); + verify(dispatcher).dispatch(request, response, "uri"); + } - verify(response).setHeader(HttpUtil.HEADER_WWW_AUTHENTICATE, "Basic realm=\"Hitchhikers finest\""); - verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, HttpUtil.STATUS_UNAUTHORIZED_MESSAGE); + } + + @Nested + class ScmClient { + + @BeforeEach + void prepareMocks() { + when(userAgentParser.parse(request)).thenReturn(userAgent); + when(userAgent.isScmClient()).thenReturn(true); } @Test - void shouldSendForbidden() throws IOException, ServletException { - callServiceWithAuthorizationException(); + void shouldHandleBadPaths() throws IOException, ServletException { + when(request.getPathInfo()).thenReturn("/illegal"); - verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN); + servlet.service(request, response); + + verify(response).setStatus(400); } - private void callServiceWithAuthorizationException() throws IOException, ServletException { + @Test + void shouldHandleNotExistingRepository() throws IOException, ServletException { + when(request.getPathInfo()).thenReturn("/not/exists"); + + NamespaceAndName repo = new NamespaceAndName("not", "exists"); + when(extractor.fromUri("/not/exists")).thenReturn(Optional.of(repo)); + when(serviceFactory.create(repo)).thenThrow(new NotFoundException("Test", "a")); + + servlet.service(request, response); + + verify(response).setStatus(404); + } + + @Test + void shouldDelegateToProvider() throws IOException, ServletException { NamespaceAndName repo = new NamespaceAndName("space", "name"); when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo)); when(serviceFactory.create(repo)).thenReturn(repositoryService); @@ -206,14 +173,93 @@ class HttpProtocolServletTest { when(request.getPathInfo()).thenReturn("/space/name"); Repository repository = RepositoryTestData.createHeartOfGold(); when(repositoryService.getRepository()).thenReturn(repository); - when(repositoryService.getProtocol(HttpScmProtocol.class)).thenThrow( - new AuthorizationException("failed") - ); + when(repositoryService.getProtocol(HttpScmProtocol.class)).thenReturn(protocol); servlet.service(request, response); + + verify(request).setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repository); + verify(protocol).serve(request, response, null); + verify(repositoryService).close(); } + @Nested + class WithSubject { + + @Mock + private Subject subject; + + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void tearDownSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldSendUnauthorizedWithCustomRealmDescription() throws IOException, ServletException { + when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS); + when(configuration.getRealmDescription()).thenReturn("Hitchhikers finest"); + + callServiceWithAuthorizationException(); + + verify(response).setHeader(HttpUtil.HEADER_WWW_AUTHENTICATE, "Basic realm=\"Hitchhikers finest\""); + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, HttpUtil.STATUS_UNAUTHORIZED_MESSAGE); + } + + @Test + void shouldSendForbidden() throws IOException, ServletException { + callServiceWithAuthorizationException(); + + verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN); + } + + private void callServiceWithAuthorizationException() throws IOException, ServletException { + NamespaceAndName repo = new NamespaceAndName("space", "name"); + when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo)); + when(serviceFactory.create(repo)).thenReturn(repositoryService); + + when(request.getPathInfo()).thenReturn("/space/name"); + Repository repository = RepositoryTestData.createHeartOfGold(); + when(repositoryService.getRepository()).thenReturn(repository); + when(repositoryService.getProtocol(HttpScmProtocol.class)).thenThrow( + new AuthorizationException("failed") + ); + + servlet.service(request, response); + } + } + } + } + + @Nested + class WithAdditionalDetector { + + @Mock + private ScmClientDetector detector; + + @BeforeEach + void createServlet() { + servlet = new HttpProtocolServlet( + configuration, + serviceFactory, + extractor, + dispatcher, + userAgentParser, + singleton(detector) + ); } + @Test + void shouldConsultScmDetector() throws ServletException, IOException { + when(userAgentParser.parse(request)).thenReturn(userAgent); + when(detector.isScmClient(request, userAgent)).thenReturn(true); + + servlet.service(request, response); + + verify(response).setStatus(400); + } } }