Make auth expiration for LFS configurable (#1697)

When SCM-Manager is used behind a reverse proxy like
Nginx it may be the case, that lfs PUT requests are
buffered by the reverse proxy and will be sent to the
SCM-Manager after the whole file has been received. Due
to the expiration time of 5 minutes for the authentivation
token that had been requested by Git before the upload
has been started, this request from the proxy to
SCM-Manager fails if the upload from the client to the
reverse proxy took longer than these 5 minutes.

To solve this, we make this expiration time configurable,
so that whenever you have very large files or small
bandwidth the expiration timeout can be increased.
This commit is contained in:
René Pfeuffer
2021-06-16 09:14:52 +02:00
committed by GitHub
parent 97b32f3918
commit b6d343bf09
15 changed files with 325 additions and 152 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -20,4 +20,12 @@ Unter dem Eintrag Git können die folgenden Git-spezifischen Einstellungen vorge
Bitte beachten Sie, dass dieser Name aufgrund von Git-Spezifika nicht bei leeren Repositories genutzt Bitte beachten Sie, dass dieser Name aufgrund von Git-Spezifika nicht bei leeren Repositories genutzt
werden kann (hier wird immer der Git-interne Default Name genutzt, derzeit also `master`). werden kann (hier wird immer der Git-interne Default Name genutzt, derzeit also `master`).
- LFS Autorisierungsablaufzeit
Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird.
Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird,
sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann. Treten
während eines länger laufenden LFS "Pushs" Autorisierungsfehler auf, sollte dieser Wert erhöht werden.
Der Default-Wert beträgt 5 Minuten.
![Administration-Plugins-Installed](assets/administration-settings-git.png) ![Administration-Plugins-Installed](assets/administration-settings-git.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -19,4 +19,11 @@ In the git section there are the following git specific settings:
Please mind, that due to git internals this cannot work for empty repositories (here git Please mind, that due to git internals this cannot work for empty repositories (here git
will always use its internal default branch, so at the time being `master`). will always use its internal default branch, so at the time being `master`).
- LFS authorization expiration
Sets the expiration time of the authorization token generated for LFS put requests in minutes.
If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this
should set up to the time, an LFS upload may take at maximum. If you experience errors during
long-running LFS push requests, this may have to be increased. The default value is 5 minutes.
![Administration-Plugins-Installed](assets/administration-settings-git.png) ![Administration-Plugins-Installed](assets/administration-settings-git.png)

View File

@@ -0,0 +1,2 @@
- type: fixed
description: Added option to increase LFS authorization token timeout ([#1697](https://github.com/scm-manager/scm-manager/pull/1697))

View File

@@ -185,7 +185,7 @@ public class GitNonFastForwardITCase {
} }
private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) { private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) {
String config = String.format("{'disabled': false, 'gcExpression': null, 'defaultBranch': 'main', 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed) String config = String.format("{'disabled': false, 'gcExpression': null, 'defaultBranch': 'main', 'lfsWriteAuthorizationExpirationInMinutes': 5, 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed)
.replace('\'', '"'); .replace('\'', '"');
given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX) given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX)

View File

@@ -31,6 +31,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern; import javax.validation.constraints.Pattern;
@@ -52,6 +53,9 @@ public class GitConfigDto extends HalRepresentation implements UpdateGitConfigDt
@Pattern(regexp = VALID_BRANCH_NAMES) @Pattern(regexp = VALID_BRANCH_NAMES)
private String defaultBranch; private String defaultBranch;
@Min(1)
private int lfsWriteAuthorizationExpirationInMinutes;
@Override @Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package @SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) { protected HalRepresentation add(Links links) {

View File

@@ -55,6 +55,9 @@ public class GitConfig extends RepositoryConfig {
@XmlElement(name = "default-branch") @XmlElement(name = "default-branch")
private String defaultBranch = FALLBACK_BRANCH; private String defaultBranch = FALLBACK_BRANCH;
@XmlElement(name = "lfs-write-authorization-expiration")
private int lfsWriteAuthorizationExpirationInMinutes = 5;
public String getGcExpression() { public String getGcExpression() {
return gcExpression; return gcExpression;
} }
@@ -82,6 +85,14 @@ public class GitConfig extends RepositoryConfig {
this.defaultBranch = defaultBranch; this.defaultBranch = defaultBranch;
} }
public int getLfsWriteAuthorizationExpirationInMinutes() {
return lfsWriteAuthorizationExpirationInMinutes;
}
public void setLfsWriteAuthorizationExpirationInMinutes(int lfsWriteAuthorizationExpirationInMinutes) {
this.lfsWriteAuthorizationExpirationInMinutes = lfsWriteAuthorizationExpirationInMinutes;
}
@Override @Override
@XmlTransient // Only for permission checks, don't serialize to XML @XmlTransient // Only for permission checks, don't serialize to XML
public String getId() { public String getId() {

View File

@@ -27,6 +27,8 @@ package sonia.scm.web.lfs;
import com.github.sdorra.ssp.PermissionCheck; import com.github.sdorra.ssp.PermissionCheck;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitConfig;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.security.AccessToken; import sonia.scm.security.AccessToken;
@@ -43,10 +45,12 @@ public class LfsAccessTokenFactory {
private static final Logger LOG = LoggerFactory.getLogger(LfsAccessTokenFactory.class); private static final Logger LOG = LoggerFactory.getLogger(LfsAccessTokenFactory.class);
private final AccessTokenBuilderFactory tokenBuilderFactory; private final AccessTokenBuilderFactory tokenBuilderFactory;
private final GitRepositoryHandler handler;
@Inject @Inject
LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory) { LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory, GitRepositoryHandler handler) {
this.tokenBuilderFactory = tokenBuilderFactory; this.tokenBuilderFactory = tokenBuilderFactory;
this.handler = handler;
} }
AccessToken createReadAccessToken(Repository repository) { AccessToken createReadAccessToken(Repository repository) {
@@ -67,7 +71,7 @@ public class LfsAccessTokenFactory {
permissions.add(push.asShiroString()); permissions.add(push.asShiroString());
} }
return createToken(Scope.valueOf(permissions)); return createToken(Scope.valueOf(permissions), 5);
} }
AccessToken createWriteAccessToken(Repository repository) { AccessToken createWriteAccessToken(Repository repository) {
@@ -80,15 +84,22 @@ public class LfsAccessTokenFactory {
PermissionCheck push = RepositoryPermissions.push(repository); PermissionCheck push = RepositoryPermissions.push(repository);
push.check(); push.check();
return createToken(Scope.valueOf(read.asShiroString(), pull.asShiroString(), push.asShiroString())); int lfsAuthorizationTimeoutInMinutes = getConfiguredLfsAuthorizationTimeoutInMinutes();
return createToken(Scope.valueOf(read.asShiroString(), pull.asShiroString(), push.asShiroString()), lfsAuthorizationTimeoutInMinutes);
} }
private AccessToken createToken(Scope scope) { private AccessToken createToken(Scope scope, int expiration) {
LOG.trace("create access token with scope: {}", scope); LOG.trace("create access token with scope: {}", scope);
return tokenBuilderFactory return tokenBuilderFactory
.create() .create()
.expiresIn(5, TimeUnit.MINUTES) .expiresIn(expiration, TimeUnit.MINUTES)
.scope(scope) .scope(scope)
.build(); .build();
} }
private int getConfiguredLfsAuthorizationTimeoutInMinutes() {
GitConfig repositoryConfig = handler.getConfig();
return repositoryConfig.getLfsWriteAuthorizationExpirationInMinutes();
}
} }

View File

@@ -1,122 +0,0 @@
/*
* 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.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Links } from "@scm-manager/ui-types";
import { InputField, Checkbox, validation as validator } from "@scm-manager/ui-components";
type Configuration = {
repositoryDirectory?: string;
gcExpression?: string;
nonFastForwardDisallowed: boolean;
defaultBranch: string;
_links: Links;
};
type Props = WithTranslation & {
initialConfiguration: Configuration;
readOnly: boolean;
onConfigurationChange: (p1: Configuration, p2: boolean) => void;
};
type State = Configuration & {};
class GitConfigurationForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
...props.initialConfiguration
};
}
onGcExpressionChange = (value: string) => {
this.setState(
{
gcExpression: value
},
() => this.props.onConfigurationChange(this.state, true)
);
};
onNonFastForwardDisallowed = (value: boolean) => {
this.setState(
{
nonFastForwardDisallowed: value
},
() => this.props.onConfigurationChange(this.state, true)
);
};
onDefaultBranchChange = (value: string) => {
this.setState(
{
defaultBranch: value
},
() => this.props.onConfigurationChange(this.state, this.isValidDefaultBranch())
);
};
isValidDefaultBranch = () => {
return validator.isBranchValid(this.state.defaultBranch);
};
render() {
const { gcExpression, nonFastForwardDisallowed, defaultBranch } = this.state;
const { readOnly, t } = this.props;
return (
<>
<InputField
name="gcExpression"
label={t("scm-git-plugin.config.gcExpression")}
helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
value={gcExpression}
onChange={this.onGcExpressionChange}
disabled={readOnly}
/>
<Checkbox
name="nonFastForwardDisallowed"
label={t("scm-git-plugin.config.nonFastForwardDisallowed")}
helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")}
checked={nonFastForwardDisallowed}
onChange={this.onNonFastForwardDisallowed}
disabled={readOnly}
/>
<InputField
name="defaultBranch"
label={t("scm-git-plugin.config.defaultBranch")}
helpText={t("scm-git-plugin.config.defaultBranchHelpText")}
value={defaultBranch}
onChange={this.onDefaultBranchChange}
disabled={readOnly}
validationError={!this.isValidDefaultBranch()}
errorMessage={t("scm-git-plugin.config.defaultBranchValidationError")}
/>
</>
);
}
}
export default withTranslation("plugins")(GitConfigurationForm);

View File

@@ -21,26 +21,80 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC, useEffect } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Title, Configuration } from "@scm-manager/ui-components"; import { Title, ConfigurationForm, InputField, Checkbox, validation } from "@scm-manager/ui-components";
import GitConfigurationForm from "./GitConfigurationForm"; import { useConfigLink } from "@scm-manager/ui-api";
import { HalRepresentation } from "@scm-manager/ui-types";
import { useForm } from "react-hook-form";
type Props = WithTranslation & { type Props = {
link: string; link: string;
}; };
class GitGlobalConfiguration extends React.Component<Props> { type Configuration = HalRepresentation & {
render() { repositoryDirectory?: string;
const { link, t } = this.props; gcExpression?: string;
nonFastForwardDisallowed: boolean;
defaultBranch: string;
lfsWriteAuthorizationExpirationInMinutes: number;
};
const GitGlobalConfiguration: FC<Props> = ({ link }) => {
const [t] = useTranslation("plugins");
const { initialConfiguration, isReadOnly, update, ...formProps } = useConfigLink(link);
const { formState, handleSubmit, register, reset } = useForm<Configuration>({ mode: "onChange" });
useEffect(() => {
if (initialConfiguration) {
reset(initialConfiguration);
}
}, [initialConfiguration]);
const isValidDefaultBranch = (value: string) => {
return validation.isBranchValid(value);
};
return ( return (
<div> <ConfigurationForm
isValid={formState.isValid}
isReadOnly={isReadOnly}
onSubmit={handleSubmit(update)}
{...formProps}
>
<Title title={t("scm-git-plugin.config.title")} /> <Title title={t("scm-git-plugin.config.title")} />
<Configuration link={link} render={(props: any) => <GitConfigurationForm {...props} />} /> <InputField
</div> label={t("scm-git-plugin.config.gcExpression")}
helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
disabled={isReadOnly}
{...register("gcExpression")}
/>
<Checkbox
label={t("scm-git-plugin.config.nonFastForwardDisallowed")}
helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")}
disabled={isReadOnly}
{...register("nonFastForwardDisallowed")}
/>
<InputField
label={t("scm-git-plugin.config.defaultBranch")}
helpText={t("scm-git-plugin.config.defaultBranchHelpText")}
disabled={isReadOnly}
validationError={!!formState.errors.defaultBranch}
errorMessage={t("scm-git-plugin.config.defaultBranchValidationError")}
{...register("defaultBranch", { validate: isValidDefaultBranch })}
/>
<InputField
type="number"
label={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutes")}
helpText={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutesHelpText")}
disabled={isReadOnly}
validationError={!!formState.errors.lfsWriteAuthorizationExpirationInMinutes}
errorMessage={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutesValidationError")}
{...register("lfsWriteAuthorizationExpirationInMinutes", { min: 1, required: true })}
/>
</ConfigurationForm>
); );
} };
}
export default withTranslation("plugins")(GitGlobalConfiguration); export default GitGlobalConfiguration;

View File

@@ -27,6 +27,9 @@
"defaultBranch": "Default Branch", "defaultBranch": "Default Branch",
"defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).", "defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).",
"defaultBranchValidationError": "Dies ist kein valider Branchname", "defaultBranchValidationError": "Dies ist kein valider Branchname",
"lfsWriteAuthorizationExpirationInMinutes": "Ablaufzeit für LFS Autorisierung",
"lfsWriteAuthorizationExpirationInMinutesHelpText": "Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird. Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird, sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann.",
"lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute",
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin", "disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin",
"submit": "Speichern" "submit": "Speichern"

View File

@@ -27,6 +27,9 @@
"defaultBranch": "Default Branch", "defaultBranch": "Default Branch",
"defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).", "defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).",
"defaultBranchValidationError": "This is not a valid branch name", "defaultBranchValidationError": "This is not a valid branch name",
"lfsWriteAuthorizationExpirationInMinutes": "LFS authorization expiration",
"lfsWriteAuthorizationExpirationInMinutesHelpText": "Expiration time of the authorization token generated for LFS put requests in minutes. If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this should set up to the time, an LFS upload may take at maximum.",
"lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute",
"disabled": "Disabled", "disabled": "Disabled",
"disabledHelpText": "Enable or disable the Git plugin", "disabledHelpText": "Enable or disable the Git plugin",
"submit": "Submit" "submit": "Submit"

View File

@@ -48,6 +48,7 @@ import sonia.scm.repository.RepositoryManager;
import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.web.GitVndMediaType; import sonia.scm.web.GitVndMediaType;
import sonia.scm.web.JsonMockHttpRequest;
import sonia.scm.web.RestDispatcher; import sonia.scm.web.RestDispatcher;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
@@ -127,10 +128,12 @@ public class GitConfigResourceTest {
String responseString = response.getContentAsString(); String responseString = response.getContentAsString();
assertTrue(responseString.contains("\"disabled\":false")); assertThat(responseString)
assertTrue(responseString.contains("\"gcExpression\":\"valid Git GC Cron Expression\"")); .contains("\"disabled\":false")
assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/git")); .contains("\"gcExpression\":\"valid Git GC Cron Expression\"")
assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/git")); .contains("\"self\":{\"href\":\"/v2/config/git")
.contains("\"update\":{\"href\":\"/v2/config/git")
.contains("\"lfsWriteAuthorizationExpirationInMinutes\":5");
} }
@Test @Test
@@ -324,9 +327,9 @@ public class GitConfigResourceTest {
} }
private MockHttpResponse put() throws URISyntaxException { private MockHttpResponse put() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.put("/" + GitConfigResource.GIT_CONFIG_PATH_V2) JsonMockHttpRequest request = JsonMockHttpRequest.put("/" + GitConfigResource.GIT_CONFIG_PATH_V2)
.contentType(GitVndMediaType.GIT_CONFIG) .contentType(GitVndMediaType.GIT_CONFIG)
.content("{\"disabled\":true, \"defaultBranch\":\"main\"}".getBytes()); .json("{'disabled':true, 'defaultBranch':'main', 'lfsWriteAuthorizationExpirationInMinutes':5}");
MockHttpResponse response = new MockHttpResponse(); MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -337,6 +340,7 @@ public class GitConfigResourceTest {
GitConfig config = new GitConfig(); GitConfig config = new GitConfig();
config.setGcExpression("valid Git GC Cron Expression"); config.setGcExpression("valid Git GC Cron Expression");
config.setDisabled(false); config.setDisabled(false);
config.setLfsWriteAuthorizationExpirationInMinutes(5);
return config; return config;
} }
} }

View File

@@ -0,0 +1,188 @@
/*
* 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.lfs;
import org.apache.shiro.authz.UnauthorizedException;
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.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.GitConfig;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilder;
import sonia.scm.security.AccessTokenBuilderFactory;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@ExtendWith(ShiroExtension.class)
class LfsAccessTokenFactoryTest {
@Mock
private AccessTokenBuilderFactory tokenBuilderFactory;
@Mock(answer = Answers.RETURNS_SELF)
private AccessTokenBuilder tokenBuilder;
@Mock
private GitRepositoryHandler handler;
@InjectMocks
private LfsAccessTokenFactory factory;
@Mock
private AccessToken createdTokenFromMock;
private Repository repository = RepositoryTestData.createHeartOfGold();
@BeforeEach
void initRepository() {
repository.setId("42");
}
@Nested
class WithPermissions {
@BeforeEach
void initTokenBuilder() {
when(tokenBuilderFactory.create()).thenReturn(tokenBuilder);
when(tokenBuilder.build()).thenReturn(createdTokenFromMock);
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read,pull:42"
)
void shouldCreateReadToken() {
AccessToken readAccessToken = factory.createReadAccessToken(repository);
assertThat(readAccessToken).isSameAs(createdTokenFromMock);
verify(tokenBuilder).expiresIn(5, TimeUnit.MINUTES);
verify(tokenBuilder).scope(argThat(scope -> {
assertThat(scope.iterator()).toIterable()
.containsExactly("repository:read:42", "repository:pull:42");
return true;
}));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read,pull,push:42"
)
void shouldCreateReadTokenWithPushIfPermitted() {
AccessToken readAccessToken = factory.createReadAccessToken(repository);
assertThat(readAccessToken).isSameAs(createdTokenFromMock);
verify(tokenBuilder).expiresIn(5, TimeUnit.MINUTES);
verify(tokenBuilder).scope(argThat(scope -> {
assertThat(scope.iterator()).toIterable()
.containsExactly("repository:read:42", "repository:pull:42", "repository:push:42");
return true;
}));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read,pull,push:42"
)
void shouldCreateWriteToken() {
GitConfig config = new GitConfig();
config.setLfsWriteAuthorizationExpirationInMinutes(23);
when(handler.getConfig()).thenReturn(config);
AccessToken writeAccessToken = factory.createWriteAccessToken(repository);
assertThat(writeAccessToken).isSameAs(createdTokenFromMock);
verify(tokenBuilder).expiresIn(23, TimeUnit.MINUTES);
verify(tokenBuilder).scope(argThat(scope -> {
assertThat(scope.iterator()).toIterable()
.containsExactly("repository:read:42", "repository:pull:42", "repository:push:42");
return true;
}));
}
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read:42"
)
void shouldFailToCreateReadTokenWithoutPullPermission() {
assertThrows(UnauthorizedException.class, () -> factory.createReadAccessToken(repository));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:pull:42"
)
void shouldFailToCreateReadTokenWithoutReadPermission() {
assertThrows(UnauthorizedException.class, () -> factory.createReadAccessToken(repository));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:pull,push:42"
)
void shouldFailToCreateWriteTokenWithoutReadPermission() {
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read,push:42"
)
void shouldFailToCreateWriteTokenWithoutPullPermission() {
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
}
@Test
@SubjectAware(
value = "trillian",
permissions = "repository:read,pull:42"
)
void shouldFailToCreateWriteTokenWithoutPushPermission() {
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
}
}