mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 15:35:49 +01:00
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:
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 175 KiB |
@@ -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
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 190 KiB |
@@ -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
|
||||
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.
|
||||
|
||||

|
||||
|
||||
2
gradle/changelog/lfs_auth_expiration.yaml
Normal file
2
gradle/changelog/lfs_auth_expiration.yaml
Normal 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))
|
||||
@@ -185,7 +185,7 @@ public class GitNonFastForwardITCase {
|
||||
}
|
||||
|
||||
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('\'', '"');
|
||||
|
||||
given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX)
|
||||
|
||||
@@ -31,6 +31,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Pattern;
|
||||
|
||||
@@ -52,6 +53,9 @@ public class GitConfigDto extends HalRepresentation implements UpdateGitConfigDt
|
||||
@Pattern(regexp = VALID_BRANCH_NAMES)
|
||||
private String defaultBranch;
|
||||
|
||||
@Min(1)
|
||||
private int lfsWriteAuthorizationExpirationInMinutes;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
||||
protected HalRepresentation add(Links links) {
|
||||
|
||||
@@ -55,6 +55,9 @@ public class GitConfig extends RepositoryConfig {
|
||||
@XmlElement(name = "default-branch")
|
||||
private String defaultBranch = FALLBACK_BRANCH;
|
||||
|
||||
@XmlElement(name = "lfs-write-authorization-expiration")
|
||||
private int lfsWriteAuthorizationExpirationInMinutes = 5;
|
||||
|
||||
public String getGcExpression() {
|
||||
return gcExpression;
|
||||
}
|
||||
@@ -82,6 +85,14 @@ public class GitConfig extends RepositoryConfig {
|
||||
this.defaultBranch = defaultBranch;
|
||||
}
|
||||
|
||||
public int getLfsWriteAuthorizationExpirationInMinutes() {
|
||||
return lfsWriteAuthorizationExpirationInMinutes;
|
||||
}
|
||||
|
||||
public void setLfsWriteAuthorizationExpirationInMinutes(int lfsWriteAuthorizationExpirationInMinutes) {
|
||||
this.lfsWriteAuthorizationExpirationInMinutes = lfsWriteAuthorizationExpirationInMinutes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@XmlTransient // Only for permission checks, don't serialize to XML
|
||||
public String getId() {
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.web.lfs;
|
||||
|
||||
import com.github.sdorra.ssp.PermissionCheck;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.GitConfig;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryPermissions;
|
||||
import sonia.scm.security.AccessToken;
|
||||
@@ -43,10 +45,12 @@ public class LfsAccessTokenFactory {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(LfsAccessTokenFactory.class);
|
||||
|
||||
private final AccessTokenBuilderFactory tokenBuilderFactory;
|
||||
private final GitRepositoryHandler handler;
|
||||
|
||||
@Inject
|
||||
LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory) {
|
||||
LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory, GitRepositoryHandler handler) {
|
||||
this.tokenBuilderFactory = tokenBuilderFactory;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
AccessToken createReadAccessToken(Repository repository) {
|
||||
@@ -67,7 +71,7 @@ public class LfsAccessTokenFactory {
|
||||
permissions.add(push.asShiroString());
|
||||
}
|
||||
|
||||
return createToken(Scope.valueOf(permissions));
|
||||
return createToken(Scope.valueOf(permissions), 5);
|
||||
}
|
||||
|
||||
AccessToken createWriteAccessToken(Repository repository) {
|
||||
@@ -80,15 +84,22 @@ public class LfsAccessTokenFactory {
|
||||
PermissionCheck push = RepositoryPermissions.push(repository);
|
||||
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);
|
||||
return tokenBuilderFactory
|
||||
.create()
|
||||
.expiresIn(5, TimeUnit.MINUTES)
|
||||
.expiresIn(expiration, TimeUnit.MINUTES)
|
||||
.scope(scope)
|
||||
.build();
|
||||
}
|
||||
|
||||
private int getConfiguredLfsAuthorizationTimeoutInMinutes() {
|
||||
GitConfig repositoryConfig = handler.getConfig();
|
||||
return repositoryConfig.getLfsWriteAuthorizationExpirationInMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -21,26 +21,80 @@
|
||||
* 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 { Title, Configuration } from "@scm-manager/ui-components";
|
||||
import GitConfigurationForm from "./GitConfigurationForm";
|
||||
import React, { FC, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Title, ConfigurationForm, InputField, Checkbox, validation } from "@scm-manager/ui-components";
|
||||
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;
|
||||
};
|
||||
|
||||
class GitGlobalConfiguration extends React.Component<Props> {
|
||||
render() {
|
||||
const { link, t } = this.props;
|
||||
type Configuration = HalRepresentation & {
|
||||
repositoryDirectory?: string;
|
||||
gcExpression?: string;
|
||||
nonFastForwardDisallowed: boolean;
|
||||
defaultBranch: string;
|
||||
lfsWriteAuthorizationExpirationInMinutes: number;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title title={t("scm-git-plugin.config.title")} />
|
||||
<Configuration link={link} render={(props: any) => <GitConfigurationForm {...props} />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const GitGlobalConfiguration: FC<Props> = ({ link }) => {
|
||||
const [t] = useTranslation("plugins");
|
||||
|
||||
export default withTranslation("plugins")(GitGlobalConfiguration);
|
||||
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 (
|
||||
<ConfigurationForm
|
||||
isValid={formState.isValid}
|
||||
isReadOnly={isReadOnly}
|
||||
onSubmit={handleSubmit(update)}
|
||||
{...formProps}
|
||||
>
|
||||
<Title title={t("scm-git-plugin.config.title")} />
|
||||
<InputField
|
||||
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 GitGlobalConfiguration;
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"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).",
|
||||
"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",
|
||||
"disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin",
|
||||
"submit": "Speichern"
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"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).",
|
||||
"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",
|
||||
"disabledHelpText": "Enable or disable the Git plugin",
|
||||
"submit": "Submit"
|
||||
|
||||
@@ -48,6 +48,7 @@ import sonia.scm.repository.RepositoryManager;
|
||||
import sonia.scm.store.ConfigurationStore;
|
||||
import sonia.scm.store.ConfigurationStoreFactory;
|
||||
import sonia.scm.web.GitVndMediaType;
|
||||
import sonia.scm.web.JsonMockHttpRequest;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
@@ -127,10 +128,12 @@ public class GitConfigResourceTest {
|
||||
|
||||
String responseString = response.getContentAsString();
|
||||
|
||||
assertTrue(responseString.contains("\"disabled\":false"));
|
||||
assertTrue(responseString.contains("\"gcExpression\":\"valid Git GC Cron Expression\""));
|
||||
assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/git"));
|
||||
assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/git"));
|
||||
assertThat(responseString)
|
||||
.contains("\"disabled\":false")
|
||||
.contains("\"gcExpression\":\"valid Git GC Cron Expression\"")
|
||||
.contains("\"self\":{\"href\":\"/v2/config/git")
|
||||
.contains("\"update\":{\"href\":\"/v2/config/git")
|
||||
.contains("\"lfsWriteAuthorizationExpirationInMinutes\":5");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -324,9 +327,9 @@ public class GitConfigResourceTest {
|
||||
}
|
||||
|
||||
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)
|
||||
.content("{\"disabled\":true, \"defaultBranch\":\"main\"}".getBytes());
|
||||
.json("{'disabled':true, 'defaultBranch':'main', 'lfsWriteAuthorizationExpirationInMinutes':5}");
|
||||
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
@@ -337,6 +340,7 @@ public class GitConfigResourceTest {
|
||||
GitConfig config = new GitConfig();
|
||||
config.setGcExpression("valid Git GC Cron Expression");
|
||||
config.setDisabled(false);
|
||||
config.setLfsWriteAuthorizationExpirationInMinutes(5);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user