mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-05-07 10:16:21 +02:00
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 <eduard.heimbuch@cloudogu.com>
This commit is contained in:
@@ -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<Changeset> 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<String> modifiedPaths) {
|
||||
for (String path : modifiedPaths) {
|
||||
fileLockStore.assertModifiable(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FileLock> status(LockStatusCommandRequest request) {
|
||||
GitFileLockStore lockStore = getLockStore();
|
||||
return lockStore.getLock(request.getFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<FileLock> getAll() {
|
||||
GitFileLockStore lockStore = getLockStore();
|
||||
return lockStore.getAll();
|
||||
}
|
||||
|
||||
private GitFileLockStore getLockStore() {
|
||||
return lockStoreFactory.create(context.getRepository());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<StoreEntry> 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<FileLock> 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<FileLock> remove(String file, boolean force) {
|
||||
StoreEntry storeEntry = readEntry();
|
||||
Optional<FileLock> 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<FileLock> removeById(String id, boolean force) {
|
||||
StoreEntry storeEntry = readEntry();
|
||||
return storeEntry.getById(id).flatMap(lock -> remove(lock.getPath(), force));
|
||||
}
|
||||
|
||||
public Optional<FileLock> 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<FileLock> getById(String id) {
|
||||
return readEntry().getById(id);
|
||||
}
|
||||
|
||||
public Collection<FileLock> 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 <file>.");
|
||||
}
|
||||
}
|
||||
|
||||
@XmlRootElement(name = "file-locks")
|
||||
@XmlAccessorType(XmlAccessType.PROPERTY)
|
||||
private static class StoreEntry {
|
||||
private Map<String, StoredFileLock> files = new TreeMap<>();
|
||||
@XmlTransient
|
||||
private final Map<String, StoredFileLock> ids = new HashMap<>();
|
||||
|
||||
public void setFiles(Map<String, StoredFileLock> files) {
|
||||
this.files = files;
|
||||
files.values().forEach(
|
||||
lock -> ids.put(lock.getId(), lock)
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, StoredFileLock> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
Optional<FileLock> get(String file) {
|
||||
if (files == null) {
|
||||
return empty();
|
||||
}
|
||||
return ofNullable(files.get(file)).map(StoredFileLock::toFileLock);
|
||||
}
|
||||
|
||||
Optional<FileLock> 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<FileLock> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Feature> 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<Command> getSupportedCommands() {
|
||||
return COMMANDS;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<FileLock> allLocks = lockStore.getAll();
|
||||
Stream<FileLock> 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<FileLock> allLocks) {
|
||||
return allLocks.size() > cursor + limit ? Integer.toString(cursor + limit) : null;
|
||||
}
|
||||
|
||||
private Stream<FileLock> limit(Collection<FileLock> 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<FileLock> allLocks = lockStore.getAll();
|
||||
int cursor = getCursor(verify.getCursor());
|
||||
int limit = getEffectiveLimit(verify.getLimit());
|
||||
if (limit < 0 || cursor < 0) {
|
||||
return;
|
||||
}
|
||||
Stream<FileLock> 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<FileLock> 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 <T> Optional<T> readObject(Class<T> 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<LockDto> ours;
|
||||
private Collection<LockDto> theirs;
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonProperty("next_cursor")
|
||||
private String nextCursor;
|
||||
|
||||
VerifyResultDto(Stream<FileLock> locks, String nextCursor) {
|
||||
String userId = SecurityUtils.getSubject().getPrincipals().oneByType(String.class);
|
||||
Map<Boolean, List<LockDto>> 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<LockDto> locks;
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonProperty("next_cursor")
|
||||
private String nextCursor;
|
||||
|
||||
LocksListDto(Optional<FileLock> locks) {
|
||||
this.locks = locks.map(LockDto::new).map(Collections::singletonList).orElse(emptyList());
|
||||
}
|
||||
|
||||
LocksListDto(Stream<FileLock> locks, String nextCursor) {
|
||||
this.locks = locks.map(LockDto::new).collect(toList());
|
||||
this.nextCursor = nextCursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FileLock> status = lockCommand.status(request);
|
||||
|
||||
AbstractObjectAssert<?, FileLock> 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<FileLock> existingLocks = new ArrayList<>();
|
||||
when(lockStore.getAll()).thenReturn(existingLocks);
|
||||
|
||||
Collection<FileLock> all = lockCommand.getAll();
|
||||
|
||||
assertThat(all).isSameAs(existingLocks);
|
||||
}
|
||||
}
|
||||
@@ -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<FileLock> retrievedLock = gitFileLockStore.getLock("some/file.txt");
|
||||
|
||||
AbstractObjectAssert<?, FileLock> 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<FileLock> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<? super Iterable<? extends JsonNode>> lockNodeWith(FileLock lock, String expectedName) {
|
||||
return new Condition<Iterable<? extends JsonNode>>() {
|
||||
@Override
|
||||
public boolean matches(Iterable<? extends JsonNode> 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<? super Iterable<? extends JsonNode>> lockNodeWith(String expectedId, String expectedPath, String expectedName, String expectedTimestamp) {
|
||||
return new Condition<Iterable<? extends JsonNode>>() {
|
||||
@Override
|
||||
public boolean matches(Iterable<? extends JsonNode> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user