Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Pension Burials] Handle 403 errors #35027

Merged
merged 5 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions src/applications/burials-ez/config/submit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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);
});
}
88 changes: 75 additions & 13 deletions src/applications/burials-ez/tests/helpers.unit.spec.jsx
Original file line number Diff line number Diff line change
@@ -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(
() => {
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
32 changes: 32 additions & 0 deletions src/applications/burials-ez/utils/ensureValidCSRFToken.js
Original file line number Diff line number Diff line change
@@ -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',
});
}
};
68 changes: 47 additions & 21 deletions src/applications/pensions/config/submit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -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);
});
}
Loading
Loading