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