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:
René Pfeuffer
2021-11-01 16:54:58 +01:00
committed by GitHub
parent 87aea1936b
commit e1a2d27256
44 changed files with 4970 additions and 787 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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