diff --git a/src/applications/burials-ez/config/submit.js b/src/applications/burials-ez/config/submit.js index 0f8c8fdddc7e..9f6a79c7fc62 100644 --- a/src/applications/burials-ez/config/submit.js +++ b/src/applications/burials-ez/config/submit.js @@ -2,6 +2,7 @@ import environment from 'platform/utilities/environment'; import { apiRequest } from 'platform/utilities/api'; import { transformForSubmit } from 'platform/forms-system/src/js/helpers'; import { format } from 'date-fns-tz'; +import { ensureValidCSRFToken } from '../utils/ensureValidCSRFToken'; export function replacer(key, value) { // clean up empty objects, which we have no reason to send @@ -28,9 +29,8 @@ export function transform(formConfig, form) { }); } -export function submit(form, formConfig) { +export async function submit(form, formConfig) { const headers = { 'Content-Type': 'application/json' }; - const body = transform(formConfig, form); const apiRequestOptions = { headers, @@ -56,10 +56,26 @@ export function submit(form, formConfig) { return Promise.reject(respOrError); }; - return apiRequest( - `${environment.API_URL}/burials/v0/claims`, - apiRequestOptions, - ) - .then(onSuccess) - .catch(onFailure); + const sendRequest = async () => { + await ensureValidCSRFToken(); + return apiRequest( + `${environment.API_URL}/burials/v0/claims`, + apiRequestOptions, + ).then(onSuccess); + }; + + return sendRequest().catch(async respOrError => { + // if it's a CSRF error, clear CSRF and retry once + const errorResponse = respOrError?.errors?.[0]; + if ( + errorResponse?.status === '403' && + errorResponse?.detail === 'Invalid Authenticity Token' + ) { + localStorage.setItem('csrfToken', ''); + return sendRequest().catch(onFailure); + } + + // in other cases, handle error regularly + return onFailure(respOrError); + }); } diff --git a/src/applications/burials-ez/tests/helpers.unit.spec.jsx b/src/applications/burials-ez/tests/helpers.unit.spec.jsx index 0f73c484c68a..bbac18a9b841 100644 --- a/src/applications/burials-ez/tests/helpers.unit.spec.jsx +++ b/src/applications/burials-ez/tests/helpers.unit.spec.jsx @@ -1,27 +1,53 @@ import { expect } from 'chai'; import sinon from 'sinon'; +import { waitFor } from '@testing-library/react'; -import { mockFetch } from 'platform/testing/unit/helpers'; +import * as api from 'platform/utilities/api'; import { fullNameUI } from 'platform/forms-system/src/js/web-component-patterns'; +import * as recordEventModule from 'platform/monitoring/record-event'; import { benefitsIntakeFullNameUI } from '../utils/helpers'; import { submit } from '../config/submit'; describe('Burials helpers', () => { describe('submit', () => { + let apiRequestStub; + let recordEventStub; + const formConfig = { + chapters: {}, + }; + const form = { + data: {}, + }; + beforeEach(() => { window.VetsGov = { pollTimeout: 1 }; - window.URL = { - createObjectURL: sinon.stub().returns('test'), - }; + localStorage.setItem('csrfToken', 'my-token'); + apiRequestStub = sinon + .stub(api, 'apiRequest') + .resolves({ data: { attributes: {} } }); + recordEventStub = sinon.stub(recordEventModule, 'default'); + }); + + afterEach(() => { + apiRequestStub.restore(); + localStorage.clear(); + recordEventStub.restore(); }); + + it('should not update csrf token on success', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + + await submit(form, formConfig); + + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + + await waitFor(() => { + expect(apiRequestStub.callCount).to.equal(1); + }); + }); + it('should reject if initial request fails', () => { - mockFetch(new Error('fake error'), false); - const formConfig = { - chapters: {}, - }; - const form = { - data: {}, - }; + apiRequestStub.onFirstCall().rejects({ message: 'fake error' }); return submit(form, formConfig).then( () => { @@ -32,9 +58,45 @@ describe('Burials helpers', () => { }, ); }); + describe('on 403 Invalid Authenticity Token error', () => { + it('should reset csrfToken', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + const invalidAuthenticityTokenResponse = { + errors: [{ status: '403', detail: 'Invalid Authenticity Token' }], + }; + apiRequestStub.onFirstCall().rejects(invalidAuthenticityTokenResponse); - afterEach(() => { - delete window.URL; + await submit(form, formConfig); + + await waitFor(() => { + // Submission attempt -> CSRF refresh -> submission attempt + expect(apiRequestStub.callCount).to.equal(3); + }); + }); + + it('should only retry once', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + const invalidAuthenticityTokenResponse = { + errors: [{ status: '403', detail: 'Invalid Authenticity Token' }], + }; + apiRequestStub.onFirstCall().rejects(invalidAuthenticityTokenResponse); + apiRequestStub.onSecondCall().resolves({}); + apiRequestStub.onThirdCall().rejects({ message: 'fake error' }); + + await submit(form, formConfig).then( + () => { + expect.fail(); + }, + err => { + expect(err.message).to.equal('fake error'); + }, + ); + + await waitFor(() => { + // Submission attempt -> CSRF refresh -> submission attempt + expect(apiRequestStub.callCount).to.equal(3); + }); + }); }); }); describe('benefitIntakeFullName', () => { diff --git a/src/applications/burials-ez/tests/utils/ensureValidCSRFToken.unit.spec.js b/src/applications/burials-ez/tests/utils/ensureValidCSRFToken.unit.spec.js new file mode 100644 index 000000000000..948cef088923 --- /dev/null +++ b/src/applications/burials-ez/tests/utils/ensureValidCSRFToken.unit.spec.js @@ -0,0 +1,97 @@ +import { expect } from 'chai'; +import * as api from 'platform/utilities/api'; +import sinon from 'sinon'; +import { waitFor } from '@testing-library/react'; +import environment from 'platform/utilities/environment'; +import * as recordEventModule from 'platform/monitoring/record-event'; +import { ensureValidCSRFToken } from '../../utils/ensureValidCSRFToken'; + +describe('Burials ensureValidCSRFToken action', () => { + const errorResponse = { bad: 'some error' }; + let apiRequestStub; + let recordEventStub; + + beforeEach(() => { + localStorage.setItem('csrfToken', 'my-token'); + apiRequestStub = sinon.stub(api, 'apiRequest').resolves([]); + recordEventStub = sinon.stub(recordEventModule, 'default'); + }); + + afterEach(() => { + apiRequestStub.restore(); + localStorage.clear(); + recordEventStub.restore(); + }); + + context('has csrfToken in localStorage', () => { + beforeEach(() => { + localStorage.setItem('csrfToken', 'my-token'); + }); + + it('calls recordEvent token-present successfully', async () => { + await ensureValidCSRFToken(); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-present', + }), + ).to.be.true; + }); + }); + }); + + context('no csrfToken in localStorage', () => { + beforeEach(() => { + localStorage.setItem('csrfToken', ''); + }); + + it('successfully makes extra HEAD request to refresh csrfToken', async () => { + apiRequestStub.onFirstCall().resolves({ meta: {} }); + + await ensureValidCSRFToken(); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-empty', + }), + ).to.be.true; + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-present', + }), + ).to.be.false; + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-success', + }), + ).to.be.true; + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-failure', + }), + ).to.be.false; + expect(apiRequestStub.firstCall.args[0]).to.equal( + `${environment.API_URL}/v0/maintenance_windows`, + ); + expect(apiRequestStub.callCount).to.equal(1); + }); + }); + + it('returns error making extra HEAD request to refresh csrfToken', async () => { + apiRequestStub.onFirstCall().rejects(errorResponse); + + await ensureValidCSRFToken('myMethod'); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'burials-21p-530-fetch-csrf-token-failure', + }), + ).to.be.true; + expect(apiRequestStub.callCount).to.equal(1); + }); + }); + }); +}); diff --git a/src/applications/burials-ez/utils/ensureValidCSRFToken.js b/src/applications/burials-ez/utils/ensureValidCSRFToken.js new file mode 100644 index 000000000000..22984422e7c8 --- /dev/null +++ b/src/applications/burials-ez/utils/ensureValidCSRFToken.js @@ -0,0 +1,32 @@ +import { apiRequest } from 'platform/utilities/api'; +import recordEvent from 'platform/monitoring/record-event'; +import environment from 'platform/utilities/environment'; + +const fetchNewCSRFToken = async () => { + const url = '/v0/maintenance_windows'; + recordEvent({ + event: 'burials-21p-530-fetch-csrf-token-empty', + }); + return apiRequest(`${environment.API_URL}${url}`, { method: 'HEAD' }) + .then(() => { + recordEvent({ + event: 'burials-21p-530-fetch-csrf-token-success', + }); + }) + .catch(() => { + recordEvent({ + event: 'burials-21p-530-fetch-csrf-token-failure', + }); + }); +}; + +export const ensureValidCSRFToken = async () => { + const csrfToken = localStorage.getItem('csrfToken'); + if (!csrfToken) { + await fetchNewCSRFToken(); + } else { + recordEvent({ + event: 'burials-21p-530-fetch-csrf-token-present', + }); + } +}; diff --git a/src/applications/pensions/config/submit.js b/src/applications/pensions/config/submit.js index 8425c9b526fb..15ac0e441c1d 100644 --- a/src/applications/pensions/config/submit.js +++ b/src/applications/pensions/config/submit.js @@ -2,6 +2,7 @@ import environment from 'platform/utilities/environment'; import { apiRequest } from 'platform/utilities/api'; import { transformForSubmit } from 'platform/forms-system/src/js/helpers'; import { format } from 'date-fns-tz'; +import { ensureValidCSRFToken } from '../ensureValidCSRFToken'; const usaPhoneKeys = ['phone', 'mobilePhone', 'dayPhone', 'nightPhone']; @@ -36,32 +37,57 @@ export function transform(formConfig, form) { }); } -export function submit(form, formConfig, apiPath = '/pensions/v0/claims') { +export async function submit( + form, + formConfig, + apiPath = '/pensions/v0/claims', +) { const headers = { 'Content-Type': 'application/json' }; const body = transform(formConfig, form); - - return apiRequest(`${environment.API_URL}${apiPath}`, { - body, + const apiRequestOptions = { headers, + body, method: 'POST', mode: 'cors', - }) - .then(resp => { - window.dataLayer.push({ - event: `${formConfig.trackingPrefix}-submission-successful`, - }); - return resp.data.attributes; - }) - .catch(respOrError => { - if (respOrError instanceof Response && respOrError.status === 429) { - const error = new Error('vets_throttled_error_pensions'); - error.extra = parseInt( - respOrError.headers.get('x-ratelimit-reset'), - 10, - ); + }; - return Promise.reject(error); - } - return Promise.reject(respOrError); + const onSuccess = resp => { + window.dataLayer.push({ + event: `${formConfig.trackingPrefix}-submission-successful`, }); + return resp.data.attributes; + }; + + const onFailure = respOrError => { + if (respOrError instanceof Response && respOrError.status === 429) { + const error = new Error('vets_throttled_error_pensions'); + error.extra = parseInt(respOrError.headers.get('x-ratelimit-reset'), 10); + + return Promise.reject(error); + } + return Promise.reject(respOrError); + }; + + const sendRequest = async () => { + await ensureValidCSRFToken(); + return apiRequest( + `${environment.API_URL}${apiPath}`, + apiRequestOptions, + ).then(onSuccess); + }; + + return sendRequest().catch(async respOrError => { + // if it's a CSRF error, clear CSRF and retry once + const errorResponse = respOrError?.errors?.[0]; + if ( + errorResponse?.status === '403' && + errorResponse?.detail === 'Invalid Authenticity Token' + ) { + localStorage.setItem('csrfToken', ''); + return sendRequest().catch(onFailure); + } + + // in other cases, handle error regularly + return onFailure(respOrError); + }); } diff --git a/src/applications/pensions/ensureValidCSRFToken.js b/src/applications/pensions/ensureValidCSRFToken.js new file mode 100644 index 000000000000..9ec36f5dd9d5 --- /dev/null +++ b/src/applications/pensions/ensureValidCSRFToken.js @@ -0,0 +1,33 @@ +import { apiRequest } from 'platform/utilities/api'; +import recordEvent from 'platform/monitoring/record-event'; +import environment from 'platform/utilities/environment'; + +const fetchNewCSRFToken = async () => { + const url = '/v0/maintenance_windows'; + recordEvent({ + event: 'pensions-21p-527-fetch-csrf-token-empty', + }); + + return apiRequest(`${environment.API_URL}${url}`, { method: 'HEAD' }) + .then(() => { + recordEvent({ + event: 'pensions-21p-527-fetch-csrf-token-success', + }); + }) + .catch(() => { + recordEvent({ + event: 'pensions-21p-527-fetch-csrf-token-failure', + }); + }); +}; + +export const ensureValidCSRFToken = async () => { + const csrfToken = localStorage.getItem('csrfToken'); + if (!csrfToken) { + await fetchNewCSRFToken(); + } else { + recordEvent({ + event: 'pensions-21p-527-fetch-csrf-token-present', + }); + } +}; diff --git a/src/applications/pensions/tests/unit/ensureValidCSRFToken.unit.spec.js b/src/applications/pensions/tests/unit/ensureValidCSRFToken.unit.spec.js new file mode 100644 index 000000000000..d4791b4d999c --- /dev/null +++ b/src/applications/pensions/tests/unit/ensureValidCSRFToken.unit.spec.js @@ -0,0 +1,97 @@ +import { expect } from 'chai'; +import * as api from 'platform/utilities/api'; +import sinon from 'sinon'; +import { waitFor } from '@testing-library/react'; +import environment from 'platform/utilities/environment'; +import * as recordEventModule from 'platform/monitoring/record-event'; +import { ensureValidCSRFToken } from '../../ensureValidCSRFToken'; + +describe('Burials ensureValidCSRFToken action', () => { + const errorResponse = { bad: 'some error' }; + let apiRequestStub; + let recordEventStub; + + beforeEach(() => { + localStorage.setItem('csrfToken', 'my-token'); + apiRequestStub = sinon.stub(api, 'apiRequest').resolves([]); + recordEventStub = sinon.stub(recordEventModule, 'default'); + }); + + afterEach(() => { + apiRequestStub.restore(); + localStorage.clear(); + recordEventStub.restore(); + }); + + context('has csrfToken in localStorage', () => { + beforeEach(() => { + localStorage.setItem('csrfToken', 'my-token'); + }); + + it('calls recordEvent token-present successfully', async () => { + await ensureValidCSRFToken(); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-present', + }), + ).to.be.true; + }); + }); + }); + + context('no csrfToken in localStorage', () => { + beforeEach(() => { + localStorage.setItem('csrfToken', ''); + }); + + it('successfully makes extra HEAD request to refresh csrfToken', async () => { + apiRequestStub.onFirstCall().resolves({ meta: {} }); + + await ensureValidCSRFToken(); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-empty', + }), + ).to.be.true; + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-present', + }), + ).to.be.false; + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-success', + }), + ).to.be.true; + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-failure', + }), + ).to.be.false; + expect(apiRequestStub.firstCall.args[0]).to.equal( + `${environment.API_URL}/v0/maintenance_windows`, + ); + expect(apiRequestStub.callCount).to.equal(1); + }); + }); + + it('returns error making extra HEAD request to refresh csrfToken', async () => { + apiRequestStub.onFirstCall().rejects(errorResponse); + + await ensureValidCSRFToken(); + + await waitFor(() => { + expect( + recordEventStub.calledWith({ + event: 'pensions-21p-527-fetch-csrf-token-failure', + }), + ).to.be.true; + expect(apiRequestStub.callCount).to.equal(1); + }); + }); + }); +}); diff --git a/src/applications/pensions/tests/unit/helpers.unit.spec.jsx b/src/applications/pensions/tests/unit/helpers.unit.spec.jsx index c8d78a0a576e..4eb87e75b379 100644 --- a/src/applications/pensions/tests/unit/helpers.unit.spec.jsx +++ b/src/applications/pensions/tests/unit/helpers.unit.spec.jsx @@ -1,8 +1,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; +import { waitFor } from '@testing-library/react'; -import { mockFetch } from 'platform/testing/unit/helpers'; +import * as api from 'platform/utilities/api'; import { transformForSubmit } from 'platform/forms-system/src/js/helpers'; +import * as recordEventModule from 'platform/monitoring/record-event'; import { formatCurrency, isHomeAcreageMoreThanTwo } from '../../helpers'; import { getMarriageTitleWithCurrent, @@ -12,20 +14,44 @@ import { replacer, submit } from '../../config/submit'; describe('Pensions helpers', () => { describe('submit', () => { + let apiRequestStub; + let recordEventStub; + const formConfig = { + chapters: {}, + }; + const form = { + data: {}, + }; + beforeEach(() => { window.VetsGov = { pollTimeout: 1 }; - window.URL = { - createObjectURL: sinon.stub().returns('test'), - }; + localStorage.setItem('csrfToken', 'my-token'); + apiRequestStub = sinon + .stub(api, 'apiRequest') + .resolves({ data: { attributes: {} } }); + recordEventStub = sinon.stub(recordEventModule, 'default'); + }); + + afterEach(() => { + apiRequestStub.restore(); + localStorage.clear(); + recordEventStub.restore(); + }); + + it('should not update csrf token on success', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + + await submit(form, formConfig); + + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + + await waitFor(() => { + expect(apiRequestStub.callCount).to.equal(1); + }); }); + it('should reject if initial request fails', () => { - mockFetch(new Error('fake error'), false); - const formConfig = { - chapters: {}, - }; - const form = { - data: {}, - }; + apiRequestStub.onFirstCall().rejects({ message: 'fake error' }); return submit(form, formConfig).then( () => { @@ -36,8 +62,46 @@ describe('Pensions helpers', () => { }, ); }); - afterEach(() => { - delete window.URL; + + describe('on 403 Invalid Authenticity Token error', () => { + it('should reset csrfToken', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + const invalidAuthenticityTokenResponse = { + errors: [{ status: '403', detail: 'Invalid Authenticity Token' }], + }; + apiRequestStub.onFirstCall().rejects(invalidAuthenticityTokenResponse); + + await submit(form, formConfig); + + await waitFor(() => { + // Submission attempt -> CSRF refresh -> submission attempt + expect(apiRequestStub.callCount).to.equal(3); + }); + }); + + it('should only retry once', async () => { + expect(localStorage.getItem('csrfToken')).to.eql('my-token'); + const invalidAuthenticityTokenResponse = { + errors: [{ status: '403', detail: 'Invalid Authenticity Token' }], + }; + apiRequestStub.onFirstCall().rejects(invalidAuthenticityTokenResponse); + apiRequestStub.onSecondCall().resolves({}); + apiRequestStub.onThirdCall().rejects({ message: 'fake error' }); + + await submit(form, formConfig).then( + () => { + expect.fail(); + }, + err => { + expect(err.message).to.equal('fake error'); + }, + ); + + await waitFor(() => { + // Submission attempt -> CSRF refresh -> submission attempt + expect(apiRequestStub.callCount).to.equal(3); + }); + }); }); }); describe('replacer', () => {