diff --git a/docs/de/user/admin/assets/administration-settings-git.png b/docs/de/user/admin/assets/administration-settings-git.png index 71ae9a6feb..d06555173c 100644 Binary files a/docs/de/user/admin/assets/administration-settings-git.png and b/docs/de/user/admin/assets/administration-settings-git.png differ diff --git a/docs/de/user/admin/git.md b/docs/de/user/admin/git.md index e80864fcd3..f7e9488b82 100644 --- a/docs/de/user/admin/git.md +++ b/docs/de/user/admin/git.md @@ -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. + ![Administration-Plugins-Installed](assets/administration-settings-git.png) diff --git a/docs/en/user/admin/assets/administration-settings-git.png b/docs/en/user/admin/assets/administration-settings-git.png index 31ed35138f..b2d2964833 100644 Binary files a/docs/en/user/admin/assets/administration-settings-git.png and b/docs/en/user/admin/assets/administration-settings-git.png differ diff --git a/docs/en/user/admin/git.md b/docs/en/user/admin/git.md index 625bca93b8..32a87865c0 100644 --- a/docs/en/user/admin/git.md +++ b/docs/en/user/admin/git.md @@ -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. + ![Administration-Plugins-Installed](assets/administration-settings-git.png) diff --git a/gradle/changelog/lfs_auth_expiration.yaml b/gradle/changelog/lfs_auth_expiration.yaml new file mode 100644 index 0000000000..285ed33662 --- /dev/null +++ b/gradle/changelog/lfs_auth_expiration.yaml @@ -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)) diff --git a/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java index c6f263fed9..38b5dbc6ee 100644 --- a/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java @@ -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) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java index 99ac469166..44fc039996 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java @@ -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) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java index 2efac8451a..0522ff63ba 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java @@ -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() { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsAccessTokenFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsAccessTokenFactory.java index 31c45f80cb..fe92c6c3e7 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsAccessTokenFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsAccessTokenFactory.java @@ -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(); + } } diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx deleted file mode 100644 index cec3f1b85f..0000000000 --- a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.tsx +++ /dev/null @@ -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 { - 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 ( - <> - - - - - ); - } -} - -export default withTranslation("plugins")(GitConfigurationForm); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx b/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx index dc8764bf99..edddfb3d9d 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx +++ b/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx @@ -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 { - render() { - const { link, t } = this.props; +type Configuration = HalRepresentation & { + repositoryDirectory?: string; + gcExpression?: string; + nonFastForwardDisallowed: boolean; + defaultBranch: string; + lfsWriteAuthorizationExpirationInMinutes: number; +}; - return ( -
- - <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; diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 1b667398b3..825a550d53 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -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" diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index 74e2f1be47..471cdad06e 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -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" diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java index c861674175..18aab1cc6f 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java @@ -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; } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsAccessTokenFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsAccessTokenFactoryTest.java new file mode 100644 index 0000000000..4ef4ca59d1 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsAccessTokenFactoryTest.java @@ -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)); + } +}