diff --git a/CHANGELOG.md b/CHANGELOG.md index 5421312a13..5c25740da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased -### Fixed +### Fixed +- JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297)) +- Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298)) - Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300)) ## [2.4.0] - 2020-08-14 diff --git a/Jenkinsfile b/Jenkinsfile index 688930d227..2ef5929e09 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -75,7 +75,9 @@ node('docker') { integrationTest: { stage('Integration Test') { mvn 'verify -Pit -DskipUnitTests -pl :scm-webapp,:scm-it -Dmaven.test.failure.ignore=true' - junit allowEmptyResults: true, testResults: '**/target/failsafe-reports/TEST-*.xml' + junit allowEmptyResults: true, testResults: '**/target/failsafe-reports/TEST-*.xml,**/target/cypress-reports/TEST-*.xml' + archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/e2e-tests/cypress/videos/*.mp4' + archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/e2e-tests/cypress/screenshots/**/*.png' } } ) @@ -190,7 +192,7 @@ node('docker') { String mainBranch Maven setupMavenBuild() { - MavenWrapperInDocker mvn = new MavenWrapperInDocker(this, "scmmanager/java-build:11.0.7_10") + MavenWrapperInDocker mvn = new MavenWrapperInDocker(this, "scmmanager/java-build:11.0.8_10") mvn.enableDockerHost = true // disable logging durring the build diff --git a/build/Dockerfile b/build/Dockerfile index c9afa6992e..6ef5ff1cfb 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -22,7 +22,7 @@ # SOFTWARE. # -FROM adoptopenjdk/openjdk11:x86_64-debian-jdk-11.0.7_10 +FROM adoptopenjdk/openjdk11:x86_64-debian-jdk-11.0.8_10 ENV DOCKER_VERSION=19.03.8 \ DOCKER_CHANNEL=stable \ @@ -34,11 +34,24 @@ COPY modprobe.sh /usr/local/bin/modprobe # install required packages RUN set -eux; \ apt-get update \ - && apt-get install -y \ + && apt-get install --no-install-recommends -y \ # mercurial is requried for integration tests of the scm-hg-plugin mercurial \ # git is required by yarn install of scm-ui git \ + # the following dependencies are required for cypress tests and are copied from + # https://github.com/cypress-io/cypress-docker-images/blob/master/base/12.18.3/Dockerfile + libgtk2.0-0 \ + libgtk-3-0 \ + libnotify-dev \ + libgconf-2-4 \ + libgbm-dev \ + libnss3 \ + libxss1 \ + libasound2 \ + libxtst6 \ + xauth \ + xvfb \ # download docker && curl -o docker-${DOCKER_VERSION}.tgz https://download.docker.com/linux/static/${DOCKER_CHANNEL}/x86_64/docker-${DOCKER_VERSION}.tgz \ && echo "${DOCKER_CHECKSUM} docker-${DOCKER_VERSION}.tgz" > docker-${DOCKER_VERSION}.sha256sum \ diff --git a/build/Makefile b/build/Makefile index 173df8bcb8..180b0db68d 100644 --- a/build/Makefile +++ b/build/Makefile @@ -1,4 +1,4 @@ -VERSION:=11.0.7_10 +VERSION:=11.0.8_10 .PHONY:build build: diff --git a/package.json b/package.json index 61331899bc..4772263be5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "webpack --mode=production --config=scm-ui/ui-scripts/src/webpack.config.js", "build:dev": "webpack --mode=development --config=scm-ui/ui-scripts/src/webpack.config.js", "test": "lerna run --scope '@scm-manager/ui-*' --scope '@scm-manager/eslint-config' test", + "e2e-tests": "lerna run --scope '@scm-manager/e2e-tests' ci", "typecheck": "lerna run --scope '@scm-manager/ui-*' typecheck", "serve": "NODE_ENV=development webpack-dev-server --hot --mode=development --config=scm-ui/ui-scripts/src/webpack.config.js", "deploy": "ui-scripts publish", diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index 04ffe12e21..a6514465fd 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -67,6 +67,7 @@ public class AuthenticationFilter extends HttpFilter { */ private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; + private final Set tokenGenerators; protected ScmConfiguration configuration; @@ -117,7 +118,7 @@ public class AuthenticationFilter extends HttpFilter { } /** - * Sends status code 403 back to client, if the authentication has failed. + * Sends status code 401 back to client, if the authentication has failed. * In all other cases the method will send status code 403 back to client. * * @param request servlet request @@ -209,12 +210,8 @@ public class AuthenticationFilter extends HttpFilter { subject.login(token); processChain(request, response, chain, subject); } catch (TokenExpiredException ex) { - if (logger.isTraceEnabled()) { - logger.trace("{} expired", token.getClass(), ex); - } else { - logger.debug("{} expired", token.getClass()); - } - handleUnauthorized(request, response, chain); + // Rethrow to be caught by TokenExpiredFilter + throw ex; } catch (AuthenticationException ex) { logger.warn("authentication failed", ex); handleUnauthorized(request, response, chain); diff --git a/scm-it/pom.xml b/scm-it/pom.xml index 44f11f82d2..6911304659 100644 --- a/scm-it/pom.xml +++ b/scm-it/pom.xml @@ -186,6 +186,36 @@ + + com.github.sdorra + buildfrontend-maven-plugin + + ${basedir}/.. + + ${nodejs.version} + + + YARN + ${yarn.version} + + + + true + + + + e2e + integration-test + + run + + + + + org.apache.maven.plugins maven-dependency-plugin @@ -284,7 +314,7 @@ DEVELOPMENT ${project.parent.build.directory}/scm-it - ${project.basedir}/../scm-webapp/src/main/resources/logback.default.xml + ${project.basedir}/../scm-webapp/src/main/resources/logback.ci.xml diff --git a/scm-ui/e2e-tests/cypress.json b/scm-ui/e2e-tests/cypress.json index 03e8546581..128f39dcfb 100644 --- a/scm-ui/e2e-tests/cypress.json +++ b/scm-ui/e2e-tests/cypress.json @@ -1,3 +1,5 @@ { - "baseUrl": "http://localhost:8081/scm" + "baseUrl": "http://localhost:8081/scm", + "videoUploadOnPasses": false, + "videoCompression": false } diff --git a/scm-ui/e2e-tests/cypress/.gitignore b/scm-ui/e2e-tests/cypress/.gitignore new file mode 100644 index 0000000000..adaba54810 --- /dev/null +++ b/scm-ui/e2e-tests/cypress/.gitignore @@ -0,0 +1,2 @@ +videos +screenshots diff --git a/scm-ui/e2e-tests/package.json b/scm-ui/e2e-tests/package.json index 91b2691a7e..d5348843d0 100644 --- a/scm-ui/e2e-tests/package.json +++ b/scm-ui/e2e-tests/package.json @@ -6,8 +6,16 @@ "author": "Eduard Heimbuch ", "license": "MIT", "private": false, - "devDependencies": { + "scripts": { + "headless": "cypress run", + "ci": "node src/index.js" + }, + "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.0.20", "cypress": "^4.12.0", + "fluent-ffmpeg": "^2.1.2" + }, + "devDependencies": { "eslint-plugin-cypress": "^2.11.1" }, "prettier": "@scm-manager/prettier-config", diff --git a/scm-ui/e2e-tests/src/index.js b/scm-ui/e2e-tests/src/index.js new file mode 100644 index 0000000000..55c08bb312 --- /dev/null +++ b/scm-ui/e2e-tests/src/index.js @@ -0,0 +1,86 @@ +/* + * 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. + */ + +const cypress = require("cypress"); +const fs = require("fs"); +const path = require("path"); +const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path; +const ffmpeg = require("fluent-ffmpeg"); + +ffmpeg.setFfmpegPath(ffmpegPath); + +const options = { + reporter: "junit", + reporterOptions: { + mochaFile: path.join("..", "target", "cypress-reports", "TEST-[hash].xml") + } +}; + +const createOutputFile = test => { + const title = test.title.join(" -- "); + return path.join("cypress", "videos", `${title}.mp4`); +}; + +const cutVideo = (video, test) => { + return new Promise((resolve, reject) => { + const title = createOutputFile(test); + ffmpeg(video) + .setStartTime(test.videoTimestamp / 1000) + .setDuration(test.wallClockDuration / 1000) + .output(title) + .on("end", err => { + if (err) { + reject(err); + } else { + resolve(); + } + }) + .on("error", err => { + reject(err); + }) + .run(); + }); +}; + +cypress + .run(options) + .then(results => { + results.runs.forEach(run => { + // remove videos of successful runs + if (!run.shouldUploadVideo) { + fs.unlinkSync(run.video); + } else { + const cuts = []; + run.tests.forEach(test => { + if (test.state !== "passed") { + cuts.push(cutVideo(run.video, test)); + } + }); + Promise.all(cuts) + .then(() => fs.unlinkSync(run.video)) + .catch(err => console.error("failed to cut video", err)); + } + }); + }) + .catch(err => console.error(err)); diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts index 346b02845d..a371b06571 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-components/src/apiclient.ts @@ -23,7 +23,14 @@ */ import { contextPath } from "./urls"; -import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError, BackendErrorContent } from "./errors"; +import { + createBackendError, + ForbiddenError, + isBackendError, + UnauthorizedError, + BackendErrorContent, + TOKEN_EXPIRED_ERROR_CODE +} from "./errors"; type SubscriptionEvent = { type: string; @@ -120,7 +127,13 @@ function handleFailure(response: Response) { if (!response.ok) { if (isBackendError(response)) { return response.json().then((content: BackendErrorContent) => { - throw createBackendError(content, response.status); + if (content.errorCode === TOKEN_EXPIRED_ERROR_CODE) { + window.location.replace(`${contextPath}/login`); + // Throw error because if redirect is not instantaneous, we want to display something senseful + throw new UnauthorizedError("Unauthorized", 401); + } else { + throw createBackendError(content, response.status); + } }); } else { if (response.status === 401) { diff --git a/scm-ui/ui-components/src/devBuild.test.ts b/scm-ui/ui-components/src/devBuild.test.ts index 8df47ec6ee..5964032f53 100644 --- a/scm-ui/ui-components/src/devBuild.test.ts +++ b/scm-ui/ui-components/src/devBuild.test.ts @@ -25,31 +25,37 @@ import { createAttributesForTesting, isDevBuild } from "./devBuild"; describe("devbuild tests", () => { - let env: string | undefined; + let stage: string | undefined; + + const setStage = (s?: string) => { + // @ts-ignore scmStage is set on the index page + window.scmStage = s; + }; beforeAll(() => { - env = process.env.NODE_ENV; + // @ts-ignore scmStage is set on the index page + stage = window.scmStage; }); afterAll(() => { - process.env.NODE_ENV = env; + setStage(stage); }); describe("isDevBuild tests", () => { it("should return true for development", () => { - process.env.NODE_ENV = "development"; + setStage("development"); expect(isDevBuild()).toBe(true); }); it("should return false for production", () => { - process.env.NODE_ENV = "production"; + setStage("production"); expect(isDevBuild()).toBe(false); }); }); describe("createAttributesForTesting in non development mode", () => { beforeAll(() => { - process.env.NODE_ENV = "production"; + setStage("production"); }); it("should return undefined for non development", () => { @@ -60,7 +66,7 @@ describe("devbuild tests", () => { describe("createAttributesForTesting in development mode", () => { beforeAll(() => { - process.env.NODE_ENV = "development"; + setStage("development"); }); it("should return undefined for non development", () => { diff --git a/scm-ui/ui-components/src/devBuild.ts b/scm-ui/ui-components/src/devBuild.ts index abf1a18dc7..4425f695ab 100644 --- a/scm-ui/ui-components/src/devBuild.ts +++ b/scm-ui/ui-components/src/devBuild.ts @@ -22,7 +22,8 @@ * SOFTWARE. */ -export const isDevBuild = () => process.env.NODE_ENV === "development"; +// @ts-ignore scmStage is set on the index page +export const isDevBuild = () => (window.scmStage || "").toUpperCase() === "DEVELOPMENT"; export const createAttributesForTesting = (testId?: string) => { if (!testId || !isDevBuild()) { diff --git a/scm-ui/ui-components/src/errors.ts b/scm-ui/ui-components/src/errors.ts index 2d4757bc72..bf1f364e67 100644 --- a/scm-ui/ui-components/src/errors.ts +++ b/scm-ui/ui-components/src/errors.ts @@ -103,3 +103,5 @@ export function createBackendError(content: BackendErrorContent, statusCode: num export function isBackendError(response: Response) { return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2"; } + +export const TOKEN_EXPIRED_ERROR_CODE = "DDS8D8unr1"; diff --git a/scm-ui/ui-scripts/src/webpack.config.js b/scm-ui/ui-scripts/src/webpack.config.js index f25e6c3122..0bc9f93fd7 100644 --- a/scm-ui/ui-scripts/src/webpack.config.js +++ b/scm-ui/ui-scripts/src/webpack.config.js @@ -22,12 +22,13 @@ * SOFTWARE. */ const path = require("path"); -const createIndexMiddleware = require("./middleware/IndexMiddleware"); -const createContextPathMiddleware = require("./middleware/ContextPathMiddleware"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); const WorkerPlugin = require("worker-plugin"); +const createIndexMiddleware = require("./middleware/IndexMiddleware"); +const createContextPathMiddleware = require("./middleware/ContextPathMiddleware"); + const isDevelopment = process.env.NODE_ENV === "development"; const root = path.resolve(process.cwd(), "scm-ui"); @@ -39,6 +40,8 @@ let mode = "production"; if (isDevelopment) { mode = "development"; babelPlugins.push(require.resolve("react-refresh/babel")); + // it is ok to use require here, because we want to load the package conditionally + // eslint-disable-next-line global-require const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); webpackPlugins.push(new ReactRefreshWebpackPlugin()); } @@ -113,13 +116,15 @@ module.exports = [ historyApiFallback: true, overlay: true, port: 3000, - before: function(app) { + before: app => { app.use(createContextPathMiddleware("/scm")); }, - after: function(app) { + after: app => { const templatePath = path.join(root, "ui-webapp", "public", "index.mustache"); + const stage = process.env.NODE_ENV || "DEVELOPMENT"; const renderParams = { - contextPath: "/scm" + contextPath: "/scm", + scmStage: stage.toUpperCase() }; app.use(createIndexMiddleware(templatePath, renderParams)); }, diff --git a/scm-ui/ui-webapp/public/index.mustache b/scm-ui/ui-webapp/public/index.mustache index d94bc552ee..3c0a51713c 100644 --- a/scm-ui/ui-webapp/public/index.mustache +++ b/scm-ui/ui-webapp/public/index.mustache @@ -47,6 +47,7 @@ --> diff --git a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx index 5fb14e4557..3844717487 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx @@ -36,9 +36,20 @@ type Props = { }; const DangerZoneContainer = styled.div` - padding: 1rem; + padding: 1.5rem 1rem; border: 1px solid #ff6a88; border-radius: 5px; + > .level { + flex-flow: wrap; + + .level-left { + max-width: 100%; + } + + .level-right { + margin-top: 0.75rem; + } + } > *:not(:last-child) { padding-bottom: 1.5rem; border-bottom: solid 2px whitesmoke; diff --git a/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx index 8585e98466..23f6ea10a5 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx @@ -26,9 +26,8 @@ import { connect } from "react-redux"; import { compose } from "redux"; import { RouteComponentProps, withRouter } from "react-router-dom"; import { WithTranslation, withTranslation } from "react-i18next"; -import { History } from "history"; import { Repository } from "@scm-manager/ui-types"; -import { confirmAlert, DeleteButton, ErrorNotification, Level, ButtonGroup } from "@scm-manager/ui-components"; +import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos"; type Props = RouteComponentProps & @@ -89,10 +88,11 @@ class DeleteRepo extends React.Component { +

{t("deleteRepo.subtitle")} -

{t("deleteRepo.description")}

- +
+ {t("deleteRepo.description")} +

} right={} /> diff --git a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx index ede6414029..9254774a6d 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx @@ -38,7 +38,7 @@ type Props = { }; const RenameRepository: FC = ({ repository, indexLinks }) => { - let history = useHistory(); + const history = useHistory(); const [t] = useTranslation("repos"); const [error, setError] = useState(undefined); const [loading, setLoading] = useState(false); @@ -156,10 +156,11 @@ const RenameRepository: FC = ({ repository, indexLinks }) => { /> +

{t("renameRepo.subtitle")} -

{t("renameRepo.description")}

- +
+ {t("renameRepo.description")} +

} right={