Skip to content

Commit

Permalink
PP-12395: Create API key functionality
Browse files Browse the repository at this point in the history
Creating an API key consists of two pages: the "API key name" and "New API key" pages.

Validation of the name/description and cypress test will be done in the next PRs.
  • Loading branch information
oswaldquek committed Dec 20, 2024
1 parent c9643af commit c2a8d0c
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 4 deletions.
6 changes: 6 additions & 0 deletions app/assets/sass/components/api-keys.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@
border-top: 1px solid $govuk-border-colour;
padding: govuk-spacing(6) 0;
}

.key-prefix {
background-color: #f3f2f1;
color: #f47738;
padding: 5px;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
const { response } = require('@utils/response')
const apiKeysService = require('@services/api-keys.service')
const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for')
const paths = require('@root/paths')

async function get (req, res) {
const activeKeys = await apiKeysService.getActiveKeys(req.account.id)
return response(req, res, 'simplified-account/settings/api-keys/index', {
accountType: req.account.type,
activeKeys,
createApiKeyLink: '#',
createApiKeyLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.apiKeys.create, req.service.externalId, req.account.type),
showRevokedKeysLink: '#'
})
}

module.exports = { get }
module.exports.createApiKey = require('./create/create-api-key.controller')
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const { response } = require('@utils/response')
const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for')
const paths = require('@root/paths')
const { TOKEN_SOURCE, createApiKey } = require('@services/api-keys.service')

async function get (req, res) {
return response(req, res, 'simplified-account/settings/api-keys/api-key-name', {
backLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.apiKeys.index, req.service.externalId, req.account.type)
})
}

async function post (req, res) {
const description = req.body.description // TODO validate length - deal with this in another PR
const newApiKey = await createApiKey(req.account, description, req.user.email, TOKEN_SOURCE.API)
response(req, res, 'simplified-account/settings/api-keys/new-api-key-details', {
description,
apiKey: newApiKey,
backToApiKeysLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.apiKeys.index, req.service.externalId, req.account.type)
})
}

module.exports = { get, post }
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const ControllerTestBuilder = require('@test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class')
const sinon = require('sinon')
const { expect } = require('chai')
const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for')
const paths = require('@root/paths')
const { TOKEN_SOURCE } = require('@services/api-keys.service')

const ACCOUNT_TYPE = 'live'
const SERVICE_ID = 'service-id-123abc'
const mockResponse = sinon.spy()
const newApiKey = 'api_test_123' // pragma: allowlist secret
const apiKeysService = {
createApiKey: sinon.stub().resolves(newApiKey),
TOKEN_SOURCE
}

const {
req,
res,
nextRequest,
call
} = new ControllerTestBuilder('@controllers/simplified-account/settings/api-keys/create/create-api-key.controller')
.withServiceExternalId(SERVICE_ID)
.withAccountType(ACCOUNT_TYPE)
.withStubs({
'@utils/response': { response: mockResponse },
'@services/api-keys.service': apiKeysService
})
.build()

describe('Controller: settings/create-api-key', () => {
describe('get', () => {
before(() => {
call('get')
})

it('should call the response method', () => {
expect(mockResponse).to.have.been.calledOnce // eslint-disable-line
})

it('should pass req, res and template path to the response method', () => {
expect(mockResponse).to.have.been.calledWith(req, res, 'simplified-account/settings/api-keys/api-key-name')
})

it('should pass context data to the response method', () => {
expect(mockResponse.args[0][3]).to.have.property('backLink').to.equal(
formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.apiKeys.index, SERVICE_ID, ACCOUNT_TYPE))
})
})

describe('post', () => {
before(() => {
nextRequest({
body: {
description: 'a test api key'
},
user: {
email: 'potter@wand.com'
}
})
call('post')
})

it('should submit values to the api keys service', () => {
expect(apiKeysService.createApiKey).to.have.been.calledWith(
sinon.match.any,
'a test api key',
'potter@wand.com',
TOKEN_SOURCE.API
)
})

it('should call the response method', () => {
expect(mockResponse).to.have.been.calledOnce // eslint-disable-line
})

it('should pass context data to the response method', () => {
expect(mockResponse.args[0][2]).to.equal('simplified-account/settings/api-keys/new-api-key-details')
expect(mockResponse.args[0][3]).to.have.property('backToApiKeysLink').to.equal(
formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.apiKeys.index, SERVICE_ID, ACCOUNT_TYPE))
expect(mockResponse.args[0][3]).to.have.property('apiKey').to.equal(newApiKey)
expect(mockResponse.args[0][3]).to.have.property('description').to.equal('a test api key')
})
})
})
3 changes: 2 additions & 1 deletion app/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ module.exports = {
index: '/settings/card-types'
},
apiKeys: {
index: '/settings/api-keys'
index: '/settings/api-keys',
create: '/settings/api-keys/create'
},
webhooks: {
index: '/settings/webhooks'
Expand Down
29 changes: 28 additions & 1 deletion app/services/api-keys.service.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
const publicAuthClient = require('@services/clients/public-auth.client')
const { Token } = require('@models/Token.class')

const TOKEN_SOURCE = {
API: 'API',
PRODUCTS: 'PRODUCTS'
}

/**
* @param {GatewayAccount} gatewayAccount
* @param {string} description
* @param {string} email - the user email
* @param {'API' | 'PRODUCTS'} tokenSource - The type of the token (must match one of TOKEN_TYPE values).
* @returns {string} the new api key
*/
const createApiKey = async (gatewayAccount, description, email, tokenSource) => {
const payload = {
description,
account_id: gatewayAccount.id,
created_by: email,
type: tokenSource,
token_type: 'CARD',
token_account_type: gatewayAccount.type
}
const response = await publicAuthClient.createTokenForAccount({ payload })
return response.token
}

/**
* Gets the list of active api keys for a gateway account
* @param {string} gatewayAccountId
Expand All @@ -14,5 +39,7 @@ const getActiveKeys = async (gatewayAccountId) => {
}

module.exports = {
getActiveKeys
createApiKey,
getActiveKeys,
TOKEN_SOURCE
}
2 changes: 2 additions & 0 deletions app/simplified-account-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ simplifiedAccount.get(paths.simplifiedAccount.settings.cardTypes.index, permissi

// api keys
simplifiedAccount.get(paths.simplifiedAccount.settings.apiKeys.index, permission('tokens-active:read'), serviceSettingsController.apiKeys.get)
simplifiedAccount.get(paths.simplifiedAccount.settings.apiKeys.create, permission('tokens:create'), serviceSettingsController.apiKeys.createApiKey.get)
simplifiedAccount.post(paths.simplifiedAccount.settings.apiKeys.create, permission('tokens:create'), serviceSettingsController.apiKeys.createApiKey.post)

// stripe details
const stripeDetailsPath = paths.simplifiedAccount.settings.stripeDetails
Expand Down
3 changes: 2 additions & 1 deletion app/utils/simplified-account/settings/service-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ module.exports = (account, service, currentUrl, permissions) => {
id: 'api-keys',
name: 'API keys',
path: paths.simplifiedAccount.settings.apiKeys.index,
permission: 'tokens_active_read'
permission: 'tokens_active_read',
alwaysViewable: true
})
.add({
id: 'webhooks',
Expand Down
34 changes: 34 additions & 0 deletions app/views/simplified-account/settings/api-keys/api-key-name.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% extends "../settings-layout.njk" %}

{% block settingsPageTitle %}
API key name
{% endblock %}

{% block settingsContent %}

<h1 class="govuk-heading-l page-title">API key name</h1>

<form id="api-key-name" method="post" novalidate>
<input id="csrf" name="csrfToken" type="hidden" value="{{ csrf }}"/>

{{ govukInput({
id: "description",
name: "description",
type: "text",
attributes: {
maxlength: "100"
},
classes: "govuk-input--width-40",
label: {
text: "Add a description for the key"
},
hint: {
text: "For example, “John Smith’s API key”"
}
}) }}

{{ govukButton({
text: "Continue"
}) }}
</form>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{% extends "../settings-layout.njk" %}

{% block settingsPageTitle %}
New API key
{% endblock %}

{% block settingsContent %}

{{ govukBackLink({
text: "Back to API keys",
href: backToApiKeysLink
}) }}

<h1 class="govuk-heading-l page-title">New API key</h1>

{% set html %}
<p class="govuk-body">
Copy your key to somewhere safe.</br></br>You won’t be able to see it again once you leave this page.
</p>
{% endset %}

{{ govukInsetText({
html: html
}) }}

<p class="govuk-body">
You must copy the whole key, including the <span class="key-prefix">api_test_</span> or <span class="key-prefix">api_live_</span> prefix.
</p>

{{ govukWarningText(
{
text: 'API security',
iconFallbackText: 'Warning'
}
) }}

<ul class="govuk-list govuk-list--bullet">
<li>You must store your API keys securely.</li>
<li>You must not share this key in publicly accessible documents or repositories.</li>
<li>You must not share it with anyone who should not be using the GOV.UK Pay API directly.</li>
</ul>

<p class="govuk-body">
More about <a class="govuk-link" href="https://docs.payments.service.gov.uk/security/#securing-your-developer-keys">
securing your API keys</a>.
</p>

<h2 class="govuk-heading-m">
{{ description }}
</h2>
<div>
<span id="apiKey" class="code copy-this-api-key govuk-!-margin-top-6">{{ apiKey }}</span>
</div>

{{ govukButton({
text: "Copy API key to clipboard",
classes: "govuk-button--secondary govuk-!-margin-top-6",
attributes: {
id: "generate-button",
"data-copy-text": true,
"data-target": "copy-this-api-key",
"data-success": "API key has been copied",
"data-notification-target": "copy-this-api-key-notification"
}
}) }}

<div class="copy-this-api-key-notification govuk-visually-hidden" aria-live="assertive"></div>

{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ describe('Settings - API keys', () => {
.within(() => {
cy.get('.govuk-summary-card__title').should('contain', token.description)

cy.get('.govuk-summary-card__action').eq(0)
.within(() => {
cy.get('a')
.should('contain.text', 'Change name')
.and('have.attr', 'href', '#')
})

cy.get('.govuk-summary-card__action').eq(1)
.within(() => {
cy.get('a')
.should('contain.text', 'Revoke')
.and('have.attr', 'href', '#')
})

cy.get('.govuk-summary-list__row').eq(0)
.within(() => {
cy.get('.govuk-summary-list__key').should('contain', 'Created by')
Expand Down

0 comments on commit c2a8d0c

Please sign in to comment.