Skip to content

Commit

Permalink
feat(EMS-2386): Add Google tag manager script (#1486)
Browse files Browse the repository at this point in the history
* feat(EMS-2386): analytics - google tag manager

* chore(docs): add documentation to e2e analytics functions

* chore(docs): add documentation to e2e analytics functions

* chore(tests): update analytics e2e test description

* feat(EMS-2386): added SHA512 for Google scripts

* docs(EMS-2386): sri hash generation documentation

---------

Co-authored-by: Abhi Markan <abhi.markan@ukexportfinance.gov.uk>
  • Loading branch information
ttbarnes and Abhi Markan authored Dec 12, 2023
1 parent 004bf17 commit 370adb5
Show file tree
Hide file tree
Showing 29 changed files with 119 additions and 27 deletions.
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ APIM_MDM_VALUE=

# GOOGLE
GOOGLE_ANALYTICS_ID=
GOOGLE_TAG_MANAGER_ID=

# GOV NOTIFY
GOV_NOTIFY_API_KEY=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ jobs:
TLS_KEY='${{ secrets.TLS_KEY }}' \
SESSION_SECRET='${{ secrets.SESSION_SECRET }}' \
GOOGLE_ANALYTICS_ID='${{ secrets.GOOGLE_ANALYTICS_ID }}' \
GOOGLE_TAG_MANAGER_ID='${{ secrets.GOOGLE_TAG_MANAGER_ID }}' \
API_KEY='${{ secrets.API_KEY }}' \
APIM_MDM_URL='${{ secrets.APIM_MDM_URL }}' \
APIM_MDM_KEY='${{ secrets.APIM_MDM_KEY }}' \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ jobs:
TLS_KEY='${{ secrets.TLS_KEY }}' \
SESSION_SECRET='${{ secrets.SESSION_SECRET }}' \
GOOGLE_ANALYTICS_ID='${{ secrets.GOOGLE_ANALYTICS_ID }}' \
GOOGLE_TAG_MANAGER_ID='${{ secrets.GOOGLE_TAG_MANAGER_ID }}' \
API_URL='https://${{ env.API_URL }}/api/graphql' \
API_KEY='${{ secrets.API_KEY }}' \
APIM_MDM_URL='${{ secrets.APIM_MDM_URL }}' \
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ env:
APIM_MDM_KEY: ${{ secrets.APIM_MDM_KEY }}
APIM_MDM_VALUE: ${{ secrets.APIM_MDM_VALUE }}
GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID }}
GOOGLE_TAG_MANAGER_ID: ${{ secrets.GOOGLE_TAG_MANAGER_ID }}
GOV_NOTIFY_API_KEY: ${{ secrets.GOV_NOTIFY_API_KEY }}
COMPANIES_HOUSE_API_URL: ${{ secrets.COMPANIES_HOUSE_API_URL }}
COMPANIES_HOUSE_API_KEY: ${{ secrets.COMPANIES_HOUSE_API_KEY }}
Expand Down Expand Up @@ -92,6 +93,7 @@ jobs:
env:
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID }}
GOOGLE_TAG_MANAGER_ID: ${{ secrets.GOOGLE_TAG_MANAGER_ID }}
GOV_NOTIFY_EMAIL_RECIPIENT_1: ${{ secrets.GOV_NOTIFY_EMAIL_RECIPIENT_1 }}
GOV_NOTIFY_EMAIL_RECIPIENT_2: ${{ secrets.GOV_NOTIFY_EMAIL_RECIPIENT_2 }}
MOCK_ACCOUNT_PASSWORD: ${{ secrets.MOCK_ACCOUNT_PASSWORD }}
Expand Down Expand Up @@ -143,7 +145,6 @@ jobs:
APIM_MDM_URL: ${{ secrets.APIM_MDM_URL }}
APIM_MDM_KEY: ${{ secrets.APIM_MDM_KEY }}
APIM_MDM_VALUE: ${{ secrets.APIM_MDM_VALUE }}
GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID }}
GOV_NOTIFY_API_KEY: ${{ secrets.GOV_NOTIFY_API_KEY }}
COMPANIES_HOUSE_API_URL: ${{ secrets.COMPANIES_HOUSE_API_URL }}
COMPANIES_HOUSE_API_KEY: ${{ secrets.COMPANIES_HOUSE_API_KEY }}
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,14 @@ This approach simplifies the handling of different versions of the application a
These are the key aspects of the UK Export Finance EXIP service codebase and development process. If you have specific questions or need more details about any particular aspect, feel free to ask!

---

## Sub-resource integrity [(SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)
JavaScript files are protected by SRI security feature which allows the browser to verify the authenticity of the JavaScript files in use.
We use `SHA512` hashing algrothim for all our JavaScript files.

To calculate file hash use the following Bash command with reference to the file in question (Webpack compiled JS file).

```bash
cat FILENAME.js | openssl dgst -sha512 -binary | openssl base64 -A
```

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ services:
APIM_MDM_KEY:
APIM_MDM_VALUE:
GOOGLE_ANALYTICS_ID:
GOOGLE_TAG_MANAGER_ID:
GOV_NOTIFY_EMAIL_RECIPIENT_1:
GOV_NOTIFY_EMAIL_RECIPIENT_2:
UNDERWRITING_TEAM_EMAIL:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,60 @@
/**
* getDomScriptSrc
* Get a particular script's SRC attribute from the DOM.
* @param {Array}: Script SRCs
* @param {String}: SRC name
* @returns {String} Script SRC
*/
const getDomScriptSrc = (srcs, name) => {
const scriptSrc = srcs.filter((src) => src.includes(name));

return scriptSrc;
};

/**
* getDomScriptAttribute
* Get a particular script attribute from the DOM.
* @param {Array}: domElements
* @param {String}: attribute
* @returns {Array} DOM elemnets that have the provided attribute.
*/
const getDomScriptAttribute = (domElements, attribute) =>
Array.from(domElements)
.map((script) => script.getAttribute(attribute))
.filter((s) => s);

/**
* checkAnalyticsScriptsAreRendered
* Check that GA and GTM scripts are rendered
*/
const checkAnalyticsScriptsAreRendered = () => {
cy.document().then((document) => {
const domElements = document.querySelectorAll('script');

const scripts = Array.from(domElements)
const scriptSrcs = Array.from(domElements)
.map((script) => script.getAttribute('src'))
.filter((s) => s);

// Ensure GA script exists
const gaSrc = getDomScriptSrc(scriptSrcs, 'googleAnalytics.js');

expect(gaSrc.length).to.equal(1);

// Ensure GA script has a data campaign
const dataCampaignGA = getDomScriptAttribute(domElements, 'data-campaign-ga');

expect(dataCampaignGA.length).to.equal(1);
expect(dataCampaignGA[0].includes('GA')).to.equal(true);

// Ensure GTM script exists
const googleTagManagerScript = scripts.filter((script) => script.includes('googletagmanager') && script.includes('G-'));
expect(googleTagManagerScript.length).to.equal(1);
const gtmSrc = getDomScriptSrc(scriptSrcs, 'googleTagManager.js');
expect(gtmSrc.length).to.equal(1);

// Ensure GA script exists
const googleAnalyticsScript = scripts.filter((script) => script.includes('googleAnalytics.js'));
expect(googleAnalyticsScript.length).to.equal(1);
// Ensure GTM script has a data campaign
const dataCampaignGTM = getDomScriptAttribute(domElements, 'data-campaign-gtm');

// Ensure GA campaign attribute exists
const dataCampaign = Array.from(domElements)
.map((script) => script.getAttribute('data-campaign'))
.filter((s) => s);
expect(dataCampaign.length).to.equal(1);
expect(dataCampaignGTM.length).to.equal(1);
expect(dataCampaignGTM[0].includes('GTM')).to.equal(true);
});
};

Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/insurance/cypress/e2e/journeys/cookies.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ context('Cookies page - Insurance', () => {
cy.checkCookiesConsentBannerDoesNotExist();
});

it('should render a google tag manager script and data layer script', () => {
it('should render a google analytics and google tag manager scripts', () => {
cy.checkAnalyticsScriptsAreRendered();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ context('Cookies consent - accept', () => {
cy.checkText(partials.cookieBanner.hideButton(), COOKIES_CONSENT.HIDE_BUTTON);
});

it('should render a google tag manager script and data layer script', () => {
it('should render a google analytics and google tag manager scripts', () => {
cy.checkAnalyticsScriptsAreRendered();
});

Expand Down Expand Up @@ -113,7 +113,7 @@ context('Cookies consent - accept', () => {
partials.cookieBanner.accepted.copy().should('not.exist');
});

it('should render a google tag manager script and data layer script', () => {
it('should render a google analytics and google tag manager scripts', () => {
cy.checkAnalyticsScriptsAreRendered();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ context('Cookies consent - change via banner and cookies page', () => {
cy.checkCookiesConsentBannerDoesNotExist();
});

it('should render a google tag manager script and data layer script', () => {
it('should render a google analytics and google tag manager scripts', () => {
cy.checkAnalyticsScriptsAreRendered();
});

Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/quote/cypress/e2e/journeys/quote/cookies.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ context('Cookies page - Quote', () => {
cy.checkCookiesConsentBannerDoesNotExist();
});

it('should render a google tag manager script and data layer script', () => {
it('should render a google analytics and google tag manager scripts', () => {
cy.checkAnalyticsScriptsAreRendered();
});

Expand Down
1 change: 1 addition & 0 deletions src/ui/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ APIM_MDM_VALUE=

# GOOGLE
GOOGLE_ANALYTICS_ID=
GOOGLE_TAG_MANAGER_ID=

# GOV NOTIFY
GOV_NOTIFY_EMAIL_RECIPIENT_1=
Expand Down
2 changes: 1 addition & 1 deletion src/ui/public/css/styles.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/ui/public/js/googleAnalytics.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/ui/public/js/googleAnalytics.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/ui/public/js/googleTagManager.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/ui/public/js/googleTagManager.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/ui/scripts/google-analytics.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const id = document.querySelector('script[data-campaign]').getAttribute('data-campaign');
const id = document.querySelector('script[data-campaign-ga]').getAttribute('data-campaign-ga');

console.info('Intialising Google Analytics with ID:', id);

window.dataLayer = window.dataLayer || [];
Expand Down
9 changes: 9 additions & 0 deletions src/ui/scripts/google-tag-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const id = document.querySelector('script[data-campaign-gtm]').getAttribute('data-campaign-gtm');

console.info('Intialising Google Tag Manager with ID:', id);

(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window, document, 'script', 'dataLayer', id);
3 changes: 2 additions & 1 deletion src/ui/server/constants/integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export const INTEGRITY = {
GOVUK: 'sha512-EdyNYxz0W40gqYt1R48s+ye7mW0p7SwUTnkyUcMJ0eGRsucWodRmVpC5+QAtUBZSYeOMeWumRLnqJZ2zzkDRqg==',
FORM: 'sha512-uyTmQxJNGlnB71lDj1CCOUhKJLch3djXW1Av0BOT4g7K7riFXsDbyUmhNkhUrtpbdFICkh3lFxmviTlOKNTh7w==',
COOKIES: 'sha512-mdioDv38Cx1nkVD8oPXoJxvie9wf/339EE8g8+jlsVaDrJeJiwUkYBwweF3fqQMZfZzO8ivTXAEyithgBzeEpw==',
GA: 'sha512-EiZ7aXrH6dgeg6B1KIkNnkTdg/12270bhonXlSgS8gItLqhW/OIAmAkgPbPSDc4moyO0Eioo78TI5m5cgG69AQ==',
GA: 'sha512-GYc1xJpYfgNCIBH1NDfs7GhzBdepN1aPqueETCi5ZFIaompI6v++beAnZgbxpSyKr2GqO/oUpm86HZyoe3tD1Q==',
GA_TAG_MANAGER: 'sha512-dAe3s9iSAGIFpxcjHcJRUNomEj+fQnwCG3Yd3xj1uPhFZakBF1tZzeO3ktWRhSkCoMD6rbORULsnbOPr4Vaekg==',
};
6 changes: 6 additions & 0 deletions src/ui/server/middleware/cookies-consent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ describe('middleware/cookies-consent', () => {

expect(res.locals.googleAnalyticsId).toEqual(process.env.GOOGLE_ANALYTICS_ID);
});

it('should add process.env.GOOGLE_TAG_MANAGER_ID to res.locals.googleTagManagerId', () => {
cookiesConsent(req, res, next);

expect(res.locals.googleTagManagerId).toEqual(process.env.GOOGLE_TAG_MANAGER_ID);
});
});

describe("when req.cookies['__Secure-optionalCookies'] is `true`", () => {
Expand Down
1 change: 1 addition & 0 deletions src/ui/server/middleware/cookies-consent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const cookiesConsent = (req: Request, res: Response, next: () => void) =>
if (req.cookies.optionalCookies === 'true' || req.cookies[COOKIE.NAME.OPTION] === 'true') {
res.locals.cookieConsent = true;
res.locals.googleAnalyticsId = process.env.GOOGLE_ANALYTICS_ID;
res.locals.googleTagManagerId = process.env.GOOGLE_TAG_MANAGER_ID;
} else {
res.locals.cookieConsent = false;
}
Expand Down
5 changes: 4 additions & 1 deletion src/ui/server/middleware/integrity/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Request, Response } from '../../../types';
import { integrity } from '.';
import { INTEGRITY } from '../../constants';

const { JS, GOVUK, FORM, COOKIES, GA, MOJ, ACCESSIBILITY } = INTEGRITY;
const { JS, GOVUK, FORM, COOKIES, GA, GA_TAG_MANAGER, MOJ, ACCESSIBILITY } = INTEGRITY;
const req: Request = mockReq();
const res: Response = mockRes();
const next = mockNext;
Expand All @@ -18,6 +18,7 @@ describe('middleware/integrity', () => {
FORM,
COOKIES,
GA,
GA_TAG_MANAGER,
MOJ,
ACCESSIBILITY,
});
Expand All @@ -33,6 +34,7 @@ describe('middleware/integrity', () => {
expect(res.locals.SRI?.FORM).toBeDefined();
expect(res.locals.SRI?.COOKIES).toBeDefined();
expect(res.locals.SRI?.GA).toBeDefined();
expect(res.locals.SRI?.GA_TAG_MANAGER).toBeDefined();
});

it('should have all SRI calculated using SHA512', () => {
Expand All @@ -45,6 +47,7 @@ describe('middleware/integrity', () => {
expect(res.locals.SRI?.FORM).toContain('sha512');
expect(res.locals.SRI?.COOKIES).toContain('sha512');
expect(res.locals.SRI?.GA).toContain('sha512');
expect(res.locals.SRI?.GA_TAG_MANAGER).toContain('sha512');
});

it('should call next()', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/ui/server/middleware/integrity/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Request, Response } from '../../../types';
import { INTEGRITY } from '../../constants';

const { JS, GOVUK, FORM, COOKIES, GA, MOJ, ACCESSIBILITY } = INTEGRITY;
const { JS, GOVUK, FORM, COOKIES, GA, GA_TAG_MANAGER, MOJ, ACCESSIBILITY } = INTEGRITY;

/**
* Middleware function that adds integrity values to the res.locals.SRI object.
Expand All @@ -20,6 +20,7 @@ export const integrity = (req: Request, res: Response, next: () => void) => {
FORM,
COOKIES,
GA,
GA_TAG_MANAGER,
};

res.locals.SRI = SRI;
Expand Down
3 changes: 2 additions & 1 deletion src/ui/server/test-mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import mockContact from './mock-contact';
import { PRODUCT } from '../content-strings';
import { INTEGRITY } from '../constants';

const { JS, GOVUK, FORM, COOKIES, GA, MOJ, ACCESSIBILITY } = INTEGRITY;
const { JS, GOVUK, FORM, COOKIES, GA, GA_TAG_MANAGER, MOJ, ACCESSIBILITY } = INTEGRITY;

const mockReq = () => {
const req = {
Expand Down Expand Up @@ -80,6 +80,7 @@ const mockRes = () => {
FORM,
COOKIES,
GA,
GA_TAG_MANAGER,
},
};

Expand Down
5 changes: 5 additions & 0 deletions src/ui/styles/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ $govuk-new-link-styles: true;
.ukef-application-submitted {
max-width: 360px;
}

.ukef-hide-iframe {
display: none;
visibility: hidden;
}
9 changes: 7 additions & 2 deletions src/ui/templates/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@
<script src="/assets/js/cookies.js" type="text/javascript" integrity="{{ SRI.COOKIES }}" crossorigin="anonymous"></script>

{% if cookieConsent %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ googleAnalyticsId }}"></script>
<script src="/assets/js/googleAnalytics.js" type="text/javascript" data-campaign="{{ googleAnalyticsId }}" integrity="{{ SRI.GA }}" crossorigin="anonymous"></script>
<script src="/assets/js/googleAnalytics.js" type="text/javascript" data-campaign-ga="{{ googleAnalyticsId }}" integrity="{{ SRI.GA }}" crossorigin="anonymous"></script>

<script src="/assets/js/googleTagManager.js" type="text/javascript" data-campaign-gtm="{{ googleTagManagerId }}" integrity="{{ SRI.GA_TAG_MANAGER }}" crossorigin="anonymous"></script>

<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id={{ googleTagManagerId }}" height="0" width="0" class="ukef-hide-iframe"></iframe>
</noscript>
{% endif %}

</body>
Expand Down
2 changes: 2 additions & 0 deletions src/ui/types/express/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface SRI {
COOKIES: string;
ACCESSIBILITY: string;
GA: string;
GA_TAG_MANAGER: string;
}

interface MetaData {
Expand All @@ -46,6 +47,7 @@ interface ResponseLocals {
cookieConsentDecision?: boolean;
cookieConsentNewDecision?: boolean;
googleAnalyticsId?: string;
googleTagManagerId?: string;
meta: MetaData;
SRI: SRI;
}
Expand Down
1 change: 1 addition & 0 deletions src/ui/webpack.common.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
cookies: './scripts/cookies.js',
formSubmission: './scripts/form-submission.js',
googleAnalytics: './scripts/google-analytics.js',
googleTagManager: './scripts/google-tag-manager.js',
},
output: {
path: path.join(__dirname, 'public/js'),
Expand Down

0 comments on commit 370adb5

Please sign in to comment.