diff --git a/package-lock.json b/package-lock.json index d549942f..1a03b333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@golevelup/ts-jest": "^0.4.0", "@types/bunyan": "^1.8.9", "@types/bunyan-format": "^0.2.6", "@types/compression": "^1.7.3", @@ -896,6 +897,12 @@ "npm": ">=6.14.13" } }, + "node_modules/@golevelup/ts-jest": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.4.0.tgz", + "integrity": "sha512-ehgllV/xU8PC+yVyEUtTzhiSQKsr7k5Jz74B6dtCaVJz7/Vo7JiaACsCLvD7/iATlJUAEqvBson0OHewD3JDzQ==", + "dev": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "dev": true, @@ -11023,6 +11030,12 @@ "@faker-js/faker": { "version": "8.1.0" }, + "@golevelup/ts-jest": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.4.0.tgz", + "integrity": "sha512-ehgllV/xU8PC+yVyEUtTzhiSQKsr7k5Jz74B6dtCaVJz7/Vo7JiaACsCLvD7/iATlJUAEqvBson0OHewD3JDzQ==", + "dev": true + }, "@humanwhocodes/config-array": { "version": "0.11.11", "dev": true, diff --git a/package.json b/package.json index afdf8a4e..e4ec819a 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@golevelup/ts-jest": "^0.4.0", "@types/bunyan": "^1.8.9", "@types/bunyan-format": "^0.2.6", "@types/compression": "^1.7.3", diff --git a/server/controllers/baseClientController.test.ts b/server/controllers/baseClientController.test.ts new file mode 100644 index 00000000..64d3dab9 --- /dev/null +++ b/server/controllers/baseClientController.test.ts @@ -0,0 +1,82 @@ +import type { DeepMocked } from '@golevelup/ts-jest' +import { createMock } from '@golevelup/ts-jest' +import type { NextFunction, Request, Response } from 'express' + +import BaseClientController from './baseClientController' +import { BaseClientService } from '../services' +import { baseClientFactory, clientFactory } from '../testutils/factories' +import listBaseClientsPresenter from '../views/presenters/listBaseClientsPresenter' +import createUserToken from '../testutils/createUserToken' +import viewBaseClientPresenter from '../views/presenters/viewBaseClientPresenter' +import nunjucksUtils from '../views/helpers/nunjucksUtils' + +describe('BaseClientController', () => { + const token = createUserToken(['ADMIN']) + let request: DeepMocked + let response: DeepMocked + const next: DeepMocked = createMock({}) + const baseClientService = createMock({}) + let baseClientController: BaseClientController + + beforeEach(() => { + request = createMock() + response = createMock({ + locals: { + clientToken: 'CLIENT_TOKEN', + user: { + token, + authSource: 'auth', + }, + }, + render: jest.fn(), + redirect: jest.fn(), + }) + + baseClientController = new BaseClientController(baseClientService) + }) + + describe('displayBaseClients', () => { + it('renders the list index template with a list of base clients', async () => { + // GIVEN a list of base clients + const baseClients = baseClientFactory.buildList(3) + baseClientService.listBaseClients.mockResolvedValue(baseClients) + + // WHEN the index page is requested + await baseClientController.displayBaseClients()(request, response, next) + + // THEN the list of base clients is rendered + const presenter = listBaseClientsPresenter(baseClients) + expect(response.render).toHaveBeenCalledWith('pages/base-clients.njk', { + presenter, + }) + + // AND the list of base clients is retrieved from the base client service + expect(baseClientService.listBaseClients).toHaveBeenCalledWith(token) + }) + }) + + describe('view base client', () => { + it('renders the main view of a base clients', async () => { + // GIVEN a list of base clients + const baseClient = baseClientFactory.build() + const clients = clientFactory.buildList(3) + baseClientService.getBaseClient.mockResolvedValue(baseClient) + baseClientService.listClientInstances.mockResolvedValue(clients) + + // WHEN the index page is requested + request = createMock({ params: { baseClientId: baseClient.baseClientId } }) + await baseClientController.displayBaseClient()(request, response, next) + + // THEN the view base client page is rendered + const presenter = viewBaseClientPresenter(baseClient, clients) + expect(response.render).toHaveBeenCalledWith('pages/base-client.njk', { + baseClient, + presenter, + ...nunjucksUtils, + }) + + // AND the base client is retrieved from the base client service + expect(baseClientService.getBaseClient).toHaveBeenCalledWith(token, baseClient.baseClientId) + }) + }) +}) diff --git a/server/controllers/baseClientController.ts b/server/controllers/baseClientController.ts index 1199d37c..53265641 100644 --- a/server/controllers/baseClientController.ts +++ b/server/controllers/baseClientController.ts @@ -1,6 +1,8 @@ import { RequestHandler } from 'express' import { BaseClientService } from '../services' import listBaseClientsPresenter from '../views/presenters/listBaseClientsPresenter' +import viewBaseClientPresenter from '../views/presenters/viewBaseClientPresenter' +import nunjucksUtils from '../views/helpers/nunjucksUtils' export default class BaseClientController { constructor(private readonly baseClientService: BaseClientService) {} @@ -17,4 +19,20 @@ export default class BaseClientController { }) } } + + public displayBaseClient(): RequestHandler { + return async (req, res) => { + const userToken = res.locals.user.token + const { baseClientId } = req.params + const baseClient = await this.baseClientService.getBaseClient(userToken, baseClientId) + const clients = await this.baseClientService.listClientInstances(userToken, baseClient) + + const presenter = viewBaseClientPresenter(baseClient, clients) + res.render('pages/base-client.njk', { + baseClient, + presenter, + ...nunjucksUtils, + }) + } + } } diff --git a/server/routes/baseClientRouter.ts b/server/routes/baseClientRouter.ts index 997b8168..8b1aee43 100644 --- a/server/routes/baseClientRouter.ts +++ b/server/routes/baseClientRouter.ts @@ -21,6 +21,7 @@ export default function baseClientRouter(services: Services): Router { const baseClientController = new BaseClientController(services.baseClientService) get('/', baseClientController.displayBaseClients()) + get('/base-clients/:baseClientId', baseClientController.displayBaseClient()) return router } diff --git a/server/testutils/createUserToken.ts b/server/testutils/createUserToken.ts new file mode 100644 index 00000000..1b4bdbbc --- /dev/null +++ b/server/testutils/createUserToken.ts @@ -0,0 +1,14 @@ +import jwt from 'jsonwebtoken' + +export default function createUserToken(authorities: string[]) { + const payload = { + user_name: 'user1', + scope: ['read', 'write'], + auth_source: 'nomis', + authorities, + jti: 'a610a10-cca6-41db-985f-e87efb303aaf', + client_id: 'clientid', + } + + return jwt.sign(payload, 'secret', { expiresIn: '1h' }) +} diff --git a/server/views/helpers/nunjucksUtils.test.ts b/server/views/helpers/nunjucksUtils.test.ts new file mode 100644 index 00000000..6d116725 --- /dev/null +++ b/server/views/helpers/nunjucksUtils.test.ts @@ -0,0 +1,47 @@ +import nunjucksUtils from './nunjucksUtils' + +describe('nunjucksUtils', () => { + describe('capitalCase', () => { + it.each([ + [null, null, ''], + ['an empty string', '', ''], + ['lower case', 'robert', 'Robert'], + ['upper case', 'ROBERT', 'Robert'], + ['mixed case', 'RoBErT', 'Robert'], + ['multiple words', 'RobeRT SMiTH', 'Robert Smith'], + ['leading spaces', ' RobeRT', ' Robert'], + ['trailing spaces', 'RobeRT ', 'Robert '], + ])('handles %s: %s -> %s', (_inputType: string | null, input: string | null, expectedOutput: string) => { + expect(nunjucksUtils.capitalCase(input)).toEqual(expectedOutput) + }) + }) + + describe('sentenceCase', () => { + it.each([ + ['an empty string', '', ''], + ['a single lower case letter', 'a', 'A'], + ['a single upper case letter', 'A', 'A'], + ['a lower case word', 'rosa', 'Rosa'], + ['an upper case word', 'ROSA', 'Rosa'], + ['a proper case word', 'Rosa', 'Rosa'], + ['a mixed case word', 'RoSa', 'Rosa'], + ['multiple words', 'the fish swam', 'The fish swam'], + ])('handles %s: %s -> %s', (_inputType: string, input: string, expectedOutput: string) => { + expect(nunjucksUtils.sentenceCase(input)).toEqual(expectedOutput) + }) + }) + + describe('capitalize', () => { + it.each([ + ['an empty string', '', ''], + ['a single lower case letter', 'a', 'A'], + ['a single upper case letter', 'A', 'A'], + ['a lower case word', 'rosa', 'Rosa'], + ['an upper case word', 'ROSA', 'Rosa'], + ['a proper case word', 'Rosa', 'Rosa'], + ['a mixed case word', 'RoSa', 'Rosa'], + ])('handles %s: %s -> %s', (_inputType: string, input: string, expectedOutput: string) => { + expect(nunjucksUtils.capitalize(input)).toEqual(expectedOutput) + }) + }) +}) diff --git a/server/views/helpers/nunjucksUtils.ts b/server/views/helpers/nunjucksUtils.ts new file mode 100644 index 00000000..764a63a8 --- /dev/null +++ b/server/views/helpers/nunjucksUtils.ts @@ -0,0 +1,39 @@ +const isBlank = (str: string): boolean => { + return !str || /^\s*$/.test(str) +} + +const capitalCase = (sentence: string | null): string => { + return sentence === null || isBlank(sentence) ? '' : sentence.split(' ').map(capitalize).join(' ') +} + +const sentenceCase = (sentence: string | null): string => { + if (sentence === null || isBlank(sentence)) { + return '' + } + + const words = sentence.split(' ') + if (words.length === 1) { + return capitalize(words[0]) + } + return `${capitalize(words[0])} ${words.slice(1).join(' ')}` +} + +const capitalize = (word: string): string => { + return word.length >= 1 ? word[0].toUpperCase() + word.toLowerCase().slice(1) : word +} + +const toLinesHtml = (str?: string[]): string | null => { + return str.join('
') +} + +const toLines = (str?: string[]): string | null => { + return str.join('/n') +} + +export default { + toLinesHtml, + toLines, + sentenceCase, + capitalize, + capitalCase, +} diff --git a/server/views/pages/base-client.njk b/server/views/pages/base-client.njk new file mode 100644 index 00000000..7aea5c86 --- /dev/null +++ b/server/views/pages/base-client.njk @@ -0,0 +1,391 @@ +{% extends "../partials/layout.njk" %} + +{% set pageTitle = applicationName + " - Home" %} +{% set mainClasses = "app-container govuk-body" %} + +{% set pageName="Home" %} +{% set bodyClasses = "extra-wide" %} + +{% block header %} + {% include "partials/header.njk" %} +{% endblock %} + +{%- from "moj/components/header/macro.njk" import mojHeader -%} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/select/macro.njk" import govukSelect %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/label/macro.njk" import govukLabel %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} +{% from "govuk/components/textarea/macro.njk" import govukTextarea %} +{%- from "govuk/components/table/macro.njk" import govukTable -%} + +{% block content %} + {{ govukBackLink({ + text: "Back", + href: "/" + }) }} + +
+
+ +

+ Client: {{ baseClient.baseClientId }} +

+ + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Client ID" + },{ + text: "Created" + },{ + text: "Last accessed" + },{ + text: "" + } + ], + rows: presenter.clientsTable + }) }} + +
+
+ {{ govukButton({ + text: "Add new client" + }) }} +
+
+ +
+
+

Base client details

+
+ +
+ + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Base client" + },{ + text: baseClient.baseClientId + }], + rows: [ + [ + { + text: "Client type" + },{ + text: capitalize(baseClient.clientType) + } + ], + [ + { + text: "Access token validity" + },{ + text: baseClient.accessTokenValidity + } + ],[ + { + text: "Approved scopes" + },{ + html: toLinesHtml(baseClient.scopes) + } + ] + ] + }) }} + + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Audit trail" + },{ + text: "" + }], + rows: [ + [{ + text: "Details" + },{ + text: baseClient.audit + }] + ] + }) }} + + + + {% if baseClient.grantType == "client_credentials" %} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Grant details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Grant type" + },{ + text: "Client credentials" + } + ],[ + { + text: "Roles" + },{ + html: toLinesHtml(baseClient.clientCredentials.authorities) + } + ],[ + { + text: "Database username" + },{ + text: baseClient.clientCredentials.databaseUserName + } + ] + ] + }) }} + {% endif %} + + {% if baseClient.grantType == "authorization_code" %} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Grant details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Grant type" + },{ + text: "Authorization code" + } + ],[ + { + text: "Registered redirect URIs" + },{ + text: toLinesHtml(baseClient.authorisationCode.registeredRedirectURIs) + } + ],[ + { + text: "JWT Fields Configuration" + },{ + text: baseClient.authorisationCode.jwtFields + } + ],[ + { + text: "Azure Ad login flow" + },{ + text: presenter.skipToAzureField + } + ] + ] + }) }} + {% endif %} + + + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Config" + },{ + text: "" + }], + rows: [ + [ + { + text: "Allow client to expire" + },{ + text: presenter.expiry + } + ],[ + { + text: "Allowed IPs" + },{ + text: toLinesHtml(baseClient.config.allowedIPs) + } + ] + ] + }) }} + + + {% if baseClient.grantType == "authorization_code" %} +
+
+

Service details

+
+ +
+ + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Service details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Name" + },{ + text: baseClient.serviceDetails.serviceName + } + ],[ + { + text: "Description" + },{ + text: baseClient.serviceDetails.serviceDescription + } + ],[ + { + text: "Authorised roles" + },{ + text: baseClient.serviceDetails.serviceAuthorisedRoles.join('
') + } + ],[ + { + text: "URL" + },{ + text: baseClient.serviceDetails.serviceURL + } + ],[ + { + text: "Contact URL/email" + },{ + text: baseClient.serviceDetails.contactUsURL + } + ],[ + { + text: "Status" + },{ + text: presenter.serviceEnabledLabel + } + ] + ] + }) }} + {% endif %} + +
+
+

Deployment details

+
+ +
+ + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Contact" + },{ + text: "" + }], + rows: [ + [ + { + text: "Team" + },{ + text: baseClient.deployment.team + } + ], + [ + { + text: "Team contact" + },{ + text: baseClient.deployment.teamContact + } + ], + [ + { + text: "Team slack" + },{ + text: baseClient.deployment.teamSlack + } + ] + ] + }) }} + + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Platform" + },{ + text: "" + }], + rows: [ + [ + { + text: "Hosting" + },{ + text: baseClient.deployment.hosting + } + ], + [ + { + text: "Namespace" + },{ + text: baseClient.deployment.namespace + } + ], + [ + { + text: "Deployment" + },{ + text: baseClient.deployment.deployment + } + ], + [ + { + text: "Secret name" + },{ + text: baseClient.deployment.secretName + } + ], + [ + { + text: "Client id key" + },{ + text: baseClient.deployment.clientIdKey + } + ], + [ + { + text: "Secret key" + },{ + text: baseClient.deployment.secretKey + } + ], + [ + { + text: "Deployment info" + },{ + text: baseClient.deployment.deploymentInfo + } + ] + + ] + }) }} + +
+ +
+ +{% endblock %} diff --git a/server/views/pages/error.njk b/server/views/pages/error.njk index db29f7e9..f1b5921d 100755 --- a/server/views/pages/error.njk +++ b/server/views/pages/error.njk @@ -3,10 +3,39 @@ {% set pageTitle = applicationName + " - Error" %} {% set mainClasses = "app-container govuk-body" %} + {% block content %} +
+
+ +

Sorry, there is a problem with the service

+ +

Try again later.

+
+
-

{{ message }}

-

{{ status }}

-
{{ stack }}
+ {% if message or stack %} +
+ + + Error details + + + +
+ {% if message %} +

{{ message }}

+ {% endif %} +

Status: {{ status }}

+ {% if stack %} +
+            {{- stack -}}
+          
+ {% endif %} +
+
+ {% endif %} {% endblock %} + + diff --git a/server/views/presenters/listBaseClientsPresenter.test.ts b/server/views/presenters/listBaseClientsPresenter.test.ts new file mode 100644 index 00000000..80048eee --- /dev/null +++ b/server/views/presenters/listBaseClientsPresenter.test.ts @@ -0,0 +1,60 @@ +import { BaseClient } from '../../interfaces/baseClientApi/baseClient' +import { baseClientFactory } from '../../testutils/factories' +import listBaseClientsPresenter from './listBaseClientsPresenter' + +let baseClients: BaseClient[] + +describe('listBaseClientsPresenter', () => { + beforeEach(() => { + // Given some base clients + const baseClientA = baseClientFactory.build({ + baseClientId: 'baseClientIdA', + count: 1, + clientCredentials: { authorities: ['ONE', 'TWO'] }, + }) + const baseClientB = baseClientFactory.build({ + baseClientId: 'baseClientIdB', + count: 2, + clientCredentials: { authorities: ['ALPHA'] }, + }) + baseClients = [baseClientA, baseClientB] + }) + + it('contains a constant for table head', () => { + // When we map to a presenter + const presenter = listBaseClientsPresenter(baseClients) + + // Then it contains a table head constant + expect(presenter.tableHead).not.toBeNull() + }) + + describe('tableHeadRows', () => { + it('maps a link to the view page in the first column', () => { + // When we map to a presenter + const presenter = listBaseClientsPresenter(baseClients) + + const expected = [ + 'baseClientIdA', + 'baseClientIdB', + ] + const actual = presenter.tableRows.map(row => row[0].html) + expect(actual).toEqual(expected) + }) + + it('maps moj-badge for count to the second column if count > 1', () => { + // When we map to a presenter + const presenter = listBaseClientsPresenter(baseClients) + const expected = ['', '2'] + const actual = presenter.tableRows.map(row => row[1].html) + expect(actual).toEqual(expected) + }) + + it('maps client credentials authorities to the sixth column joining using html breaks', () => { + // When we map to a presenter + const presenter = listBaseClientsPresenter(baseClients) + const expected = ['ONE
TWO', 'ALPHA'] + const actual = presenter.tableRows.map(row => row[5].html) + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/server/views/presenters/listBaseClientsPresenter.ts b/server/views/presenters/listBaseClientsPresenter.ts index 8f7a2c28..8685ff61 100644 --- a/server/views/presenters/listBaseClientsPresenter.ts +++ b/server/views/presenters/listBaseClientsPresenter.ts @@ -72,7 +72,7 @@ const indexTableHead = () => { const indexTableRows = (data: BaseClient[]) => { return data.map(item => [ { - html: `${item.baseClientId}`, + html: `${item.baseClientId}`, }, { html: item.count > 1 ? `${item.count}` : '', diff --git a/server/views/presenters/viewBaseClientPresenter.test.ts b/server/views/presenters/viewBaseClientPresenter.test.ts new file mode 100644 index 00000000..e173eb73 --- /dev/null +++ b/server/views/presenters/viewBaseClientPresenter.test.ts @@ -0,0 +1,92 @@ +import { baseClientFactory, clientFactory } from '../../testutils/factories' +import viewBaseClientPresenter from './viewBaseClientPresenter' + +describe('viewBaseClientPresenter', () => { + describe('clientsTable', () => { + it('formats dates as DD/MM/YYYY', () => { + // Given some base clients with some clients + const baseClient = baseClientFactory.build() + const clients = [ + clientFactory.build({ + clientId: 'clientIdA', + created: new Date('2020-01-01'), + accessed: new Date('2020-01-02'), + }), + clientFactory.build({ + clientId: 'clientIdB', + created: new Date('2020-02-01'), + accessed: new Date('2020-02-02'), + }), + ] + + // When we map to a presenter + const presenter = viewBaseClientPresenter(baseClient, clients) + + // Then the dates are formatted as DD/MM/YYYY + const expectedCreated = ['01/01/2020', '01/02/2020'] + const actualCreated = presenter.clientsTable.map(row => row[1].html) + expect(expectedCreated).toEqual(actualCreated) + + const expectedAccessed = ['02/01/2020', '02/02/2020'] + const actualAccessed = presenter.clientsTable.map(row => row[2].html) + expect(expectedAccessed).toEqual(actualAccessed) + }) + + it('links to a delete page for each client', () => { + // Given some base clients with some clients + const baseClient = baseClientFactory.build({ baseClientId: 'baseClientId' }) + const clients = [clientFactory.build({ clientId: 'clientIdA' }), clientFactory.build({ clientId: 'clientIdB' })] + + // When we map to a presenter + const presenter = viewBaseClientPresenter(baseClient, clients) + + // Then the dates are formatted as DD/MM/YYYY + const expected = [ + 'delete', + 'delete', + ] + const actual = presenter.clientsTable.map(row => row[3].html) + expect(expected).toEqual(actual) + }) + }) + + describe('expiry', () => { + it('expiryDate is null returns "No"', () => { + // Given a base client with no expiry date + const baseClient = baseClientFactory.build({ config: { expiryDate: null } }) + const clients = clientFactory.buildList(2) + + // When we map to a presenter + const presenter = viewBaseClientPresenter(baseClient, clients) + + // Then the expiry is "No" + expect(presenter.expiry).toEqual('No') + }) + + it('expiryDate is in the past returns "Yes - days remaining 0"', () => { + // Given a base client with expiry date in the past + const baseClient = baseClientFactory.build({ config: { expiryDate: '2020-01-01' } }) + const clients = clientFactory.buildList(2) + + // When we map to a presenter + const presenter = viewBaseClientPresenter(baseClient, clients) + + // Then the expiry is "No" + expect(presenter.expiry).toEqual('Yes - days remaining 0') + }) + + it('expiryDate is in two days returns "Yes - days remaining 2"', () => { + // Given a base client with expiry date in two days + const expiryDate = new Date() + expiryDate.setDate(expiryDate.getDate() + 2) + const baseClient = baseClientFactory.build({ config: { expiryDate: expiryDate.toISOString() } }) + const clients = clientFactory.buildList(2) + + // When we map to a presenter + const presenter = viewBaseClientPresenter(baseClient, clients) + + // Then the expiry is "No" + expect(presenter.expiry).toEqual('Yes - days remaining 2') + }) + }) +}) diff --git a/server/views/presenters/viewBaseClientPresenter.ts b/server/views/presenters/viewBaseClientPresenter.ts new file mode 100644 index 00000000..f29ad0ff --- /dev/null +++ b/server/views/presenters/viewBaseClientPresenter.ts @@ -0,0 +1,39 @@ +import { BaseClient } from '../../interfaces/baseClientApi/baseClient' +import { Client } from '../../interfaces/baseClientApi/client' + +export default (baseClient: BaseClient, clients: Client[]) => { + return { + clientsTable: clients.map(item => [ + { + text: item.clientId, + }, + { + html: item.created.toLocaleDateString('en-GB'), + }, + { + html: item.accessed.toLocaleDateString('en-GB'), + }, + { + html: `delete`, + }, + ]), + expiry: baseClient.config.expiryDate + ? `Yes - days remaining ${calculateDaysRemaining(baseClient.config.expiryDate)}` + : 'No', + skipToAzureField: '', + serviceEnabledCode: '', + } +} + +const calculateDaysRemaining = (expiryDate?: string) => { + if (!expiryDate) { + return 0 + } + const now = new Date() + const expiry = new Date(expiryDate) + const diff = expiry.getTime() - now.getTime() + if (diff < 0) { + return 0 + } + return Math.ceil(diff / (1000 * 60 * 60 * 24)) +}