} - The status and data of the retrieved resource metadata.
+ */
+const getResourceMetaDataJob = async (job) => {
+ const {
+ resourceId, resourceUrl,
+ } = job.data;
+
+ try {
+ // Determine if this is an ECLKC resource.
+ const isEclkc = resourceUrl.includes('eclkc.ohs.acf.hhs.gov');
+
+ let statusCode;
+ let mimeType;
+ let title = null;
+
+ // Get the MIME type and status code of the resource.
+ // eslint-disable-next-line prefer-const
+ ({ mimeType, statusCode } = await getMimeType(resourceUrl));
+
+ // Check if the MIME type is unparsable.
+ if (mimeType && unparsableMimeTypes.includes(mimeType.toLowerCase().replace(/\s+/g, ''))) {
+ auditLogger.error(`Resource Queue: Warning, unable to process resource '${resourceUrl}', received status code '${statusCode}'.`);
+ return {
+ status: httpCodes.NO_CONTENT,
+ data: { url: resourceUrl },
+ };
+ }
+
+ // Check if the status code indicates an error.
+ if (statusCode !== httpCodes.OK) {
+ auditLogger.error(`Resource Queue: Warning, unable to retrieve resource '${resourceUrl}', received status code '${statusCode}'.`);
+ return { status: statusCode || 500, data: { url: resourceUrl } };
+ }
+
+ // If it is an ECLKC resource, get the metadata values.
+ if (isEclkc) {
+ ({ title, statusCode } = await getMetadataValues(resourceUrl));
+ if (statusCode !== httpCodes.OK) {
+ auditLogger.error(`Resource Queue: Warning, unable to retrieve metadata or resource TITLE for resource '${resourceUrl}', received status code '${statusCode || 500}'.`);
+ return { status: statusCode || 500, data: { url: resourceUrl } };
+ }
+ } else {
+ // If it is not an ECLKC resource, scrape the page title.
+ ({ title, statusCode } = await getPageScrapeValues(resourceUrl));
+ if (statusCode !== httpCodes.OK) {
+ auditLogger.error(`Resource Queue: Warning, unable to retrieve resource TITLE for resource '${resourceUrl}', received status code '${statusCode || 500}'.`);
+ return { status: statusCode || 500, data: { url: resourceUrl } };
+ }
+ }
+ logger.info(`Resource Queue: Successfully retrieved resource metadata for resource '${resourceUrl}'`);
+ return { status: httpCodes.OK, data: { url: resourceUrl } };
+ } catch (error) {
+ auditLogger.error(`Resource Queue: Unable to retrieve metadata or title for Resource (ID: ${resourceId} URL: ${resourceUrl}), please make sure this is a valid address:`, error);
+ return { status: httpCodes.NOT_FOUND, data: { url: resourceUrl } };
+ }
+};
+
+export {
+ getResourceMetaDataJob,
+};
diff --git a/src/lib/resource.test.js b/src/lib/resource.test.js
index db12f0924d..c48c4c4b09 100644
--- a/src/lib/resource.test.js
+++ b/src/lib/resource.test.js
@@ -1,568 +1,588 @@
-/* eslint-disable no-useless-escape */
-import axios from 'axios';
-import { expect } from '@playwright/test';
-import { auditLogger } from '../logger';
-import { getResourceMetaDataJob } from './resource';
-import db, { Resource } from '../models';
-
-jest.mock('../logger');
-jest.mock('bull');
-
-const urlReturn = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-Head Start | ECLKC
-
-test
-
-
-`;
-
-const urlMissingTitle = `
-
-
-
-test
-
-
-`;
-
-const metadata = {
- created: [{ value: '2020-04-21T15:20:23+00:00' }],
- changed: [{ value: '2023-05-26T18:57:15+00:00' }],
- title: [{ value: 'Head Start Heals Campaign' }],
- field_taxonomy_national_centers: [{ target_type: 'taxonomy_term' }],
- field_taxonomy_topic: [{ target_type: 'taxonomy_term' }],
- langcode: [{ value: 'en' }],
- field_context: [{ value: ' ' }],
-};
-
-const mockAxios = jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve());
-const mockAxiosHead = jest.spyOn(axios, 'head').mockImplementation(() => Promise.resolve());
-const axiosCleanMimeResponse = { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } };
-const axiosCleanResponse = { ...axiosCleanMimeResponse, data: urlReturn };
-const axiosNoTitleResponse = { status: 404, data: urlMissingTitle, headers: { 'content-type': 'text/html; charset=utf-8' } };
-const axiosResourceNotFound = { status: 404, data: 'Not Found' };
-const axiosError = new Error();
-axiosError.response = { status: 500, data: 'Error' };
-const mockUpdate = jest.spyOn(Resource, 'update').mockImplementation(() => Promise.resolve());
-
-describe('resource worker tests', () => {
- let resource;
- afterAll(async () => {
- await Resource.destroy({ where: { id: resource.id } });
- await db.sequelize.close();
- });
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('non-eclkc clean resource title get', async () => {
- // Mock TITLE get.
- mockAxios.mockImplementationOnce(() => Promise.resolve(axiosCleanResponse));
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- // Call the function.
- const got = await getResourceMetaDataJob({ data: { resourceId: 100000, resourceUrl: 'https://test.gov/mental-health/article/head-start-heals-campaign' } });
-
- // Check the response.
- expect(got.status).toBe(200);
-
- // Check the data.
- expect(got.data).toStrictEqual({ url: 'https://test.gov/mental-health/article/head-start-heals-campaign' });
-
- // Check the axios call.
- expect(mockAxios).toBeCalled();
-
- // expect mockUpdate to have only been called once.
- expect(mockUpdate).toBeCalledTimes(2);
-
- // Check title update.
- expect(mockUpdate).toHaveBeenNthCalledWith(
- 1,
- {
- // title: 'Head Start | ECLKC',
- lastStatusCode: 200,
- mimeType: axiosCleanResponse.headers['content-type'],
- },
- {
- where: { url: 'https://test.gov/mental-health/article/head-start-heals-campaign' },
- individualHooks: true,
- },
- );
-
- expect(mockUpdate).toHaveBeenLastCalledWith(
- {
- title: 'Head Start | ECLKC',
- lastStatusCode: 200,
- mimeType: axiosCleanResponse.headers['content-type'],
- metadata: {
- language: 'en',
- topic: 'Mental Health',
- 'resource-type': 'Article',
- 'national-centers': 'Health, Behavioral Health, and Safety',
- 'node-id': '7858',
- 'exclude-from-dynamic-view': 'False',
- title: 'Head Start | ECLKC',
- },
- metadataUpdatedAt: expect.anything(),
- },
- { where: { url: 'https://test.gov/mental-health/article/head-start-heals-campaign' }, individualHooks: true },
- );
- });
-
- it('non-eclkc error on resource title get', async () => {
- // Mock TITLE get.
- const axiosHtmlScrapeError = new Error();
- axiosHtmlScrapeError.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
- mockAxios.mockImplementationOnce(() => Promise.reject(axiosHtmlScrapeError));
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- // Call the function.
- const got = await getResourceMetaDataJob({ data: { resourceId: 100000, resourceUrl: 'https://test.gov/mental-health/article/head-start-heals-campaign' } });
-
- // Check the response.
- expect(got.status).toBe(500);
- });
-
- it('tests a clean resource metadata get', async () => {
- // Metadata.
- mockAxios.mockImplementationOnce(() => Promise.resolve({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: metadata,
- }));
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
- expect(got.status).toBe(200);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
-
- expect(mockUpdate).toBeCalledTimes(2);
-
- // Check the update call.
- expect(mockUpdate).toHaveBeenLastCalledWith(
- {
- metadata: {
- changed: [
- {
- value: '2023-05-26T18:57:15+00:00',
- },
- ],
- created: [
- {
- value: '2020-04-21T15:20:23+00:00',
- },
- ],
- field_context: [
- {
- value: '
',
- },
- ],
- field_taxonomy_national_centers: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- field_taxonomy_topic: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- langcode: [
- {
- value: 'en',
- },
- ],
- title: [
- {
- value: 'Head Start Heals Campaign',
- },
- ],
- },
- metadataUpdatedAt: expect.anything(),
- title: 'Head Start Heals Campaign',
- lastStatusCode: 200,
- },
- {
- individualHooks: true,
- where: {
- url: 'http://www.eclkc.ohs.acf.hhs.gov',
- },
- },
- );
- });
-
- it('tests a clean resource metadata get with a url that has params', async () => {
- // Metadata.
- mockAxios.mockImplementationOnce(() => Promise.resolve({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: metadata,
- }));
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1' } });
- expect(got.status).toBe(200);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1' });
-
- expect(mockUpdate).toBeCalledTimes(2);
-
- expect(mockAxios).toBeCalledWith(
- 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1&_format=json',
- {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
- maxRedirects: 25,
- responseEncoding: 'utf8',
- },
- );
-
- // Check the update call.
- expect(mockUpdate).toHaveBeenLastCalledWith(
- {
- metadata: {
- changed: [
- {
- value: '2023-05-26T18:57:15+00:00',
- },
- ],
- created: [
- {
- value: '2020-04-21T15:20:23+00:00',
- },
- ],
- field_context: [
- {
- value: '
',
- },
- ],
- field_taxonomy_national_centers: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- field_taxonomy_topic: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- langcode: [
- {
- value: 'en',
- },
- ],
- title: [
- {
- value: 'Head Start Heals Campaign',
- },
- ],
- },
- metadataUpdatedAt: expect.anything(),
- title: 'Head Start Heals Campaign',
- lastStatusCode: 200,
- },
- {
- individualHooks: true,
- where: {
- url: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1',
- },
- },
- );
- });
-
- it('tests a clean resource metadata get with a url that has a pound sign', async () => {
- // Metadata.
- mockAxios.mockImplementationOnce(() => Promise.resolve({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: metadata,
- }));
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov/section#2' } });
- expect(got.status).toBe(200);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov/section#2' });
-
- expect(mockUpdate).toBeCalledTimes(2);
-
- // Expect axios get to have been called with the correct url.
- expect(mockAxios).toBeCalledWith(
- 'http://www.eclkc.ohs.acf.hhs.gov/section?_format=json',
- {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
- maxRedirects: 25,
- responseEncoding: 'utf8',
- },
- );
-
- // Check the update call.
- expect(mockUpdate).toHaveBeenLastCalledWith(
- {
- metadata: {
- changed: [
- {
- value: '2023-05-26T18:57:15+00:00',
- },
- ],
- created: [
- {
- value: '2020-04-21T15:20:23+00:00',
- },
- ],
- field_context: [
- {
- value: '
',
- },
- ],
- field_taxonomy_national_centers: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- field_taxonomy_topic: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- langcode: [
- {
- value: 'en',
- },
- ],
- title: [
- {
- value: 'Head Start Heals Campaign',
- },
- ],
- },
- metadataUpdatedAt: expect.anything(),
- title: 'Head Start Heals Campaign',
- lastStatusCode: 200,
- },
- {
- individualHooks: true,
- where: {
- url: 'http://www.eclkc.ohs.acf.hhs.gov/section#2',
- },
- },
- );
- });
-
- it('tests error with a response from get metadata', async () => {
- const axiosMetadataErrorResponse = new Error();
- axiosMetadataErrorResponse.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
- mockAxios.mockImplementationOnce(
- () => Promise.reject(axiosMetadataErrorResponse),
- ).mockImplementationOnce(() => Promise.resolve(axiosMetadataErrorResponse));
-
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
- expect(got.status).toBe(500);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
-
- expect(mockUpdate).toBeCalledTimes(2);
- });
-
- it('tests error without a response from get metadata', async () => {
- const axiosMetadataErrorResponse = new Error();
- axiosMetadataErrorResponse.response = { data: 'Error' };
- mockAxios.mockImplementationOnce(() => Promise.reject(axiosMetadataErrorResponse));
-
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
-
- // Verify auditlogger.error was called with the message we expect.
- expect(auditLogger.error).toBeCalledTimes(3);
- });
-
- it('eclkc resource we get metadata but no title', async () => {
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
- mockAxios.mockImplementationOnce(() => Promise.resolve({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: { ...metadata, title: null },
- }));
- mockAxios.mockImplementationOnce(() => Promise.resolve({
- status: 200,
- headers: { 'content-type': 'text/html; charset=utf-8' },
- data: urlMissingTitle,
- }));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- // Scrape.
- mockAxios.mockImplementationOnce(() => Promise.resolve(axiosCleanResponse));
- mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
-
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
- expect(got.status).toBe(200);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
-
- expect(mockUpdate).toBeCalledTimes(2);
-
- // Check title scrape update..
- expect(mockUpdate).toBeCalledWith(
- {
- lastStatusCode: 200,
- mimeType: 'text/html; charset=utf-8',
- },
- {
- individualHooks: true,
- where: { url: 'http://www.eclkc.ohs.acf.hhs.gov' },
- },
- );
-
- // Check the update call.
- expect(mockUpdate).toBeCalledWith(
- {
- metadata: {
- changed: [
- {
- value: '2023-05-26T18:57:15+00:00',
- },
- ],
- created: [
- {
- value: '2020-04-21T15:20:23+00:00',
- },
- ],
- field_context: [
- {
- value: '
',
- },
- ],
- field_taxonomy_national_centers: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- field_taxonomy_topic: [
- {
- target_type: 'taxonomy_term',
- },
- ],
- langcode: [
- {
- value: 'en',
- },
- ],
- title: null,
- },
- metadataUpdatedAt: expect.anything(),
- title: 'null',
- lastStatusCode: 200,
- },
- {
- individualHooks: true,
- where: {
- url: 'http://www.eclkc.ohs.acf.hhs.gov',
- },
- },
- );
- });
-
- it('non-eclkc resource missing title', async () => {
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
- mockAxios.mockImplementationOnce(() => Promise.resolve(axiosNoTitleResponse));
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
- expect(got.status).toBe(404);
- expect(got.data).toStrictEqual({ url: 'http://www.test.gov' });
- expect(mockAxiosHead).toBeCalled();
- expect(mockAxios).not.toBeCalled();
- expect(mockUpdate).toBeCalled();
- });
-
- it('non-eclkc resource url not found', async () => {
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
- mockAxios.mockImplementationOnce(() => Promise.resolve(axiosResourceNotFound));
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
- expect(got.status).toBe(404);
- expect(got.data).toStrictEqual({ url: 'http://www.test.gov' });
- expect(mockAxiosHead).toBeCalled();
- expect(mockAxios).not.toBeCalled();
- expect(mockUpdate).toBeCalled();
- });
-
- it('eclkc resource url not found', async () => {
- mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
- mockAxios.mockImplementationOnce(() => Promise.resolve(axiosResourceNotFound));
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
- expect(got.status).toBe(404);
- expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
- expect(mockAxiosHead).toBeCalled();
- expect(mockAxios).not.toBeCalled();
- expect(mockUpdate).toBeCalled();
- });
-
- it('get mime type handles error response correctly', async () => {
- // Mock auditLogger.error.
- const mockAuditLogger = jest.spyOn(auditLogger, 'error');
- // Mock error on axios head error.
- const axiosMimeError = new Error();
- axiosMimeError.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
- mockAxiosHead.mockImplementationOnce(() => Promise.reject(axiosMimeError));
-
- // Call the function.
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
- // Check the response.
- expect(got.status).toBe(500);
-
- // Expect auditLogger.error to have been called with the correct message.
- expect(mockAuditLogger).toBeCalledTimes(2);
-
- // Check the axios call.
- expect(mockAxiosHead).toBeCalled();
-
- // Check the update call.
- expect(mockUpdate).toBeCalledTimes(1);
-
- // Check the update call.
- expect(mockUpdate).toBeCalledWith(
- {
- lastStatusCode: 500,
- mimeType: 'text/html; charset=utf-8',
- },
- {
- individualHooks: true,
- where: {
- url: 'http://www.test.gov',
- },
- },
- );
- });
-
- it('get mime type handles no error response correctly', async () => {
- // Mock error on axios head error.
- const axiosMimeError = new Error();
- axiosMimeError.response = { data: 'Error' };
- mockAxiosHead.mockImplementationOnce(() => Promise.reject(axiosMimeError));
-
- // Call the function.
- const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
-
- // Check the response.
- expect(got).toEqual({
- status: 500,
- data: { url: 'http://www.test.gov' },
- });
- });
-});
+/* eslint-disable no-useless-escape */
+import axios from 'axios';
+import { expect } from '@playwright/test';
+import { auditLogger } from '../logger';
+import { getResourceMetaDataJob, overrideStatusCodeOnAuthRequired } from './resource';
+import db, { Resource } from '../models';
+
+jest.mock('../logger');
+jest.mock('bull');
+
+const urlReturn = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+Head Start | ECLKC
+
+test
+
+
+`;
+
+const urlMissingTitle = `
+
+
+
+test
+
+
+`;
+
+const metadata = {
+ created: [{ value: '2020-04-21T15:20:23+00:00' }],
+ changed: [{ value: '2023-05-26T18:57:15+00:00' }],
+ title: [{ value: 'Head Start Heals Campaign' }],
+ field_taxonomy_national_centers: [{ target_type: 'taxonomy_term' }],
+ field_taxonomy_topic: [{ target_type: 'taxonomy_term' }],
+ langcode: [{ value: 'en' }],
+ field_context: [{ value: ' ' }],
+};
+
+const mockAxios = jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve());
+const mockAxiosHead = jest.spyOn(axios, 'head').mockImplementation(() => Promise.resolve());
+const axiosCleanMimeResponse = { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } };
+const axiosCleanResponse = { ...axiosCleanMimeResponse, data: urlReturn };
+const axiosNoTitleResponse = { status: 404, data: urlMissingTitle, headers: { 'content-type': 'text/html; charset=utf-8' } };
+const axiosResourceNotFound = { status: 404, data: 'Not Found' };
+const axiosError = new Error();
+axiosError.response = { status: 500, data: 'Error' };
+const mockUpdate = jest.spyOn(Resource, 'update').mockImplementation(() => Promise.resolve());
+
+describe('resource worker tests', () => {
+ let resource;
+ afterAll(async () => {
+ await Resource.destroy({ where: { id: resource.id } });
+ await db.sequelize.close();
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('non-eclkc clean resource title get', async () => {
+ // Mock TITLE get.
+ mockAxios.mockImplementationOnce(() => Promise.resolve(axiosCleanResponse));
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ // Call the function.
+ const got = await getResourceMetaDataJob({ data: { resourceId: 100000, resourceUrl: 'https://test.gov/mental-health/article/head-start-heals-campaign' } });
+
+ // Check the response.
+ expect(got.status).toBe(200);
+
+ // Check the data.
+ expect(got.data).toStrictEqual({ url: 'https://test.gov/mental-health/article/head-start-heals-campaign' });
+
+ // Check the axios call.
+ expect(mockAxios).toBeCalled();
+
+ // expect mockUpdate to have only been called once.
+ expect(mockUpdate).toBeCalledTimes(2);
+
+ // Check title update.
+ expect(mockUpdate).toHaveBeenNthCalledWith(
+ 1,
+ {
+ // title: 'Head Start | ECLKC',
+ lastStatusCode: 200,
+ mimeType: axiosCleanResponse.headers['content-type'],
+ },
+ {
+ where: { url: 'https://test.gov/mental-health/article/head-start-heals-campaign' },
+ individualHooks: true,
+ },
+ );
+
+ expect(mockUpdate).toHaveBeenLastCalledWith(
+ {
+ title: 'Head Start | ECLKC',
+ lastStatusCode: 200,
+ mimeType: axiosCleanResponse.headers['content-type'],
+ metadata: {
+ language: 'en',
+ topic: 'Mental Health',
+ 'resource-type': 'Article',
+ 'national-centers': 'Health, Behavioral Health, and Safety',
+ 'node-id': '7858',
+ 'exclude-from-dynamic-view': 'False',
+ title: 'Head Start | ECLKC',
+ },
+ metadataUpdatedAt: expect.anything(),
+ },
+ { where: { url: 'https://test.gov/mental-health/article/head-start-heals-campaign' }, individualHooks: true },
+ );
+ });
+
+ it('non-eclkc error on resource title get', async () => {
+ // Mock TITLE get.
+ const axiosHtmlScrapeError = new Error();
+ axiosHtmlScrapeError.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
+ mockAxios.mockImplementationOnce(() => Promise.reject(axiosHtmlScrapeError));
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ // Call the function.
+ const got = await getResourceMetaDataJob({ data: { resourceId: 100000, resourceUrl: 'https://test.gov/mental-health/article/head-start-heals-campaign' } });
+
+ // Check the response.
+ expect(got.status).toBe(500);
+ });
+
+ it('tests a clean resource metadata get', async () => {
+ // Metadata.
+ mockAxios.mockImplementationOnce(() => Promise.resolve({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: metadata,
+ }));
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
+ expect(got.status).toBe(200);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
+
+ expect(mockUpdate).toBeCalledTimes(2);
+
+ // Check the update call.
+ expect(mockUpdate).toHaveBeenLastCalledWith(
+ {
+ metadata: {
+ changed: [
+ {
+ value: '2023-05-26T18:57:15+00:00',
+ },
+ ],
+ created: [
+ {
+ value: '2020-04-21T15:20:23+00:00',
+ },
+ ],
+ field_context: [
+ {
+ value: '
',
+ },
+ ],
+ field_taxonomy_national_centers: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ field_taxonomy_topic: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ langcode: [
+ {
+ value: 'en',
+ },
+ ],
+ title: [
+ {
+ value: 'Head Start Heals Campaign',
+ },
+ ],
+ },
+ metadataUpdatedAt: expect.anything(),
+ title: 'Head Start Heals Campaign',
+ lastStatusCode: 200,
+ },
+ {
+ individualHooks: true,
+ where: {
+ url: 'http://www.eclkc.ohs.acf.hhs.gov',
+ },
+ },
+ );
+ });
+
+ it('tests a clean resource metadata get with a url that has params', async () => {
+ // Metadata.
+ mockAxios.mockImplementationOnce(() => Promise.resolve({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: metadata,
+ }));
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1' } });
+ expect(got.status).toBe(200);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1' });
+
+ expect(mockUpdate).toBeCalledTimes(2);
+
+ expect(mockAxios).toBeCalledWith(
+ 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1&_format=json',
+ {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
+ maxRedirects: 25,
+ responseEncoding: 'utf8',
+ },
+ );
+
+ // Check the update call.
+ expect(mockUpdate).toHaveBeenLastCalledWith(
+ {
+ metadata: {
+ changed: [
+ {
+ value: '2023-05-26T18:57:15+00:00',
+ },
+ ],
+ created: [
+ {
+ value: '2020-04-21T15:20:23+00:00',
+ },
+ ],
+ field_context: [
+ {
+ value: '
',
+ },
+ ],
+ field_taxonomy_national_centers: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ field_taxonomy_topic: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ langcode: [
+ {
+ value: 'en',
+ },
+ ],
+ title: [
+ {
+ value: 'Head Start Heals Campaign',
+ },
+ ],
+ },
+ metadataUpdatedAt: expect.anything(),
+ title: 'Head Start Heals Campaign',
+ lastStatusCode: 200,
+ },
+ {
+ individualHooks: true,
+ where: {
+ url: 'http://www.eclkc.ohs.acf.hhs.gov/activity-reports?region.in[]=1',
+ },
+ },
+ );
+ });
+
+ it('tests a clean resource metadata get with a url that has a pound sign', async () => {
+ // Metadata.
+ mockAxios.mockImplementationOnce(() => Promise.resolve({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: metadata,
+ }));
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov/section#2' } });
+ expect(got.status).toBe(200);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov/section#2' });
+
+ expect(mockUpdate).toBeCalledTimes(2);
+
+ // Expect axios get to have been called with the correct url.
+ expect(mockAxios).toBeCalledWith(
+ 'http://www.eclkc.ohs.acf.hhs.gov/section?_format=json',
+ {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
+ maxRedirects: 25,
+ responseEncoding: 'utf8',
+ },
+ );
+
+ // Check the update call.
+ expect(mockUpdate).toHaveBeenLastCalledWith(
+ {
+ metadata: {
+ changed: [
+ {
+ value: '2023-05-26T18:57:15+00:00',
+ },
+ ],
+ created: [
+ {
+ value: '2020-04-21T15:20:23+00:00',
+ },
+ ],
+ field_context: [
+ {
+ value: '
',
+ },
+ ],
+ field_taxonomy_national_centers: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ field_taxonomy_topic: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ langcode: [
+ {
+ value: 'en',
+ },
+ ],
+ title: [
+ {
+ value: 'Head Start Heals Campaign',
+ },
+ ],
+ },
+ metadataUpdatedAt: expect.anything(),
+ title: 'Head Start Heals Campaign',
+ lastStatusCode: 200,
+ },
+ {
+ individualHooks: true,
+ where: {
+ url: 'http://www.eclkc.ohs.acf.hhs.gov/section#2',
+ },
+ },
+ );
+ });
+
+ it('tests error with a response from get metadata', async () => {
+ const axiosMetadataErrorResponse = new Error();
+ axiosMetadataErrorResponse.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
+ mockAxios.mockImplementationOnce(
+ () => Promise.reject(axiosMetadataErrorResponse),
+ ).mockImplementationOnce(() => Promise.resolve(axiosMetadataErrorResponse));
+
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
+ expect(got.status).toBe(500);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
+
+ expect(mockUpdate).toBeCalledTimes(2);
+ });
+
+ it('tests error without a response from get metadata', async () => {
+ const axiosMetadataErrorResponse = new Error();
+ axiosMetadataErrorResponse.response = { data: 'Error' };
+ mockAxios.mockImplementationOnce(() => Promise.reject(axiosMetadataErrorResponse));
+
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
+
+ // Verify auditlogger.error was called with the message we expect.
+ expect(auditLogger.error).toBeCalledTimes(3);
+ });
+
+ it('eclkc resource we get metadata but no title', async () => {
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve(axiosCleanMimeResponse));
+ mockAxios.mockImplementationOnce(() => Promise.resolve({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: { ...metadata, title: null },
+ }));
+ mockAxios.mockImplementationOnce(() => Promise.resolve({
+ status: 200,
+ headers: { 'content-type': 'text/html; charset=utf-8' },
+ data: urlMissingTitle,
+ }));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ // Scrape.
+ mockAxios.mockImplementationOnce(() => Promise.resolve(axiosCleanResponse));
+ mockUpdate.mockImplementationOnce(() => Promise.resolve([1]));
+
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
+ expect(got.status).toBe(200);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
+
+ expect(mockUpdate).toBeCalledTimes(2);
+
+ // Check title scrape update..
+ expect(mockUpdate).toBeCalledWith(
+ {
+ lastStatusCode: 200,
+ mimeType: 'text/html; charset=utf-8',
+ },
+ {
+ individualHooks: true,
+ where: { url: 'http://www.eclkc.ohs.acf.hhs.gov' },
+ },
+ );
+
+ // Check the update call.
+ expect(mockUpdate).toBeCalledWith(
+ {
+ metadata: {
+ changed: [
+ {
+ value: '2023-05-26T18:57:15+00:00',
+ },
+ ],
+ created: [
+ {
+ value: '2020-04-21T15:20:23+00:00',
+ },
+ ],
+ field_context: [
+ {
+ value: '
',
+ },
+ ],
+ field_taxonomy_national_centers: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ field_taxonomy_topic: [
+ {
+ target_type: 'taxonomy_term',
+ },
+ ],
+ langcode: [
+ {
+ value: 'en',
+ },
+ ],
+ title: null,
+ },
+ metadataUpdatedAt: expect.anything(),
+ title: 'null',
+ lastStatusCode: 200,
+ },
+ {
+ individualHooks: true,
+ where: {
+ url: 'http://www.eclkc.ohs.acf.hhs.gov',
+ },
+ },
+ );
+ });
+
+ it('non-eclkc resource missing title', async () => {
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
+ mockAxios.mockImplementationOnce(() => Promise.resolve(axiosNoTitleResponse));
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
+ expect(got.status).toBe(404);
+ expect(got.data).toStrictEqual({ url: 'http://www.test.gov' });
+ expect(mockAxiosHead).toBeCalled();
+ expect(mockAxios).not.toBeCalled();
+ expect(mockUpdate).toBeCalled();
+ });
+
+ it('non-eclkc resource url not found', async () => {
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
+ mockAxios.mockImplementationOnce(() => Promise.resolve(axiosResourceNotFound));
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
+ expect(got.status).toBe(404);
+ expect(got.data).toStrictEqual({ url: 'http://www.test.gov' });
+ expect(mockAxiosHead).toBeCalled();
+ expect(mockAxios).not.toBeCalled();
+ expect(mockUpdate).toBeCalled();
+ });
+
+ it('eclkc resource url not found', async () => {
+ mockAxiosHead.mockImplementationOnce(() => Promise.resolve({ headers: { 'content-type': 'text/html; charset=utf-8' }, status: 404 }));
+ mockAxios.mockImplementationOnce(() => Promise.resolve(axiosResourceNotFound));
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.eclkc.ohs.acf.hhs.gov' } });
+ expect(got.status).toBe(404);
+ expect(got.data).toStrictEqual({ url: 'http://www.eclkc.ohs.acf.hhs.gov' });
+ expect(mockAxiosHead).toBeCalled();
+ expect(mockAxios).not.toBeCalled();
+ expect(mockUpdate).toBeCalled();
+ });
+
+ it('get mime type handles error response correctly', async () => {
+ // Mock auditLogger.error.
+ const mockAuditLogger = jest.spyOn(auditLogger, 'error');
+ // Mock error on axios head error.
+ const axiosMimeError = new Error();
+ axiosMimeError.response = { status: 500, data: 'Error', headers: { 'content-type': 'text/html; charset=utf-8' } };
+ mockAxiosHead.mockImplementationOnce(() => Promise.reject(axiosMimeError));
+
+ // Call the function.
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
+ // Check the response.
+ expect(got.status).toBe(500);
+
+ // Expect auditLogger.error to have been called with the correct message.
+ expect(mockAuditLogger).toBeCalledTimes(2);
+
+ // Check the axios call.
+ expect(mockAxiosHead).toBeCalled();
+
+ // Check the update call.
+ expect(mockUpdate).toBeCalledTimes(1);
+
+ // Check the update call.
+ expect(mockUpdate).toBeCalledWith(
+ {
+ lastStatusCode: 500,
+ mimeType: 'text/html; charset=utf-8',
+ },
+ {
+ individualHooks: true,
+ where: {
+ url: 'http://www.test.gov',
+ },
+ },
+ );
+ });
+
+ it('get mime type handles no error response correctly', async () => {
+ // Mock error on axios head error.
+ const axiosMimeError = new Error();
+ axiosMimeError.response = { data: 'Error' };
+ mockAxiosHead.mockImplementationOnce(() => Promise.reject(axiosMimeError));
+
+ // Call the function.
+ const got = await getResourceMetaDataJob({ data: { resourceUrl: 'http://www.test.gov' } });
+
+ // Check the response.
+ expect(got).toEqual({
+ status: 500,
+ data: { url: 'http://www.test.gov' },
+ });
+ });
+});
+
+describe('overrideStatusCodeOnAuthRequired', () => {
+ const httpCodes = { OK: 200, UNAUTHORIZED: 401, SERVICE_UNAVAILABLE: 503 };
+
+ it('returns UNAUTHORIZED if status code is OK and authentication is required', () => {
+ const statusCode = httpCodes.OK;
+ const list = ['auth'];
+ const data = 'some data with auth requirement';
+ const result = overrideStatusCodeOnAuthRequired(statusCode, list, data);
+ expect(result).toBe(httpCodes.UNAUTHORIZED);
+ });
+
+ it('returns OK if status code is OK and authentication is not required', () => {
+ const statusCode = httpCodes.OK;
+ const list = ['no-auth'];
+ const data = 'data without auth requirement';
+ const result = overrideStatusCodeOnAuthRequired(statusCode, list, data);
+ expect(result).toBe(httpCodes.OK);
+ });
+});
diff --git a/src/lib/semaphore.test.js b/src/lib/semaphore.test.js
index a0eb580632..d595bdf154 100644
--- a/src/lib/semaphore.test.js
+++ b/src/lib/semaphore.test.js
@@ -53,4 +53,60 @@ describe('Semaphore', () => {
semaphore.release();
expect(semaphore.data[''].currentConcurrency).toBe(0);
});
+
+ it('should not crash when releasing without any operation in progress', () => {
+ const semaphore = new Semaphore();
+ expect(() => semaphore.release()).not.toThrow();
+ });
+
+ it('should correctly decrement currentConcurrency when no waiting promises', async () => {
+ const semaphore = new Semaphore(2);
+
+ // Acquire two locks
+ await semaphore.acquire();
+ await semaphore.acquire();
+ expect(semaphore.data[''].currentConcurrency).toBe(2);
+
+ // Release one lock
+ semaphore.release();
+ expect(semaphore.data[''].currentConcurrency).toBe(1);
+ });
+
+ it('should resolve the oldest waiting promise when released', async () => {
+ const semaphore = new Semaphore(1);
+
+ // Acquire the first lock
+ await semaphore.acquire();
+ let wasFirstPromiseResolved = false;
+ let wasSecondPromiseResolved = false;
+
+ // Attempt to acquire two more locks
+ semaphore.acquire().then(() => {
+ wasFirstPromiseResolved = true;
+ });
+ semaphore.acquire().then(() => {
+ wasSecondPromiseResolved = true;
+ });
+
+ // Initially, none should be resolved
+ expect(wasFirstPromiseResolved).toBe(false);
+ expect(wasSecondPromiseResolved).toBe(false);
+
+ // Release the lock, expecting the first waiting promise to resolve
+ semaphore.release();
+ await new Promise(
+ // eslint-disable-next-line no-promise-executor-return
+ (resolve) => setTimeout(resolve, 0),
+ ); // Wait for promises to potentially resolve
+ expect(wasFirstPromiseResolved).toBe(true);
+ expect(wasSecondPromiseResolved).toBe(false);
+
+ // Release the lock again, expecting the second waiting promise to resolve
+ semaphore.release();
+ await new Promise(
+ // eslint-disable-next-line no-promise-executor-return
+ (resolve) => setTimeout(resolve, 0),
+ ); // Wait for promises to potentially resolve
+ expect(wasSecondPromiseResolved).toBe(true);
+ });
});
diff --git a/src/lib/stream/tests/s3.test.js b/src/lib/stream/tests/s3.test.js
index e9f58ea41c..53e17d44b5 100644
--- a/src/lib/stream/tests/s3.test.js
+++ b/src/lib/stream/tests/s3.test.js
@@ -2,6 +2,7 @@ import AWS from 'aws-sdk';
import { Readable } from 'stream';
import { auditLogger } from '../../../logger';
import S3Client from '../s3';
+import { generateS3Config } from '../../s3';
jest.mock('aws-sdk');
@@ -37,6 +38,30 @@ describe('S3Client', () => {
jest.clearAllMocks();
});
+ describe('constructor', () => {
+ it('should create an S3 client with default configuration', () => {
+ const s3Config = generateS3Config();
+ const client = new S3Client();
+ expect(AWS.S3).toHaveBeenCalledWith(s3Config.s3Config);
+ });
+
+ it('should create an S3 client with custom configuration', () => {
+ const customConfig = {
+ bucketName: 'custom-bucket',
+ s3Config: {
+ accessKeyId: 'customAccessKeyId',
+ endpoint: 'customEndpoint',
+ region: 'customRegion',
+ secretAccessKey: 'customSecretAccessKey',
+ signatureVersion: 'v4',
+ s3ForcePathStyle: true,
+ },
+ };
+ const client = new S3Client(customConfig);
+ expect(AWS.S3).toHaveBeenCalledWith(customConfig.s3Config);
+ });
+ });
+
describe('uploadFileAsStream', () => {
it('should upload file as stream', async () => {
const key = 'test-key';
diff --git a/src/lib/updateGrantsRecipients.js b/src/lib/updateGrantsRecipients.js
index a83720b67d..23f8adc381 100644
--- a/src/lib/updateGrantsRecipients.js
+++ b/src/lib/updateGrantsRecipients.js
@@ -61,7 +61,7 @@ function combineNames(firstName, lastName) {
return joinedName === '' ? null : joinedName;
}
-function getPersonnelField(role, field, program) {
+export function getPersonnelField(role, field, program) {
// return if program is not an object.
if (typeof program !== 'object') {
return null;
diff --git a/src/lib/updateGrantsRecipients.test.js b/src/lib/updateGrantsRecipients.test.js
index bb0a084214..19ee6efff6 100644
--- a/src/lib/updateGrantsRecipients.test.js
+++ b/src/lib/updateGrantsRecipients.test.js
@@ -2,7 +2,7 @@
import { Op, QueryTypes } from 'sequelize';
import axios from 'axios';
import fs from 'mz/fs';
-import updateGrantsRecipients, { processFiles, updateCDIGrantsWithOldGrantData } from './updateGrantsRecipients';
+import updateGrantsRecipients, { getPersonnelField, processFiles, updateCDIGrantsWithOldGrantData } from './updateGrantsRecipients';
import db, {
sequelize, Recipient, Goal, Grant, Program, ZALGrant, ActivityRecipient, ProgramPersonnel,
} from '../models';
@@ -1003,3 +1003,10 @@ describe('Update grants, program personnel, and recipients', () => {
});
});
});
+
+describe('getPersonnelField', () => {
+ it('returns null when data is not an object', () => {
+ const out = getPersonnelField('role', 'field', 'program');
+ expect(out).toBeNull();
+ });
+});
diff --git a/src/migrations/20240801000000-merge_duplicate_args.js b/src/migrations/20240801000000-merge_duplicate_args.js
new file mode 100644
index 0000000000..01f0abda85
--- /dev/null
+++ b/src/migrations/20240801000000-merge_duplicate_args.js
@@ -0,0 +1,41 @@
+const {
+ prepMigration,
+} = require('../lib/migration');
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.transaction(async (transaction) => {
+ const sessionSig = __filename;
+ await prepMigration(queryInterface, transaction, sessionSig);
+ await queryInterface.sequelize.query(`
+
+ -- Call the preexisting function for deduping args
+ -- created in 20240520000000-merge_duplicate_args.js
+ SELECT dedupe_args();
+
+ -- The expected results look like:
+ -- op_order | op_name | record_cnt
+ ------------+-----------------+------------
+ -- 1 | relinked_argfrs | 0
+ -- 2 | deleted_argfrs | 0
+ -- 3 | relinked_argrs | 0
+ -- 4 | deleted_argrs | 0
+ -- 5 | deleted_args | 66
+ SELECT
+ 1 op_order,
+ 'relinked_argfrs' op_name,
+ COUNT(*) record_cnt
+ FROM relinked_argfrs
+ UNION SELECT 2, 'deleted_argfrs', COUNT(*) FROM deleted_argfrs
+ UNION SELECT 3, 'relinked_argrs', COUNT(*) FROM relinked_argrs
+ UNION SELECT 4, 'deleted_argrs', COUNT(*) FROM deleted_argrs
+ UNION SELECT 5, 'deleted_args', COUNT(*) FROM deleted_args
+ ORDER BY 1;
+ `, { transaction });
+ });
+ },
+ async down() {
+ // rolling back merges and deletes would be a mess
+ },
+};
diff --git a/src/migrations/20240802204120-repair-multiple-aros.js b/src/migrations/20240802204120-repair-multiple-aros.js
new file mode 100644
index 0000000000..cfd142a777
--- /dev/null
+++ b/src/migrations/20240802204120-repair-multiple-aros.js
@@ -0,0 +1,27 @@
+const {
+ prepMigration,
+} = require('../lib/migration');
+
+module.exports = {
+ up: async (queryInterface) => queryInterface.sequelize.transaction(
+ async (transaction) => {
+ await prepMigration(queryInterface, transaction, __filename);
+ // somehow this ARO was duplicated three times. This migration repairs it.
+ // To verify, view the report (45555) as it's owner and confirm that the goals and
+ // objectives page shows 1 goal with TTA provided and 2 files
+ await queryInterface.sequelize.query(/* sql */`
+ DELETE FROM "ActivityReportObjectiveFiles" WHERE "activityReportObjectiveId" IN (232020, 232022);
+ DELETE FROM "ActivityReportObjectives" WHERE id IN (232020, 232022)
+
+ `, { transaction });
+ },
+ ),
+
+ down: async (queryInterface) => queryInterface.sequelize.transaction(
+ async (transaction) => {
+ await prepMigration(queryInterface, transaction, __filename);
+ // If we end up needing to revert this, it would be easier to use a separate
+ // migration using the txid (or a similar identifier) after it's already set
+ },
+ ),
+};
diff --git a/src/policies/event.js b/src/policies/event.js
index 9bb7138f36..c4e11b589b 100644
--- a/src/policies/event.js
+++ b/src/policies/event.js
@@ -26,6 +26,11 @@ export default class EventReport {
].includes(p.scopeId) && p.regionId === this.eventReport.regionId);
}
+ hasPocInRegion() {
+ // eslint-disable-next-line max-len
+ return !!this.permissions.find((p) => p.scopeId === SCOPES.POC_TRAINING_REPORTS && p.regionId === this.eventReport.regionId);
+ }
+
/**
* Determines if the user has write access to the specified region
* or to the region of their current event report.
diff --git a/src/services/activityReports.js b/src/services/activityReports.js
index 3b5f7fb50c..001f17ef5a 100644
--- a/src/services/activityReports.js
+++ b/src/services/activityReports.js
@@ -14,7 +14,6 @@ import {
ActivityReportCollaborator,
ActivityReportFile,
sequelize,
- Sequelize,
ActivityRecipient,
File,
Grant,
@@ -1043,6 +1042,7 @@ export async function setStatus(report, status) {
* @returns {*} Grants and Other entities
*/
export async function possibleRecipients(regionId, activityReportId = null) {
+ const inactiveDayDuration = 61;
const grants = await Recipient.findAll({
attributes: [
'id',
@@ -1080,7 +1080,18 @@ export async function possibleRecipients(regionId, activityReportId = null) {
'$grants.regionId$': regionId,
[Op.or]: [
{ '$grants.status$': 'Active' },
- { '$grants->activityRecipients.activityReportId$': activityReportId },
+ { ...(activityReportId ? { '$grants.activityRecipients.activityReportId$': activityReportId } : {}) },
+ {
+ '$grants.inactivationDate$': {
+ [Op.gte]: sequelize.literal(`
+ CASE
+ WHEN ${activityReportId ? 'true' : 'false'}
+ THEN (SELECT COALESCE("startDate", NOW() - INTERVAL '${inactiveDayDuration} days') FROM "ActivityReports" WHERE "id" = ${activityReportId})
+ ELSE date_trunc('day', NOW()) - interval '${inactiveDayDuration} days'
+ END
+ `),
+ },
+ },
],
},
});
diff --git a/src/services/activityReports.test.js b/src/services/activityReports.test.js
index 93e2c88253..5cab8335a2 100644
--- a/src/services/activityReports.test.js
+++ b/src/services/activityReports.test.js
@@ -1,3 +1,4 @@
+import faker from '@faker-js/faker';
import { APPROVER_STATUSES, REPORT_STATUSES } from '@ttahub/common';
import db, {
ActivityReport,
@@ -45,6 +46,14 @@ const ALERT_RECIPIENT_ID = 345;
const RECIPIENT_WITH_PROGRAMS_ID = 425;
const DOWNLOAD_RECIPIENT_WITH_PROGRAMS_ID = 426;
+const INACTIVE_GRANT_ID_ONE = faker.datatype.number({ min: 9999 });
+const INACTIVE_GRANT_ID_TWO = faker.datatype.number({ min: 9999 });
+const INACTIVE_GRANT_ID_THREE = faker.datatype.number({ min: 9999 });
+
+let inactiveActivityReportOne;
+let inactiveActivityReportTwo;
+let inactiveActivityReportMissingStartDate;
+
const mockUser = {
id: 1115665161,
homeRegionId: 1,
@@ -388,6 +397,80 @@ describe('Activity report service', () => {
startDate: new Date(),
endDate: new Date(),
});
+
+ // Create a inactive grant with a 'inactivationDate' date less than 60 days ago.
+ await Grant.create({
+ id: INACTIVE_GRANT_ID_ONE,
+ number: faker.datatype.number({ min: 9999 }),
+ recipientId: RECIPIENT_ID,
+ regionId: 19,
+ status: 'Inactive',
+ startDate: new Date(),
+ endDate: new Date(),
+ inactivationDate: new Date(new Date().setDate(new Date().getDate() - 60)),
+ });
+
+ // Create a inactive grant with a 'inactivationDate' date more than 60 days ago.
+ await Grant.create({
+ id: INACTIVE_GRANT_ID_TWO,
+ number: faker.datatype.number({ min: 9999 }),
+ recipientId: RECIPIENT_ID,
+ regionId: 19,
+ status: 'Inactive',
+ startDate: new Date(),
+ endDate: new Date(),
+ inactivationDate: new Date(new Date().setDate(new Date().getDate() - 62)),
+ });
+
+ await Grant.create({
+ id: INACTIVE_GRANT_ID_THREE,
+ number: faker.datatype.number({ min: 9999 }),
+ recipientId: RECIPIENT_ID,
+ regionId: 19,
+ status: 'Inactive',
+ startDate: new Date(),
+ endDate: new Date(),
+ inactivationDate: new Date(),
+ });
+
+ // Create a ActivityReport within 60 days.
+ inactiveActivityReportOne = await ActivityReport.create({
+ ...submittedReport,
+ userId: mockUser.id,
+ lastUpdatedById: mockUser.id,
+ submissionStatus: REPORT_STATUSES.DRAFT,
+ calculatedStatus: REPORT_STATUSES.DRAFT,
+ activityRecipients: [],
+ // Set a start date that will return the inactive grant.
+ startDate: new Date(new Date().setDate(new Date().getDate() - 62)),
+ endDate: new Date(new Date().setDate(new Date().getDate() - 62)),
+ });
+
+ // Create a ActivityReport outside of 60 days.
+ inactiveActivityReportTwo = await ActivityReport.create({
+ ...submittedReport,
+ userId: mockUser.id,
+ lastUpdatedById: mockUser.id,
+ submissionStatus: REPORT_STATUSES.DRAFT,
+ calculatedStatus: REPORT_STATUSES.DRAFT,
+ activityRecipients: [],
+ // Set a start date that will NOT return the inactive grant.
+ startDate: new Date(new Date().setDate(new Date().getDate() + 62)),
+ endDate: new Date(new Date().setDate(new Date().getDate() + 62)),
+ });
+
+ // Create a ActivityReport without start date.
+ inactiveActivityReportMissingStartDate = await ActivityReport.create({
+ ...submittedReport,
+ userId: mockUser.id,
+ lastUpdatedById: mockUser.id,
+ submissionStatus: REPORT_STATUSES.DRAFT,
+ calculatedStatus: REPORT_STATUSES.DRAFT,
+ activityRecipients: [],
+ // If there is no start date use today's date.
+ startDate: null,
+ endDate: null,
+ });
});
afterAll(async () => {
@@ -1068,8 +1151,19 @@ describe('Activity report service', () => {
it('retrieves correct recipients in region', async () => {
const region = 19;
const recipients = await possibleRecipients(region);
-
expect(recipients.grants.length).toBe(1);
+
+ // Get the grant with the id ALERT_RECIPIENT_ID.
+ const alertRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === RECIPIENT_ID,
+ );
+ expect(alertRecipient.length).toBe(1);
+
+ // Get the grant with the id inactiveGrantIdOne.
+ const inactiveRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === INACTIVE_GRANT_ID_ONE,
+ );
+ expect(inactiveRecipient.length).toBe(1);
});
it('retrieves no recipients in empty region', async () => {
@@ -1078,6 +1172,63 @@ describe('Activity report service', () => {
expect(recipients.grants.length).toBe(0);
});
+
+ it('retrieves inactive grant inside of range with report', async () => {
+ const region = 19;
+ const recipients = await possibleRecipients(region, inactiveActivityReportOne.id);
+ expect(recipients.grants.length).toBe(1);
+
+ // Get the grant with the id RECIPIENT_ID.
+ const alertRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === RECIPIENT_ID,
+ );
+ expect(alertRecipient.length).toBe(1);
+
+ // Get the grant with the id inactiveGrantIdOne.
+ const inactiveRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === INACTIVE_GRANT_ID_ONE,
+ );
+ expect(inactiveRecipient.length).toBe(1);
+ });
+
+ it('doesn\'t retrieve inactive grant outside of range with report', async () => {
+ const region = 19;
+ const recipients = await possibleRecipients(region, inactiveActivityReportTwo.id);
+ expect(recipients.grants.length).toBe(1);
+ expect(recipients.grants[0].grants.length).toBe(1);
+
+ // Get the grant with the id RECIPIENT_ID.
+ const alertRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === RECIPIENT_ID,
+ );
+ expect(alertRecipient.length).toBe(1);
+ });
+
+ it('retrieves inactive grant inside of range with report missing start date', async () => {
+ const region = 19;
+ // eslint-disable-next-line max-len
+ const recipients = await possibleRecipients(region, inactiveActivityReportMissingStartDate.id);
+ expect(recipients.grants.length).toBe(1);
+ expect(recipients.grants[0].grants.length).toBe(3);
+
+ // Get the grant with the id RECIPIENT_ID.
+ const alertRecipient = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === RECIPIENT_ID,
+ );
+ expect(alertRecipient.length).toBe(1);
+
+ // Get the grant with the id INACTIVE_GRANT_ID_ONE.
+ const inactiveGrantOne = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === INACTIVE_GRANT_ID_ONE,
+ );
+ expect(inactiveGrantOne.length).toBe(1);
+
+ // Get the grant with the id INACTIVE_GRANT_ID_THREE (todays date).
+ const inactiveGrantThree = recipients.grants[0].grants.filter(
+ (grant) => grant.dataValues.activityRecipientId === INACTIVE_GRANT_ID_THREE,
+ );
+ expect(inactiveGrantThree.length).toBe(1);
+ });
});
describe('getAllDownloadableActivityReports', () => {
diff --git a/src/services/event.test.js b/src/services/event.test.js
index f97edf77c1..1dd8bf0014 100644
--- a/src/services/event.test.js
+++ b/src/services/event.test.js
@@ -445,31 +445,48 @@ describe('event service', () => {
});
describe('tr import', () => {
- let user;
let data;
let buffer;
let created;
- const eventIdsToDestroy = [];
const userId = faker.datatype.number();
+ const pocId = faker.datatype.number();
+ let poc;
+ const collaboratorId = faker.datatype.number();
+ let collaborator;
- const eventId = 'R01-TR-02-3333';
+ let ncOne;
+ let ncTwo;
+
+ const eventId = 'R01-TR-3333';
const regionId = 1;
- const editTitle = 'Hogwarts Academy';
- const istName = 'Harry Potter';
+ const eventTitle = 'Hogwarts Academy';
const email = 'smartsheetevents@ss.com';
const audience = 'Recipients';
const vision = 'To learn';
- const duration = 'Series';
+ const trainingType = 'Series';
const targetPopulation = `"Program Staff
Affected by Disaster"`;
const reasons = `"Complaint
Planning/Coordination"`;
- const organizer = 'Dumbledore';
-
- const headings = ['Event ID', 'Edit Title', 'IST Name:', 'Creator', 'Event Organizer - Type of Event', 'Event Duration/# NC Days of Support', 'Reason for Activity', 'Target Population(s)', 'Audience', 'Overall Vision/Goal for the PD Event'];
+ const typeOfEvent = 'IST TTA/Visit';
+
+ const headings = [
+ 'IST/Creator',
+ 'Event ID',
+ 'Event Title',
+ 'Event Organizer - Type of Event',
+ 'National Centers',
+ 'Event Duration',
+ 'Reason(s) for PD',
+ 'Vision/Goal/Outcomes for the PD Event',
+ 'Target Population(s)',
+ 'Audience',
+ 'Designated Region POC for Event/Request',
+ ];
beforeAll(async () => {
+ // owner
await db.User.create({
id: userId,
homeRegionId: regionId,
@@ -477,55 +494,114 @@ describe('event service', () => {
hsesUserId: faker.datatype.string(),
email,
lastLogin: new Date(),
+ name: `${faker.name.firstName()} ${faker.name.lastName()}`,
});
+
await db.Permission.create({
userId,
regionId: 1,
scopeId: SCOPES.READ_WRITE_TRAINING_REPORTS,
});
- user = await db.User.findOne({ where: { id: userId } });
+
+ // collaborator
+ collaborator = await db.User.create({
+ id: collaboratorId,
+ homeRegionId: regionId,
+ hsesUsername: faker.datatype.string(),
+ hsesUserId: faker.datatype.string(),
+ email: faker.internet.email(),
+ lastLogin: new Date(),
+ name: `${faker.name.firstName()} ${faker.name.lastName()}`,
+ });
+
+ await db.Permission.create({
+ userId: collaboratorId,
+ regionId: 1,
+ scopeId: SCOPES.READ_WRITE_TRAINING_REPORTS,
+ });
+
+ // poc
+ poc = await db.User.create({
+ id: pocId,
+ homeRegionId: regionId,
+ hsesUsername: faker.datatype.string(),
+ hsesUserId: faker.datatype.string(),
+ email: faker.internet.email(),
+ lastLogin: new Date(),
+ name: `${faker.name.firstName()} ${faker.name.lastName()}`,
+ });
+
+ await db.Permission.create({
+ userId: pocId,
+ regionId: 1,
+ scopeId: SCOPES.POC_TRAINING_REPORTS,
+ });
+
+ // national centers
+ ncOne = await db.NationalCenter.create({
+ name: faker.hacker.abbreviation(),
+ });
+
+ // owner for national center 1
+ await db.NationalCenterUser.create({
+ userId,
+ nationalCenterId: ncOne.id,
+ });
+
+ ncTwo = await db.NationalCenter.create({
+ name: faker.hacker.abbreviation(),
+ });
+
+ // collab is national center user 2
+ await db.NationalCenterUser.create({
+ userId: collaboratorId,
+ nationalCenterId: ncTwo.id,
+ });
+
data = `${headings.join(',')}
-${eventId},${editTitle},${istName},${email},${organizer},${duration},${reasons},${targetPopulation},${audience},${vision}
-R01-TR-4234,bad_title,bad_istname,bad_email,bad_organizer,bad_duration,bad_reasons,bad_target,${audience},bad_vision`;
+${email},${eventId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
buffer = Buffer.from(data);
});
afterAll(async () => {
- await db.User.destroy({ where: { id: userId } });
await db.EventReportPilot.destroy({ where: { ownerId: userId } });
- // await db.EventReportPilot.destroy({ where: { id: created.id } });
- await db.Permission.destroy({ where: { userId } });
+ await db.NationalCenterUser.destroy({
+ where: { userId: [userId, collaboratorId] },
+ });
+ await db.NationalCenter.destroy({ where: { id: [ncOne.id, ncTwo.id] } });
+ await db.Permission.destroy({ where: { id: [userId, collaboratorId, pocId] } });
+ await db.User.destroy({ where: { id: [userId, collaboratorId, pocId] } });
});
it('imports good data correctly', async () => {
const result = await csvImport(buffer);
+ expect(result.errors).toEqual([]);
+ expect(result.count).toEqual(1);
+
// eventId is now a field in the jsonb body of the "data" column on
// db.EventReportPilot.
// Let's make sure it exists.
created = await db.EventReportPilot.findOne({
where: { 'data.eventId': eventId },
- raw: true,
});
+ expect(created).not.toBeNull();
+
expect(created).toHaveProperty('ownerId', userId);
expect(created).toHaveProperty('regionId', regionId);
expect(created.data.reasons).toEqual(['Complaint', 'Planning/Coordination']);
expect(created.data.vision).toEqual(vision);
- expect(created.data.audience).toEqual(audience);
+ expect(created.data.eventIntendedAudience).toEqual(audience);
expect(created.data.targetPopulations).toEqual(['Program Staff', 'Affected by Disaster']);
- expect(created.data.eventOrganizer).toEqual(organizer);
+ expect(created.data.eventOrganizer).toEqual(typeOfEvent);
expect(created.data.creator).toEqual(email);
- expect(created.data.istName).toEqual(istName);
- expect(created.data.eventName).toEqual(editTitle);
- expect(created.data.eventDuration).toEqual(duration);
-
- expect(result.count).toEqual(1);
- expect(result.errors).toEqual(['User bad_email does not exist']);
+ expect(created.data.eventName).toEqual(eventTitle);
+ expect(created.data.trainingType).toEqual(trainingType);
const secondImport = `${headings.join(',')}
-${eventId},bad_title,bad_istname,${email},bad_organizer,bad_duration,bad_reasons,bad_target,${audience},bad_vision`;
+${email},${eventId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
// Subsequent import with event ID that already exists in the database
// should skip importing this TR.
@@ -536,9 +612,12 @@ ${eventId},bad_title,bad_istname,${email},bad_organizer,bad_duration,bad_reasons
it('gives an error if the user can\'t write in the region', async () => {
await db.Permission.destroy({ where: { userId } });
- const result = await csvImport(buffer);
+ const d = `${headings.join(',')}
+${email},R01-TR-3334,${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
expect(result.count).toEqual(0);
- expect(result.errors).toEqual([`User ${email} does not have permission to write in region ${regionId}`, 'User bad_email does not exist']);
+ expect(result.errors).toEqual([`User ${email} does not have permission to write in region ${regionId}`]);
await db.Permission.create({
userId,
regionId: 1,
@@ -546,36 +625,56 @@ ${eventId},bad_title,bad_istname,${email},bad_organizer,bad_duration,bad_reasons
});
});
+ it('errors if the POC user lacks permissions', async () => {
+ await db.Permission.destroy({ where: { userId: pocId } });
+ const d = `${headings.join(',')}
+${email},R01-TR-3334,${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
+ expect(result.count).toEqual(0);
+ expect(result.errors).toEqual([`User ${poc.name} does not have POC permission in region ${regionId}`]);
+ await db.Permission.create({
+ userId: pocId,
+ regionId: 1,
+ scopeId: SCOPES.POC_TRAINING_REPORTS,
+ });
+ });
+
+ it('errors if the IST Collaborator user lacks permissions', async () => {
+ await db.Permission.destroy({ where: { userId: collaboratorId } });
+ const d = `${headings.join(',')}
+${email},R01-TR-3334,${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
+ expect(result.count).toEqual(0);
+ expect(result.errors).toEqual([`User ${collaborator.name} does not have permission to write in region ${regionId}`]);
+ await db.Permission.create({
+ userId: collaboratorId,
+ regionId: 1,
+ scopeId: SCOPES.READ_WRITE_TRAINING_REPORTS,
+ });
+ });
+
it('skips rows that don\'t start with the correct prefix', async () => {
- const reportId = 'R01-TR-5842';
const dataToTest = `${headings.join(',')}
-${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,${audience},tr_vision
-01-TR-4256,tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,${audience},tr_vision
-R-TR-3426,tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,${audience},tr_vision`;
-
- eventIdsToDestroy.push(reportId);
+${email},01-TR-4256,${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name}`;
const bufferWithSkips = Buffer.from(dataToTest);
const result = await csvImport(bufferWithSkips);
- expect(result.count).toEqual(1);
- expect(result.skipped.length).toEqual(2);
+ expect(result.skipped.length).toEqual(1);
expect(result.skipped).toEqual(
- ['Invalid "Event ID" format expected R##-TR-#### received 01-TR-4256', 'Invalid "Event ID" format expected R##-TR-#### received R-TR-3426'],
+ ['Invalid "Event ID" format expected R##-TR-#### received 01-TR-4256'],
);
});
it('only imports valid columns ignores others', async () => {
const mixedColumns = `${headings.join(',')},Extra Column`;
const reportId = 'R01-TR-3478';
- const dataToTest = `${mixedColumns}
-${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,${audience},tr_vision,extra_data`;
-
- eventIdsToDestroy.push(reportId);
-
- const bufferWithSkips = Buffer.from(dataToTest);
-
- const result = await csvImport(bufferWithSkips);
+ const d = `${mixedColumns}
+${email},${reportId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},${audience},${poc.name},JIBBER-JABBER`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
expect(result.count).toEqual(1);
expect(result.skipped.length).toEqual(0);
expect(result.errors.length).toEqual(0);
@@ -585,26 +684,21 @@ ${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_
});
expect(importedEvent).not.toBeNull();
- // Assert 11 core fields, plus goal and goals[].
- expect(Object.keys(importedEvent.data).length).toEqual(12);
// Assert data does not contain the extra column.
expect(importedEvent.data).not.toHaveProperty('Extra Column');
});
it('only imports valid reasons ignores others', async () => {
+ const mixedColumns = `${headings.join(',')},Extra Column`;
const reportId = 'R01-TR-9528';
const reasonsToTest = `"New Director or Management
Complaint
Planning/Coordination
Invalid Reason"`;
- const dataToTest = `${headings.join(',')}
-${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,${reasonsToTest},tr_target,${audience},tr_vision`;
-
- eventIdsToDestroy.push(reportId);
-
- const bufferWithSkips = Buffer.from(dataToTest);
-
- const result = await csvImport(bufferWithSkips);
+ const d = `${mixedColumns}
+${email},${reportId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasonsToTest},${vision},${targetPopulation},${audience},${poc.name},JIBBER-JABBER`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
expect(result.count).toEqual(1);
expect(result.skipped.length).toEqual(0);
expect(result.errors.length).toEqual(0);
@@ -613,24 +707,20 @@ ${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,${reasonsToTes
where: { 'data.eventId': reportId },
});
expect(importedEvent).not.toBeNull();
-
- // Assert data.reasons contains only valid reasons.
expect(importedEvent.data.reasons).toEqual(['New Director or Management', 'Complaint', 'Planning/Coordination']);
});
it('only imports valid target populations ignores others', async () => {
+ const mixedColumns = `${headings.join(',')},Extra Column`;
const reportId = 'R01-TR-6578';
const tgtPopToTest = `"Program Staff
- Pregnant Women / Pregnant Persons
- Invalid Pop"`;
- const dataToTest = `${headings.join(',')}
-${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,Complaint,${tgtPopToTest},${audience},tr_vision`;
-
- eventIdsToDestroy.push(reportId);
+ Pregnant Women / Pregnant Persons
+ Invalid Pop"`;
- const bufferWithSkips = Buffer.from(dataToTest);
-
- const result = await csvImport(bufferWithSkips);
+ const d = `${mixedColumns}
+${email},${reportId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${tgtPopToTest},${audience},${poc.name},JIBBER-JABBER`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
expect(result.count).toEqual(1);
expect(result.skipped.length).toEqual(0);
expect(result.errors.length).toEqual(0);
@@ -639,36 +729,19 @@ ${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,Complaint,${tg
where: { 'data.eventId': reportId },
});
expect(importedEvent).not.toBeNull();
-
- // Assert data.reasons contains only valid reasons.
expect(importedEvent.data.targetPopulations).toEqual(['Program Staff', 'Pregnant Women / Pregnant Persons']);
});
it('skips rows that have an invalid audience', async () => {
const reportId = 'R01-TR-5725';
- const dataToTest = `${headings.join(',')}
-${reportId},tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,"Regional office/TTA",tr_vision
-R01-TR-4658,tr_title,tr_istname,${email},tr_organizer,tr_duration,tr_reasons,tr_target,"Invalid Audience",tr_vision`;
-
- eventIdsToDestroy.push(reportId);
-
- const bufferWithSkips = Buffer.from(dataToTest);
-
- const result = await csvImport(bufferWithSkips);
- expect(result.count).toEqual(1);
+ const mixedColumns = `${headings.join(',')},Extra Column`;
+ const d = `${mixedColumns}
+${email},${reportId},${eventTitle},${typeOfEvent},${ncTwo.name},${trainingType},${reasons},${vision},${targetPopulation},Invalid Audience,${poc.name},JIBBER-JABBER`;
+ const b = Buffer.from(d);
+ const result = await csvImport(b);
+ expect(result.count).toEqual(0);
expect(result.skipped.length).toEqual(1);
- expect(result.skipped).toEqual(['Value "Invalid Audience" is invalid for column "Audience". Must be of one of Recipients, Regional office/TTA: R01-TR-4658']);
-
- // Retrieve the imported event.
- const importedEvent = await db.EventReportPilot.findOne({
- where: { 'data.eventId': reportId },
- });
-
- // Assert the imported event is not null.
- expect(importedEvent).not.toBeNull();
-
- // Assert the imported event has the correct audience.
- expect(importedEvent.data.audience).toEqual('Regional office/TTA');
+ expect(result.skipped).toEqual(['Value "Invalid Audience" is invalid for column "Audience". Must be of one of Recipients, Regional office/TTA: R01-TR-5725']);
});
});
diff --git a/src/services/event.ts b/src/services/event.ts
index 3536ca4b98..9c271e5c20 100644
--- a/src/services/event.ts
+++ b/src/services/event.ts
@@ -6,6 +6,7 @@ import {
TRAINING_REPORT_STATUSES as TRS,
REASONS,
TARGET_POPULATIONS,
+ EVENT_TARGET_POPULATIONS,
EVENT_AUDIENCE,
} from '@ttahub/common';
import { auditLogger } from '../logger';
@@ -24,6 +25,19 @@ const {
EventReportPilotNationalCenterUser,
} = db;
+type UserWhereOptions = {
+ name?: { [Op.iLike]: string };
+ email?: string;
+};
+
+type WhereOptions = {
+ id?: number;
+ ownerId?: number;
+ pocIds?: number;
+ collaboratorIds?: number[];
+ regionId?: number;
+};
+
export const validateFields = (request, requiredFields) => {
const missingFields = requiredFields.filter((field) => !request[field]);
@@ -267,14 +281,6 @@ async function findEventHelperBlob({
return events || null;
}
-type WhereOptions = {
- id?: number;
- ownerId?: number;
- pocIds?: number;
- collaboratorIds?: number[];
- regionId?: number;
-};
-
/**
* Updates an existing event in the database or creates a new one if it doesn't exist.
* @param request An object containing all fields to be updated for the event.
@@ -496,15 +502,17 @@ export async function findAllEvents(): Promise {
const splitPipe = (str: string) => str.split('\n').map((s) => s.trim()).filter(Boolean);
const mappings: Record = {
- Audience: 'audience',
- Creator: 'creator',
- 'Edit Title': 'eventName',
+ Audience: 'eventIntendedAudience',
+ 'IST/Creator': 'creator',
'Event Title': 'eventName',
- 'Event Duration/#NC Days of Support': 'eventDuration',
- 'Event Duration/# NC Days of Support': 'eventDuration',
+ 'Event Duration': 'trainingType',
+ 'Event Duration/#NC Days of Support': 'trainingType',
+ 'Event Duration/# NC Days of Support': 'trainingType',
'Event ID': 'eventId',
'Overall Vision/Goal for the PD Event': 'vision',
+ 'Vision/Goal/Outcomes for the PD Event': 'vision',
'Reason for Activity': 'reasons',
+ 'Reason(s) for PD': 'reasons',
'Target Population(s)': 'targetPopulations',
'Event Organizer - Type of Event': 'eventOrganizer',
'IST Name:': 'istName',
@@ -536,26 +544,63 @@ const mapLineToData = (line: Record) => {
return data;
};
-const checkUserExists = async (creator: string) => {
+const checkUserExists = async (userWhere: UserWhereOptions) => {
+ const user = await db.User.findOne({
+ where: userWhere,
+ include: [
+ {
+ model: db.Permission,
+ as: 'permissions',
+ },
+ {
+ model: db.NationalCenter,
+ as: 'nationalCenters',
+ },
+ ],
+ });
+
+ if (!user) {
+ throw new Error(`User with ${
+ Object.keys(userWhere).map((key) => `${key}: ${userWhere[key]}`).join('AND ')
+ } does not exist`);
+ }
+ return user;
+};
+
+const checkUserExistsByNationalCenter = async (identifier: string) => {
const user = await db.User.findOne({
- where: { email: creator },
+ attributes: ['id', 'name'],
include: [
{
model: db.Permission,
as: 'permissions',
},
{
+ attributes: ['name'],
model: db.NationalCenter,
as: 'nationalCenters',
+ where: {
+ name: identifier,
+ },
+ required: true,
},
],
});
- if (!user) throw new Error(`User ${creator} does not exist`);
+
+ if (!user) throw new Error(`User associated with National Center: ${identifier} does not exist`);
return user;
};
+const checkUserExistsByName = async (name: string) => checkUserExists({
+ name: {
+ [Op.iLike]: name,
+ },
+});
+const checkUserExistsByEmail = async (email: string) => checkUserExists({ email });
+
const checkEventExists = async (eventId: string) => {
const event = await db.EventReportPilot.findOne({
+ attributes: ['id'],
where: {
id: {
[Op.in]: sequelize.literal(
@@ -564,6 +609,7 @@ const checkEventExists = async (eventId: string) => {
},
},
});
+
if (event) throw new Error(`Event ${eventId} already exists`);
};
@@ -581,11 +627,14 @@ export async function csvImport(buffer: Buffer) {
const eventId = cleanLine['Event ID'];
// If the eventId doesn't start with the prefix R and two numbers, it's invalid.
- if (!eventId.match(/^R\d{2}/i)) {
+ const match = eventId.match(/^R\d{2}/i);
+ if (match === null) {
skipped.push(`Invalid "Event ID" format expected R##-TR-#### received ${eventId}`);
return false;
}
+ await checkEventExists(eventId);
+
// Validate audience else skip.
if (!EVENT_AUDIENCE.includes(cleanLine.Audience)) {
skipped.push(`Value "${cleanLine.Audience}" is invalid for column "Audience". Must be of one of ${EVENT_AUDIENCE.join(', ')}: ${eventId}`);
@@ -594,10 +643,15 @@ export async function csvImport(buffer: Buffer) {
const regionId = Number(eventId.split('-')[0].replace(/\D/g, '').replace(/^0+/, ''));
- const creator = cleanLine.Creator;
+ const creator = cleanLine['IST/Creator'] || cleanLine.Creator;
+ if (!creator) {
+ errors.push(`No creator listed on import for ${eventId}`);
+ return false;
+ }
let owner;
if (creator) {
- owner = await checkUserExists(creator);
+ owner = await checkUserExistsByEmail(creator);
+
const policy = new EventReport(owner, {
regionId,
});
@@ -608,33 +662,70 @@ export async function csvImport(buffer: Buffer) {
}
}
- await checkEventExists(eventId);
+ const collaborators = [];
+ const pocs = [];
+
+ if (cleanLine['Designated Region POC for Event/Request']) {
+ const pocNames = cleanLine['Designated Region POC for Event/Request'].split('/').map((name) => name.trim());
+ // eslint-disable-next-line no-restricted-syntax
+ for await (const pocName of pocNames) {
+ const poc = await checkUserExistsByName(pocName);
+ const policy = new EventReport(poc, {
+ regionId,
+ });
+
+ if (!policy.hasPocInRegion()) {
+ errors.push(`User ${pocName} does not have POC permission in region ${regionId}`);
+ return false;
+ }
+ pocs.push(poc.id);
+ }
+ }
- const data = mapLineToData(cleanLine);
+ if (cleanLine['National Centers']) {
+ const nationalCenterNames = cleanLine['National Centers'].split('\n').map((name) => name.trim());
+ // eslint-disable-next-line no-restricted-syntax
+ for await (const center of nationalCenterNames) {
+ const collaborator = await checkUserExistsByNationalCenter(center);
+ const policy = new EventReport(collaborator, {
+ regionId,
+ });
+
+ if (!policy.canWriteInRegion()) {
+ errors.push(`User ${collaborator.name} does not have permission to write in region ${regionId}`);
+ return false;
+ }
+ collaborators.push(collaborator.id);
+ }
+ }
+
+ if (!collaborators.length) {
+ errors.push(`No collaborators found for ${eventId}`);
+ return false;
+ }
- data.goals = []; // shape: { grantId: number, goalId: number, sessionId: number }[]
- data.goal = '';
+ const data = mapLineToData(cleanLine);
// Reasons, remove duplicates and invalid values.
- data.reasons = [...new Set(data.reasons as string[])];
- data.reasons = (data.reasons as string[]).filter((reason) => REASONS.includes(reason));
+ data.reasons = [...new Set(data.reasons as string[])].filter((reason) => REASONS.includes(reason));
// Target Populations, remove duplicates and invalid values.
- data.targetPopulations = [...new Set(data.targetPopulations as string[])];
- data.targetPopulations = (data.targetPopulations as string[]).filter((target) => TARGET_POPULATIONS.includes(target));
+ data.targetPopulations = [...new Set(data.targetPopulations as string[])].filter((target) => [...TARGET_POPULATIONS, ...EVENT_TARGET_POPULATIONS].includes(target));
await db.EventReportPilot.create({
- collaboratorIds: [],
+ collaboratorIds: collaborators,
ownerId: owner.id,
regionId,
+ pocIds: pocs,
data: sequelize.cast(JSON.stringify(data), 'jsonb'),
imported: sequelize.cast(JSON.stringify(cleanLine), 'jsonb'),
});
return true;
} catch (error) {
- if (error.message.startsWith('User')) {
- errors.push(error.message);
+ const message = (error.message || '').replace(/\/t/g, '');
+ if (message.startsWith('User')) {
+ errors.push(message);
} else if (error.message.startsWith('Event')) {
skipped.push(line['Event ID']);
}
diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues
index 49bae093ea..83ed067419 100644
--- a/yarn-audit-known-issues
+++ b/yarn-audit-known-issues
@@ -1,4 +1,5 @@
{"type":"auditAdvisory","data":{"resolution":{"id":1096366,"path":"email-templates>preview-email>mailparser>nodemailer","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"6.7.3","paths":["email-templates>preview-email>mailparser>nodemailer"]}],"metadata":null,"vulnerable_versions":"<=6.9.8","module_name":"nodemailer","severity":"moderate","github_advisory_id":"GHSA-9h6g-pr28-7cqp","cves":[],"access":"public","patched_versions":">=6.9.9","cvss":{"score":5.3,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"},"updated":"2024-02-01T17:58:50.000Z","recommendation":"Upgrade to version 6.9.9 or later","cwe":["CWE-1333"],"found_by":null,"deleted":null,"id":1096366,"references":"- https://github.com/nodemailer/nodemailer/security/advisories/GHSA-9h6g-pr28-7cqp\n- https://gist.github.com/francoatmega/890dd5053375333e40c6fdbcc8c58df6\n- https://gist.github.com/francoatmega/9aab042b0b24968d7b7039818e8b2698\n- https://github.com/nodemailer/nodemailer/commit/dd8f5e8a4ddc99992e31df76bcff9c590035cd4a\n- https://github.com/advisories/GHSA-9h6g-pr28-7cqp","created":"2024-01-31T22:42:54.000Z","reported_by":null,"title":"nodemailer ReDoS when trying to send a specially crafted email","npm_advisory_id":null,"overview":"### Summary\nA ReDoS vulnerability occurs when nodemailer tries to parse img files with the parameter `attachDataUrls` set, causing the stuck of event loop. \nAnother flaw was found when nodemailer tries to parse an attachments with a embedded file, causing the stuck of event loop. \n\n### Details\n\nRegex: /^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/\n\nPath: compile -> getAttachments -> _processDataUrl\n\nRegex: /( ]* src\\s*=[\\s\"']*)(data:([^;]+);[^\"'>\\s]+)/\n\nPath: _convertDataImages\n\n### PoC\n\nhttps://gist.github.com/francoatmega/890dd5053375333e40c6fdbcc8c58df6\nhttps://gist.github.com/francoatmega/9aab042b0b24968d7b7039818e8b2698\n\n### Impact\n\nReDoS causes the event loop to stuck a specially crafted evil email can cause this problem.\n","url":"https://github.com/advisories/GHSA-9h6g-pr28-7cqp"}}}
+{"type":"auditAdvisory","data":{"resolution":{"id":1098307,"path":"newrelic>@newrelic/security-agent>@aws-sdk/client-lambda>@aws-sdk/client-sts>fast-xml-parser","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"4.2.5","paths":["newrelic>@newrelic/security-agent>@aws-sdk/client-lambda>@aws-sdk/client-sts>fast-xml-parser"]}],"metadata":null,"vulnerable_versions":"<4.4.1","module_name":"fast-xml-parser","severity":"high","github_advisory_id":"GHSA-mpg4-rc92-vx8v","cves":["CVE-2024-41818"],"access":"public","patched_versions":">=4.4.1","cvss":{"score":7.5,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"updated":"2024-07-29T19:47:38.000Z","recommendation":"Upgrade to version 4.4.1 or later","cwe":["CWE-400"],"found_by":null,"deleted":null,"id":1098307,"references":"- https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-mpg4-rc92-vx8v\n- https://github.com/NaturalIntelligence/fast-xml-parser/commit/d0bfe8a3a2813a185f39591bbef222212d856164\n- https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/src/v5/valueParsers/currency.js#L10\n- https://nvd.nist.gov/vuln/detail/CVE-2024-41818\n- https://github.com/advisories/GHSA-mpg4-rc92-vx8v","created":"2024-07-29T17:46:16.000Z","reported_by":null,"title":"fast-xml-parser vulnerable to ReDOS at currency parsing","npm_advisory_id":null,"overview":"### Summary\nA ReDOS exists on currency.js was discovered by Gauss Security Labs R&D team.\n\n### Details\nhttps://github.com/NaturalIntelligence/fast-xml-parser/blob/master/src/v5/valueParsers/currency.js#L10\ncontains a vulnerable regex \n\n### PoC\npass the following string '\\t'.repeat(13337) + '.'\n\n### Impact\nDenial of service during currency parsing in experimental version 5 of fast-xml-parser-library\n\nhttps://gauss-security.com","url":"https://github.com/advisories/GHSA-mpg4-rc92-vx8v"}}}
{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}}
{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>joi>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}}
{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>joi>topo>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}}