diff --git a/.github/workflows/test-plugins.yaml b/.github/workflows/test-plugins.yaml new file mode 100644 index 0000000000..3d4c1a6fbd --- /dev/null +++ b/.github/workflows/test-plugins.yaml @@ -0,0 +1,56 @@ +name: Tests (Acceptance-Plugins) + +on: + push: + branches: + - main + - support/* + pull_request: + +jobs: + tests: + + strategy: + fail-fast: false # continue other tests if one test in matrix fails + matrix: + node-version: [16.x, 18.x] + os: [macos-latest, windows-latest, ubuntu-latest] + + name: Acceptance test kit on Node v${{ matrix.node-version }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + + env: + CYPRESS_CACHE_FOLDER: ~/.cache/Cypress + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Use Node v${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + cache: 'npm' + node-version: ${{ matrix.node-version }} + + - name: Cache Cypress binary + uses: actions/cache@v3 + with: + path: ~/.cache/Cypress + key: cypress-${{ runner.os }}-cypress-${{ hashFiles('**/package.json') }} + + - run: npm ci + + - run: npm run test:acceptance:plugins + env: + CYPRESS_REQUEST_TIMEOUT: 20000 + CYPRESS_DEFAULT_COMMAND_TIMEOUT: 40000 + CYPRESS_PAGE_LOAD_TIMEOUT: 120000 + CYPRESS_RETRIES: 3 + + - if: ${{ failure() }} + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots-dev-${{ runner.os }}-${{ matrix.node-version }} + path: cypress/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d5522a3978..c5814418c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New features + +- [#1824: Add feature to manage plugins without using the command line](https://github.com/alphagov/govuk-prototype-kit/pull/1824) + ### Fixes - [#1849: Update dependencies](https://github.com/alphagov/govuk-prototype-kit/pull/1849) diff --git a/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js b/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js deleted file mode 100644 index 28eb0a621c..0000000000 --- a/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js +++ /dev/null @@ -1,72 +0,0 @@ -const { waitForApplication, uninstallPlugin, installPlugin } = require('../../utils') -const managePluginsPagePath = '/manage-prototype/plugins' -const plugin = '@govuk-prototype-kit/step-by-step' -const version1 = '@1.0.0' -const version2 = '@2.1.0' -const pluginName = 'Step By Step' - -const cleanup = () => { - // Make sure plugin version 1 is installed - installPlugin(plugin, version1) - cy.wait(5000) -} - -describe('Management plugins: ', () => { - before(() => { - cy.task('log', 'Visit the manage prototype plugins page') - cleanup() - waitForApplication(managePluginsPagePath) - }) - - after(() => { - cleanup() - }) - - it(`Make sure ${plugin}${version1} is installed and it can be upgraded to ${plugin}${version2} and then uninstalled`, () => { - cy.task('log', `Make sure ${plugin}${version1} is installed and it can be upgraded to ${plugin}${version2} and then uninstalled`) - cy.get(`a[href*="/uninstall?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Uninstall') - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}&mode=upgrade"]`) - .should('contains.text', 'Upgrade') - }) - - it(`Upgrade the ${plugin} plugin to ${plugin}${version2}`, () => { - cy.task('log', `Upgrade the ${plugin} plugin`) - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}&mode=upgrade"]`) - .should('contains.text', 'Upgrade').click() - - cy.task('log', `The ${plugin} plugin should be displayed`) - cy.get('h1') - .should('contains.text', `Upgrade ${pluginName}`) - - cy.get('code').eq(0) - .should('have.text', `npm install ${plugin}${version2}`) - - installPlugin(plugin, version2) - - cy.wait(10000) - - waitForApplication(managePluginsPagePath) - - cy.task('log', `Uninstall the ${plugin}${version2} plugin`) - cy.get(`a[href*="/uninstall?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Uninstall').click() - - cy.task('log', `The ${plugin} plugin should be displayed`) - cy.get('h1') - .should('contains.text', `Uninstall ${pluginName}`) - - cy.get('code').eq(0) - .should('have.text', `npm uninstall ${plugin}`) - - uninstallPlugin(plugin) - - cy.wait(5000) - - waitForApplication(managePluginsPagePath) - - cy.task('log', `Install the ${plugin} plugin`) - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Install') - }) -}) diff --git a/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js b/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js index b99b964fcf..d561296355 100644 --- a/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js +++ b/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js @@ -12,13 +12,13 @@ describe('Management plugins: ', () => { before(() => { cy.task('log', 'Visit the manage prototype plugins page') installPlugin(plugin, version2) - cy.wait(5000) + cy.wait(8000) waitForApplication(manageTemplatesPagePath) }) after(() => { installPlugin(plugin, version1) - cy.wait(5000) + cy.wait(8000) }) it(`Preview a ${plugin}${version2} template`, () => { diff --git a/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js b/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js new file mode 100644 index 0000000000..d65e4f813b --- /dev/null +++ b/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js @@ -0,0 +1,212 @@ +const { waitForApplication, installPlugin, getTemplateLink, deleteFile } = require('../../utils') +const { capitalize } = require('lodash') +const path = require('path') +const { showHideAllLinkQuery, assertVisible, assertHidden } = require('../../step-by-step-utils') +const appViews = path.join(Cypress.env('projectFolder'), 'app', 'views') +const managePluginsPagePath = '/manage-prototype/plugins' +const plugin = '@govuk-prototype-kit/step-by-step' +const version1 = '@1.0.0' +const version2 = '@2.1.0' +const pluginName = 'Step By Step' +const pluginPageTemplate = '/templates/step-by-step-navigation.html' +const pluginPageTitle = 'Step by step navigation' +const pluginPagePath = '/step-by-step-navigation' + +const cleanup = () => { + deleteFile(path.join(appViews, 'step-by-step-navigation.html')) + // Make sure plugin version 1 is installed + installPlugin(plugin, version1) + cy.wait(8000) +} + +const panelProcessingQuery = '[aria-live="polite"] #panel-processing' +const panelCompleteQuery = '[aria-live="polite"] #panel-complete' +const panelErrorQuery = '[aria-live="polite"] #panel-error' + +const performPluginAction = (action) => { + cy.task('log', `The ${plugin} plugin should be displayed`) + cy.get('h2') + .should('contains.text', `${capitalize(action)} ${pluginName}`) + + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelProcessingQuery) + .should('be.visible') + .should('contain.text', `${capitalize(action === 'upgrade' ? 'Upgrad' : action)}ing ...`) + + cy.get(panelProcessingQuery, { timeout: 20000 }) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelCompleteQuery) + .should('be.visible') + .should('contain.text', `${capitalize(action)} complete`) + + cy.get('#instructions-complete a') + .should('contain.text', 'Back to plugins') + .wait(3000) + .click() + + cy.get('h1').should('have.text', 'Plugins') +} + +const provePluginFunctionalityWorks = () => { + cy.wait(2000) + + cy.task('log', `Prove ${pluginName} functionality works`) + + cy.visit(pluginPagePath) + + // click toggle button and check that all steps details are visible + cy.get(showHideAllLinkQuery).should('contains.text', 'Show all').click() + assertVisible(1) + assertVisible(2) + + // click toggle button and check that all steps details are hidden + cy.get(showHideAllLinkQuery).should('contains.text', 'Hide all').click() + assertHidden(1) + assertHidden(2) +} + +const provePluginFunctionalityFails = () => { + cy.on('uncaught:exception', (err, runnable) => { + console.log(err) + // returning false here prevents Cypress from + // failing a test when javascript in the browser fails + return false + }) + + cy.wait(2000) + + cy.task('log', `Prove ${pluginName} functionality fails`) + + cy.visit(pluginPagePath) + + cy.get(showHideAllLinkQuery).should('not.exist') +} + +describe('Management plugins: ', () => { + before(() => { + cleanup() + cy.task('log', 'Visit the manage prototype plugins page') + waitForApplication(managePluginsPagePath) + }) + + after(() => { + cleanup() + }) + + beforeEach(() => { + cy.wait(4000) + }) + + it('CSRF Protection on POST action', () => { + const installUrl = `${managePluginsPagePath}/install` + cy.task('log', `Posting to ${installUrl} without csrf protection`) + cy.request({ + url: `${managePluginsPagePath}/install`, + method: 'POST', + failOnStatusCode: false, + body: { package: plugin } + }).then(response => { + expect(response.status).to.eq(403) + expect(response.body).to.eq('invalid csrf token') + }) + }) + + it(`Upgrade the ${plugin}${version1} plugin to ${plugin}${version2}`, () => { + cy.task('log', `Upgrade the ${plugin} plugin`) + cy.get(`button[formaction*="/upgrade?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Upgrade').click() + + performPluginAction('upgrade', version2) + }) + + it(`Create a page using a template from the ${plugin} plugin`, () => { + cy.get('a[href*="/templates"]') + .should('contains.text', 'Templates').click() + + cy.get('h2').eq(2).should('contain.text', pluginName) + + cy.task('log', `Create a new ${pluginPageTitle} page`) + + cy.get(`a[href="${getTemplateLink('install', '@govuk-prototype-kit/step-by-step', pluginPageTemplate)}"]`).click() + + // create step-by-step-navigation page + cy.get('.govuk-heading-l') + .should('contains.text', `Create new ${pluginPageTitle} page`) + cy.get('#chosen-url') + .type(pluginPagePath) + cy.get('.govuk-button') + .should('contains.text', 'Create page').click() + + provePluginFunctionalityWorks() + }) + + it(`Uninstall the ${plugin} plugin`, () => { + cy.visit(managePluginsPagePath) + cy.task('log', `Uninstall the ${plugin} plugin`) + cy.get(`button[formaction*="/uninstall?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Uninstall').click() + + performPluginAction('uninstall') + + provePluginFunctionalityFails() + }) + + it(`Install the ${plugin} plugin`, () => { + cy.visit(managePluginsPagePath) + cy.task('log', `Install the ${plugin} plugin`) + cy.get(`button[formaction*="/install?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Install').click() + + performPluginAction('install') + + provePluginFunctionalityWorks() + }) + + describe('Get plugin page directly', () => { + it('Pass when installing a plugin already installed', () => { + cy.task('log', `Simulate refreshing the install ${plugin} plugin confirmation page`) + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}`) + + cy.get('#plugin-action-button').click() + + performPluginAction('install') + }) + + it('Fail when installing a non existent plugin', () => { + const pkg = 'invalid-prototype-kit-plugin' + const pluginName = 'Invalid Prototype Kit Plugin' + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(pkg)}`) + cy.get('h2') + .should('contains.text', `Install ${pluginName}`) + + cy.get('#plugin-action-button').click() + + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelProcessingQuery) + .should('be.visible') + .should('contain.text', 'Installing ...') + + cy.get(panelProcessingQuery, { timeout: 40000 }) + .should('not.be.visible') + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('be.visible') + + cy.get(`${panelErrorQuery} .govuk-panel__title`) + .should('contain.text', 'There was a problem installing') + cy.get(`${panelErrorQuery} a`) + .should('contain.text', 'Please contact support') + }) + }) +}) diff --git a/govuk-prototype-kit.config.json b/govuk-prototype-kit.config.json index e061512696..e5725b7e8b 100644 --- a/govuk-prototype-kit.config.json +++ b/govuk-prototype-kit.config.json @@ -1,7 +1,8 @@ { "assets": [ "/lib/assets/images", - "/lib/assets/javascripts/optional" + "/lib/assets/javascripts/optional", + "/lib/assets/javascripts/manage-prototype" ], "nunjucksPaths": "/lib/nunjucks", "nunjucksFilters": "/lib/filters/coreFilters", diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.js b/lib/assets/javascripts/manage-prototype/manage-plugins.js new file mode 100644 index 0000000000..9b377c657a --- /dev/null +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.js @@ -0,0 +1,127 @@ +window.GOVUKPrototypeKit.documentReady(() => { + const params = new URLSearchParams(window.location.search) + const packageName = params.get('package') + const mode = params.get('mode') || window.location.pathname.split('/').pop() + let startTimestamp = '' + let requestTimeoutId + let timedOut = false + const timeout = 30 * 1000 + const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content') + + const show = (id) => { + const element = document.getElementById(id) + element.hidden = false + } + + const hide = (id) => { + const element = document.getElementById(id) + element.hidden = true + } + + const log = (status) => { + window.console.info(`GOV.UK Prototype Kit - ${status} ${mode} of ${packageName}`) + } + + const showCompleteStatus = () => { + clearTimeout(actionTimeoutId) + log('Successful') + hide('panel-processing') + show('panel-complete') + show('instructions-complete') + } + + const showErrorStatus = () => { + log('Failed') + hide('panel-processing') + show('panel-error') + } + + const postRequest = (url) => fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'CSRF-Token': token + }, + body: JSON.stringify({ + package: packageName + }) + }) + + const actionTimeoutId = setTimeout(() => { + timedOut = true + }, timeout) + + const pollStatus = () => { + log('Processing') + if (requestTimeoutId) { + clearTimeout(requestTimeoutId) + } + if (timedOut) { + showErrorStatus() + } else { + postRequest('/manage-prototype/plugins/status') + .then(response => response.json()) + .then(data => { + requestTimeoutId = setTimeout(() => { + if (data.status === 'error') { + clearTimeout(actionTimeoutId) + showErrorStatus() + } else if (data.startTimestamp === startTimestamp) { + // poll status again if prototype hasn't restarted + pollStatus() + } else { + showCompleteStatus() + } + }, 1000) + }) + .catch(() => { + requestTimeoutId = setTimeout(pollStatus, 1000) + }) + } + } + + const performAction = (event) => { + log('Starting') + + if (event) { + event.preventDefault() + hide('plugin-action-confirmation') + } + + show('panel-processing') + + postRequest(`/manage-prototype/plugins/${mode}`) + .then(response => response.json()) + .then(data => { + switch (data.status) { + case 'completed': { + // Command has already been run + showCompleteStatus() + break + } + case 'processing': { + startTimestamp = data.startTimestamp + pollStatus() + break + } + default: { + // Default to error + showErrorStatus() + } + } + }) + .catch(() => { + showErrorStatus() + }) + } + + hide('panel-manual-instructions') + const actionButton = document.getElementById('plugin-action-button') + + if (actionButton) { + actionButton.addEventListener('click', performAction) + } else { + performAction() + } +}) diff --git a/lib/assets/sass/internal/manage-prototype.scss b/lib/assets/sass/internal/manage-prototype.scss index 64a30e2fa8..a2d69f27c2 100644 --- a/lib/assets/sass/internal/manage-prototype.scss +++ b/lib/assets/sass/internal/manage-prototype.scss @@ -317,12 +317,12 @@ .govuk-prototype-kit-manage-prototype-template-list-template-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-template-list-template-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -339,12 +339,12 @@ margin-right: 1em; } .govuk-prototype-kit-manage-prototype-template-list-template-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-template-list-template-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -371,12 +371,12 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -389,33 +389,80 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { margin-right: 1em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-name { display: inline-block; - width: 45% + width: 35%; + vertical-align: middle; } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-links, +.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons, .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version{ display: inline-block; + vertical-align: middle; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version { - width: 23% + width: 15% } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-links { - width: 30% +@media(min-width: 40.0525em) { + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons { + .govuk-button { + margin: 0 + } + } } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-link { - margin-right: 1em; +@media(max-width: 40.0525em) { + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-name, + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons, + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version { + display: block; + width: auto; + } } + +.govuk-prototype-kit-manage-prototype-plugin-instructions { + &.js-hidden { + display: initial; + } + &.js-visible { + display: none; + } +} + +.js-enabled { + .govuk-prototype-kit-manage-prototype-plugin-instructions { + &.js-hidden { + display: none; + } + &.js-visible { + display: initial; + } + } +} + +.govuk-prototype-kit-manage-prototype-plugin-processing{ + .panel-processing{ + background: #fff; + color: #0b0c0c; + border: 2px solid #0b0c0c; + } + .panel-error{ + background: #fff; + color: #d4351c; + border: 2px solid #d4351c; + a,a:visited{ + color: #d4351c; + } + } +} \ No newline at end of file diff --git a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk index dd7405b5e5..2969756dcf 100644 --- a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk +++ b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk @@ -1,29 +1,88 @@ {% extends "govuk-prototype-kit/internal/views/manage-prototype/layout.njk" %} -{% from "govuk/components/button/macro.njk" import govukButton %} -{% block beforeContent %} - {{ super() }} - Back +{% block meta %} + {% endblock %} {% block content %} -
+
-

+ +

Plugins

+ +

{{ verb.title }} {{ chosenPlugin.name }} {% if chosenPlugin.scope %} from {{ chosenPlugin.scope }} {% endif %} -

-

- In terminal, press ctrl + c to stop your prototype, then run: -

-

- {{ command }} -

-

- When you've {{ verb.status }} the plugin, restart your prototype in the terminal by typing: -

-

- npm run dev -

+ + +
+

+ In terminal, press ctrl + c to stop your prototype, then run: +

+

+ {{ command }} +

+

+ When you've {{ verb.status }} the plugin, restart your prototype in the terminal by typing: +

+

+ npm run dev +

+
+ +
+ + {% if not isSameOrigin %} +
+

+ Are you sure you want to {{ verb.para }} this plugin? +

+ {{ govukButton({ + text: verb.title, + attributes: { id: "plugin-action-button" } + }) }} +
+ {% endif %} + + + + + + + + + +
+
{% endblock %} + +{% block pageScripts %} + +{% endblock %} diff --git a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-process.njk b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-process.njk deleted file mode 100644 index 71fdad2b25..0000000000 --- a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-process.njk +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "govuk-prototype-kit/internal/views/manage-prototype/layout.njk" %} -{% from "govuk/components/button/macro.njk" import govukButton %} - -{% block content %} - -
-
-

- {{ verb.title }} {{ chosenPlugin.name }} {% if chosenPlugin.scope %} from {{ chosenPlugin.scope }} {% endif %} -

-

You'll be redirected back to the Plugins page soon.

-
-
-{% endblock %} diff --git a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugins.njk b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugins.njk index 28d46f26a1..806751fa70 100644 --- a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugins.njk +++ b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugins.njk @@ -2,23 +2,9 @@ {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% block content %} +
- {{ successBanner | log }} - {% if successBanner %} - - {% set html %} -

- Successfully {{ successBanner.verb }} {{ successBanner.package.name }} {% if successBanner.package.scope %} from {{ successBanner.package.scope }} {% endif %} -

-

The list below shows the current status of your plugins and has been updated since you {{ successBanner.verb }} {{ successBanner.package.name }}.

- {% endset %} - - {{ govukNotificationBanner({ - html: html, - type: 'success' - }) }} - {% endif %}

Plugins

@@ -32,42 +18,52 @@
    {% for plugin in group.plugins %}
  • -
    + {{ plugin.name }} {% if plugin.scope %} -
    +
    From {{ plugin.scope }} {% endif %} {% if plugin.upgradeLink %}
    v{{ plugin.latestVersion }} available {% endif %} -
    -
    + + {{ plugin.installedVersion or plugin.latestVersion }} -
    - +
  • {% endfor %}
{% endfor %}
+
{% endblock %} diff --git a/lib/routes/prototype-admin-routes.js b/lib/routes/prototype-admin-routes.js index 0a208f38a6..c365c051f3 100644 --- a/lib/routes/prototype-admin-routes.js +++ b/lib/routes/prototype-admin-routes.js @@ -1,6 +1,7 @@ const templateRenderNunjucks = require('nunjucks') const fse = require('fs-extra') const querystring = require('querystring') +const csrf = require('csurf') const contextPath = '/manage-prototype' const router = require('../../index').requests.setupRouter(contextPath) @@ -13,6 +14,8 @@ const config = require('../config').getConfig() const { requestHttpsJson, prototypeAppScripts } = require('../utils') const { exec } = require('../exec') +const csrfProtection = csrf({ cookie: false }) + const appViews = extensions.getAppViews([ path.join(projectDir, 'node_modules'), path.join(projectDir, 'app/views/') @@ -22,6 +25,7 @@ const pkgPath = path.join(projectDir, 'package.json') const currentKitVersion = require(path.join(packageDir, 'package.json')).version const knownPlugins = require(path.join(packageDir, 'known-plugins.json')) +const { preparePackageNameForDisplay } = require('../extensions/extensions') const latestReleaseVersions = knownPlugins.plugins.available .reduce((releaseVersions, nextPlugin) => { @@ -361,7 +365,7 @@ async function getPluginDetails () { .map(async (packageDetails) => { const pack = await packageDetails Object.assign(pack, extensions.preparePackageNameForDisplay(pack.packageName), { - installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(pack.packageName)}&mode=install`, + installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(pack.packageName)}`, installCommand: `npm install ${pack.packageName}`, upgradeCommand: `npm install ${pack.packageName}@${pack.latestVersion}`, uninstallCommand: `npm uninstall ${pack.packageName}` @@ -374,7 +378,7 @@ async function getPluginDetails () { } else { } if (pack.latestVersion && pack.installedVersion && pack.installedVersion !== pack.latestVersion) { - pack.upgradeLink = `${contextPath}/plugins/install?package=${encodeURIComponent(pack.packageName)}&mode=upgrade` + pack.upgradeLink = `${contextPath}/plugins/upgrade?package=${encodeURIComponent(pack.packageName)}` } return pack })) @@ -404,6 +408,35 @@ const prepareForPluginPage = async () => { return output } +const getCommand = (mode, chosenPlugin) => { + switch (mode) { + case 'upgrade': return chosenPlugin.upgradeCommand + case 'install': return chosenPlugin.installCommand + case 'uninstall': return chosenPlugin.uninstallCommand + } +} + +const verbs = { + upgrade: { + title: 'Upgrade', + para: 'upgrade', + status: 'upgraded', + progressive: 'upgrading' + }, + install: { + title: 'Install', + para: 'install', + status: 'installed', + progressive: 'installing' + }, + uninstall: { + title: 'Uninstall', + para: 'uninstall', + status: 'uninstalled', + progressive: 'uninstalling' + } +} + router.get('/plugins', async (req, res) => { const pageName = 'Plugins' const model = { @@ -412,115 +445,115 @@ router.get('/plugins', async (req, res) => { mode: req.query.mode } - const isSuccessResult = !!req.query.success - - function getVerb () { - const mode = req.query.mode - if (mode === 'upgrade') { - return 'upgraded' - } else if (mode === 'install') { - return 'installed' - } else if (mode === 'uninstall') { - return 'uninstalled' - } - } + const isSuccessResult = !!req.query.package if (isSuccessResult) { model.successBanner = { - package: extensions.preparePackageNameForDisplay(req.query.success), + package: extensions.preparePackageNameForDisplay(req.query.package), mode: req.query.mode, - verb: getVerb() + verb: verbs[req.query.mode].status } } - - setTimeout(async () => { - model.groupsOfPlugins = await prepareForPluginPage() - res.render(getManagementView('plugins.njk'), model) - }, isSuccessResult ? 1000 : 0) + model.groupsOfPlugins = await prepareForPluginPage() + res.render(getManagementView('plugins.njk'), model) }) async function getPluginForRequest (req) { - const searchPackage = req.query.package - const chosenPlugin = (await getPluginDetails()) - .filter(({ packageName }) => packageName === searchPackage)[0] - return chosenPlugin + const searchPackage = req.query.package || req.body.package + if (searchPackage) { + return (await getPluginDetails()) + .filter(({ packageName }) => packageName === searchPackage)[0] + } } -router.get('/plugins/install', async (req, res) => { - const pageName = 'Plugins' - const chosenPlugin = await getPluginForRequest(req) +function modeIsComplete (mode, { installedVersion, latestVersion }) { + switch (mode) { + case 'upgrade': return installedVersion === latestVersion + case 'install': return !!installedVersion + case 'uninstall': return !installedVersion + } +} + +router.get('/plugins/:mode', csrfProtection, async (req, res) => { + const isSameOrigin = req.headers['sec-fetch-site'] === 'same-origin' + const { mode } = req.params + const verb = verbs[mode] - if (!chosenPlugin) { - res.status(404).send('Plugin not found') + if (!verb) { + res.status(404).send(`Page not found: ${req.path}`) return } + const chosenPlugin = await getPluginForRequest(req) || preparePackageNameForDisplay(req.query.package) + + const pageName = `${verb.title} ${chosenPlugin.name}` + res.render(getManagementView('plugin-install-or-uninstall.njk'), { currentPage: pageName, currentUrl: req.originalUrl, links: managementLinks, chosenPlugin, - command: req.query.mode === 'upgrade' ? chosenPlugin.upgradeCommand : chosenPlugin.installCommand, - verb: { - title: req.query.mode === 'upgrade' ? 'Upgrade' : 'Install', - para: req.query.mode === 'upgrade' ? 'upgrade' : 'install', - status: req.query.mode === 'upgrade' ? 'upgraded' : 'installed' - } + command: getCommand(mode, chosenPlugin), + verb, + isSameOrigin, + csrfToken: req.csrfToken() }) }) -router.post('/plugins/install', async (req, res) => { - const chosenPlugin = await getPluginForRequest(req) - res.render(getManagementView('plugin-install-process.njk'), { - chosenPlugin, - verb: { - title: req.query.mode === 'upgrade' ? 'Upgrading' : 'Installing', - para: req.query.mode === 'upgrade' ? 'upgrading' : 'installing' - } - }) +const startTimestamp = Date.now() + +router.post('/plugins/status', async (req, res) => { + let status = 'processing' try { - await exec(chosenPlugin.installCommand, { cwd: projectDir }) + const chosenPlugin = await getPluginForRequest(req) + if (!chosenPlugin) { + status = 'error' + } } catch (e) { - res.status(500).send(['Something went wrong', e.message, e.stack].join('br/')) + console.log(e) } + res.json({ startTimestamp, status }) }) -router.get('/plugins/uninstall', async (req, res) => { - const pageName = 'Plugins' - const chosenPlugin = await getPluginForRequest(req) +router.post('/plugins/:mode', async (req, res, next) => { + // Redirect to the GET route of the same url when the post request is not an ajax request + if (req.headers['content-type'].indexOf('json') === -1) { + res.redirect(req.originalUrl) + } else { + next() + } +}) + +router.post('/plugins/:mode', csrfProtection, async (req, res) => { + const { mode } = req.params + const verb = verbs[mode] - if (!chosenPlugin) { - res.status(404).send('Plugin not found') + if (!verb) { + res.json({ startTimestamp, status: 'error' }) return } - res.render(getManagementView('plugin-install-or-uninstall.njk'), { - currentPage: pageName, - currentUrl: req.originalUrl, - links: managementLinks, - command: chosenPlugin.uninstallCommand, - chosenPlugin, - verb: { - title: 'Uninstall', - para: 'uninstall', - status: 'uninstalled' - } - }) -}) -router.post('/plugins/uninstall', async (req, res) => { - const chosenPlugin = await getPluginForRequest(req) - res.render(getManagementView('plugin-install-process.njk'), { - chosenPlugin, - verb: { - title: 'Uninstalling', - para: 'uninstalling' - } - }) + let status = 'processing' try { - await exec(chosenPlugin.uninstallCommand, { cwd: projectDir }) + const chosenPlugin = await getPluginForRequest(req) + if (!chosenPlugin) { + status = 'error' + } else if (modeIsComplete(mode, chosenPlugin)) { + status = 'completed' + } else { + const command = getCommand(mode, chosenPlugin) + exec(command, { cwd: projectDir }).finally(() => { + console.log(`Completed ${command}`) + // force the application to stop after a delay as nodemon restart does not always work on Windows when running acceptance tests + setTimeout(() => { + process.exit(1) + }, 6000) + }) + } } catch (e) { - res.status(500).send(['Something went wrong', e.message, e.stack].join('br/')) + console.log(e) } + res.json({ startTimestamp, status }) }) module.exports = router diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 8abdb5dc4b..00cc9fc1fe 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,6 +14,7 @@ "chokidar": "^3.5.3", "cookie-parser": "^1.4.6", "cross-spawn": "^7.0.2", + "csurf": "^1.11.0", "del": "^6.0.0", "dotenv": "^10.0.0", "express": "^4.18.2", @@ -3030,6 +3031,75 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "Please use another csrf package", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/cypress": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.6.0.tgz", @@ -9763,6 +9833,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==" + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -11026,6 +11101,14 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -13854,6 +13937,61 @@ "which": "^2.0.1" } }, + "csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "requires": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + } + }, + "csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "dependencies": { + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + } + } + }, "cypress": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.6.0.tgz", @@ -18831,6 +18969,11 @@ "glob": "^7.1.3" } }, + "rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==" + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -19826,6 +19969,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 83022a1aae..0a5ffa9bce 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "cypress:e2e:dev": "cypress run --spec \"cypress/e2e/dev/*/*\"", "cypress:e2e:prod": "cypress run --spec \"cypress/e2e/prod/*/*\"", "cypress:e2e:smoke": "cypress run --spec \"cypress/e2e/smoke/*/*\"", + "cypress:e2e:plugins": "cypress run --spec \"cypress/e2e/plugins/*/*\"", "test:heroku": "start-server-and-test start:package:heroku 3000 cypress:e2e:smoke", "test:acceptance:dev": "cross-env KIT_TEST_DIR=tmp/test-prototype-package start-server-and-test start:package 3000 cypress:e2e:dev", "test:acceptance:prod": "cross-env KIT_TEST_DIR=tmp/test-prototype-package start-server-and-test start:package:prodtest 3000 cypress:e2e:prod", "test:acceptance:smoke": "cross-env KIT_TEST_DIR=tmp/test-prototype-package start-server-and-test start:package 3000 cypress:e2e:smoke", + "test:acceptance:plugins": "cross-env KIT_TEST_DIR=tmp/test-plugins start-server-and-test start:package 3000 cypress:e2e:plugins", "test:acceptance:open": "cross-env KIT_TEST_DIR=tmp/test-prototype-package start-server-and-test start:package 3000 cypress:open", "test:unit": "jest --detectOpenHandles lib bin", "test:integration": "cross-env CREATE_KIT_TIMEOUT=90000 jest --detectOpenHandles --testTimeout=60000 __tests__", @@ -55,6 +57,7 @@ "chokidar": "^3.5.3", "cookie-parser": "^1.4.6", "cross-spawn": "^7.0.2", + "csurf": "^1.11.0", "del": "^6.0.0", "dotenv": "^10.0.0", "express": "^4.18.2",