├── .nvmrc ├── version ├── src ├── lib │ ├── accounts │ │ └── index.ts │ ├── prom │ │ ├── index.ts │ │ └── prom.test.data.ts │ ├── billing │ │ ├── index.ts │ │ └── billing.test.data.ts │ ├── cf │ │ ├── index.ts │ │ └── test-data │ │ │ ├── wrap-resources.ts │ │ │ ├── roles.ts │ │ │ ├── org-quota.ts │ │ │ ├── app.ts │ │ │ ├── org.ts │ │ │ └── audit-event.ts │ ├── notify │ │ ├── index.ts │ │ ├── notify.client.ts │ │ └── notify.client.test.ts │ ├── validation │ │ ├── index.ts │ │ ├── sanitizers.ts │ │ ├── types.ts │ │ ├── sanitzers.test.ts │ │ ├── const.ts │ │ └── validators.ts │ ├── uaa │ │ ├── index.ts │ │ └── uaa.types.ts │ ├── router │ │ ├── index.ts │ │ ├── errors.ts │ │ ├── route.ts │ │ └── router.ts │ ├── metric-data-getters │ │ ├── index.ts │ │ ├── rds.test.ts │ │ ├── cloudwatch.ts │ │ ├── elasticache.test.ts │ │ ├── elasticsearch.test.ts │ │ ├── elasticsearch.ts │ │ ├── prometheus.ts │ │ ├── sqs.ts │ │ └── rds.ts │ ├── moment │ │ ├── round.ts │ │ └── round.test.ts │ ├── aws │ │ ├── aws-tags.test.data.ts │ │ └── aws-cloudwatch.test.data.ts │ ├── axios-logger │ │ └── axios.ts │ └── metrics │ │ └── index.ts ├── components │ ├── events │ │ └── index.ts │ ├── terms │ │ ├── index.ts │ │ ├── views.tsx │ │ ├── views.test.tsx │ │ └── middleware.tsx │ ├── errors │ │ ├── index.ts │ │ ├── types.ts │ │ ├── views.tsx │ │ ├── views.test.tsx │ │ ├── middleware.tsx │ │ └── middleware.test.tsx │ ├── org-users │ │ ├── index.ts │ │ ├── _permissions.scss │ │ ├── _org-users.scss │ │ └── test-helpers.ts │ ├── reports │ │ ├── index.ts │ │ └── _reports.scss │ ├── services │ │ ├── index.ts │ │ └── services.scss │ ├── shared │ │ ├── index.ts │ │ ├── success-page.tsx │ │ └── success-page.test.tsx │ ├── spaces │ │ └── index.ts │ ├── support │ │ └── index.ts │ ├── users │ │ └── index.ts │ ├── applications │ │ ├── index.ts │ │ ├── views.test.tsx │ │ └── controllers.tsx │ ├── calculator │ │ ├── index.ts │ │ ├── formulaGrammar.pegjs │ │ ├── views.test.tsx │ │ ├── calculator.scss │ │ └── formulaGrammar.test.ts │ ├── marketplace │ │ ├── index.ts │ │ ├── icons │ │ │ ├── s3.png │ │ │ ├── sqs.png │ │ │ ├── cloud.png │ │ │ ├── mysql.png │ │ │ ├── redis.png │ │ │ ├── influxdb.png │ │ │ ├── postgres.png │ │ │ ├── autoscaler.png │ │ │ ├── opensearch.png │ │ │ └── cdn.svg │ │ ├── _marketplace.scss │ │ ├── controllers.tsx │ │ └── controllers.test.tsx │ ├── organizations │ │ ├── index.ts │ │ └── owners.ts │ ├── service-events │ │ └── index.ts │ ├── statements │ │ ├── index.ts │ │ └── _statements.scss │ ├── application-events │ │ └── index.ts │ ├── service-metrics │ │ ├── index.ts │ │ ├── service-metrics.scss │ │ ├── views.test.tsx │ │ ├── metrics.test.ts │ │ └── utils.ts │ ├── auth │ │ ├── index.ts │ │ └── has-role.ts │ ├── breadcrumbs │ │ ├── index.ts │ │ ├── generators.ts │ │ ├── views.tsx │ │ └── views.test.tsx │ ├── platform-admin │ │ ├── index.ts │ │ ├── views.test.tsx │ │ └── redirect.ts │ ├── account │ │ ├── index.ts │ │ ├── account_user.ts │ │ ├── account_user.test.ts │ │ ├── oidc.ts │ │ └── oidc.test.fixtures.ts │ ├── app │ │ ├── index.ts │ │ ├── app.csp.ts │ │ ├── app.test-helpers.ts │ │ ├── app.test.config.ts │ │ ├── context.test.ts │ │ ├── router-middleware.ts │ │ └── context.ts │ └── charts │ │ └── line-graph.scss ├── server │ ├── index.ts │ └── server.ts ├── layouts │ ├── index.ts │ ├── constants.ts │ ├── _elements.scss │ ├── constants.test.ts │ ├── _subnav.scss │ ├── _header.scss │ ├── template.test.tsx │ ├── partials.test.tsx │ ├── helpers.tsx │ ├── react-spacing.test.tsx │ ├── govuk.screen.scss │ └── helpers.test.tsx ├── @types │ ├── fnv-plus │ │ └── index.d.ts │ ├── scss │ │ └── index.d.ts │ ├── jsx │ │ └── index.d.ts │ ├── express-static-gzip │ │ └── index.d.ts │ ├── images │ │ └── index.d.ts │ ├── express-pino-logger │ │ └── index.d.ts │ └── notifications-node-client │ │ └── index.d.ts └── frontend │ └── javascript │ ├── tooltip.js │ ├── init.js │ └── sankey.js ├── stub-api ├── start-with-stub-api.sh ├── start-stub-api.sh ├── stub-apis-env.sh ├── stub-aws.ts ├── stub-prometheus.ts ├── index.ts ├── stub-uaa.ts └── stub-accounts.ts ├── dist └── downloads │ ├── govuk-paas-crown-mou.pdf │ └── govuk-paas-noncrown-mou.pdf ├── .cfignore ├── manifest.yml ├── notify_templates ├── README └── welcome.txt ├── vitest.setup.ts ├── .stylelintrc ├── vitest.config.ts ├── ci └── integration.yml ├── config ├── server.config.cjs ├── schema.json └── use-dev-env.sh ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── pr-tests.yml └── dependabot.yml ├── tsconfig.json ├── LICENSE ├── .gitignore └── .editorconfig /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/Iron 2 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 0.3622.0 2 | -------------------------------------------------------------------------------- /src/lib/accounts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accounts'; 2 | -------------------------------------------------------------------------------- /src/components/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './views'; 2 | -------------------------------------------------------------------------------- /src/components/terms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './middleware'; 2 | -------------------------------------------------------------------------------- /src/lib/prom/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './prom'; 2 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './server'; 2 | -------------------------------------------------------------------------------- /src/components/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './middleware'; 2 | -------------------------------------------------------------------------------- /src/components/org-users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/reports/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './success-page'; 2 | -------------------------------------------------------------------------------- /src/components/spaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/support/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/applications/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/calculator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/marketplace/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/organizations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/service-events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/statements/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/application-events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/service-metrics/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | -------------------------------------------------------------------------------- /src/components/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './has-role'; 3 | -------------------------------------------------------------------------------- /src/lib/billing/index.ts: -------------------------------------------------------------------------------- 1 | export { default, default as BillingClient } from './billing'; 2 | -------------------------------------------------------------------------------- /src/components/breadcrumbs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generators'; 2 | export * from './views'; 3 | -------------------------------------------------------------------------------- /src/components/reports/_reports.scss: -------------------------------------------------------------------------------- 1 | .sankey-box { 2 | width: 100%; 3 | height: 95vh; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/platform-admin/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | export * from './redirect'; 3 | -------------------------------------------------------------------------------- /src/lib/cf/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './cf'; 2 | export { eventTypeDescriptions } from './events'; 3 | -------------------------------------------------------------------------------- /src/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export * from './helpers'; 3 | export * from './template'; 4 | -------------------------------------------------------------------------------- /src/components/account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account'; 2 | export * from './account_user'; 3 | export * from './oidc'; 4 | -------------------------------------------------------------------------------- /src/lib/notify/index.ts: -------------------------------------------------------------------------------- 1 | export { NotifyClient } from 'notifications-node-client'; 2 | export { default } from './notify.client'; 3 | -------------------------------------------------------------------------------- /src/lib/validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validators'; 2 | export * from './sanitizers'; 3 | 4 | export * from './const'; 5 | -------------------------------------------------------------------------------- /stub-api/start-with-stub-api.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | source ./stub-api/stub-apis-env.sh; npm start 5 | -------------------------------------------------------------------------------- /dist/downloads/govuk-paas-crown-mou.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/dist/downloads/govuk-paas-crown-mou.pdf -------------------------------------------------------------------------------- /src/components/marketplace/icons/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/s3.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/sqs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/sqs.png -------------------------------------------------------------------------------- /src/components/services/services.scss: -------------------------------------------------------------------------------- 1 | .service-log-list-item__attribute { 2 | &:last-child { 3 | display: block; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/uaa/index.ts: -------------------------------------------------------------------------------- 1 | export { default, authenticate, authenticateUser, IUaaInvitation } from './uaa'; 2 | export * from './uaa.types'; 3 | -------------------------------------------------------------------------------- /.cfignore: -------------------------------------------------------------------------------- 1 | .* 2 | config/ 3 | LICENSE 4 | manifest.yml 5 | node_modules/ 6 | README.md 7 | src/ 8 | tsconfig.json 9 | tslint.json 10 | -------------------------------------------------------------------------------- /dist/downloads/govuk-paas-noncrown-mou.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/dist/downloads/govuk-paas-noncrown-mou.pdf -------------------------------------------------------------------------------- /src/components/marketplace/icons/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/cloud.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/mysql.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/redis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/redis.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/influxdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/influxdb.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/postgres.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/autoscaler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/autoscaler.png -------------------------------------------------------------------------------- /src/components/marketplace/icons/opensearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/govuk-paas/paas-admin/HEAD/src/components/marketplace/icons/opensearch.png -------------------------------------------------------------------------------- /src/lib/validation/sanitizers.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeEmail(email?: string): string { 2 | return email ? email.replace(/\s/g, '') : ''; 3 | } 4 | -------------------------------------------------------------------------------- /src/@types/fnv-plus/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'fnv-plus' { 2 | export function hash(input: string, bitlength: 64): { readonly hex: () => string }; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors'; 2 | export * from './route'; 3 | export { default as Route } from './route'; 4 | export { default } from './router'; 5 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: paas-admin 4 | command: node dist/main.mjs 5 | memory: 2G 6 | buildpack: nodejs_buildpack 7 | stack: cflinuxfs4 8 | -------------------------------------------------------------------------------- /src/@types/scss/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { readonly [className: string]: string }; 3 | const process: any; 4 | export default content; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/app/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './app'; 2 | export * from './app.csp'; 3 | export * from './app'; 4 | export * from './context'; 5 | export * from './router'; 6 | -------------------------------------------------------------------------------- /notify_templates/README: -------------------------------------------------------------------------------- 1 | These templates have to be manually updated in the Notify admin UI. 2 | 3 | They are kept here for reference, and so that we have a record of them in source control. 4 | -------------------------------------------------------------------------------- /src/lib/metric-data-getters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cloudfront'; 2 | export * from './elasticache'; 3 | export * from './elasticsearch'; 4 | export * from './rds'; 5 | export * from './sqs'; 6 | -------------------------------------------------------------------------------- /stub-api/start-stub-api.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | source ./stub-api/stub-apis-env.sh; node --no-warnings=ExperimentalWarning --loader ts-node/esm stub-api/index.ts 5 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/vitest'; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: stylelint-config-gds/scss 3 | rules: 4 | selector-no-qualifying-type: 5 | - true 6 | - ignore: 7 | - attribute 8 | - class 9 | value-keyword-case: ~ 10 | -------------------------------------------------------------------------------- /src/lib/validation/types.ts: -------------------------------------------------------------------------------- 1 | export interface IValidationError { 2 | readonly field: string; 3 | readonly message: string; 4 | } 5 | export interface IDualValidationError extends IValidationError{ 6 | readonly messageExtra?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/@types/jsx/index.d.ts: -------------------------------------------------------------------------------- 1 | interface IFallbackImage extends React.SVGProps { 2 | readonly src: string; 3 | } 4 | 5 | declare namespace JSX { 6 | interface IntrinsicElements { 7 | readonly image: IFallbackImage; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/moment/round.ts: -------------------------------------------------------------------------------- 1 | import { Duration, milliseconds } from 'date-fns'; 2 | 3 | export default function roundDown(date: Date, duration: Duration): Date { 4 | return new Date(Math.floor(+date / +milliseconds(duration)) * +milliseconds(duration)); 5 | } 6 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: 'node', 7 | setupFiles: 'vitest.setup.ts', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/errors/types.ts: -------------------------------------------------------------------------------- 1 | // export interface IValidationError { 2 | // readonly field: string; 3 | // readonly message: string; 4 | // } 5 | // export interface IDualValidationError extends IValidationError{ 6 | // readonly messageExtra?: string; 7 | // } 8 | -------------------------------------------------------------------------------- /src/components/service-metrics/service-metrics.scss: -------------------------------------------------------------------------------- 1 | .border-bottom-box { 2 | border-bottom: 1px solid $govuk-border-colour; 3 | margin-bottom: 10px; 4 | } 5 | 6 | .metrics-filters { 7 | @include govuk-responsive-padding(6); 8 | background-color: govuk-colour("light-grey"); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/router/errors.ts: -------------------------------------------------------------------------------- 1 | export class NotFoundError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'NotFoundError'; 5 | } 6 | } 7 | 8 | export class NotAuthorisedError extends Error { 9 | constructor(message: string) { 10 | super(message); 11 | this.name = 'NotAuthorisedError'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/layouts/constants.ts: -------------------------------------------------------------------------------- 1 | export const DATE = 'MMMM do yyyy'; 2 | export const TIME = 'HH:mm'; 3 | export const DATE_TIME = 'h:mmaaa, d MMMM yyyy'; 4 | 5 | export const KIBIBYTE = 1024; 6 | export const MEBIBYTE = KIBIBYTE * 1024; 7 | export const GIBIBYTE = MEBIBYTE * 1024; 8 | export const TEBIBYTE = GIBIBYTE * 1024; 9 | 10 | export const SLUG_REGEX = '^([a-z0-9-]+)$'; 11 | -------------------------------------------------------------------------------- /ci/integration.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | image_resource: 4 | type: docker-image 5 | source: 6 | repository: node 7 | tag: 20-alpine 8 | inputs: 9 | - name: repo 10 | run: 11 | dir: repo 12 | path: sh 13 | args: 14 | - -ex 15 | - -c 16 | - | 17 | NODE_ENV=development npm ci 18 | TEST_TIMEOUT=60000 npm test 19 | npm run build 20 | 21 | -------------------------------------------------------------------------------- /src/components/org-users/_permissions.scss: -------------------------------------------------------------------------------- 1 | table.permissions { 2 | th.name { 3 | min-width: 280px; 4 | width: 35%; 5 | } 6 | } 7 | 8 | .user-permission-block { 9 | margin-bottom: govuk-spacing(3); 10 | } 11 | 12 | .user-permission-block--shaded { 13 | background-color: $app-super-light-grey; 14 | } 15 | 16 | .user-permission-block--padded { 17 | padding: govuk-spacing(3); 18 | } 19 | -------------------------------------------------------------------------------- /src/@types/express-static-gzip/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-static-gzip' { 2 | import express from 'express'; 3 | 4 | interface IOptions { 5 | readonly immutable: boolean; 6 | } 7 | 8 | type MiddlewareFunction = ( 9 | req: express.Request, 10 | res: express.Response, 11 | next: express.NextFunction, 12 | ) => void; 13 | 14 | export default function(path: string, opts?: IOptions): MiddlewareFunction; 15 | } 16 | -------------------------------------------------------------------------------- /config/server.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const NodemonPlugin = require('nodemon-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = cfg => { 7 | if (cfg.watch) { 8 | cfg.plugins.push(new NodemonPlugin({ 9 | watch: path.resolve('./dist'), 10 | ignore: ['*.map'], 11 | verbose: true, 12 | script: './dist/main.mjs', 13 | })); 14 | } 15 | 16 | return cfg; 17 | }; 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | What 2 | ---- 3 | 4 | Describe what you have changed and why. 5 | 6 | How to review 7 | ------------- 8 | 9 | Describe the steps required to test the changes. 10 | 11 | Who can review 12 | --------------- 13 | 14 | Describe who can review the changes. Or more importantly, list the people 15 | that can't review, because they worked on it. 16 | 17 | --- 18 | 19 | 🚨⚠️ Please do not merge this pull request via the GitHub UI ⚠️🚨 20 | -------------------------------------------------------------------------------- /src/components/org-users/_org-users.scss: -------------------------------------------------------------------------------- 1 | .user-roles { 2 | h3 { 3 | @include govuk-font($size: 19, $weight: bold); 4 | } 5 | 6 | p { 7 | @include govuk-font($size: 19); 8 | } 9 | } 10 | 11 | .user-list-table { 12 | @include govuk-media-query($from:desktop) { 13 | .name { 14 | min-width: 280px; 15 | } 16 | } 17 | } 18 | 19 | .paas-remove-user { 20 | button + a { 21 | display: inline-block; 22 | margin-left: 15px; 23 | margin-top: 8px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/aws/aws-tags.test.data.ts: -------------------------------------------------------------------------------- 1 | export function getStubResourcesByTag(): string { 2 | return JSON.stringify({ 3 | ResourceTagMappingList: [ 4 | { 5 | ComplianceDetails: { 6 | ComplianceStatus: true, 7 | KeysWithNoncompliantValues: [], 8 | NoncompliantKeys: [], 9 | }, 10 | ResourceARN: 11 | 'arn:aws:cloudfront::123456789012:distribution/EDFDVBD632BHDS5', 12 | Tags: [], 13 | }, 14 | ], 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/@types/images/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: string; 3 | export default value; 4 | } 5 | 6 | declare module '*.jpg' { 7 | const value: string; 8 | export default value; 9 | } 10 | 11 | declare module '*.ico' { 12 | const value: string; 13 | export default value; 14 | } 15 | 16 | declare module '*.svg' { 17 | const value: string; 18 | export default value; 19 | } 20 | 21 | declare module '*.gif' { 22 | const value: string; 23 | export default value; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/app/app.csp.ts: -------------------------------------------------------------------------------- 1 | export const csp = { 2 | directives: { 3 | connectSrc: ['\'self\''], 4 | defaultSrc: ['\'self\''], 5 | fontSrc: ['\'self\'', 'data:'], 6 | frameSrc: ['\'self\''], 7 | imgSrc: ['\'self\''], 8 | mediaSrc: ['\'self\''], 9 | objectSrc: ['\'self\''], 10 | scriptSrc: [ 11 | '\'self\'', 12 | '\'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw=\'', 13 | ], 14 | styleSrc: ['\'self\''], 15 | }, 16 | }; 17 | 18 | export default csp; 19 | -------------------------------------------------------------------------------- /src/layouts/_elements.scss: -------------------------------------------------------------------------------- 1 | code { 2 | background: #f5f5f5; 3 | padding: 3px 6px; 4 | border-radius: 3px; 5 | border: 1px solid #bfc1c3; 6 | overflow-wrap: break-word; 7 | word-wrap: break-word; 8 | -ms-word-break: break-all; 9 | word-break: break-all; 10 | } 11 | 12 | ul, 13 | ol { 14 | &.single-line li { 15 | display: inline; 16 | } 17 | } 18 | 19 | .non-breaking { 20 | white-space: nowrap; 21 | } 22 | 23 | .tick-symbol { 24 | color: govuk-colour("green"); 25 | } 26 | 27 | .pull-right { 28 | float: right; 29 | } 30 | -------------------------------------------------------------------------------- /src/@types/express-pino-logger/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-pino-logger' { 2 | import express from 'express'; 3 | import { BaseLogger, SerializerFn } from 'pino'; 4 | 5 | interface IOptions { 6 | readonly logger: BaseLogger; 7 | readonly serializers?: { readonly [key: string]: SerializerFn }; 8 | } 9 | 10 | type MiddlewareFunction = ( 11 | req: express.Request, 12 | res: express.Response, 13 | next: express.NextFunction, 14 | ) => void; 15 | 16 | export default function(opts?: IOptions): MiddlewareFunction; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/service-metrics/views.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import { parseURL } from './views'; 6 | 7 | describe(parseURL, () => { 8 | it('should correctly prepare URL', () => { 9 | expect(parseURL('/test', { a: 'test' })).not.toContain('//'); 10 | expect(parseURL('/test', { a: 'test', b: 'success' })).toEqual('/test?a=test&b=success'); 11 | expect(parseURL('/test?d=good&c=old', { a: 'test', b: 'success', c: 'new' })) 12 | .toEqual('/test?d=good&c=new&a=test&b=success'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/lib/prom/prom.test.data.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash-es'; 2 | 3 | import { getGappyRandomData } from '../metrics'; 4 | 5 | export function getStubPrometheusMetricsSeriesData( 6 | instances: ReadonlyArray, 7 | ): string { 8 | return JSON.stringify({ 9 | data: { 10 | result: instances.map(instance => { 11 | const { timestamps, values } = getGappyRandomData(); 12 | 13 | return { 14 | metric: { instance }, 15 | values: _.zip(timestamps, values), 16 | }; 17 | }), 18 | resultType: 'series', 19 | }, 20 | status: 'success', 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/metric-data-getters/rds.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { RDSMetricDataGetter } from './rds'; 4 | 5 | describe('RDS', () => { 6 | describe('getRdsDbInstanceIdentifier', () => { 7 | it('should prepend rdsbroker- to RDS service guids', () => { 8 | const dg = new RDSMetricDataGetter({} as any); 9 | 10 | expect(dg.getRdsDbInstanceIdentifier('some-guid')).toBe( 11 | 'rdsbroker-some-guid', 12 | ); 13 | expect(dg.getRdsDbInstanceIdentifier('some-other-guid')).toBe( 14 | 'rdsbroker-some-other-guid', 15 | ); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/cf/test-data/wrap-resources.ts: -------------------------------------------------------------------------------- 1 | import { IV3Response } from '../types'; 2 | 3 | export function wrapResources(...resources: ReadonlyArray) { 4 | return { 5 | total_pages: 1, 6 | total_results: resources.length, 7 | 8 | resources, 9 | 10 | prev_url: null, 11 | next_url: null, 12 | }; 13 | } 14 | 15 | export function wrapV3Resources( 16 | ...resources: ReadonlyArray 17 | ): IV3Response { 18 | return { 19 | pagination: { 20 | total_pages: 1, 21 | total_results: resources.length, 22 | first: { href: '/not-implemented' }, 23 | last: { href: '/not-implemented' }, 24 | }, 25 | resources, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/pr-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: pr-tests 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-22.04 9 | 10 | env: 11 | NODE_ENV: development 12 | TEST_TIMEOUT: 60000 13 | 14 | steps: 15 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 16 | - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 17 | with: 18 | node-version-file: "package.json" 19 | - run: npm ci 20 | 21 | - name: Build 22 | run: npm run build 23 | 24 | - name: Test - Unit 25 | run: npm run test:unit 26 | 27 | - name: Lint 28 | run: npm run lint 29 | -------------------------------------------------------------------------------- /src/components/org-users/test-helpers.ts: -------------------------------------------------------------------------------- 1 | export function composeOrgRoles(setup: object) { 2 | const defaultRoles = { 3 | auditors: { 4 | current: '0', 5 | }, 6 | billing_managers: { 7 | current: '0', 8 | }, 9 | managers: { 10 | current: '0', 11 | }, 12 | }; 13 | 14 | return { 15 | ...defaultRoles, 16 | ...setup, 17 | }; 18 | } 19 | 20 | export function composeSpaceRoles(setup: object) { 21 | const defaultRoles = { 22 | auditors: { 23 | current: '0', 24 | }, 25 | developers: { 26 | current: '0', 27 | }, 28 | managers: { 29 | current: '0', 30 | }, 31 | }; 32 | 33 | return { 34 | ...defaultRoles, 35 | ...setup, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/errors/views.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface IErrorPageParameters { 4 | readonly title: string; 5 | readonly children?: string; 6 | } 7 | 8 | export function ErrorPage(params: IErrorPageParameters): ReactElement { 9 | return ( 10 | <> 11 |

{params.title}

12 |

13 | {params.children || 14 | 'Something went wrong while processing the request.'} 15 |

16 |

17 | You can browse from the{' '} 18 | 19 | homepage 20 | {' '} 21 | to find the information you need. 22 |

23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ES2022" 5 | ], 6 | "module": "ES2022", 7 | "target": "ES2022", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "allowSyntheticDefaultImports": true, 13 | "skipLibCheck": true, 14 | "moduleResolution": "Node", 15 | "noImplicitAny": true, 16 | "isolatedModules": false, 17 | "pretty": true, 18 | "allowJs": true, 19 | "importHelpers": true, 20 | "outDir": "dist", 21 | "jsx" : "react", 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "dist" 26 | ], 27 | "ts-node": { 28 | "esm": true, 29 | "experimentalSpecifierResolution": "node", 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/components/account/account_user.ts: -------------------------------------------------------------------------------- 1 | import { IUaaUser } from '../../lib/uaa'; 2 | 3 | export class AccountUser { 4 | constructor(private readonly user: IUaaUser) {} 5 | 6 | get name(): string { 7 | return `${this.user.name.givenName} ${this.user.name.familyName}`; 8 | } 9 | 10 | get username(): string { 11 | return this.user.userName; 12 | } 13 | 14 | get id(): string { 15 | return this.user.id; 16 | } 17 | 18 | get authenticationMethod(): string { 19 | switch (this.user.origin) { 20 | case 'uaa': 21 | return 'Username & password'; 22 | case 'google': 23 | return 'Google'; 24 | } 25 | 26 | return 'Unknown'; 27 | } 28 | 29 | get origin(): string { 30 | return this.user.origin; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/shared/success-page.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactNode } from 'react'; 2 | 3 | interface ISuccessPageProperties { 4 | readonly heading: string; 5 | readonly text?: string; 6 | readonly children?: ReactNode; 7 | } 8 | 9 | export function SuccessPage(props: ISuccessPageProperties): ReactElement { 10 | return
11 |
12 |
13 |

{props.heading}

14 | {props.text ?
{props.text}
: <>} 15 |
16 | {props.children ?

{props.children}

: <>} 17 |
18 |
; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/validation/sanitzers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { sanitizeEmail } from './sanitizers'; 4 | 5 | describe('sanitizeEmail', () => { 6 | it('removes whitespace from email', () => { 7 | const email = ' test@example.com '; 8 | const sanitizedEmail = sanitizeEmail(email); 9 | expect(sanitizedEmail).toBe('test@example.com'); 10 | }); 11 | 12 | it('returns empty string if email is undefined', () => { 13 | const email = undefined; 14 | const sanitizedEmail = sanitizeEmail(email); 15 | expect(sanitizedEmail).toBe(''); 16 | }); 17 | 18 | it('returns empty string if email is empty', () => { 19 | const email = ''; 20 | const sanitizedEmail = sanitizeEmail(email); 21 | expect(sanitizedEmail).toBe(''); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/breadcrumbs/generators.ts: -------------------------------------------------------------------------------- 1 | import { IContext } from '../app'; 2 | 3 | import { IBreadcrumbsItem } from './views'; 4 | 5 | export interface IOrganizationSkeleton { 6 | readonly metadata: { 7 | readonly guid: string; 8 | }; 9 | readonly entity: { 10 | readonly name: string; 11 | }; 12 | } 13 | 14 | export function fromOrg( 15 | ctx: IContext, 16 | organization: IOrganizationSkeleton, 17 | children: ReadonlyArray, 18 | ): ReadonlyArray { 19 | return [ 20 | { text: 'Organisations', href: ctx.linkTo('admin.organizations') }, 21 | { 22 | text: organization.entity.name, 23 | href: ctx.linkTo('admin.organizations.view', { 24 | organizationGUID: organization.metadata.guid, 25 | }), 26 | }, 27 | ...children, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /src/layouts/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { SLUG_REGEX } from './constants'; 3 | 4 | describe(SLUG_REGEX, () => { 5 | it('should only match the approved slugs', () => { 6 | expect('organisationname'.match(SLUG_REGEX)).not.toBeNull(); 7 | expect('organisation-name'.match(SLUG_REGEX)).not.toBeNull(); 8 | expect('very-long-organisation-name-please'.match(SLUG_REGEX)).not.toBeNull(); 9 | expect('OrganisationName'.match(SLUG_REGEX)).toBeNull(); 10 | expect('Organisation-Name'.match(SLUG_REGEX)).toBeNull(); 11 | expect('Organisation_Name'.match(SLUG_REGEX)).toBeNull(); 12 | expect('Organisation Name'.match(SLUG_REGEX)).toBeNull(); 13 | expect('organisation_name'.match(SLUG_REGEX)).toBeNull(); 14 | expect('organisation name'.match(SLUG_REGEX)).toBeNull(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/layouts/_subnav.scss: -------------------------------------------------------------------------------- 1 | .subnav { 2 | border-bottom: 1px solid govuk-colour("mid-grey"); 3 | margin: 0; 4 | 5 | div:first-of-type { 6 | padding-left: 0; 7 | } 8 | 9 | strong { 10 | display: inline-block; 11 | padding-bottom: govuk-spacing(2); 12 | padding-top: govuk-spacing(2); 13 | } 14 | } 15 | 16 | .subnav nav { 17 | margin: 0; 18 | padding-right: 0; 19 | text-align: right; 20 | } 21 | 22 | .subnav nav a { 23 | border-bottom: govuk-spacing(1) solid transparent; 24 | display: inline-block; 25 | margin-left: govuk-spacing(2); 26 | padding-bottom: govuk-spacing(2); 27 | padding-top: govuk-spacing(2); 28 | text-decoration: none; 29 | transition: border-bottom .1s ease; 30 | 31 | &:last-of-type { 32 | padding-right: 0; 33 | } 34 | 35 | &.active { 36 | border-bottom: govuk-spacing(1) solid govuk-colour("black"); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/@types/notifications-node-client/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'notifications-node-client' { 2 | interface IResponse { 3 | readonly data: { 4 | readonly id: string; 5 | readonly reference: string | null; 6 | readonly content: { 7 | readonly subject: string; 8 | readonly body: string; 9 | readonly from_email: string; 10 | }; 11 | readonly uri: string; 12 | readonly template: { 13 | readonly id: string; 14 | readonly version: number; 15 | readonly uri: string; 16 | }; 17 | }; 18 | readonly status: number; 19 | } 20 | 21 | export class NotifyClient { 22 | constructor(apiKey: string); 23 | sendEmail( 24 | template: string, 25 | email: string, 26 | params?: { 27 | readonly personalisation?: object; 28 | readonly reference?: string; 29 | readonly emailReplyToId?: string; 30 | }, 31 | ): Promise; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/breadcrumbs/views.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface IBreadcrumbsProperties { 4 | readonly items: ReadonlyArray; 5 | } 6 | 7 | export interface IBreadcrumbsItem { 8 | readonly href?: string; 9 | readonly text: string; 10 | } 11 | 12 | export function Breadcrumbs(props: IBreadcrumbsProperties): ReactElement { 13 | const items = props.items.map((item, index) => ( 14 |
  • 19 | {item.href ? ( 20 | 21 | {item.text} 22 | 23 | ) : ( 24 | item.text 25 | )} 26 |
  • 27 | )); 28 | 29 | return ( 30 |
    31 |
      {items}
    32 |
    33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/shared/success-page.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import React from 'react'; 5 | import { describe, expect, it } from 'vitest'; 6 | 7 | import { SuccessPage } from './success-page'; 8 | 9 | describe(SuccessPage, () => { 10 | it('should parse simple SuccessPage', () => { 11 | render(); 12 | 13 | expect(screen.getByRole('heading',{ level: 1 })).toHaveTextContent('Success!'); 14 | }); 15 | 16 | it('should parse rich SuccessPage', () => { 17 | render( 18 | Read more elsewhere! 19 | Elsewhere 20 | ); 21 | 22 | expect(screen.getByRole('heading',{ level: 1 })).toHaveTextContent('Success!'); 23 | expect(screen.getByText('Read more elsewhere!')).toBeTruthy(); 24 | expect(screen.getByRole('link')).toHaveTextContent('Elsewhere'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/service-metrics/metrics.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { bytesLabel, numberLabel, percentLabel } from './metrics'; 4 | 5 | describe(bytesLabel, () => { 6 | it('should correctly print out the label', () => { 7 | expect(bytesLabel(2048, 0)).toEqual('2.00KiB'); 8 | }); 9 | }); 10 | 11 | describe(numberLabel, () => { 12 | it('should correctly print out the label', () => { 13 | expect(numberLabel(1, 0)).toEqual('1'); 14 | expect(numberLabel(1000, 0)).toEqual('1.00k'); 15 | expect(numberLabel(1234, 0)).toEqual('1.23k'); 16 | expect(numberLabel(1000000, 0)).toEqual('1.00m'); 17 | expect(numberLabel(1234567, 0)).toEqual('1.23m'); 18 | expect(numberLabel(1000000000, 0)).toEqual('1.00b'); 19 | expect(numberLabel(1234567890, 0)).toEqual('1.23b'); 20 | }); 21 | }); 22 | 23 | describe(percentLabel, () => { 24 | it('should correctly print out the label', () => { 25 | expect(percentLabel(75, 0)).toEqual('75%'); 26 | }); 27 | }); 28 | 29 | -------------------------------------------------------------------------------- /src/layouts/_header.scss: -------------------------------------------------------------------------------- 1 | .app-region-tag--ireland { 2 | background-color: $govuk-success-colour; 3 | } 4 | 5 | .app-region-tag--london { 6 | background-color: $govuk-brand-colour; 7 | } 8 | 9 | .app-region-tag { 10 | margin-top: -2px; 11 | margin-bottom: -3px; 12 | padding-top: 2px; 13 | padding-right: 8px; 14 | padding-bottom: 3px; 15 | padding-left: 8px; 16 | text-transform: uppercase; 17 | @include govuk-media-query($until: tablet) { 18 | text-transform: capitalize; 19 | background: none; 20 | padding: 0; 21 | margin: 0; 22 | letter-spacing: normal; 23 | } 24 | } 25 | 26 | .app-region-tag__text { 27 | @include govuk-media-query($from: tablet) { 28 | display: none; 29 | } 30 | } 31 | 32 | @include govuk-media-query(tablet) { 33 | // GOV.UK Platform-as-a-Service is too long 34 | .govuk-header__logo { 35 | width: auto; 36 | } 37 | 38 | .govuk-header__content { 39 | width: auto; 40 | padding-left: 0; 41 | text-align: right; 42 | } 43 | } 44 | 45 | @include govuk-media-query($from: wideDesktop) { 46 | .govuk-header__content { 47 | float: right; 48 | padding-left: govuk-spacing(3); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Crown Copyright (Government Digital Service) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/lib/cf/test-data/roles.ts: -------------------------------------------------------------------------------- 1 | import { IRole } from '../types'; 2 | 3 | export function orgRole( 4 | roleType: string, 5 | orgGUID: string, 6 | userGUID: string, 7 | ): IRole { 8 | return JSON.parse(`{ 9 | "type": "${roleType}", 10 | 11 | "relationships": { 12 | "user": { 13 | "data": { 14 | "guid": "${userGUID}" 15 | } 16 | }, 17 | "organization": { 18 | "data": { 19 | "guid": "${orgGUID}" 20 | } 21 | }, 22 | "space": { 23 | "data": null 24 | } 25 | } 26 | }`); 27 | } 28 | 29 | export function spaceRole( 30 | roleType: string, 31 | orgGUID: string, 32 | spaceGUID: string, 33 | userGUID: string, 34 | ): IRole { 35 | return JSON.parse(`{ 36 | "type": "${roleType}", 37 | 38 | "relationships": { 39 | "user": { 40 | "data": { 41 | "guid": "${userGUID}" 42 | } 43 | }, 44 | "organization": { 45 | "data": { 46 | "guid": "${orgGUID}" 47 | } 48 | }, 49 | "space": { 50 | "data": { 51 | "guid": "${spaceGUID}" 52 | } 53 | } 54 | } 55 | }`); 56 | } 57 | -------------------------------------------------------------------------------- /notify_templates/welcome.txt: -------------------------------------------------------------------------------- 1 | Subject: Welcome to the Government PaaS 2 | 3 | Hello, 4 | 5 | Open this link to create your account and set your password. It only works once. 6 | 7 | ((url)) 8 | 9 | In some departments antivirus systems check and invalidate links in inbound emails. If you’re unable to access the URL above, please contact 10 | gov-uk-paas-support@digital.cabinet-office.gov.uk. 11 | 12 | We’ve added your account to the following organisation in our ((location)) region: 13 | 14 | ((organisation)) 15 | 16 | New organisations are created on our London platform. If you require hosting in Ireland please contact gov-uk-paas-support@digital.cabinet-office.gov.uk. 17 | 18 | Read more about organisations: 19 | https://docs.cloud.service.gov.uk/orgs_spaces_users.html 20 | 21 | To get started, look at our Quick Setup Guide: 22 | https://docs.cloud.service.gov.uk/get_started.html 23 | 24 | You can find our privacy policy here: 25 | https://www.cloud.service.gov.uk/privacy-notice 26 | 27 | To check the status of GOV.UK PaaS, and see the availability of live 28 | applications and database connectivity, visit 29 | https://status.cloud.service.gov.uk. We recommend you sign up to this service 30 | to get alerts and incident updates. 31 | 32 | Regards, 33 | Government PaaS team. 34 | -------------------------------------------------------------------------------- /src/components/calculator/formulaGrammar.pegjs: -------------------------------------------------------------------------------- 1 | /* Parser for the billing formulas in paas-billing's pricing plans. 2 | * 3 | * These are quite simple, so there's no need to go all the way to 4 | * real postgres to calculate them. 5 | * 6 | * Examples: 7 | * 8 | * `ceil(8280/3600) * 0.00685` 9 | * `(2 * 8280 * (2048/1024.0) * (0.01 / 3600)) * 0.40` 10 | */ 11 | 12 | Expression 13 | = head:Term tail:(_ ("+" / "-") _ Term)* { 14 | return tail.reduce(function(result, element) { 15 | if (element[1] === "+") { return result + element[3]; } 16 | if (element[1] === "-") { return result - element[3]; } 17 | }, head); 18 | } 19 | 20 | Term 21 | = head:Factor tail:(_ ("*" / "/") _ Factor)* { 22 | return tail.reduce(function(result, element) { 23 | if (element[1] === "*") { return result * element[3]; } 24 | if (element[1] === "/") { return result / element[3]; } 25 | }, head); 26 | } 27 | 28 | Factor 29 | = "(" _ expr:Expression _ ")" { return expr; } 30 | / Ceiling 31 | / Number 32 | 33 | Number "number" 34 | = Int Fraction? { return parseFloat(text()); } 35 | 36 | Fraction = "." [0-9]+ 37 | Int = "0" / ([1-9] [0-9]*) 38 | 39 | Ceiling "ceil" 40 | = "ceil(" val:Expression ")" { return Math.ceil(val) } 41 | 42 | _ "whitespace" = [ \t\n\r]* 43 | -------------------------------------------------------------------------------- /src/components/marketplace/_marketplace.scss: -------------------------------------------------------------------------------- 1 | .marketplace-list, 2 | .service-details { 3 | figure { 4 | display: block; 5 | margin: 0; 6 | 7 | div { 8 | display: inline-block; 9 | height: 100px; 10 | margin-bottom: 1rem; 11 | } 12 | 13 | img { 14 | max-height: 100%; 15 | max-width: 100%; 16 | } 17 | } 18 | } 19 | 20 | .marketplace-list { 21 | display: grid; 22 | grid-template-columns: repeat(2, auto); 23 | grid-gap: .5rem; 24 | 25 | @include govuk-media-query($from: tablet) { 26 | grid-template-columns: repeat(3, auto); 27 | } 28 | 29 | li { 30 | box-sizing: border-box; 31 | margin: 0; 32 | text-align: center; 33 | 34 | a { 35 | border: 5px solid transparent; 36 | display: inline-block; 37 | width: 100%; 38 | 39 | &:focus { 40 | background-color: transparent; 41 | border-color: govuk-colour("yellow"); 42 | box-shadow: none; 43 | } 44 | 45 | &:hover { 46 | background: govuk-colour("light-grey"); 47 | } 48 | } 49 | } 50 | } 51 | 52 | .service-details figure { 53 | div { 54 | height: auto; 55 | } 56 | 57 | img { 58 | max-width: 150px; 59 | 60 | @include govuk-media-query($from: tablet) { 61 | max-width: 100%; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /stub-api/stub-apis-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export PORT=${PORT-3000} 4 | export DOMAIN_NAME="http://localhost:3000/" 5 | 6 | export STUB_ACCOUNTS_PORT=${STUB_ACCOUNTS_PORT-1337} 7 | export STUB_BILLING_PORT=${STUB_BILLING_PORT-1338} 8 | export STUB_CF_PORT=${STUB_CF_PORT-1339} 9 | export STUB_UAA_PORT=${STUB_UAA_PORT-1340} 10 | export STUB_AWS_PORT=${STUB_AWS_PORT-1341} 11 | export STUB_PROMETHEUS_PORT=${STUB_PROMETHEUS_PORT-1342} 12 | 13 | export ACCOUNTS_URL=http://0:${STUB_ACCOUNTS_PORT} 14 | export BILLING_URL=http://0:${STUB_BILLING_PORT} 15 | export API_URL=http://0:${STUB_CF_PORT} 16 | export UAA_URL=http://0:${STUB_UAA_PORT} 17 | export AUTHORIZATION_URL=http://0:${STUB_UAA_PORT} 18 | export AWS_CLOUDWATCH_ENDPOINT=http://0:${STUB_AWS_PORT} 19 | export PROMETHEUS_ENDPOINT=http://0:${STUB_PROMETHEUS_PORT} 20 | export PROMETHEUS_USERNAME=not-used 21 | export PROMETHEUS_PASSWORD=not-used 22 | 23 | export OAUTH_CLIENT_ID=my-client-id 24 | export OAUTH_CLIENT_SECRET=my-secret 25 | export ACCOUNTS_SECRET=my-accounts-secret 26 | export NOTIFY_API_KEY=qwerty123456 27 | export NOTIFY_WELCOME_TEMPLATE_ID=qwerty123456 28 | export AWS_REGION=eu-west-2 29 | export MS_CLIENT_ID=clientid 30 | export MS_CLIENT_SECRET=clientsecret 31 | export MS_TENANT_ID=tenantid 32 | export GOOGLE_CLIENT_ID=googleclientid 33 | export GOOGLE_CLIENT_SECRET=googleclientsecret 34 | 35 | export ENABLE_FAKE_AWS_CREDENTIALS=true 36 | -------------------------------------------------------------------------------- /src/components/charts/line-graph.scss: -------------------------------------------------------------------------------- 1 | $graphcolours: ( 2 | "1": govuk-colour("orange"), 3 | "2": govuk-colour("pink"), 4 | "3": govuk-colour("light-blue"), 5 | "4": govuk-colour("brown"), 6 | "5": govuk-colour("green"), 7 | "6": govuk-colour("turquoise"), 8 | ); 9 | 10 | svg.govuk-paas-line-graph { 11 | max-width: 960px; 12 | 13 | line, 14 | path { 15 | vector-effect: non-scaling-stroke; 16 | } 17 | 18 | path { 19 | &.gaps, 20 | &.series { 21 | stroke-linecap: round; 22 | stroke-linejoin: round; 23 | fill: none; 24 | } 25 | 26 | &.gaps { 27 | stroke: govuk-colour("mid-grey"); 28 | stroke-width: 1px; 29 | } 30 | 31 | &.series { 32 | stroke: govuk-colour("blue"); 33 | stroke-width: 2.5px; 34 | } 35 | } 36 | 37 | .legend rect { 38 | fill: govuk-colour("blue"); 39 | } 40 | 41 | @each $graphcolour, $i in $graphcolours { 42 | .legend-#{$graphcolour} rect { 43 | fill: $i; 44 | } 45 | 46 | path.series-#{$graphcolour} { 47 | stroke: $i; 48 | } 49 | } 50 | 51 | .axis { 52 | text { 53 | font-size: 16px; 54 | } 55 | } 56 | 57 | .axis.bottom .tick text { 58 | text-anchor: end; 59 | } 60 | 61 | .grid line, 62 | .grid path { 63 | stroke: govuk-colour("mid-grey"); 64 | stroke-opacity: .75; 65 | shape-rendering: crispEdges; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /config/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "configure": { 5 | "type": "string" 6 | }, 7 | "includePaths": { 8 | "type": "array", 9 | "items": { 10 | "type": "string" 11 | } 12 | }, 13 | "autoescape": { 14 | "type": "boolean" 15 | }, 16 | "throwOnUndefined": { 17 | "type": "boolean" 18 | }, 19 | "trimBlocks": { 20 | "type": "boolean" 21 | }, 22 | "lstripBlocks": { 23 | "type": "boolean" 24 | }, 25 | "tags": { 26 | "type": "object", 27 | "properties": { 28 | "blockStart": { 29 | "type": "string", 30 | "minLength": 1 31 | }, 32 | "blockEnd": { 33 | "type": "string", 34 | "minLength": 1 35 | }, 36 | "variableStart": { 37 | "type": "string", 38 | "minLength": 1 39 | }, 40 | "variableEnd": { 41 | "type": "string", 42 | "minLength": 1 43 | }, 44 | "commentStart": { 45 | "type": "string", 46 | "minLength": 1 47 | }, 48 | "commentEnd": { 49 | "type": "string", 50 | "minLength": 1 51 | } 52 | } 53 | }, 54 | "precompile": { 55 | "oneOf": [ 56 | {"type": "boolean"}, 57 | {"enum": ["auto"]} 58 | ] 59 | } 60 | }, 61 | "additionalProperties": false 62 | } 63 | -------------------------------------------------------------------------------- /src/components/breadcrumbs/views.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | 4 | import { render, screen } from '@testing-library/react'; 5 | import React from 'react'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | import { spacesMissingAroundInlineElements } from '../../layouts/react-spacing.test'; 9 | 10 | import { Breadcrumbs } from './views'; 11 | 12 | describe(Breadcrumbs, () => { 13 | it('should produce path of items', () => { 14 | const breadcrumbs = [ 15 | { text: '1', href: '/1' }, 16 | { text: '2', href: '/2' }, 17 | { text: '3', href: '/3' }, 18 | { text: '4' }, 19 | ]; 20 | 21 | const { container } = render(); 22 | 23 | 24 | expect(screen.getAllByRole('listitem')).toHaveLength(4); 25 | // first item checks 26 | expect(container.getElementsByTagName('li')[0]).toHaveTextContent('1'); 27 | expect(screen.getByText('1')).toHaveAttribute('href', expect.stringContaining('1')); 28 | expect(screen.getByText('1')).not.toHaveAttribute('aria-current'); 29 | //last item checks 30 | expect(container.getElementsByTagName('li')[3]).toHaveTextContent('4'); 31 | expect(screen.getByText('4')).not.toHaveAttribute('href'); 32 | expect(screen.getByText('4')).toHaveAttribute('aria-current', expect.stringContaining('page')); 33 | 34 | expect( 35 | spacesMissingAroundInlineElements(container.innerHTML), 36 | ).toHaveLength(0); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/lib/validation/const.ts: -------------------------------------------------------------------------------- 1 | import { SLUG_REGEX } from '../../layouts/constants'; 2 | 3 | // Allegedly an RFC 5322 compliant email regex 4 | // eslint-disable-next-line max-len, no-empty-character-class 5 | export const VALID_EMAIL_REGEX = /^([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|"([]!#-[^-~ \t]|(\\[\t -~]))+")@([0-9A-Za-z]([0-9A-Za-z-]{0,61}[0-9A-Za-z])?(\.[0-9A-Za-z]([0-9A-Za-z-]{0,61}[0-9A-Za-z])?)*|\[((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|IPv6:((((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){6}|::((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){5}|[0-9A-Fa-f]{0,4}::((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){4}|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):)?(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){3}|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){0,2}(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){2}|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){0,3}(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){0,4}(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::)((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3})|(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){0,5}(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3})|(((0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}):){0,6}(0|[1-9A-Fa-f][0-9A-Fa-f]{0,3}))?::)|(?!IPv6:)[0-9A-Za-z-]*[0-9A-Za-z]:[!-Z^-~]+)])$/; 6 | 7 | export const VALID_SLUG_REGEX = new RegExp(SLUG_REGEX); 8 | -------------------------------------------------------------------------------- /src/components/app/app.test-helpers.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import * as _ from 'lodash-es'; 3 | import pino from 'pino'; 4 | 5 | import { Token } from '../auth'; 6 | 7 | import { config } from './app.test.config'; 8 | import { IContext, RouteLinker } from './context'; 9 | 10 | class FakeSession implements CookieSessionInterfaces.CookieSessionObject { 11 | public readonly isChanged: boolean; 12 | public readonly isNew: boolean; 13 | public readonly isPopulated: boolean; 14 | 15 | constructor() { 16 | this.isChanged = false; 17 | this.isNew = true; 18 | this.isPopulated = true; 19 | } 20 | 21 | readonly [propertyName: string]: any; 22 | } 23 | 24 | export function createTestContext(ctx?: {}, linkTo?: RouteLinker): IContext { 25 | const linker = linkTo || (route => `__LINKED_TO__${route}`); 26 | 27 | return _.cloneDeep({ 28 | absoluteLinkTo: () => '__ABSOLUTE_LINKED_TO__', 29 | app: config, 30 | linkTo: linker, 31 | log: pino({ level: 'silent' }), 32 | routePartOf: () => false, 33 | session: new FakeSession(), 34 | token: new Token( 35 | jwt.sign( 36 | { 37 | exp: 2535018460, 38 | origin: 'uaa', 39 | scope: [], 40 | user_id: 'uaa-user-123', 41 | }, 42 | 'secret', 43 | ), 44 | ['secret'], 45 | ), 46 | viewContext: { 47 | csrf: 'CSRF_TOKEN', 48 | isPlatformAdmin: false, 49 | location: config.location, 50 | }, 51 | 52 | ...ctx, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/errors/views.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | 4 | import { render } from '@testing-library/react'; 5 | import React from 'react'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | import { spacesMissingAroundInlineElements } from '../../layouts/react-spacing.test'; 9 | 10 | import { ErrorPage } from './views'; 11 | 12 | describe(ErrorPage, () => { 13 | it('should print the default error message', () => { 14 | const { container } = render(); 15 | expect(container.querySelector('h1')).toHaveTextContent(/TEST CASE/); 16 | expect(container.querySelector('.govuk-body')).toHaveTextContent( 17 | /Something went wrong while processing the request./, 18 | ); 19 | expect(container.querySelector('.govuk-body + p')).toHaveTextContent( 20 | 'You can browse from the', 21 | ); 22 | expect(spacesMissingAroundInlineElements(container.innerHTML)).toHaveLength(0); 23 | }); 24 | 25 | it('should print custom error message', () => { 26 | const { container } = render( 27 | Expected Test, 28 | ); 29 | expect(container.querySelector('h1')).toHaveTextContent(/TEST CASE/); 30 | expect(container.querySelector('.govuk-body')).toHaveTextContent(/Expected Test/); 31 | expect(container.querySelector('.govuk-body + p')).toHaveTextContent( 32 | 'You can browse from the', 33 | ); 34 | expect(spacesMissingAroundInlineElements(container.innerHTML)).toHaveLength(0); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/frontend/javascript/tooltip.js: -------------------------------------------------------------------------------- 1 | var KEY_ESC = 27 2 | 3 | function Tooltip ($module) { 4 | this.$module = $module 5 | } 6 | 7 | Tooltip.prototype.init = function () { 8 | if (!this.$module) { 9 | return 10 | } 11 | this.$module.addEventListener('keyup', this.handleKeyUp.bind(this)) 12 | this.$module.addEventListener('focus', this.handleFocus.bind(this)) 13 | this.$module.addEventListener('blur', this.handleBlur.bind(this)) 14 | this.$module.addEventListener('mouseenter', this.handleMouseEnter.bind(this)) 15 | this.$module.addEventListener('mouseleave', this.handleMouseLeave.bind(this)) 16 | } 17 | 18 | Tooltip.prototype.makeActive = function () { 19 | this.$module.setAttribute("tooltip", "active"); 20 | } 21 | 22 | Tooltip.prototype.makeInActive = function () { 23 | this.$module.setAttribute("tooltip", "inactive"); 24 | } 25 | 26 | Tooltip.prototype.keyboardEvents = function (event) { 27 | if (event.keyCode === KEY_ESC) { 28 | this.makeInActive(); 29 | } 30 | } 31 | 32 | Tooltip.prototype.handleKeyUp = function (event) { 33 | if (event.keyCode === KEY_ESC) { 34 | this.makeInActive(); 35 | } 36 | } 37 | 38 | Tooltip.prototype.handleFocus = function () { 39 | this.makeActive(); 40 | } 41 | Tooltip.prototype.handleBlur = function () { 42 | this.makeInActive(); 43 | } 44 | 45 | Tooltip.prototype.handleMouseEnter = function () { 46 | this.makeActive(); 47 | } 48 | 49 | Tooltip.prototype.handleMouseLeave = function () { 50 | this.makeInActive(); 51 | } 52 | 53 | 54 | export default Tooltip 55 | -------------------------------------------------------------------------------- /stub-api/stub-aws.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import express from 'express'; 3 | import _ from 'lodash'; 4 | 5 | import { getStubCloudwatchMetricsData } from '../src/lib/aws/aws-cloudwatch.test.data'; 6 | 7 | import { IStubServerPorts } from './index'; 8 | 9 | 10 | 11 | const red = '\x1b[31m'; 12 | const cyan = '\x1b[36m'; 13 | const reset = '\x1b[0m'; 14 | 15 | const cyanStubName = `${cyan}stub-aws-api${reset}`; 16 | const redStubName = `${red}stub-aws-api${reset}`; 17 | 18 | export default function mockAWS(app: express.Application, _config: IStubServerPorts): express.Application { 19 | app.use(bodyParser.urlencoded()); 20 | 21 | app.post('/', (req, res) => { 22 | const action = req.body.Action; 23 | console.log(`${cyanStubName} Action = ${action}`); 24 | switch(action) { 25 | case 'GetMetricData': 26 | const seriesIds = Object.keys(req.body) 27 | .filter(key => /^MetricDataQueries\.member\.\d+\.Id$/.test(key)) 28 | .map(key => req.body[key]); 29 | 30 | res.send(getStubCloudwatchMetricsData( 31 | // Create two series for each ID to simulate a service with multiple instances 32 | _.flatMap(seriesIds, id => [ 33 | { id, label: 'instance-001' }, 34 | { id, label: 'instance-002' }, 35 | ]), 36 | )); 37 | 38 | return; 39 | default: 40 | console.log(`${redStubName} ${action} is not implemented`); 41 | res.end('{}'); 42 | 43 | return; 44 | } 45 | }); 46 | 47 | return app; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/terms/views.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | 4 | interface ITermsPageProperties { 5 | readonly csrf: string; 6 | readonly name: string; 7 | readonly content: string; 8 | } 9 | 10 | export function TermsPage(props: ITermsPageProperties): ReactElement { 11 | return ( 12 |
    13 | 14 |

    {children}

    , 18 | h2: ({ children }) =>

    {children}

    , 19 | h3: ({ children }) =>

    {children}

    , 20 | h4: ({ children }) =>

    {children}

    , 21 | h5: ({ children }) =>
    {children}
    , 22 | h6: ({ children }) =>
    {children}
    , 23 | ul: ({ node, children }) => { if (node?.tagName === 'ul') return
      {children}
    }, 24 | ol: ({ node, children }) => { if (node?.tagName === 'ol') return
      {children}
    }, 25 | }} 26 | > 27 | {props.content} 28 |
    29 | 30 | 31 | 32 | 35 |
    36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/calculator/views.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | import { render } from '@testing-library/react'; 4 | import { describe, expect, it } from 'vitest'; 5 | 6 | import { 7 | appInstanceDescription, 8 | appInstanceDescriptionText, 9 | } from './views'; 10 | 11 | describe(appInstanceDescription, () => { 12 | it('should display "1 app instance with 64 MiB of memory" wihout a decimal point', () => { 13 | const { container } = render(appInstanceDescription(64,1)); 14 | expect(container).toHaveTextContent('1 app instance with 64 MiB of memory'); 15 | }); 16 | 17 | it('should display "1 app instance with 1.5 GiB of memory" with a decimal point', () => { 18 | const { container } = render(appInstanceDescription(1536,1)); 19 | expect(container).toHaveTextContent('1 app instance with 1.5 GiB of memory'); 20 | }); 21 | 22 | it('should display "1 app instance with 2 GiB of memory" without a decimal point', () => { 23 | const { container } = render(appInstanceDescription(2048,1)); 24 | expect(container).toHaveTextContent('1 app instance with 2 GiB of memory'); 25 | }); 26 | }); 27 | 28 | describe(appInstanceDescriptionText, () => { 29 | it('should return text "1 app instance with 64 mebibytes of memory', () => { 30 | const text = appInstanceDescriptionText(64,1); 31 | expect(text).toContain('1 app instance with 64 mebibytes of memory'); 32 | }); 33 | it('should return text "1 app instance with 1.5 gibibytes of memory', () => { 34 | const text = appInstanceDescriptionText(1536,1); 35 | expect(text).toContain('1 app instance with 1.5 gibibytes of memory'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/lib/uaa/uaa.types.ts: -------------------------------------------------------------------------------- 1 | // These interfaces are extracted 2 | // from the example payloads given 3 | // by the UAA API documentation 4 | 5 | export interface IUaaName { 6 | readonly familyName: string; 7 | readonly givenName: string; 8 | } 9 | 10 | export interface IUaaEmail { 11 | readonly value: string; 12 | readonly primary: boolean; 13 | } 14 | 15 | export interface IUaaGroup { 16 | readonly display: string; 17 | readonly type: string; 18 | readonly value: string; 19 | } 20 | 21 | export interface IUaaApproval { 22 | readonly clientId: string; 23 | readonly lastUpdatedAt: string; 24 | readonly scope: string; 25 | readonly userId: string; 26 | readonly expiresAt: string; 27 | readonly status: string; 28 | } 29 | 30 | export interface IUaaPhoneNumber { 31 | readonly value: string; 32 | } 33 | 34 | export interface IUaaUserMeta { 35 | readonly created: string; 36 | readonly lastModified: string; 37 | readonly version: number; 38 | } 39 | 40 | export interface IUaaUser { 41 | readonly id: string; 42 | readonly externalId: string; 43 | readonly meta: IUaaUserMeta; 44 | readonly userName: string; 45 | readonly name: IUaaName; 46 | readonly emails: ReadonlyArray; 47 | readonly groups: ReadonlyArray; 48 | readonly approvals: ReadonlyArray; 49 | readonly phoneNumbers: ReadonlyArray; 50 | readonly active: boolean; 51 | readonly verified: boolean; 52 | readonly origin: string; 53 | readonly zoneId: string; 54 | readonly passwordLastModified: string; 55 | readonly previousLogonTime: number; 56 | readonly lastLogonTime: number; 57 | readonly schemas: ReadonlyArray; 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/notify/notify.client.ts: -------------------------------------------------------------------------------- 1 | import { IResponse, NotifyClient } from 'notifications-node-client'; 2 | 3 | interface ITemplates { 4 | readonly welcome?: string; 5 | readonly passwordReset?: string; 6 | } 7 | 8 | interface IConfig { 9 | readonly apiKey: string; 10 | readonly templates: ITemplates; 11 | } 12 | 13 | interface IWelcomeEmailParameters { 14 | readonly organisation: string; 15 | readonly url: string; 16 | readonly location: string; 17 | } 18 | 19 | export default class NotificationClient { 20 | private readonly client: NotifyClient; 21 | private readonly templates: ITemplates; 22 | 23 | constructor(config: IConfig) { 24 | this.client = new NotifyClient(config.apiKey); 25 | /* istanbul ignore next */ 26 | this.templates = config.templates || {}; 27 | } 28 | 29 | public async sendWelcomeEmail(emailAddress: string, personalisation: IWelcomeEmailParameters): Promise { 30 | /* istanbul ignore next */ 31 | if (!this.templates.welcome) { 32 | throw new Error('NotifyClient: templates.welcome: id is required'); 33 | } 34 | 35 | const templateID = this.templates.welcome; 36 | 37 | return await this.client.sendEmail(templateID, emailAddress, { 38 | personalisation, 39 | }); 40 | } 41 | 42 | public async sendPasswordReminder(emailAddress: string, url: string): Promise { 43 | /* istanbul ignore next */ 44 | if (!this.templates.passwordReset) { 45 | throw new Error('NotifyClient: templates.passwordReset: id is required'); 46 | } 47 | 48 | return await this.client.sendEmail(this.templates.passwordReset, emailAddress, { 49 | personalisation: { url }, 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/app/app.test.config.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino'; 2 | 3 | import { IAppConfig, IOIDCConfig, OIDCProviderName } from './app'; 4 | 5 | const logger = pino({ level: 'silent' }); 6 | 7 | const sessionSecret = 'mysecret'; 8 | 9 | const providers = new Map(); 10 | 11 | providers.set('google', { 12 | clientID: 'CLIENTID', 13 | clientSecret: 'CLIENTSECRET', 14 | discoveryURL: 'https://accounts.google.com/.well-known/openid-configuration', 15 | providerName: 'google', 16 | }); 17 | 18 | export const config: IAppConfig = { 19 | accountsAPI: 'https://example.com/accounts', 20 | accountsSecret: 'acc_secret', 21 | adminFee: 0.1, 22 | allowInsecureSession: true, 23 | authorizationAPI: 'https://example.com/login', 24 | awsCloudwatchEndpoint: 'https://aws-cloudwatch.example.com/', 25 | awsRegion: 'eu-west-1', 26 | awsResourceTaggingAPIEndpoint: 'https://aws-tags.example.com', 27 | billingAPI: 'https://example.com/billing', 28 | cloudFoundryAPI: 'https://example.com/api', 29 | domainName: 'https://admin.example.com/', 30 | location: 'Ireland', 31 | logger, 32 | notifyAPIKey: 'test-123456-qwerty', 33 | notifyPasswordResetTemplateID: 'qwerty-123456', 34 | notifyWelcomeTemplateID: 'qwerty-123456', 35 | oauthClientID: 'key', 36 | oauthClientSecret: 'secret', 37 | oidcProviders: providers, 38 | prometheusEndpoint: 'https://example.com/prom', 39 | prometheusPassword: 'prometheusPassword', 40 | prometheusUsername: 'prometheusUsername', 41 | sessionSecret, 42 | uaaAPI: 'https://example.com/uaa', 43 | zendeskConfig: { 44 | remoteUri: 'https://example.com/zendesk', 45 | token: 'qwerty-1234567890', 46 | username: 'poiuytrewq', 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /stub-api/stub-prometheus.ts: -------------------------------------------------------------------------------- 1 | import { add, fromUnixTime } from 'date-fns'; 2 | import express from 'express'; 3 | import lodash from 'lodash'; 4 | 5 | import { IStubServerPorts } from './index'; 6 | 7 | function mockPrometheus( 8 | app: express.Application, 9 | _config: IStubServerPorts, 10 | ): express.Application { 11 | 12 | app.get( 13 | /query_range/, 14 | (req, res) => { 15 | console.log(req.query.start); 16 | const historicTime = parseInt(req.query.start as string, 10); 17 | const instantTime = parseInt(req.query.end as string, 10); 18 | const step = parseInt(req.query.step as string, 10); 19 | 20 | const length = Math.ceil(((instantTime - historicTime)) / step); 21 | 22 | const response = { 23 | data: { 24 | result : [{ 25 | values: lodash 26 | .range(0, length, 1) 27 | .map(i => { 28 | return [ 29 | add(fromUnixTime(historicTime * 1000), { seconds: step * i }).getTime() / 1000, 30 | `${Math.random() * 100}`, 31 | ]; 32 | }) 33 | , 34 | }], 35 | }, 36 | status: 'success', 37 | }; 38 | 39 | res.send(JSON.stringify(response)); 40 | }, 41 | ); 42 | 43 | app.get( 44 | /query/, 45 | (_req, res) => { 46 | res.send(JSON.stringify({ 47 | data: { 48 | result: [{ 49 | value: [ 50 | (new Date()).getTime() / 1000, `${Math.random() * 100}`, 51 | ], 52 | }], 53 | }, 54 | status: 'success', 55 | })); 56 | }, 57 | ); 58 | 59 | return app; 60 | } 61 | 62 | export default mockPrometheus; 63 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | interface IServerOptions { 4 | readonly port?: number; 5 | } 6 | 7 | export default class Server { 8 | public http: any; 9 | 10 | private handler: any; 11 | private readonly port: number; 12 | 13 | constructor(handler: any, opts: IServerOptions = {}) { 14 | this.handler = handler; 15 | this.port = opts.port || 0; 16 | } 17 | 18 | public async start() { 19 | if (this.http) { 20 | throw new Error('Server: cannot start server: server is already started'); 21 | } 22 | this.http = createServer(this.handler); 23 | this.http.listen(this.port); 24 | 25 | return await new Promise((resolve, reject) => { 26 | this.http.once('listening', () => resolve(this)); 27 | this.http.once('error', reject); 28 | }); 29 | } 30 | 31 | public async stop() { 32 | if (!this.http) { 33 | throw new Error('Server: cannot stop server: server is not started'); 34 | } 35 | const wait = this.wait(); 36 | const h = this.http; 37 | this.http = null; 38 | h.close(); 39 | 40 | return await wait; 41 | } 42 | 43 | public async wait() { 44 | if (!this.http) { 45 | throw new Error('Server: cannot wait on server: server is not started'); 46 | } 47 | 48 | return await new Promise((resolve, reject) => { 49 | this.http.once('close', resolve); 50 | this.http.once('error', reject); 51 | }); 52 | } 53 | 54 | public update(handler: any) { 55 | const oldHandler = this.handler; 56 | this.handler = handler; 57 | if (!this.http) { 58 | return; 59 | } 60 | this.http.removeListener('request', oldHandler); 61 | this.http.on('request', this.handler); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/notify/notify.client.test.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; 4 | 5 | import NotificationClient from '.'; 6 | 7 | describe('lib/notify test suite', () => { 8 | 9 | const handlers = [ 10 | http.post('https://api.notifications.service.gov.uk/v2/notifications/email', () => { 11 | return HttpResponse.json( 12 | { content: { body: 'FAKE_NOTIFY_RESPONSE' } }, 13 | { status: 200 }, 14 | ); 15 | }), 16 | ]; 17 | const server = setupServer(...handlers); 18 | 19 | beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); 20 | beforeEach(() => server.resetHandlers()); 21 | afterAll(() => server.close()); 22 | 23 | it('notify middleware should include NotifyClient on req', async () => { 24 | 25 | const notify = new NotificationClient({ 26 | apiKey: 'test-key-1234', 27 | templates: { passwordReset:'PASSWORD_RESET_ID', welcome: 'WELCOME_ID' }, 28 | }); 29 | 30 | const personalisation = { 31 | location: 'DefaultLocation', 32 | organisation: 'DefaultOrg', 33 | url: 'https://default.url', 34 | }; 35 | 36 | const notifyWelcomeResponse = await notify.sendWelcomeEmail( 37 | 'jeff@jeff.com', 38 | personalisation, 39 | ); 40 | 41 | const notifyPasswordResetResponse = await notify.sendPasswordReminder( 42 | 'jeff@jeff.com', 43 | 'https://example.com/reset?code=1234567890', 44 | ); 45 | 46 | 47 | expect(notifyWelcomeResponse.data.content.body).toContain('FAKE_NOTIFY_RESPONSE'); 48 | expect(notifyPasswordResetResponse.data.content.body).toContain('FAKE_NOTIFY_RESPONSE'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/lib/metric-data-getters/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import { MetricDataResult as CloudWatchResult } from '@aws-sdk/client-cloudwatch'; 2 | import { add, Duration, isBefore, isEqual } from 'date-fns'; 3 | import _ from 'lodash-es'; 4 | 5 | import { IMetric, MetricName } from '../metrics'; 6 | 7 | export interface ICloudWatchMetric { 8 | readonly name: string; 9 | readonly stat: string; 10 | } 11 | 12 | export class CloudWatchMetricDataGetter { 13 | public addPlaceholderData( 14 | results: ReadonlyArray, 15 | 16 | period: Duration, 17 | rangeStart: Date, 18 | rangeStop: Date, 19 | ) { 20 | const placeholderData: { [key: number]: IMetric } = {}; 21 | for ( 22 | let time = new Date(rangeStart); 23 | isEqual(time, rangeStop) || isBefore(time, rangeStop); 24 | time = add(time, period) 25 | ) { 26 | placeholderData[+time] = { date: time, value: NaN }; 27 | } 28 | 29 | return _.chain(results) 30 | .groupBy(result => result.Id! as MetricName) 31 | .mapValues(series => { 32 | return series.map(serie => { 33 | const label = serie.Label!; 34 | 35 | const metricPairs = _.zip(serie.Timestamps!, serie.Values!); 36 | 37 | const dataWithoutPlaceholders: { readonly [key: number]: IMetric } = _.chain( 38 | metricPairs, 39 | ) 40 | .keyBy(p => +p[0]!) 41 | .mapValues(p => ({ date: p[0]!, value: p[1]! })) 42 | .value(); 43 | 44 | const metrics: ReadonlyArray = Object.values({ 45 | ...placeholderData, 46 | ...dataWithoutPlaceholders, 47 | }); 48 | 49 | return { label, metrics }; 50 | }); 51 | }) 52 | .value(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/app/context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import Router, { IParameters, IResponse } from '../../lib/router'; 4 | 5 | import { config } from './app.test.config'; 6 | import { IContext, initContext } from './context'; 7 | 8 | const noopActionFunc = async ( 9 | _: IContext, 10 | __: IParameters, 11 | ): Promise => { 12 | return await new Promise(resolve => resolve({ body: 'noop' })); 13 | }; 14 | 15 | describe('IContext', () => { 16 | describe('linkTo', () => { 17 | it('should generate a relative URL by default', () => { 18 | const router = new Router([ 19 | { 20 | action: noopActionFunc, 21 | name: 'test', 22 | path: '/test', 23 | }, 24 | ],[]); 25 | const req = { 26 | csrfToken: () => '', 27 | log: {}, 28 | session: {}, 29 | token: {}, 30 | }; 31 | const ctx = initContext(req, router, router.routes[0], config); 32 | 33 | const link = ctx.linkTo('test'); 34 | 35 | expect(link).not.toContain(config.domainName); 36 | }); 37 | }); 38 | 39 | describe('absoluteLinkTo', () => { 40 | it('should generate an absolute URL', () => { 41 | const router = new Router([ 42 | { 43 | action: noopActionFunc, 44 | name: 'test', 45 | path: '/test', 46 | }, 47 | ],[]); 48 | const req = { 49 | csrfToken: () => '', 50 | log: {}, 51 | session: {}, 52 | token: {}, 53 | }; 54 | const ctx = initContext(req, router, router.routes[0], config); 55 | 56 | const link = ctx.absoluteLinkTo('test'); 57 | 58 | expect(link).toContain(config.domainName); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/platform-admin/views.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | 4 | import { render } from '@testing-library/react'; 5 | import React from 'react'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | import { IParameters } from '../../lib/router'; 9 | 10 | import { 11 | CreateOrganizationPage, 12 | CreateOrganizationSuccessPage, 13 | } from './views'; 14 | 15 | function linker(route: string, params?: IParameters): string { 16 | return `__LINKS_TO__${route}_WITH_${(new URLSearchParams(params)).toString()}`; 17 | } 18 | 19 | describe(CreateOrganizationPage, () => { 20 | it('should have csrf token', () => { 21 | const { container } = render (); 22 | 23 | expect(container.querySelector('[name=_csrf]')).toBeTruthy(); 24 | expect(container.querySelector('[name=_csrf]')).toHaveValue('CSRF_TOKEN'); 25 | }); 26 | 27 | it('should correctly printout errors', () => { 28 | const { container } = render (); 34 | 35 | expect(container.querySelector('.govuk-error-summary')).toBeTruthy(); 36 | expect(container.querySelector('.govuk-error-summary')).toHaveTextContent('required field'); 37 | }); 38 | }); 39 | 40 | describe(CreateOrganizationSuccessPage, () => { 41 | it('should correctly compose a success page', () => { 42 | const { container } = render (); 43 | 44 | expect(container.innerHTML) 45 | .toContain('href="__LINKS_TO__admin.organizations.users.invite_WITH_organizationGUID=ORG_GUID"'); 46 | }); 47 | }); -------------------------------------------------------------------------------- /src/frontend/javascript/init.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ErrorSummary, 4 | Header, 5 | Radios, 6 | SkipLink 7 | 8 | } from 'govuk-frontend' 9 | 10 | import Tooltip from './tooltip.js'; 11 | 12 | // there is ever only one header per page 13 | var $header = document.querySelector('[data-module="govuk-header"]') 14 | if ($header) { 15 | new Header($header) 16 | } 17 | 18 | var $buttons = document.querySelectorAll('[data-module="govuk-button"]'); 19 | if ($buttons) { 20 | for (var i = 0; i < $buttons.length; i++) { 21 | new Button($buttons[i]); 22 | }; 23 | } 24 | 25 | // there is ever only one error summuary per page 26 | var $errorSummary = document.querySelector('[data-module="govuk-error-summary"]'); 27 | if ($errorSummary) { 28 | new ErrorSummary($errorSummary); 29 | } 30 | 31 | var $radios = document.querySelectorAll('[data-module="govuk-radios"]'); 32 | if ($radios) { 33 | for (var i = 0; i < $radios.length; i++) { 34 | new Radios($radios[i]); 35 | }; 36 | } 37 | 38 | var $tooltips = document.querySelectorAll('[data-module="tooltip"]'); 39 | if ($tooltips) { 40 | for (var i = 0; i < $tooltips.length; i++) { 41 | new Tooltip($tooltips[i]); 42 | }; 43 | } 44 | 45 | // Find first skip link module to enhance. 46 | var $skipLink = document.querySelector('[data-module="govuk-skip-link"]') 47 | new SkipLink($skipLink) 48 | 49 | 50 | var $preventMultiClickBtns = document.querySelectorAll('[data-module="preventMultiClick"]'); 51 | if ($preventMultiClickBtns) { 52 | for (var i = 0; i < $preventMultiClickBtns.length; i++) { 53 | $preventMultiClickBtns[i].addEventListener("click", function () { 54 | this.form.submit(); 55 | this.setAttribute("disabled", "disabled"); 56 | this.textContent = "Loading data..."; 57 | this.setAttribute("aria-disabled", "true"); 58 | }); 59 | }; 60 | } -------------------------------------------------------------------------------- /src/lib/axios-logger/axios.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import { BaseLogger } from 'pino'; 3 | 4 | function newAxiosRequestInterceptor(name: string, logger: BaseLogger) { 5 | return (cfg: InternalAxiosRequestConfig) => { 6 | const { url, method } = cfg; 7 | 8 | logger.info({ 9 | name, 10 | method, 11 | url, 12 | message: `${name} making ${method} request to ${url}`, 13 | }); 14 | 15 | (cfg as any).startTime = Date.now(); 16 | 17 | return cfg; 18 | }; 19 | } 20 | 21 | function newAxiosResponseInterceptor(name: string, logger: BaseLogger) { 22 | return (resp: AxiosResponse) => { 23 | const { config, status } = resp; 24 | const { url, method } = config; 25 | 26 | const timeDelta = Date.now() - (config as any).startTime; 27 | 28 | const contents = { 29 | name, 30 | method, 31 | url, 32 | status, 33 | time: timeDelta, 34 | message: `${name} received ${status} when making ${method} request to ${url} in ${timeDelta}ms`, 35 | }; 36 | 37 | if (200 <= status && status < 300) { 38 | logger.info(contents); 39 | } else { 40 | logger.warn(contents); 41 | } 42 | 43 | return resp; 44 | }; 45 | } 46 | 47 | function newAxiosErrorInterceptor(name: string, logger: BaseLogger) { 48 | return (err: any) => { 49 | logger.error({ message: `${name} encountered error` }); 50 | 51 | return err; 52 | }; 53 | } 54 | 55 | export function intercept(inst: AxiosInstance, name: string, log: BaseLogger) { 56 | inst.interceptors.request.use( 57 | newAxiosRequestInterceptor(name, log), 58 | newAxiosErrorInterceptor(name, log), 59 | ); 60 | inst.interceptors.response.use( 61 | newAxiosResponseInterceptor(name, log), 62 | newAxiosErrorInterceptor(name, log), 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/cf/test-data/org-quota.ts: -------------------------------------------------------------------------------- 1 | import { IOrganizationQuota } from '../types'; 2 | 3 | export const billableOrgQuotaName = 'billable'; 4 | export const billableOrgQuotaGUID = 'ORG-QUOTA-GUID'; 5 | 6 | export const billableOrgQuota = (): IOrganizationQuota => 7 | JSON.parse(`{ 8 | "metadata": { 9 | "guid": "${billableOrgQuotaGUID}", 10 | "url": "/v2/quota_definitions/${billableOrgQuotaGUID}", 11 | "created_at": "2016-06-08T16:41:39Z", 12 | "updated_at": "2016-06-08T16:41:26Z" 13 | }, 14 | "entity": { 15 | "name": "${billableOrgQuotaName}", 16 | "non_basic_services_allowed": true, 17 | "total_services": 60, 18 | "total_routes": 1000, 19 | "total_private_domains": -1, 20 | "memory_limit": 20480, 21 | "trial_db_allowed": true, 22 | "instance_memory_limit": -1, 23 | "app_instance_limit": -1, 24 | "app_task_limit": -1, 25 | "total_service_keys": -1, 26 | "total_reserved_route_ports": 5 27 | } 28 | }`); 29 | 30 | export const trialOrgQuotaName = 'default'; 31 | export const trialOrgQuotaGUID = '99999999-a8c0-4c43-9c72-649df53da8cb'; 32 | 33 | export const trialOrgQuota = (): IOrganizationQuota => 34 | JSON.parse(`{ 35 | "metadata": { 36 | "guid": "${trialOrgQuotaGUID}", 37 | "url": "/v2/quota_definitions/${trialOrgQuotaGUID}", 38 | "created_at": "2016-06-08T16:41:39Z", 39 | "updated_at": "2016-06-08T16:41:26Z" 40 | }, 41 | "entity": { 42 | "name": "${trialOrgQuotaName}", 43 | "non_basic_services_allowed": false, 44 | "total_services": 60, 45 | "total_routes": 1000, 46 | "total_private_domains": -1, 47 | "memory_limit": 20480, 48 | "trial_db_allowed": false, 49 | "instance_memory_limit": -1, 50 | "app_instance_limit": -1, 51 | "app_task_limit": -1, 52 | "total_service_keys": -1, 53 | "total_reserved_route_ports": 5 54 | } 55 | }`); 56 | -------------------------------------------------------------------------------- /src/components/app/router-middleware.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Router, { IResponse } from '../../lib/router'; 4 | import { IAppConfig } from '../app/app'; 5 | import { initContext } from '../app/context'; 6 | 7 | function handleResponse(res: express.Response) { 8 | return (r: IResponse) => { 9 | if (r.redirect) { 10 | return res.redirect(r.redirect); 11 | } 12 | 13 | if (r.mimeType) { 14 | res.contentType(r.mimeType); 15 | } 16 | 17 | if (r.download) { 18 | res.attachment(r.download.name); 19 | 20 | return res.send(r.download.data); 21 | } 22 | 23 | res.status(r.status || 200).send(r.body); 24 | }; 25 | } 26 | 27 | export function routerMiddleware( 28 | router: Router, 29 | appConfig: IAppConfig, 30 | ): express.Application { 31 | const app = express(); 32 | 33 | app.use((req: any, _res: express.Response, next: express.NextFunction) => { 34 | req.router = router; 35 | next(); 36 | }); 37 | 38 | app.use( 39 | ( 40 | req: express.Request, 41 | res: express.Response, 42 | next: express.NextFunction, 43 | ) => { 44 | let route; 45 | try { 46 | route = router.find(req.path, req.method); 47 | } catch (err) { 48 | return next(err); 49 | } 50 | 51 | const params = { 52 | ...req.query, 53 | ...req.params, 54 | ...route.parser.match(req.path), 55 | }; 56 | 57 | const ctx = initContext(req, router, route, appConfig); 58 | 59 | if (route.headers) { 60 | route.headers.forEach(header => { 61 | res.set(header.name, header.value); 62 | }); 63 | } 64 | 65 | route.definition 66 | .action(ctx, params, req.body) 67 | .then(handleResponse(res)) 68 | .catch(next); 69 | }, 70 | ); 71 | 72 | return app; 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/cf/test-data/app.ts: -------------------------------------------------------------------------------- 1 | import { IApplication } from '../types'; 2 | 3 | export const appName = 'name-2401'; 4 | export const appGUID = '15b3885d-0351-4b9b-8697-86641668c123'; 5 | export const appSpaceGUID = '7846301e-c84c-4ba9-9c6a-2dfdae948d52'; 6 | export const appStackGUID = 'bb9ca94f-b456-4ebd-ab09-eb7987cce728'; 7 | 8 | export const app = (): IApplication => 9 | JSON.parse(`{ 10 | "metadata": { 11 | "guid": "${appGUID}", 12 | "url": "/v2/apps/${appGUID}", 13 | "created_at": "2016-06-08T16:41:44Z", 14 | "updated_at": "2016-06-08T16:41:44Z" 15 | }, 16 | "entity": { 17 | "name": "${appName}", 18 | "production": false, 19 | "space_guid": "${appSpaceGUID}", 20 | "stack_guid": "${appStackGUID}", 21 | "buildpack": "python_buildpack", 22 | "docker-image": null, 23 | "detected_buildpack": null, 24 | "detected_buildpack_guid": null, 25 | "environment_json": null, 26 | "memory": 1024, 27 | "instances": 1, 28 | "disk_quota": 1024, 29 | "state": "STOPPED", 30 | "version": "df19a7ea-2003-4ecb-a909-e630e43f2719", 31 | "command": null, 32 | "console": false, 33 | "debug": null, 34 | "staging_task_id": null, 35 | "package_state": "PENDING", 36 | "health_check_http_endpoint": "", 37 | "health_check_type": "port", 38 | "health_check_timeout": null, 39 | "staging_failed_reason": null, 40 | "staging_failed_description": null, 41 | "diego": false, 42 | "package_updated_at": "2016-06-08T16:41:45Z", 43 | "detected_start_command": "", 44 | "enable_ssh": true, 45 | "ports": null, 46 | "space_url": "/v2/spaces/${appSpaceGUID}", 47 | "stack_url": "/v2/stacks/${appStackGUID}", 48 | "routes_url": "/v2/apps/${appGUID}/routes", 49 | "events_url": "/v2/apps/${appGUID}/events", 50 | "service_bindings_url": "/v2/apps/${appGUID}/service_bindings", 51 | "route_mappings_url": "/v2/apps/${appGUID}/route_mappings" 52 | } 53 | }`); 54 | -------------------------------------------------------------------------------- /src/components/marketplace/icons/cdn.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | open-pull-requests-limit: 10 6 | # Group packages into shared PR 7 | groups: 8 | aws-sdk: 9 | patterns: 10 | - '@aws-sdk/*' 11 | 12 | test: 13 | patterns: 14 | - '@testing-library/*' 15 | - 'cheerio' 16 | - 'supertest' 17 | - 'vitest' 18 | - 'puppeteer' 19 | - 'msw' 20 | - 'jsdom' 21 | 22 | lint: 23 | patterns: 24 | - '@types/eslint' 25 | - '@typescript-eslint/*' 26 | - 'eslint' 27 | - 'eslint-*' 28 | - 'stylelint' 29 | - 'stylelint-*' 30 | 31 | types: 32 | patterns: 33 | - '@types/*' 34 | 35 | # Exclude packages in other groups 36 | exclude-patterns: 37 | - '@types/aws-sdk' 38 | - '@types/eslint' 39 | - '@types/d3-*' 40 | 41 | webpack-plus-plugins-and-loaders: 42 | patterns: 43 | - 'webpack' 44 | - 'mini-css-extract-plugin' 45 | - 'css-minimizer-webpack-plugin' 46 | - 'compression-webpack-plugin' 47 | - 'webpack-node-externals' 48 | - 'webpack-cli' 49 | - 'nodemon-webpack-plugin' 50 | - 'sass-loader' 51 | - 'css-loader' 52 | - 'ts-loader' 53 | 54 | react: 55 | patterns: 56 | - 'react' 57 | - 'react-*' 58 | 59 | d3: 60 | patterns: 61 | - 'd3' 62 | - 'd3-*' 63 | - '@types/d3-*' 64 | 65 | # this is ESM only from v6. 66 | exclude-patterns: 67 | - 'react-markdown' 68 | 69 | # Schedule run every Monday, local time 70 | schedule: 71 | interval: weekly 72 | time: '03:00' 73 | timezone: 'Europe/London' 74 | 75 | versioning-strategy: increase 76 | - package-ecosystem: github-actions 77 | directory: / 78 | schedule: 79 | interval: weekly 80 | commit-message: 81 | prefix: github-action 82 | -------------------------------------------------------------------------------- /src/lib/moment/round.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import roundDown from './round'; 4 | 5 | describe('rounding dates down', () => { 6 | it('should round down the time to nearest 5min interval', () => { 7 | const fiveMins = { minutes: 5 }; 8 | expect( 9 | roundDown(new Date('2019-11-13 09:03:00'), fiveMins).toISOString(), 10 | ).toEqual('2019-11-13T09:00:00.000Z'); 11 | expect( 12 | roundDown(new Date('2019-12-13 12:34:23'), fiveMins).toISOString(), 13 | ).toEqual('2019-12-13T12:30:00.000Z'); 14 | expect( 15 | roundDown(new Date('2019-11-10 14:15:00'), fiveMins).toISOString(), 16 | ).toEqual('2019-11-10T14:15:00.000Z'); 17 | expect( 18 | roundDown(new Date('2019-11-09 10:00:00'), fiveMins).toISOString(), 19 | ).toEqual('2019-11-09T10:00:00.000Z'); 20 | expect( 21 | roundDown(new Date('2019-11-08 16:47:00'), fiveMins).toISOString(), 22 | ).toEqual('2019-11-08T16:45:00.000Z'); 23 | }); 24 | 25 | it('should round down the time to nearest day', () => { 26 | const oneDay = { days: 1 }; 27 | expect( 28 | roundDown(new Date('2019-11-13 09:03:00'), oneDay).toISOString(), 29 | ).toEqual('2019-11-13T00:00:00.000Z'); 30 | expect( 31 | roundDown(new Date('2019-12-13 12:34:23'), oneDay).toISOString(), 32 | ).toEqual('2019-12-13T00:00:00.000Z'); 33 | expect( 34 | roundDown(new Date('2019-11-10 14:15:00'), oneDay).toISOString(), 35 | ).toEqual('2019-11-10T00:00:00.000Z'); 36 | expect( 37 | roundDown(new Date('2019-11-09 10:00:00'), oneDay).toISOString(), 38 | ).toEqual('2019-11-09T00:00:00.000Z'); 39 | expect( 40 | roundDown(new Date('2019-11-08 16:47:00'), oneDay).toISOString(), 41 | ).toEqual('2019-11-08T00:00:00.000Z'); 42 | }); 43 | 44 | it('should round down the time to nearest second', () => { 45 | const oneSecond = { seconds: 1 }; 46 | expect( 47 | roundDown(new Date('2019-11-13 09:03:00.123'), oneSecond).toISOString(), 48 | ).toEqual('2019-11-13T09:03:00.000Z'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /config/use-dev-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | DEPLOY_ENV="$1" 4 | SYSTEM_DOMAIN="${DEPLOY_ENV}.dev.cloudpipeline.digital" 5 | 6 | function service_domain () { 7 | echo "https://${1}.${SYSTEM_DOMAIN}" 8 | } 9 | 10 | echo "You will need to access to Credhub for ${DEPLOY_ENV}." 11 | echo "In the paas-cf repository run" 12 | echo " gds aws paas-dev-admin -- make ${DEPLOY_ENV} credhub" 13 | echo "and follow the instructions on screen" 14 | echo "" 15 | 16 | 17 | ## Set up environment 18 | export PORT=${PORT-3000} 19 | export DOMAIN_NAME="http://localhost:3000/" 20 | 21 | export STUB_ACCOUNTS_PORT=${STUB_ACCOUNTS_PORT-1337} 22 | export STUB_BILLING_PORT=${STUB_BILLING_PORT-1338} 23 | export STUB_CF_PORT=${STUB_CF_PORT-1339} 24 | export STUB_UAA_PORT=${STUB_UAA_PORT-1340} 25 | export STUB_AWS_PORT=${STUB_AWS_PORT-1341} 26 | export STUB_PROMETHEUS_PORT=${STUB_PROMETHEUS_PORT-1342} 27 | 28 | export ACCOUNTS_URL=$(service_domain "accounts") 29 | export BILLING_URL=$(service_domain "billing") 30 | export API_URL=$(service_domain "api") 31 | export UAA_URL=$(service_domain "uaa") 32 | export AUTHORIZATION_URL=$(service_domain "uaa") 33 | 34 | export AWS_REGION=eu-west-1 35 | export AWS_CLOUDWATCH_ENDPOINT="https://monitoring.${AWS_REGION}.amazonaws.com" 36 | export PROMETHEUS_ENDPOINT=http://0:${STUB_PROMETHEUS_PORT} 37 | export PROMETHEUS_USERNAME=not-used 38 | export PROMETHEUS_PASSWORD=not-used 39 | 40 | read -p $'Enter the PaaS accounts secret: (credhub get -q -n "/concourse/main/create-cloudfoundry/paas_accounts_password")\n' accounts_secret 41 | export ACCOUNTS_SECRET="${accounts_secret}" 42 | 43 | read -p $'Enter the Notify API key: (credhub get -q -n "/concourse/main/create-cloudfoundry/notify_api_key")\n' notify_api_key 44 | export NOTIFY_API_KEY="${notify_api_key}" 45 | export NOTIFY_WELCOME_TEMPLATE_ID="1859ce68-f133-4218-ac6e-a8ef32a41292" 46 | 47 | export OAUTH_CLIENT_ID=paas-admin-local 48 | export OAUTH_CLIENT_SECRET=local-dev 49 | export GOOGLE_CLIENT_ID=googleclientid 50 | export GOOGLE_CLIENT_SECRET=googleclientsecret 51 | 52 | ## Start the dev server 53 | npm start 54 | -------------------------------------------------------------------------------- /src/components/organizations/owners.ts: -------------------------------------------------------------------------------- 1 | export const owners = [ 2 | '10 Downing Street', 3 | 'Bristol City Council', 4 | 'British Council', 5 | 'Cabinet Office', 6 | 'Chief Digital Information Office', 7 | 'Civil Aviation Authority', 8 | 'College of Policing', 9 | 'Crown Commercial Service', 10 | 'Darlington Borough Council', 11 | 'Department for Business, Energy & Industrial Strategy', 12 | 'Department for Education', 13 | 'Department of Energy Security & Net Zero', 14 | 'Department for Environment, Food & Rural Affairs', 15 | 'Department for Business & Trade', 16 | 'Department for Levelling Up, Housing & Communities', 17 | 'Department for Science, Innovation & Technology', 18 | 'Department for Transport', 19 | 'Department for Work & Pensions', 20 | 'Department of Finance Northern Ireland', 21 | 'Department of Health & Social Care', 22 | 'Derry Strabane District Council', 23 | 'Disclosure Scotland', 24 | 'Environment Agency', 25 | 'Foreign, Commonwealth & Development Office', 26 | 'Government Digital Service', 27 | 'Government Equalities Office', 28 | 'Government Office for Science', 29 | 'Government Property Agency', 30 | 'Greater London Authority', 31 | 'Hackney London Borough Council', 32 | 'HM Passport Office', 33 | 'HM Revenue & Customs', 34 | 'Home Office', 35 | 'Homes England', 36 | 'Kingston upon Thames London Borough Council', 37 | 'London Borough of Camden', 38 | 'Marine Management Organisation', 39 | 'Ministry of Defence', 40 | 'Ministry of Housing, Communities and Local Government', 41 | 'Ministry of Justice', 42 | 'National Cyber Security Centre', 43 | 'National Lottery Heritage Fund', 44 | 'Natural England', 45 | 'NHS Digital', 46 | 'NHS England', 47 | 'Northern Ireland Department for Communities', 48 | 'Northern Ireland Department for the Economy', 49 | 'Ofgem', 50 | 'Ofsted', 51 | 'PaaS engagement', 52 | 'Platform', 53 | 'Public Health England', 54 | 'Student Awards Agency Scotland', 55 | 'UK Export Finance', 56 | 'UK Research and Innovation', 57 | 'UK Space Agency', 58 | ] 59 | -------------------------------------------------------------------------------- /src/lib/validation/validators.ts: -------------------------------------------------------------------------------- 1 | import { VALID_EMAIL_REGEX, VALID_SLUG_REGEX } from './const'; 2 | import { IValidationError } from './types'; 3 | 4 | export function validateRequired( 5 | value?: string, field = 'value', 6 | message = 'This field is required'): ReadonlyArray { 7 | const errors = []; 8 | 9 | if (!value || value.length === 0) { 10 | errors.push({ field, message }); 11 | } 12 | 13 | return errors; 14 | } 15 | 16 | export function validateEmail( 17 | email?: string, field = 'email', 18 | message = 'Enter an email address in the correct format, like name@example.com'): ReadonlyArray { 19 | if (!email) { 20 | return []; 21 | } 22 | const errors = []; 23 | 24 | if (email.length === 0 || !VALID_EMAIL_REGEX.test(email)) { 25 | errors.push({ field, message }); 26 | } 27 | 28 | return errors; 29 | } 30 | 31 | export function validateSlug( 32 | slug?: string, field = 'slug', 33 | message = 'Enter a valid slug (lowercase letters, numbers and hyphens)'): ReadonlyArray { 34 | if (!slug) { 35 | return []; 36 | } 37 | const errors = []; 38 | 39 | if (slug.length === 0 || !VALID_SLUG_REGEX.test(slug)) { 40 | errors.push({ field, message }); 41 | } 42 | 43 | return errors; 44 | } 45 | 46 | 47 | export function validateMaxLength( 48 | value?: string, maxLength = 255, field = 'value', 49 | message = `This field must be less than ${maxLength} characters`): ReadonlyArray { 50 | if (!value) { 51 | return []; 52 | } 53 | const errors = []; 54 | 55 | if (value.length > maxLength) { 56 | errors.push({ field, message }); 57 | } 58 | 59 | return errors; 60 | } 61 | 62 | export function validateArrayMember( 63 | value?: string, array: ReadonlyArray = [], field = 'value', 64 | message = 'This field is not valid'): ReadonlyArray { 65 | if (!value) { 66 | return []; 67 | } 68 | const errors = []; 69 | 70 | if (array.indexOf(value) === -1) { 71 | errors.push({ field, message }); 72 | } 73 | 74 | return errors; 75 | } 76 | -------------------------------------------------------------------------------- /src/layouts/template.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | import { describe, expect, it } from "vitest"; 3 | import React from 'react'; 4 | 5 | import { spacesMissingAroundInlineElements } from './react-spacing.test'; 6 | import { Template } from './template'; 7 | 8 | describe(Template, () => { 9 | it('should be able to render GOV.UK frontend correctly', () => { 10 | const template = new Template( 11 | { 12 | csrf: 'qwertyuiop-1234567890', 13 | isPlatformAdmin: false, 14 | location: 'eu-west-2', 15 | }, 16 | 'TEST CASE', 17 | ); 18 | const markup = template.render(

    This is just a test

    ); 19 | 20 | expect(markup).toContain(''); 21 | expect(markup).toContain(''); 22 | expect(markup).toContain(''); 23 | expect(markup).toContain('TEST CASE'); 24 | expect(markup).toContain( 25 | '', 26 | ); 27 | expect(markup).toContain(''); 28 | expect(markup).toContain('

    This is just a test

    '); 29 | expect(spacesMissingAroundInlineElements(markup)).toHaveLength(0); 30 | }); 31 | 32 | it('should set the default title if one is not provided.', () => { 33 | const template = new Template({ 34 | csrf: 'qwertyuiop-1234567890', 35 | isPlatformAdmin: false, 36 | location: 'eu-west-2', 37 | }); 38 | const markup = template.render(

    This is just a test

    ); 39 | 40 | expect(markup).toContain( 41 | '[Decommissioned] GOV.UK Platform as a Service - Administration Tool', 42 | ); 43 | }); 44 | it('should html encode special characters in the title', () => { 45 | const template = new Template({ 46 | csrf: 'qwertyuiop-1234567890', 47 | isPlatformAdmin: false, 48 | location: 'eu-west-2', 49 | }, 'TEST CASE '); 50 | const markup = template.render(

    This is just a test

    ); 51 | expect(markup).toContain( 52 | 'TEST CASE <script>alert("XSS")</script>', 53 | ); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/app/context.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino'; 2 | 3 | import Router, { IParameters, Route } from '../../lib/router'; 4 | import { Token } from '../auth'; 5 | 6 | import { IAppConfig } from './app'; 7 | 8 | export type RouteLinker = (name: string, params?: IParameters) => string; 9 | export type RouteActiveChecker = (name: string) => boolean; 10 | 11 | export interface IRawToken { 12 | readonly user_id: string; 13 | readonly scope: ReadonlyArray; 14 | } 15 | 16 | export interface IViewContext { 17 | readonly authenticated?: boolean; 18 | readonly csrf: string; 19 | readonly location: string; 20 | readonly origin?: string; 21 | readonly isPlatformAdmin: boolean; 22 | } 23 | 24 | export interface IContext { 25 | readonly app: IAppConfig; 26 | readonly routePartOf: RouteActiveChecker; 27 | readonly linkTo: RouteLinker; 28 | readonly absoluteLinkTo: RouteLinker; 29 | readonly log: Logger; 30 | readonly token: Token; 31 | readonly session: CookieSessionInterfaces.CookieSessionObject; 32 | readonly viewContext: IViewContext; 33 | } 34 | 35 | export function initContext( 36 | req: any, 37 | router: Router, 38 | route: Route, 39 | config: IAppConfig, 40 | ): IContext { 41 | const origin = req.token && req.token.origin; 42 | const isPlatformAdmin = 43 | req.token && req.token.hasAdminScopes && req.token.hasAdminScopes(); 44 | 45 | const absoluteLinkTo = (name: string, params: IParameters = {}): string => { 46 | return router 47 | .findByName(name) 48 | .composeAbsoluteURL(config.domainName, params); 49 | }; 50 | const linkTo = (name: string, params: IParameters = {}): string => { 51 | return router.findByName(name).composeURL(params); 52 | }; 53 | const routePartOf = (name: string): boolean => 54 | route.definition.name === name || route.definition.name.startsWith(name); 55 | 56 | return { 57 | absoluteLinkTo, 58 | app: config, 59 | linkTo, 60 | log: req.log, 61 | routePartOf, 62 | session: req.session, 63 | token: req.token, 64 | viewContext: { 65 | authenticated: !!req.user, 66 | csrf: req.csrfToken(), 67 | isPlatformAdmin, 68 | location: config.location, 69 | origin, 70 | }, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/components/service-metrics/utils.ts: -------------------------------------------------------------------------------- 1 | import { bisectLeft } from 'd3-array'; 2 | import { differenceInSeconds, isBefore, sub } from 'date-fns'; 3 | 4 | export function getPeriod(rangeStart: Date, rangeStop: Date): number { 5 | const secondsDifference = differenceInSeconds(rangeStop, rangeStart); 6 | const desiredNumberOfPoints = 300; 7 | const idealPeriod = secondsDifference / desiredNumberOfPoints; 8 | 9 | // https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricStat.html 10 | 11 | // The granularity, in seconds, of the returned data points. For metrics with 12 | // regular resolution, a period can be as short as one minute (60 seconds) and 13 | // must be a multiple of 60. For high-resolution metrics that are collected at 14 | // intervals of less than one minute, the period can be 1, 5, 10, 30, 60, or 15 | // any multiple of 60. High-resolution metrics are those metrics stored by a 16 | // PutMetricData call that includes a StorageResolution of 1 second. 17 | // 18 | // If the StartTime parameter specifies a time stamp that is greater than 3 19 | // hours ago, you must specify the period as follows or no data points in that 20 | // time range is returned: 21 | // 22 | // Start time between 3 hours and 15 days ago - Use a multiple of 60 seconds (1 minute). 23 | // Start time between 15 and 63 days ago - Use a multiple of 300 seconds (5 minutes). 24 | // Start time greater than 63 days ago - Use a multiple of 3600 seconds (1 hour). 25 | 26 | const threeHoursAgo = sub(new Date(), { hours: 3 }); 27 | const fifteenDaysAgo = sub(new Date(), { days: 15 }); 28 | const sixtyThreeDaysAgo = sub(new Date(), { days: 63 }); 29 | 30 | if (isBefore(threeHoursAgo, rangeStart)) { 31 | const allowedPeriods = [1, 5, 10, 30, 60]; 32 | if (idealPeriod <= 60) { 33 | return allowedPeriods[bisectLeft(allowedPeriods, idealPeriod)]; 34 | } 35 | 36 | return Math.ceil(idealPeriod / 60) * 60; 37 | } 38 | if (isBefore(fifteenDaysAgo, rangeStart)) { 39 | return Math.ceil(idealPeriod / 60) * 60; 40 | } 41 | if (isBefore(sixtyThreeDaysAgo, rangeStart)) { 42 | return Math.ceil(idealPeriod / 300) * 300; 43 | } 44 | 45 | return Math.ceil(idealPeriod / 3600) * 3600; 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/cf/test-data/org.ts: -------------------------------------------------------------------------------- 1 | import { IOrganization, IV3OrganizationResource } from '../types'; 2 | 3 | export const orgName = 'the-system_domain-org-name'; 4 | export const orgGUID = 'a7aff246-5f5b-4cf8-87d8-f316053e4a20'; 5 | export const orgQuotaGUID = 'ORG-QUOTA-GUID'; 6 | 7 | export const org = (): IOrganization => 8 | JSON.parse(`{ 9 | "metadata": { 10 | "guid": "${orgGUID}", 11 | "url": "/v2/organizations/${orgGUID}", 12 | "created_at": "2016-06-08T16:41:33Z", 13 | "updated_at": "2016-06-08T16:41:26Z" 14 | }, 15 | "entity": { 16 | "name": "${orgName}", 17 | "billing_enabled": false, 18 | "quota_definition_guid": "${orgQuotaGUID}", 19 | "status": "active", 20 | "quota_definition_url": "/v2/quota_definitions/${orgQuotaGUID}", 21 | "spaces_url": "/v2/organizations/${orgGUID}/spaces", 22 | "domains_url": "/v2/organizations/${orgGUID}/domains", 23 | "private_domains_url": "/v2/organizations/${orgGUID}/private_domains", 24 | "users_url": "/v2/organizations/${orgGUID}/users", 25 | "managers_url": "/v2/organizations/${orgGUID}/managers", 26 | "billing_managers_url": "/v2/organizations/${orgGUID}/billing_managers", 27 | "auditors_url": "/v2/organizations/${orgGUID}/auditors", 28 | "app_events_url": "/v2/organizations/${orgGUID}/app_events", 29 | "space_quota_definitions_url": "/v2/organizations/${orgGUID}/space_quota_definitions" 30 | } 31 | }`); 32 | 33 | export const v3Org = (): IV3OrganizationResource => 34 | JSON.parse(`{ 35 | "guid": "${orgGUID}", 36 | "created_at": "2016-06-08T16:41:33Z", 37 | "updated_at": "2016-06-08T16:41:26Z", 38 | "name": "${orgName}", 39 | "suspended": false, 40 | "relationships": { 41 | "quota": { 42 | "data": { 43 | "guid": "${orgQuotaGUID}" 44 | } 45 | } 46 | }, 47 | "links": { 48 | "self": { 49 | "href": "/v3/organizations/${orgGUID}" 50 | }, 51 | "domains": { 52 | "href": "/v3/organizations/${orgGUID}/domains" 53 | }, 54 | "default_domain": { 55 | "href": "/v3/organizations/${orgGUID}/domains/default" 56 | } 57 | }, 58 | "metadata": { 59 | "labels": {}, 60 | "annotations": { 61 | "owner": "some-owner" 62 | } 63 | } 64 | }`); 65 | -------------------------------------------------------------------------------- /src/lib/metric-data-getters/elasticache.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { ElastiCacheMetricDataGetter } from './elasticache'; 4 | 5 | describe('ElastiCache', () => { 6 | describe('getElasticacheReplicationGroupId', () => { 7 | it('should hash Elasticache service guids with FNV-1a and prepend cf-', () => { 8 | const dg = new ElastiCacheMetricDataGetter({} as any); 9 | 10 | // These golden values were generated using `paas-elasticache-broker/cache-cluster-name-generator` 11 | expect(dg.getElasticacheReplicationGroupId('guid-1')).toBe( 12 | 'cf-ux4xkdjccy5em', 13 | ); 14 | expect(dg.getElasticacheReplicationGroupId('guid-2')).toBe( 15 | 'cf-ux4xidjccy4jg', 16 | ); 17 | expect(dg.getElasticacheReplicationGroupId('guid-3')).toBe( 18 | 'cf-ux4xgdjccy3oa', 19 | ); 20 | expect(dg.getElasticacheReplicationGroupId('guid-4')).toBe( 21 | 'cf-ux4xudjcczbmk', 22 | ); 23 | expect(dg.getElasticacheReplicationGroupId('guid-5')).toBe( 24 | 'cf-ux4xsdjcczare', 25 | ); 26 | expect(dg.getElasticacheReplicationGroupId('guid-6')).toBe( 27 | 'cf-ux4xqdjccy7v6', 28 | ); 29 | expect(dg.getElasticacheReplicationGroupId('guid-7')).toBe( 30 | 'cf-ux4xodjccy62y', 31 | ); 32 | expect(dg.getElasticacheReplicationGroupId('guid-8')).toBe( 33 | 'cf-ux4x4djcczezc', 34 | ); 35 | expect(dg.getElasticacheReplicationGroupId('guid-9')).toBe( 36 | 'cf-ux4x2djcczd54', 37 | ); 38 | expect(dg.getElasticacheReplicationGroupId('guid-10')).toBe( 39 | 'cf-duofwuhlyvlie', 40 | ); 41 | expect(dg.getElasticacheReplicationGroupId('guid-11')).toBe( 42 | 'cf-duofyuhlyvmdk', 43 | ); 44 | expect(dg.getElasticacheReplicationGroupId('guid-12')).toBe( 45 | 'cf-duofsuhlyvjry', 46 | ); 47 | expect(dg.getElasticacheReplicationGroupId('guid-13')).toBe( 48 | 'cf-duofuuhlyvkm6', 49 | ); 50 | expect(dg.getElasticacheReplicationGroupId('guid-14')).toBe( 51 | 'cf-duofouhlyvh3m', 52 | ); 53 | expect(dg.getElasticacheReplicationGroupId('guid-15')).toBe( 54 | 'cf-duofquhlyviws', 55 | ); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/layouts/partials.test.tsx: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | import { describe, expect, it } from "vitest"; 3 | import { render, screen } from '@testing-library/react'; 4 | import React from 'react'; 5 | 6 | import { Footer, Header, Main } from './partials'; 7 | 8 | describe(Header, () => { 9 | it('should successfully display the header element', () => { 10 | const { container } = render( 11 |
    , 12 | ); 13 | expect(container.querySelector('header nav li .app-region-tag')).toHaveTextContent('London'); 14 | expect(container.querySelector('header nav li.admin')).toBeFalsy(); 15 | // The following is for simply compliance with the design system. 16 | // https://github.com/alphagov/govuk-frontend/issues/1688 17 | expect( 18 | container 19 | .querySelector('header.govuk-header svg')) 20 | .toHaveClass('govuk-header__logotype') 21 | }); 22 | 23 | it('should show the admin link if platform admin', () => { 24 | const { container } = render( 25 |
    , 26 | ); 27 | expect(container.querySelector('header nav li .app-region-tag')).toHaveTextContent('Ireland'); 28 | expect(container.querySelector('header nav li.admin')).toBeTruthy(); 29 | }); 30 | }); 31 | 32 | describe(Main, () => { 33 | it('should successfully display the main element', () => { 34 | render( 35 |
    36 |

    This is a test

    37 |
    , 38 | ); 39 | expect(screen.getByRole('main')).toHaveTextContent('This is a test'); 40 | }); 41 | }); 42 | 43 | describe(Footer, () => { 44 | it('should successfully display the footer element', () => { 45 | const { container } = render(