Skip to content

Commit

Permalink
Haar 1905 create new base client journey (#29)
Browse files Browse the repository at this point in the history
* add tests for list base clients page

* add view base client page

* Fix errors in template

* basic view for create new base client screen

* post new base client with error loop

* added controller tests

* add tests for presenter

* add test for expiry today

* correct test comments and refactor time functions
  • Loading branch information
thomasridd committed Oct 30, 2023
1 parent 6d13bd5 commit 9880e45
Show file tree
Hide file tree
Showing 21 changed files with 856 additions and 41 deletions.
13 changes: 13 additions & 0 deletions assets/js/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// For guidance on how to add JavaScript see:
// https://prototype-kit.service.gov.uk/docs/adding-css-javascript-and-images
//
function showDiv(divId, element, val) {
document.getElementById(divId).style.display = element.value === val ? 'block' : 'none'
}

document.addEventListener('DOMContentLoaded', function (event) {
const select = document.getElementById('access-token-validity')
select.onchange = () => showDiv('custom-access-token-validity-element', select, 'custom')
showDiv('custom-access-token-validity-element', select, 'custom')
})
109 changes: 107 additions & 2 deletions server/controllers/baseClientController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { NextFunction, Request, Response } from 'express'

import BaseClientController from './baseClientController'
import { BaseClientService } from '../services'
import { baseClientFactory, clientFactory } from '../testutils/factories'
import { baseClientFactory, clientFactory, clientSecretsFactory } from '../testutils/factories'
import listBaseClientsPresenter from '../views/presenters/listBaseClientsPresenter'
import createUserToken from '../testutils/createUserToken'
import viewBaseClientPresenter from '../views/presenters/viewBaseClientPresenter'
Expand All @@ -15,7 +15,7 @@ describe('BaseClientController', () => {
let request: DeepMocked<Request>
let response: DeepMocked<Response>
const next: DeepMocked<NextFunction> = createMock<NextFunction>({})
const baseClientService = createMock<BaseClientService>({})
let baseClientService = createMock<BaseClientService>({})
let baseClientController: BaseClientController

beforeEach(() => {
Expand All @@ -32,6 +32,7 @@ describe('BaseClientController', () => {
redirect: jest.fn(),
})

baseClientService = createMock<BaseClientService>({})
baseClientController = new BaseClientController(baseClientService)
})

Expand Down Expand Up @@ -79,4 +80,108 @@ describe('BaseClientController', () => {
expect(baseClientService.getBaseClient).toHaveBeenCalledWith(token, baseClient.baseClientId)
})
})

describe('create base client', () => {
describe('journey', () => {
it('if grant is not specified as parameter renders the select grant screen', async () => {
// GIVEN a request without grant parameter
request = createMock<Request>({ query: {} })

// WHEN the create base client page is requested
await baseClientController.displayNewBaseClient()(request, response, next)

// THEN the choose client type page is rendered
expect(response.render).toHaveBeenCalledWith('pages/new-base-client-grant.njk')
})

it('if grant is specified with client-credentials renders the details screen', async () => {
// GIVEN a request with grant="client-credentials" parameter
request = createMock<Request>({ query: { grant: 'client-credentials' } })

// WHEN the create base client page is requested
await baseClientController.displayNewBaseClient()(request, response, next)

// THEN the enter client details page is rendered with client credentials selected
expect(response.render).toHaveBeenCalledWith('pages/new-base-client-details.njk', {
grant: 'client-credentials',
...nunjucksUtils,
})
})

it('if grant is specified with authorization-code renders the details screen', async () => {
// GIVEN a request with grant="client-credentials" parameter
request = createMock<Request>({ query: { grant: 'authorization-code' } })

// WHEN the create base client page is requested
await baseClientController.displayNewBaseClient()(request, response, next)

// THEN the enter client details page is rendered with authorisation code selected
expect(response.render).toHaveBeenCalledWith('pages/new-base-client-details.njk', {
grant: 'authorization-code',
...nunjucksUtils,
})
})

it('if grant is specified as random parameter renders the select grant screen', async () => {
// GIVEN a request without grant parameter
request = createMock<Request>({ query: { grant: 'xxxyyy' } })

// WHEN the create base client page is requested
await baseClientController.displayNewBaseClient()(request, response, next)

// THEN the choose client type page is rendered
expect(response.render).toHaveBeenCalledWith('pages/new-base-client-grant.njk')
})

it('if validation fails because no id specified renders the details screen with error message', async () => {
// GIVEN no id is specified
request = createMock<Request>({ body: {} })

// WHEN it is posted
await baseClientController.createBaseClient()(request, response, next)

// THEN the new base client page is rendered with error message
const expectedError = 'This field is required'
expect(response.render).toHaveBeenCalledWith(
'pages/new-base-client-details.njk',
expect.objectContaining({ errorMessage: { text: expectedError } }),
)
})

it('if validation fails because id exists then render the details screen with error message', async () => {
// GIVEN base client with id already exists
baseClientService.getBaseClient.mockResolvedValue(baseClientFactory.build())
baseClientService.listClientInstances.mockResolvedValue(clientFactory.buildList(3))
request = createMock<Request>({ body: { baseClientId: 'abcd' } })

// WHEN it is posted
await baseClientController.createBaseClient()(request, response, next)

// THEN the new base client page is rendered with error message
const expectedError = 'A base client with this ID already exists'
expect(response.render).toHaveBeenCalledWith(
'pages/new-base-client-details.njk',
expect.objectContaining({ errorMessage: { text: expectedError } }),
)
})

it('if success renders the secrets screen', async () => {
// GIVEN the service returns success and a set of secrets
baseClientService.getBaseClient.mockRejectedValue({ status: 404 })
request = createMock<Request>({ body: { baseClientId: 'abcd' } })

const secrets = clientSecretsFactory.build()
baseClientService.addBaseClient.mockResolvedValue(secrets)

// WHEN it is posted
await baseClientController.createBaseClient()(request, response, next)

// THEN the new base client success page is rendered
expect(response.render).toHaveBeenCalledWith(
'pages/new-base-client-success.njk',
expect.objectContaining({ secrets }),
)
})
})
})
})
59 changes: 59 additions & 0 deletions server/controllers/baseClientController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { BaseClientService } from '../services'
import listBaseClientsPresenter from '../views/presenters/listBaseClientsPresenter'
import viewBaseClientPresenter from '../views/presenters/viewBaseClientPresenter'
import nunjucksUtils from '../views/helpers/nunjucksUtils'
import { mapCreateBaseClientForm } from '../mappers'
import { BaseClient } from '../interfaces/baseClientApi/baseClient'
import editBaseClientPresenter from '../views/presenters/editBaseClientPresenter'

export default class BaseClientController {
constructor(private readonly baseClientService: BaseClientService) {}
Expand Down Expand Up @@ -35,4 +38,60 @@ export default class BaseClientController {
})
}
}

public displayNewBaseClient(): RequestHandler {
return async (req, res) => {
const { grant } = req.query
if (!(grant === 'client-credentials' || grant === 'authorization-code')) {
res.render('pages/new-base-client-grant.njk')
return
}
res.render('pages/new-base-client-details.njk', { grant, ...nunjucksUtils })
}
}

public createBaseClient(): RequestHandler {
return async (req, res, next) => {
const userToken = res.locals.user.token
const baseClient = mapCreateBaseClientForm(req)

// Simple validation
const error = await this.validateCreateBaseClient(userToken, baseClient)
if (error) {
res.render('pages/new-base-client-details.njk', {
errorMessage: { text: error },
grant: baseClient.grantType,
baseClient,
presenter: editBaseClientPresenter(baseClient),
...nunjucksUtils,
})
return
}

// Create base client
const secrets = await this.baseClientService.addBaseClient(userToken, baseClient)

// Display success page
res.render('pages/new-base-client-success.njk', {
title: `Client has been added`,
baseClientId: baseClient.baseClientId,
secrets,
})
}
}

async validateCreateBaseClient(userToken: string, baseClient: BaseClient) {
// if baseClient.baseClientId is null or empty string, throw error
if (!baseClient.baseClientId) {
return 'This field is required'
}

// if baseClient.baseClientId is not unique, throw error
try {
await this.baseClientService.getBaseClient(userToken, baseClient.baseClientId)
return 'A base client with this ID already exists'
} catch (e) {
return ''
}
}
}
12 changes: 5 additions & 7 deletions server/mappers/baseClientApi/addBaseClient.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { BaseClient } from '../../interfaces/baseClientApi/baseClient'
import { AddBaseClientRequest } from '../../interfaces/baseClientApi/baseClientRequestBody'
import { daysRemaining } from '../../utils/utils'

export default (baseClient: BaseClient): AddBaseClientRequest => {
// valid days is calculated from expiry date
const expiryDate = baseClient.config.expiryDate ? new Date(baseClient.config.expiryDate) : null

return {
clientId: baseClient.baseClientId,
scopes: ['read', 'write'],
authorities: ['ROLE_CLIENT_CREDENTIALS'],
ips: [],
scopes: baseClient.scopes,
authorities: baseClient.clientCredentials.authorities,
ips: baseClient.config.allowedIPs,
jiraNumber: baseClient.audit,
databaseUserName: baseClient.clientCredentials.databaseUserName,
validDays: expiryDate ? Math.ceil(expiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) : null,
validDays: baseClient.config.expiryDate ? daysRemaining(baseClient.config.expiryDate) : null,
accessTokenValidityMinutes: baseClient.accessTokenValidity ? baseClient.accessTokenValidity / 60 : null,
}
}
6 changes: 2 additions & 4 deletions server/mappers/baseClientApi/updateBaseClient.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { BaseClient } from '../../interfaces/baseClientApi/baseClient'
import { UpdateBaseClientRequest } from '../../interfaces/baseClientApi/baseClientRequestBody'
import { daysRemaining } from '../../utils/utils'

export default (baseClient: BaseClient): UpdateBaseClientRequest => {
// valid days is calculated from expiry date
const expiryDate = baseClient.config.expiryDate ? new Date(baseClient.config.expiryDate) : null

return {
scopes: ['read', 'write'],
authorities: ['ROLE_CLIENT_CREDENTIALS'],
ips: [],
jiraNumber: baseClient.audit,
databaseUserName: baseClient.clientCredentials.databaseUserName,
validDays: expiryDate ? Math.ceil(expiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) : null,
validDays: baseClient.config.expiryDate ? daysRemaining(baseClient.config.expiryDate) : null,
accessTokenValidityMinutes: baseClient.accessTokenValidity ? baseClient.accessTokenValidity / 60 : null,
}
}
73 changes: 73 additions & 0 deletions server/mappers/forms/mapCreateBaseClientForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Request } from 'express'
import { BaseClient } from '../../interfaces/baseClientApi/baseClient'
import { multiSeparatorSplit } from '../../utils/utils'

function getDayOfExpiry(daysRemaining: string) {
const daysRemainingInt = parseIntWithDefault(daysRemaining, 0)
const timeOfExpiry: Date = new Date(Date.now() + daysRemainingInt * 24 * 60 * 60 * 1000)
return timeOfExpiry.toISOString().split('T')[0]
}

function getAccessTokenValiditySeconds(accessTokenValidity: string, customAccessTokenValidity?: string) {
if (accessTokenValidity === 'custom' && customAccessTokenValidity) {
return parseIntWithDefault(customAccessTokenValidity, 0)
}
return parseIntWithDefault(accessTokenValidity, 0)
}

function parseIntWithDefault(value: string, defaultValue: number) {
const parsed = parseInt(value, 10)
return Number.isNaN(parsed) ? defaultValue : parsed
}

export default (request: Request): BaseClient => {
// valid days is calculated from expiry date
const data = request.body

const { accessTokenValidity, customAccessTokenValidity } = data
const accessTokenValiditySeconds = getAccessTokenValiditySeconds(accessTokenValidity, customAccessTokenValidity)
const dayOfExpiry = data.expiry ? getDayOfExpiry(data.expiryDays) : null

return {
baseClientId: data.baseClientId,
clientType: data.clientType,
accessTokenValidity: accessTokenValiditySeconds,
scopes: multiSeparatorSplit(data.approvedScopes, [',', '\r\n', '\n']),
audit: data.audit,
count: 1,
grantType: data.grant,
clientCredentials: {
authorities: multiSeparatorSplit(data.authorities, [',', '\r\n', '\n']),
databaseUserName: data.databaseUserName,
},
authorisationCode: {
registeredRedirectURIs: [],
jwtFields: '',
azureAdLoginFlow: false,
},
service: {
serviceName: '',
description: '',
authorisedRoles: [],
url: '',
contact: '',
status: '',
},
deployment: {
team: '',
teamContact: '',
teamSlack: '',
hosting: '',
namespace: '',
deployment: '',
secretName: '',
clientIdKey: '',
secretKey: '',
deploymentInfo: '',
},
config: {
allowedIPs: multiSeparatorSplit(data.allowedIPs, [',', '\r\n', '\n']),
expiryDate: dayOfExpiry,
},
}
}
2 changes: 2 additions & 0 deletions server/mappers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import mapClientSecrets from './baseClientApi/clientSecrets'
import mapUpdateBaseClientRequest from './baseClientApi/updateBaseClient'
import mapUpdateBaseClientDeploymentRequest from './baseClientApi/updateBaseClientDeployment'
import mapListClientInstancesResponse from './baseClientApi/listClientInstances'
import mapCreateBaseClientForm from './forms/mapCreateBaseClientForm'

export {
mapGetBaseClientResponse,
Expand All @@ -16,4 +17,5 @@ export {
mapUpdateBaseClientRequest,
mapUpdateBaseClientDeploymentRequest,
mapListClientInstancesResponse,
mapCreateBaseClientForm,
}
13 changes: 7 additions & 6 deletions server/routes/baseClientRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ export default function baseClientRouter(services: Services): Router {
handlers.map(handler => asyncMiddleware(handler)),
)

// const post = (path: string | string[], ...handlers: RequestHandler[]) =>
// router.post(
// path,
// handlers.map(handler => asyncMiddleware(handler)),
// )
const post = (path: string | string[], ...handlers: RequestHandler[]) =>
router.post(
path,
handlers.map(handler => asyncMiddleware(handler)),
)

const baseClientController = new BaseClientController(services.baseClientService)

get('/', baseClientController.displayBaseClients())
get('/base-clients/new', baseClientController.displayNewBaseClient())
get('/base-clients/:baseClientId', baseClientController.displayBaseClient())

post('/base-clients/new', baseClientController.createBaseClient())
return router
}
2 changes: 2 additions & 0 deletions server/testutils/factories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import clientSecretsResponseFactory from './responses/clientSecretsResponse'
import updateBaseClientRequestFactory from './requests/updateBaseClientRequest'
import updateBaseClientDeploymentFactory from './requests/updateBaseClientDeploymentRequest'
import listClientInstancesResponseFactory from './responses/listClientInstancesResponse'
import clientSecretsFactory from './secrets'

export {
baseClientFactory,
clientFactory,
clientSecretsFactory,
listBaseClientResponseFactory,
getBaseClientResponseFactory,
clientSecretsResponseFactory,
Expand Down
10 changes: 10 additions & 0 deletions server/testutils/factories/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Factory } from 'fishery'
import { faker } from '@faker-js/faker'
import { ClientSecrets } from '../../interfaces/baseClientApi/baseClient'

export default Factory.define<ClientSecrets>(() => ({
clientId: faker.string.uuid(),
clientSecret: faker.string.uuid(),
base64ClientId: faker.string.uuid(),
base64ClientSecret: faker.string.uuid(),
}))

0 comments on commit 9880e45

Please sign in to comment.