From 4881c953eaaccc0afe805f10538effb4232bed05 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Thu, 4 Jan 2024 16:31:04 -0800 Subject: [PATCH 01/13] updated seed for artifacts --- .../src/seeds/02_populate_feature_tables.ts | 3 +- database/src/seeds/04_mock_test_data.ts | 4 +-- database/src/seeds/06_submission_data.ts | 33 ++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/database/src/seeds/02_populate_feature_tables.ts b/database/src/seeds/02_populate_feature_tables.ts index 5480dbbe..16df6820 100644 --- a/database/src/seeds/02_populate_feature_tables.ts +++ b/database/src/seeds/02_populate_feature_tables.ts @@ -26,6 +26,7 @@ export async function seed(knex: Knex): Promise { insert into feature_property_type (name, description, record_effective_date) values ('boolean', 'A boolean type', now()) ON CONFLICT DO NOTHING; insert into feature_property_type (name, description, record_effective_date) values ('object', 'An object type', now()) ON CONFLICT DO NOTHING; insert into feature_property_type (name, description, record_effective_date) values ('array', 'An array type', now()) ON CONFLICT DO NOTHING; + insert into feature_property_type (name, description, record_effective_date) values ('s3_key', 'An S3 key type', now()) ON CONFLICT DO NOTHING; -- populate feature_property table insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('name', 'Name', 'The name of the record', (select feature_property_type_id from feature_property_type where name = 'string'), null, now()) ON CONFLICT DO NOTHING; @@ -39,7 +40,7 @@ export async function seed(knex: Knex): Promise { insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('count', 'Count', 'The count of the record', (select feature_property_type_id from feature_property_type where name = 'number'), null, now()) ON CONFLICT DO NOTHING; insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('latitude', 'Latitude', 'The latitude of the record', (select feature_property_type_id from feature_property_type where name = 'number'), null, now()) ON CONFLICT DO NOTHING; insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('longitude', 'Longitude', 'The longitude of the record', (select feature_property_type_id from feature_property_type where name = 'number'), null, now()) ON CONFLICT DO NOTHING; - insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('s3_key', 'Key', 'The S3 storage key for an artifact', (select feature_property_type_id from feature_property_type where name = 'string'), null, now()) ON CONFLICT DO NOTHING; + insert into feature_property (name, display_name, description, feature_property_type_id, parent_feature_property_id, record_effective_date) values ('s3_key', 'Key', 'The S3 storage key for an artifact', (select feature_property_type_id from feature_property_type where name = 's3_key'), null, now()) ON CONFLICT DO NOTHING; -- populate feature_type table insert into feature_type (name, display_name, description, sort, record_effective_date) values ('dataset', 'Dataset', 'A related collection of data (ie: survey)', 1, now()) ON CONFLICT DO NOTHING; diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index 89d5756e..e150b5c7 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -281,10 +281,10 @@ export const insertSubmission = (includeSecurityReviewTimestamp: boolean, includ `; }; -const insertSubmissionFeature = (options: { +export const insertSubmissionFeature = (options: { submission_id: number; parent_submission_feature_id: number | null; - feature_type: 'dataset' | 'sample_site' | 'observation' | 'animal'; + feature_type: 'dataset' | 'sample_site' | 'observation' | 'animal' | 'artifact'; data: { [key: string]: any }; }) => ` INSERT INTO submission_feature diff --git a/database/src/seeds/06_submission_data.ts b/database/src/seeds/06_submission_data.ts index ba3abf8f..19f93a14 100644 --- a/database/src/seeds/06_submission_data.ts +++ b/database/src/seeds/06_submission_data.ts @@ -1,6 +1,11 @@ import { faker } from '@faker-js/faker'; import { Knex } from 'knex'; -import { insertDatasetRecord, insertSampleSiteRecord, insertSubmissionRecord } from './04_mock_test_data'; +import { + insertDatasetRecord, + insertSampleSiteRecord, + insertSubmissionFeature, + insertSubmissionRecord +} from './04_mock_test_data'; /** * Inserts mock submission data @@ -46,6 +51,30 @@ const insertFeatureSecurity = async (knex: Knex, submission_feature_id: number, VALUES($$${submission_feature_id}$$, $$${security_rule_id}$$, $$${faker.date.past().toISOString()}$$);`); }; +const insertArtifactRecord = async (knex: Knex, row: { submission_id: number }) => { + const S3_KEY = 'blah'; + + const sql = insertSubmissionFeature({ + submission_id: row.submission_id, + parent_submission_feature_id: null, + feature_type: 'artifact', + data: { s3_key: S3_KEY } + }); + + const submission_feature = await knex.raw(sql); + + const submission_feature_id = submission_feature.rows[0].submission_feature_id; + + await knex.raw(` + INSERT INTO search_string (submission_feature_id, feature_property_id, value) + VALUES + ( + ${submission_feature_id}, + (select feature_property_id from feature_property where name = 's3_key'), + $$${S3_KEY}$$ + );`); +}; + const createSubmissionWithSecurity = async ( knex: Knex, securityLevel: 'PARTIALLY SECURE' | 'SECURE' | 'UNSECURE', @@ -58,6 +87,8 @@ const createSubmissionWithSecurity = async ( submission_id }); + await insertArtifactRecord(knex, { submission_id }); + if (securityLevel === 'PARTIALLY SECURE') { await insertFeatureSecurity(knex, submission_feature_id, 1); return; From 7aed7216b27607e86958a747655568b3cf75181a Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Mon, 8 Jan 2024 16:08:01 -0800 Subject: [PATCH 02/13] artifacts now downloadable from frontend --- .../{submissionFeatureId}/signed-url.ts | 119 ++++++++++++++++++ api/src/repositories/submission-repository.ts | 74 +++++++++++ api/src/services/submission-service.ts | 34 ++++- .../submissions/AdminSubmissionPage.tsx | 2 +- .../SubmissionDataGrid.tsx | 66 +++++++--- .../SubmissionDataGridColDefs.tsx | 0 app/src/hooks/api/useSubmissionsApi.ts | 14 ++- .../interfaces/useSubmissionsApi.interface.ts | 7 ++ database/src/seeds/06_submission_data.ts | 2 +- 9 files changed, 300 insertions(+), 18 deletions(-) create mode 100644 api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts rename app/src/features/submissions/components/{ => SubmissionDataGrid}/SubmissionDataGrid.tsx (71%) create mode 100644 app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx diff --git a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts new file mode 100644 index 00000000..b2c5f4ad --- /dev/null +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts @@ -0,0 +1,119 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection, getDBConnection } from '../../../../../database/db'; +import { defaultErrorResponses } from '../../../../../openapi/schemas/http-responses'; +import { SubmissionService } from '../../../../../services/submission-service'; +import { UserService } from '../../../../../services/user-service'; +import { getLogger } from '../../../../../utils/logger'; + +const defaultLog = getLogger('paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url'); + +export const GET: Operation = [getSubmissionFeatureSignedUrl()]; + +GET.apiDoc = { + description: 'Retrieves a signed url of a submission feature', + tags: ['eml'], + security: [ + { + OptionalBearer: [] + } + ], + parameters: [ + { + description: 'Submission ID.', + in: 'path', + name: 'submissionId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + description: 'Submission Feature ID.', + in: 'path', + name: 'submissionFeatureId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + description: 'submission feature property key search query', + in: 'query', + name: 'key', + required: true, + schema: { + type: 'string' + } + }, + { + description: 'submission feature property value search query', + in: 'query', + name: 'value', + required: true, + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'The signed url for a key of a submission feature', + content: { + 'application/json': { + schema: { + type: 'string', + nullable: true + } + } + } + }, + ...defaultErrorResponses + } +}; + +/** + * Retrieves signed url of a submission feature key + * + * @returns {RequestHandler} + */ +export function getSubmissionFeatureSignedUrl(): RequestHandler { + return async (req, res) => { + const connection = req['keycloak_token'] ? getDBConnection(req['keycloak_token']) : getAPIUserDBConnection(); + + //const submissionId = Number(req.params.submissionId); + + const submissionFeatureId = Number(req.params.submissionFeatureId); + + const submissionFeatureDataKey = String(req.query.key); + + const submissionFeatureDataValue = String(req.query.value); + + try { + await connection.open(); + + const userService = new UserService(connection); + const submissionService = new SubmissionService(connection); + + const isAdmin = await userService.isSystemUserAdmin(); + + const result = await submissionService.getSubmissionFeatureSignedUrl({ + submissionFeatureId, + submissionFeatureObj: { key: submissionFeatureDataKey, value: submissionFeatureDataValue }, + isAdmin + }); + + await connection.commit(); + + res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getSubmissionFeatureSignedUrl', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index ea65c1fe..629064d4 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -317,6 +317,12 @@ export const PatchSubmissionRecord = z.object({ export type PatchSubmissionRecord = z.infer; +export type SubmissionFeatureSignedUrlPayload = { + submissionFeatureId: number; + submissionFeatureObj: { key: string; value: string }; + isAdmin: boolean; +}; + /** * A repository class for accessing submission data. * @@ -1753,4 +1759,72 @@ export class SubmissionRepository extends BaseRepository { return response.rows; } + + /** + * Retrieves submission feature (artifact) key from data column key value pair. + * Checks submission feature is not secure. + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} payload + * @throws {ApiExecuteSQLError} + * @memberof SubmissionRepository + * @returns {Promise} - submission feature (artifact) key + */ + async getSubmissionFeatureArtifactKey(payload: SubmissionFeatureSignedUrlPayload): Promise { + const sqlStatement = SQL` + SELECT ss.value + FROM search_string ss + INNER JOIN feature_property fp + ON ss.feature_property_id = fp.feature_property_id + WHERE ss.submission_feature_id = ${payload.submissionFeatureId} + AND NOT EXISTS ( + SELECT NULL + FROM submission_feature_security sfs + WHERE sfs.submission_feature_id = ss.submission_feature_id + ) + AND ss.value = ${payload.submissionFeatureObj.value} + AND fp.name = ${payload.submissionFeatureObj.key};`; + + const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); + + if (response.rows.length === 0) { + throw new ApiExecuteSQLError('Failed to get key for signed URL', [ + `submissionFeature is secure or matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, + 'SubmissionRepository->getSubmissionFeatureArtifactKey' + ]); + } + + return response.rows[0].value; + } + + /** + * Retrieves submission feature (artifact) key from data column key value pair. Skips security checks. + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} payload + * @throws {ApiExecuteSQLError} + * @memberof SubmissionRepository + * @returns {Promise} - submission feature (artifact) key + */ + async getAdminSubmissionFeatureArtifactKey(payload: SubmissionFeatureSignedUrlPayload): Promise { + const sqlStatement = SQL` + SELECT ss.value + FROM search_string ss + INNER JOIN feature_property fp + ON ss.feature_property_id = fp.feature_property_id + WHERE ss.submission_feature_id = ${payload.submissionFeatureId} + AND ss.value = ${payload.submissionFeatureObj.value} + AND fp.name = ${payload.submissionFeatureObj.key};`; + + const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); + + if (response.rows.length === 0) { + throw new ApiExecuteSQLError('Failed to get key for signed URL', [ + `matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, + 'SubmissionRepository->getAdminSubmissionFeatureArtifactKey' + ]); + } + + return response.rows[0].value; + } } diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index 58327d3a..649c7ece 100644 --- a/api/src/services/submission-service.ts +++ b/api/src/services/submission-service.ts @@ -2,7 +2,7 @@ import { default as dayjs } from 'dayjs'; import { JSONPath } from 'jsonpath-plus'; import { z } from 'zod'; import { IDBConnection } from '../database/db'; -import { ApiExecuteSQLError } from '../errors/api-error'; +import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; import { IDatasetsForReview, IHandlebarsTemplates, @@ -19,6 +19,7 @@ import { SubmissionFeatureDownloadRecord, SubmissionFeatureRecord, SubmissionFeatureRecordWithTypeAndSecurity, + SubmissionFeatureSignedUrlPayload, SubmissionMessageRecord, SubmissionRecord, SubmissionRecordPublished, @@ -28,6 +29,7 @@ import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../repositories/submission-repository'; +import { getS3SignedURL } from '../utils/file-utils'; import { EMLFile } from '../utils/media/eml/eml-file'; import { DBService } from './db-service'; @@ -715,4 +717,34 @@ export class SubmissionService extends DBService { async downloadPublishedSubmission(submissionId: number): Promise { return this.submissionRepository.downloadPublishedSubmission(submissionId); } + + /** + * Generates a signed URL for submission feature artifact key value pair + * + * Handles admin requests differently, admin can bypass submission feature security check. + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} payload + * @throws {ApiGeneralError} + * @memberof SubmissionService + * @returns {Promise} signed URL for artifact + */ + async getSubmissionFeatureSignedUrl(payload: SubmissionFeatureSignedUrlPayload): Promise { + // const artifactKey = payload.isAdmin + // ? await this.submissionRepository.getAdminSubmissionFeatureArtifactKey(payload) + // : await this.submissionRepository.getSubmissionFeatureArtifactKey(payload); + + const artifactKey = await this.submissionRepository.getSubmissionFeatureArtifactKey(payload); + + const signedUrl = await getS3SignedURL(artifactKey); + + if (!signedUrl) { + throw new ApiGeneralError( + `Failed to generate signed URL for "${payload.submissionFeatureObj.key}":"${payload.submissionFeatureObj.value}"`, + ['SubmissionRepository->getSubmissionFeatureSignedUrl', 'getS3SignedUrl returned NULL'] + ); + } + + return signedUrl; + } } diff --git a/app/src/features/submissions/AdminSubmissionPage.tsx b/app/src/features/submissions/AdminSubmissionPage.tsx index cdf02b4a..fe20dbc3 100644 --- a/app/src/features/submissions/AdminSubmissionPage.tsx +++ b/app/src/features/submissions/AdminSubmissionPage.tsx @@ -4,7 +4,7 @@ import Stack from '@mui/material/Stack'; import SubmissionHeader from 'features/submissions/components/SubmissionHeader'; import { useSubmissionContext } from 'hooks/useContext'; import { IGetSubmissionGroupedFeatureResponse } from 'interfaces/useSubmissionsApi.interface'; -import SubmissionDataGrid from './components/SubmissionDataGrid'; +import SubmissionDataGrid from './components/SubmissionDataGrid/SubmissionDataGrid'; /** * AdminSubmissionPage component for reviewing submissions. diff --git a/app/src/features/submissions/components/SubmissionDataGrid.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx similarity index 71% rename from app/src/features/submissions/components/SubmissionDataGrid.tsx rename to app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx index 93e6c91f..a00f29a6 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx @@ -1,6 +1,6 @@ import { mdiLock, mdiLockOpenOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { Divider, Paper, Stack, Toolbar } from '@mui/material'; +import { Button, Divider, Paper, Stack, Toolbar } from '@mui/material'; import Typography from '@mui/material/Typography'; import { Box } from '@mui/system'; import { @@ -10,10 +10,14 @@ import { GridRowSelectionModel, GridValueGetterParams } from '@mui/x-data-grid'; +import { useApi } from 'hooks/useApi'; import { useCodesContext } from 'hooks/useContext'; import { IFeatureTypeProperties } from 'interfaces/useCodesApi.interface'; -import { SubmissionFeatureRecordWithTypeAndSecurity } from 'interfaces/useSubmissionsApi.interface'; -import { useState } from 'react'; +import { + SubmissionFeatureRecordWithTypeAndSecurity, + SubmissionFeatureSignedUrlPayload +} from 'interfaces/useSubmissionsApi.interface'; +import React, { useState } from 'react'; export interface ISubmissionDataGridProps { feature_type_display_name: string; @@ -29,6 +33,8 @@ export interface ISubmissionDataGridProps { */ export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { const codesContext = useCodesContext(); + const api = useApi(); + const { submissionFeatures, feature_type_display_name, feature_type_name } = props; const featureTypesWithProperties = codesContext.codesDataLoader.data?.feature_type_with_properties; @@ -40,23 +46,55 @@ export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { const [rowSelectionModel, setRowSelectionModel] = useState([]); const fieldColumns = featureTypeWithProperties.map((featureType: IFeatureTypeProperties) => { + if (featureType.type === 's3_key') { + const openLinkInNewTab = async (payload: SubmissionFeatureSignedUrlPayload) => { + try { + const signedUrl = await api.submissions.getSubmissionFeatureSignedUrl(payload); + window.open(signedUrl, '_blank'); + } catch (err) { + console.log(err); + } + }; + return { + field: featureType.name, + headerName: '', + flex: 1, + disableColumnMenu: true, + valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, + renderCell: (params: GridRenderCellParams) => { + return ( + + ); + } + }; + } return { field: featureType.name, headerName: featureType.display_name, flex: 1, disableColumnMenu: true, valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, - renderCell: (params: GridRenderCellParams) => { - return ( - - {String(params.value)} - - ); - } + renderCell: (params: GridRenderCellParams) => ( + + {String(params.value)} + + ) }; }); diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx new file mode 100644 index 00000000..e69de29b diff --git a/app/src/hooks/api/useSubmissionsApi.ts b/app/src/hooks/api/useSubmissionsApi.ts index 7707a562..e610e6f4 100644 --- a/app/src/hooks/api/useSubmissionsApi.ts +++ b/app/src/hooks/api/useSubmissionsApi.ts @@ -3,6 +3,7 @@ import { IGetDownloadSubmissionResponse, IGetSubmissionGroupedFeatureResponse, IListSubmissionsResponse, + SubmissionFeatureSignedUrlPayload, SubmissionRecordPublished, SubmissionRecordWithSecurity, SubmissionRecordWithSecurityAndRootFeature @@ -139,6 +140,16 @@ const useSubmissionsApi = (axios: AxiosInstance) => { return data; }; + const getSubmissionFeatureSignedUrl = async (params: SubmissionFeatureSignedUrlPayload): Promise => { + const { submissionFeatureKey, submissionFeatureValue, submissionId, submissionFeatureId } = params; + + const { data } = await axios.get( + `api/submission/${submissionId}/features/${submissionFeatureId}/signed-url?key=${submissionFeatureKey}&value=${submissionFeatureValue}` + ); + + return data; + }; + return { listSubmissions, getSignedUrl, @@ -149,7 +160,8 @@ const useSubmissionsApi = (axios: AxiosInstance) => { getUnreviewedSubmissionsForAdmins, getReviewedSubmissionsForAdmins, updateSubmissionRecord, - getPublishedSubmissions + getPublishedSubmissions, + getSubmissionFeatureSignedUrl }; }; diff --git a/app/src/interfaces/useSubmissionsApi.interface.ts b/app/src/interfaces/useSubmissionsApi.interface.ts index 7d89fafd..1ce15843 100644 --- a/app/src/interfaces/useSubmissionsApi.interface.ts +++ b/app/src/interfaces/useSubmissionsApi.interface.ts @@ -91,3 +91,10 @@ export interface IGetDownloadSubmissionResponse { data: Record; level: number; } + +export type SubmissionFeatureSignedUrlPayload = { + submissionId: number; + submissionFeatureId: number; + submissionFeatureKey: string; + submissionFeatureValue: string; +}; diff --git a/database/src/seeds/06_submission_data.ts b/database/src/seeds/06_submission_data.ts index 19f93a14..7097301b 100644 --- a/database/src/seeds/06_submission_data.ts +++ b/database/src/seeds/06_submission_data.ts @@ -52,7 +52,7 @@ const insertFeatureSecurity = async (knex: Knex, submission_feature_id: number, }; const insertArtifactRecord = async (knex: Knex, row: { submission_id: number }) => { - const S3_KEY = 'blah'; + const S3_KEY = 'dev-artifacts/artifact.txt'; const sql = insertSubmissionFeature({ submission_id: row.submission_id, From 7bb4b76911642fc567b2b06b9148e69a549213ad Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Tue, 9 Jan 2024 16:25:42 -0800 Subject: [PATCH 03/13] added tests for repo / service / endpoint --- api/package-lock.json | 131 +++++------------- .../{submissionFeatureId}/signed-url.test.ts | 95 +++++++++++++ .../{submissionFeatureId}/signed-url.ts | 2 - .../submission-repository.test.ts | 90 ++++++++++++ api/src/repositories/submission-repository.ts | 4 +- api/src/services/submission-service.test.ts | 78 ++++++++++- api/src/services/submission-service.ts | 15 +- 7 files changed, 304 insertions(+), 111 deletions(-) create mode 100644 api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts diff --git a/api/package-lock.json b/api/package-lock.json index d991a84b..06b241c0 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -538,36 +538,6 @@ "strip-ansi": "^7.0.1" } }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, "strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -576,21 +546,6 @@ "ansi-regex": "^6.0.1" } }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - } - } - }, "wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -600,54 +555,6 @@ "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - } - } } } }, @@ -2827,7 +2734,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true } } @@ -7913,7 +7820,7 @@ "find-up": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", "dev": true, "requires": { "path-exists": "^2.0.0", @@ -7923,7 +7830,7 @@ "path-exists": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", "dev": true, "requires": { "pinkie-promise": "^2.0.0" @@ -8574,7 +8481,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "dev": true }, "source-map-resolve": { @@ -8785,6 +8692,16 @@ "strip-ansi": "^6.0.1" } }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "string.prototype.padend": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.4.tgz", @@ -8842,6 +8759,14 @@ "ansi-regex": "^5.0.1" } }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, "strip-bom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", @@ -8854,7 +8779,7 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" }, "strnum": { "version": "1.0.5", @@ -9879,6 +9804,16 @@ "strip-ansi": "^6.0.0" } }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts new file mode 100644 index 00000000..ce8eca31 --- /dev/null +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts @@ -0,0 +1,95 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../database/db'; +import { HTTP400, HTTPError } from '../../../../../errors/http-error'; +import { SubmissionService } from '../../../../../services/submission-service'; +import { UserService } from '../../../../../services/user-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../__mocks__/db'; +import { getSubmissionFeatureSignedUrl } from './signed-url'; + +chai.use(sinonChai); + +describe('getSubmissionFeatureSignedUrl', () => { + afterEach(() => { + sinon.restore(); + }); + + it.only('throws error if submissionService throws error', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionService.prototype, 'getSubmissionFeatureSignedUrl') + .throws(new HTTP400('Error', ['Error'])); + + const isSystemUserAdminStub = sinon.stub(UserService.prototype, 'isSystemUserAdmin').resolves(false); + + const requestHandler = getSubmissionFeatureSignedUrl(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + submissionId: '1', + submissionFeatureId: '2' + }; + + mockReq.query = { + key: 'KEY', + value: 'VALUE' + }; + + try { + await requestHandler(mockReq, mockRes, mockNext); + + expect.fail(); + } catch (error) { + expect(isSystemUserAdminStub).to.have.been.calledOnce; + expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledOnce; + expect((error as HTTPError).status).to.equal(400); + expect((error as HTTPError).message).to.equal('Error'); + } + }); + + it.only('should return 200 on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockResponse = [] as unknown as any; + + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionService.prototype, 'getSubmissionFeatureSignedUrl') + .resolves(mockResponse); + + const isSystemUserAdminStub = sinon.stub(UserService.prototype, 'isSystemUserAdmin').resolves(false); + + const requestHandler = getSubmissionFeatureSignedUrl(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + submissionId: '1', + submissionFeatureId: '2' + }; + + mockReq.query = { + key: 'KEY', + value: 'VALUE' + }; + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledOnce; + expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledWith({ + submissionFeatureId: 2, + submissionFeatureObj: { key: 'KEY', value: 'VALUE' }, + isAdmin: false + }); + expect(isSystemUserAdminStub).to.have.been.calledOnce; + expect(mockRes.statusValue).to.eql(200); + expect(mockRes.jsonValue).to.eql(mockResponse); + }); +}); diff --git a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts index b2c5f4ad..7ea1272d 100644 --- a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts @@ -83,8 +83,6 @@ export function getSubmissionFeatureSignedUrl(): RequestHandler { return async (req, res) => { const connection = req['keycloak_token'] ? getDBConnection(req['keycloak_token']) : getAPIUserDBConnection(); - //const submissionId = Number(req.params.submissionId); - const submissionFeatureId = Number(req.params.submissionFeatureId); const submissionFeatureDataKey = String(req.query.key); diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts index 49914e55..f47122c7 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -1704,4 +1704,94 @@ describe('SubmissionRepository', () => { expect(response).to.eql([mockResponse]); }); }); + + describe('getAdminSubmissionFeatureAritifactKey', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should throw an error when insert sql fails', async () => { + const mockQueryResponse = { rowCount: 0 } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + try { + await submissionRepository.getAdminSubmissionFeatureArtifactKey({ + isAdmin: true, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + expect.fail(); + } catch (actualError) { + expect((actualError as ApiGeneralError).message).to.match(/signed url/i); + } + }); + + it('should succeed with valid data', async () => { + const mockResponse = { + value: 'KEY' + }; + + const mockQueryResponse = { rowCount: 1, rows: [mockResponse] } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + const response = await submissionRepository.getAdminSubmissionFeatureArtifactKey({ + isAdmin: true, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + + expect(response).to.eql('KEY'); + }); + }); + + describe('getSubmissionFeatureAritifactKey', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should throw an error when insert sql fails', async () => { + const mockQueryResponse = { rowCount: 0 } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + try { + await submissionRepository.getSubmissionFeatureArtifactKey({ + isAdmin: false, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + expect.fail(); + } catch (actualError) { + expect((actualError as ApiGeneralError).message).to.match(/signed url/i); + } + }); + + it('should succeed with valid data', async () => { + const mockResponse = { + value: 'KEY' + }; + + const mockQueryResponse = { rowCount: 1, rows: [mockResponse] } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + const response = await submissionRepository.getSubmissionFeatureArtifactKey({ + isAdmin: false, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + + expect(response).to.eql('KEY'); + }); + }); }); diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 629064d4..7259a367 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -1787,7 +1787,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (response.rows.length === 0) { + if (response.rowCount === 0) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `submissionFeature is secure or matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getSubmissionFeatureArtifactKey' @@ -1818,7 +1818,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (response.rows.length === 0) { + if (response.rowCount === 0) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getAdminSubmissionFeatureArtifactKey' diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index da63534a..0103c23a 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -4,7 +4,7 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ApiExecuteSQLError } from '../errors/api-error'; +import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; import { SECURITY_APPLIED_STATUS } from '../repositories/security-repository'; import { ISourceTransformModel, @@ -14,6 +14,7 @@ import { ISubmissionObservationRecord, PatchSubmissionRecord, SubmissionFeatureDownloadRecord, + SubmissionFeatureSignedUrlPayload, SubmissionRecord, SubmissionRecordPublished, SubmissionRecordWithSecurityAndRootFeatureType, @@ -22,6 +23,7 @@ import { SUBMISSION_STATUS_TYPE } from '../repositories/submission-repository'; import { SystemUserExtended } from '../repositories/user-repository'; +import * as fileUtils from '../utils/file-utils'; import { EMLFile } from '../utils/media/eml/eml-file'; import { getMockDBConnection } from '../__mocks__/db'; import { SubmissionService } from './submission-service'; @@ -1134,4 +1136,78 @@ describe('SubmissionService', () => { expect(response).to.be.eql(mockResponse); }); }); + + describe('getSubmissionFeatureSignedUrl', () => { + const payload: SubmissionFeatureSignedUrlPayload = { + isAdmin: true, + submissionFeatureId: 1, + submissionFeatureObj: { key: 'a', value: 'b' } + }; + + it('should call admin repository when isAdmin == true', async () => { + const mockDBConnection = getMockDBConnection(); + + const getAdminSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getAdminSubmissionFeatureArtifactKey') + .resolves('KEY'); + + const submissionService = new SubmissionService(mockDBConnection); + + await submissionService.getSubmissionFeatureSignedUrl(payload); + + expect(getAdminSubmissionFeatureSignedUrlStub).to.be.calledOnceWith(payload); + }); + + it('should call regular user repository when isAdmin == false', async () => { + const mockDBConnection = getMockDBConnection(); + + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getSubmissionFeatureArtifactKey') + .resolves('KEY'); + + const submissionService = new SubmissionService(mockDBConnection); + + await submissionService.getSubmissionFeatureSignedUrl({ ...payload, isAdmin: false }); + + expect(getSubmissionFeatureSignedUrlStub).to.be.calledOnceWith({ ...payload, isAdmin: false }); + }); + + it('should return signed url if no error', async () => { + const mockDBConnection = getMockDBConnection(); + + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getSubmissionFeatureArtifactKey') + .resolves('KEY'); + + const getS3SignedUrlStub = sinon.stub(fileUtils, 'getS3SignedURL').resolves('S3KEY'); + + const submissionService = new SubmissionService(mockDBConnection); + + const response = await submissionService.getSubmissionFeatureSignedUrl({ ...payload, isAdmin: false }); + + expect(getS3SignedUrlStub).to.be.calledOnceWith('KEY'); + expect(getSubmissionFeatureSignedUrlStub).to.be.calledOnceWith({ ...payload, isAdmin: false }); + expect(response).to.be.eql('S3KEY'); + }); + + it('should throw error if getS3SignedURL fails to generate (null)', async () => { + const mockDBConnection = getMockDBConnection(); + + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getSubmissionFeatureArtifactKey') + .resolves('KEY'); + + const getS3SignedUrlStub = sinon.stub(fileUtils, 'getS3SignedURL').resolves(null); + + const submissionService = new SubmissionService(mockDBConnection); + + try { + await submissionService.getSubmissionFeatureSignedUrl({ ...payload, isAdmin: false }); + } catch (err) { + expect(getS3SignedUrlStub).to.be.calledOnceWith('KEY'); + expect(getSubmissionFeatureSignedUrlStub).to.be.calledOnceWith({ ...payload, isAdmin: false }); + expect((err as ApiGeneralError).message).to.match(/signed/i); + } + }); + }); }); diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index 649c7ece..638e7be1 100644 --- a/api/src/services/submission-service.ts +++ b/api/src/services/submission-service.ts @@ -719,22 +719,21 @@ export class SubmissionService extends DBService { } /** - * Generates a signed URL for submission feature artifact key value pair + * Generates a signed URL for a submission_feature's (artifact) key value pair + * ie: "s3_key": "artifact/test-file.txt" * - * Handles admin requests differently, admin can bypass submission feature security check. + * Note: admin's can generate signed urls for secure submission_features * * @async * @param {SubmissionFeatureSignedUrlPayload} payload * @throws {ApiGeneralError} * @memberof SubmissionService - * @returns {Promise} signed URL for artifact + * @returns {Promise} signed URL */ async getSubmissionFeatureSignedUrl(payload: SubmissionFeatureSignedUrlPayload): Promise { - // const artifactKey = payload.isAdmin - // ? await this.submissionRepository.getAdminSubmissionFeatureArtifactKey(payload) - // : await this.submissionRepository.getSubmissionFeatureArtifactKey(payload); - - const artifactKey = await this.submissionRepository.getSubmissionFeatureArtifactKey(payload); + const artifactKey = payload.isAdmin + ? await this.submissionRepository.getAdminSubmissionFeatureArtifactKey(payload) + : await this.submissionRepository.getSubmissionFeatureArtifactKey(payload); const signedUrl = await getS3SignedURL(artifactKey); From df5f495dce64d5cb69619025b3edbf384fdc1b00 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Wed, 10 Jan 2024 10:43:26 -0800 Subject: [PATCH 04/13] added custom hook for generating column definitions for SubmissionDataGrid --- .../SubmissionDataGrid/SubmissionDataGrid.tsx | 130 +---------------- .../SubmissionDataGridColDefs.tsx | 0 .../useSubmisssionDataGridColumns.tsx | 134 ++++++++++++++++++ 3 files changed, 139 insertions(+), 125 deletions(-) delete mode 100644 app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx create mode 100644 app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx index a00f29a6..a76c0a14 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx @@ -1,23 +1,10 @@ -import { mdiLock, mdiLockOpenOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Button, Divider, Paper, Stack, Toolbar } from '@mui/material'; +import { Divider, Paper, Toolbar } from '@mui/material'; import Typography from '@mui/material/Typography'; import { Box } from '@mui/system'; -import { - DataGrid, - GridColDef, - GridRenderCellParams, - GridRowSelectionModel, - GridValueGetterParams -} from '@mui/x-data-grid'; -import { useApi } from 'hooks/useApi'; -import { useCodesContext } from 'hooks/useContext'; -import { IFeatureTypeProperties } from 'interfaces/useCodesApi.interface'; -import { - SubmissionFeatureRecordWithTypeAndSecurity, - SubmissionFeatureSignedUrlPayload -} from 'interfaces/useSubmissionsApi.interface'; +import { DataGrid, GridRowSelectionModel } from '@mui/x-data-grid'; +import { SubmissionFeatureRecordWithTypeAndSecurity } from 'interfaces/useSubmissionsApi.interface'; import React, { useState } from 'react'; +import useColumns from './useSubmisssionDataGridColumns'; export interface ISubmissionDataGridProps { feature_type_display_name: string; @@ -32,119 +19,12 @@ export interface ISubmissionDataGridProps { * @return {*} */ export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { - const codesContext = useCodesContext(); - const api = useApi(); - const { submissionFeatures, feature_type_display_name, feature_type_name } = props; - const featureTypesWithProperties = codesContext.codesDataLoader.data?.feature_type_with_properties; - - const featureTypeWithProperties = - featureTypesWithProperties?.find((item) => item.feature_type['name'] === feature_type_name) - ?.feature_type_properties || []; + const columns = useColumns(feature_type_name); const [rowSelectionModel, setRowSelectionModel] = useState([]); - const fieldColumns = featureTypeWithProperties.map((featureType: IFeatureTypeProperties) => { - if (featureType.type === 's3_key') { - const openLinkInNewTab = async (payload: SubmissionFeatureSignedUrlPayload) => { - try { - const signedUrl = await api.submissions.getSubmissionFeatureSignedUrl(payload); - window.open(signedUrl, '_blank'); - } catch (err) { - console.log(err); - } - }; - return { - field: featureType.name, - headerName: '', - flex: 1, - disableColumnMenu: true, - valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, - renderCell: (params: GridRenderCellParams) => { - return ( - - ); - } - }; - } - return { - field: featureType.name, - headerName: featureType.display_name, - flex: 1, - disableColumnMenu: true, - valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, - renderCell: (params: GridRenderCellParams) => ( - - {String(params.value)} - - ) - }; - }); - - const columns: GridColDef[] = [ - { - field: 'submission_feature_security_ids', - headerName: 'Security', - flex: 0, - disableColumnMenu: true, - width: 160, - renderCell: (params) => { - if (params.value.length > 0) { - return ( - - - SECURED - - ); - } - return ( - - - UNSECURED - - ); - } - }, - { - field: 'submission_feature_id', - headerName: 'ID', - flex: 0, - disableColumnMenu: true, - width: 100 - }, - { - field: 'parent_submission_feature_id', - headerName: 'Parent ID', - flex: 0, - disableColumnMenu: true, - width: 120 - }, - ...fieldColumns - ]; - return ( diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGridColDefs.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx new file mode 100644 index 00000000..97ae3133 --- /dev/null +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx @@ -0,0 +1,134 @@ +import { mdiLock, mdiLockOpenOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Button, Stack } from '@mui/material'; +import { Box } from '@mui/system'; +import { GridColDef, GridRenderCellParams, GridValueGetterParams } from '@mui/x-data-grid'; +import { useApi } from 'hooks/useApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { IFeatureTypeProperties } from 'interfaces/useCodesApi.interface'; +import React from 'react'; + +/** + * Hook to generate columns for SubmissionDataGrid + * + * @param {string} featureTypeName - current feature type + * @returns {GridColDef[]} + */ +const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => { + const api = useApi(); + const codesContext = useCodesContext(); + const dialogContext = useDialogContext(); + + const featureTypesWithProperties = codesContext.codesDataLoader.data?.feature_type_with_properties; + + const featureTypeWithProperties = + featureTypesWithProperties?.find((item) => item.feature_type['name'] === featureTypeName) + ?.feature_type_properties || []; + + const fieldColumns = featureTypeWithProperties.map((featureType: IFeatureTypeProperties) => { + if (featureType.type === 's3_key') { + return { + field: featureType.name, + headerName: '', + flex: 1, + disableColumnMenu: true, + valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, + renderCell: (params: GridRenderCellParams) => { + return ( + + ); + } + }; + } + return { + field: featureType.name, + headerName: featureType.display_name, + flex: 1, + disableColumnMenu: true, + valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, + renderCell: (params: GridRenderCellParams) => ( + + {String(params.value)} + + ) + }; + }); + + const columns: GridColDef[] = [ + { + field: 'submission_feature_security_ids', + headerName: 'Security', + flex: 0, + disableColumnMenu: true, + width: 160, + renderCell: (params) => { + if (params.value.length > 0) { + return ( + + + SECURED + + ); + } + return ( + + + UNSECURED + + ); + } + }, + { + field: 'submission_feature_id', + headerName: 'ID', + flex: 0, + disableColumnMenu: true, + width: 100 + }, + { + field: 'parent_submission_feature_id', + headerName: 'Parent ID', + flex: 0, + disableColumnMenu: true, + width: 120 + }, + ...fieldColumns + ]; + + return columns; +}; + +export default useSubmissionDataGridColumns; From 3081436d5b4348bf79061d4e7e274abaccc360bc Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Wed, 10 Jan 2024 13:24:00 -0800 Subject: [PATCH 05/13] updated some missing tests --- .../{submissionFeatureId}/signed-url.test.ts | 12 ++++++++---- .../repositories/submission-repository.test.ts | 8 ++++---- .../useSubmisssionDataGridColumns.tsx | 2 ++ app/src/hooks/api/useSubmissionsApi.test.ts | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts index ce8eca31..f98a2878 100644 --- a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts @@ -16,10 +16,10 @@ describe('getSubmissionFeatureSignedUrl', () => { sinon.restore(); }); - it.only('throws error if submissionService throws error', async () => { + it('throws error if submissionService throws error', async () => { const dbConnectionObj = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const getSubmissionFeatureSignedUrlStub = sinon .stub(SubmissionService.prototype, 'getSubmissionFeatureSignedUrl') @@ -31,6 +31,8 @@ describe('getSubmissionFeatureSignedUrl', () => { const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq['keycloak_token'] = 'TOKEN'; + mockReq.params = { submissionId: '1', submissionFeatureId: '2' @@ -46,6 +48,7 @@ describe('getSubmissionFeatureSignedUrl', () => { expect.fail(); } catch (error) { + expect(getDBConnectionStub).to.have.been.calledWith('TOKEN'); expect(isSystemUserAdminStub).to.have.been.calledOnce; expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledOnce; expect((error as HTTPError).status).to.equal(400); @@ -53,10 +56,10 @@ describe('getSubmissionFeatureSignedUrl', () => { } }); - it.only('should return 200 on success', async () => { + it('should return 200 on success', async () => { const dbConnectionObj = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + const getAPIUserDBConnectionStub = sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); const mockResponse = [] as unknown as any; @@ -82,6 +85,7 @@ describe('getSubmissionFeatureSignedUrl', () => { await requestHandler(mockReq, mockRes, mockNext); + expect(getAPIUserDBConnectionStub).to.have.been.calledOnce; expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledOnce; expect(getSubmissionFeatureSignedUrlStub).to.have.been.calledWith({ submissionFeatureId: 2, diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts index f47122c7..cc90dff9 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -1710,7 +1710,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it('should throw an error when insert sql fails', async () => { + it.only('should throw an error when insert sql fails', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); @@ -1725,7 +1725,7 @@ describe('SubmissionRepository', () => { }); expect.fail(); } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.match(/signed url/i); + expect((actualError as ApiGeneralError).message).to.equal('Failed to get key for signed URL'); } }); @@ -1755,7 +1755,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it('should throw an error when insert sql fails', async () => { + it.only('should throw an error when insert sql fails', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); @@ -1770,7 +1770,7 @@ describe('SubmissionRepository', () => { }); expect.fail(); } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.match(/signed url/i); + expect((actualError as ApiGeneralError).message).to.equal('Failed to get key for signed URL'); } }); diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx index 97ae3133..2cd34e5f 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx @@ -32,6 +32,8 @@ const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => headerName: '', flex: 1, disableColumnMenu: true, + disableReorder: true, + hideSortIcons: true, valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, renderCell: (params: GridRenderCellParams) => { return ( diff --git a/app/src/hooks/api/useSubmissionsApi.test.ts b/app/src/hooks/api/useSubmissionsApi.test.ts index 56cb76a5..81d4869b 100644 --- a/app/src/hooks/api/useSubmissionsApi.test.ts +++ b/app/src/hooks/api/useSubmissionsApi.test.ts @@ -51,4 +51,21 @@ describe('useSubmissionApi', () => { expect(result).toEqual('test-signed-url'); }); + + describe('getSubmissionFeatureSignedUrl', () => { + it('should return signed URL', async () => { + const payload = { + submissionId: 1, + submissionFeatureId: 1, + submissionFeatureValue: 'VALUE', + submissionFeatureKey: 'KEY' + }; + + mock.onGet('/api/submission/1/features/1/signed-url?key=KEY&value=VALUE').reply(200, 'SIGNED_URL'); + + const result = await useSubmissionsApi(axios).getSubmissionFeatureSignedUrl(payload); + + expect(result).toEqual('SIGNED_URL'); + }); + }); }); From 901ef08f40141afdeb4b57f3dfd9076b49e24f77 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Wed, 10 Jan 2024 13:45:59 -0800 Subject: [PATCH 06/13] updated tests for repo and service --- api/src/repositories/submission-repository.test.ts | 4 ++-- api/src/repositories/submission-repository.ts | 4 ++-- api/src/services/submission-service.test.ts | 12 +++++++++++- .../useSubmisssionDataGridColumns.tsx | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts index cc90dff9..c3be053f 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -1710,7 +1710,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it.only('should throw an error when insert sql fails', async () => { + it('should throw an error when insert sql fails', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); @@ -1755,7 +1755,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it.only('should throw an error when insert sql fails', async () => { + it('should throw an error when insert sql fails', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 7259a367..3d217432 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -1787,7 +1787,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (response.rowCount === 0) { + if (!response.rows?.[0]?.value) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `submissionFeature is secure or matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getSubmissionFeatureArtifactKey' @@ -1818,7 +1818,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (response.rowCount === 0) { + if (!response.rows?.[0]?.value) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getAdminSubmissionFeatureArtifactKey' diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index 0103c23a..e68da4c1 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -1144,18 +1144,23 @@ describe('SubmissionService', () => { submissionFeatureObj: { key: 'a', value: 'b' } }; - it('should call admin repository when isAdmin == true', async () => { + it.only('should call admin repository when isAdmin == true', async () => { const mockDBConnection = getMockDBConnection(); const getAdminSubmissionFeatureSignedUrlStub = sinon .stub(SubmissionRepository.prototype, 'getAdminSubmissionFeatureArtifactKey') .resolves('KEY'); + const getSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getSubmissionFeatureArtifactKey') + .resolves('KEY'); + const submissionService = new SubmissionService(mockDBConnection); await submissionService.getSubmissionFeatureSignedUrl(payload); expect(getAdminSubmissionFeatureSignedUrlStub).to.be.calledOnceWith(payload); + expect(getSubmissionFeatureSignedUrlStub).to.not.be.called; }); it('should call regular user repository when isAdmin == false', async () => { @@ -1165,11 +1170,16 @@ describe('SubmissionService', () => { .stub(SubmissionRepository.prototype, 'getSubmissionFeatureArtifactKey') .resolves('KEY'); + const getAdminSubmissionFeatureSignedUrlStub = sinon + .stub(SubmissionRepository.prototype, 'getAdminSubmissionFeatureArtifactKey') + .resolves('KEY'); + const submissionService = new SubmissionService(mockDBConnection); await submissionService.getSubmissionFeatureSignedUrl({ ...payload, isAdmin: false }); expect(getSubmissionFeatureSignedUrlStub).to.be.calledOnceWith({ ...payload, isAdmin: false }); + expect(getAdminSubmissionFeatureSignedUrlStub).to.not.be.called; }); it('should return signed url if no error', async () => { diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx index 2cd34e5f..96ee693d 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx @@ -23,7 +23,7 @@ const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => const featureTypeWithProperties = featureTypesWithProperties?.find((item) => item.feature_type['name'] === featureTypeName) - ?.feature_type_properties || []; + ?.feature_type_properties ?? []; const fieldColumns = featureTypeWithProperties.map((featureType: IFeatureTypeProperties) => { if (featureType.type === 's3_key') { From 88b6b339ce1d69a5b15379d885866e75a2085a8f Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Wed, 10 Jan 2024 15:42:28 -0800 Subject: [PATCH 07/13] moved download logic into useDownload hook --- api/src/services/submission-service.test.ts | 4 +- .../components/ReviewedSubmissionsTable.tsx | 6 +-- .../SubmissionDataGrid/SubmissionDataGrid.tsx | 2 +- .../useSubmissionDataGridColumns.test.tsx | 18 +++++++ ...s.tsx => useSubmissionDataGridColumns.tsx} | 29 ++++------- .../submissions/list/SubmissionsListPage.tsx | 6 +-- app/src/hooks/useDownload.tsx | 52 +++++++++++++++++++ app/src/hooks/useDownloadJSON.tsx | 25 --------- 8 files changed, 89 insertions(+), 53 deletions(-) create mode 100644 app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.test.tsx rename app/src/features/submissions/components/SubmissionDataGrid/{useSubmisssionDataGridColumns.tsx => useSubmissionDataGridColumns.tsx} (77%) create mode 100644 app/src/hooks/useDownload.tsx delete mode 100644 app/src/hooks/useDownloadJSON.tsx diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index e68da4c1..785a93b2 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -1200,7 +1200,7 @@ describe('SubmissionService', () => { expect(response).to.be.eql('S3KEY'); }); - it('should throw error if getS3SignedURL fails to generate (null)', async () => { + it.only('should throw error if getS3SignedURL fails to generate (null)', async () => { const mockDBConnection = getMockDBConnection(); const getSubmissionFeatureSignedUrlStub = sinon @@ -1216,7 +1216,7 @@ describe('SubmissionService', () => { } catch (err) { expect(getS3SignedUrlStub).to.be.calledOnceWith('KEY'); expect(getSubmissionFeatureSignedUrlStub).to.be.calledOnceWith({ ...payload, isAdmin: false }); - expect((err as ApiGeneralError).message).to.match(/signed/i); + expect((err as ApiGeneralError).message).to.equal(`Failed to generate signed URL for "a":"b"`); } }); }); diff --git a/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx index d3ca0bb3..4d4dd3fa 100644 --- a/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx +++ b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx @@ -18,7 +18,7 @@ import { DATE_FORMAT } from 'constants/dateTimeFormats'; import SubmissionsListSortMenu from 'features/submissions/list/SubmissionsListSortMenu'; import { useApi } from 'hooks/useApi'; import useDataLoader from 'hooks/useDataLoader'; -import useDownloadJSON from 'hooks/useDownloadJSON'; +import useDownload from 'hooks/useDownload'; import { SubmissionRecordWithSecurityAndRootFeature } from 'interfaces/useSubmissionsApi.interface'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -26,7 +26,7 @@ import { getFormattedDate, pluralize as p } from 'utils/Utils'; const ReviewedSubmissionsTable = () => { const biohubApi = useApi(); - const download = useDownloadJSON(); + const { downloadJSON } = useDownload(); const reviewedSubmissionsDataLoader = useDataLoader(() => biohubApi.submissions.getReviewedSubmissionsForAdmins()); @@ -37,7 +37,7 @@ const ReviewedSubmissionsTable = () => { const onDownload = async (submission: SubmissionRecordWithSecurityAndRootFeature) => { // make request here for JSON data of submission and children const data = await biohubApi.submissions.getSubmissionDownloadPackage(submission.submission_id); - download(data, `${submission.name.toLowerCase().replace(/ /g, '-')}-${submission.submission_id}`); + downloadJSON(data, `${submission.name.toLowerCase().replace(/ /g, '-')}-${submission.submission_id}`); }; const handleSortSubmissions = (submissions: SubmissionRecordWithSecurityAndRootFeature[]) => { diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx index a76c0a14..db0a20c8 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx @@ -4,7 +4,7 @@ import { Box } from '@mui/system'; import { DataGrid, GridRowSelectionModel } from '@mui/x-data-grid'; import { SubmissionFeatureRecordWithTypeAndSecurity } from 'interfaces/useSubmissionsApi.interface'; import React, { useState } from 'react'; -import useColumns from './useSubmisssionDataGridColumns'; +import useColumns from './useSubmissionDataGridColumns'; export interface ISubmissionDataGridProps { feature_type_display_name: string; diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.test.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.test.tsx new file mode 100644 index 00000000..cf397e76 --- /dev/null +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.test.tsx @@ -0,0 +1,18 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { PropsWithChildren } from 'react'; +import { AuthProvider } from 'react-oidc-context'; +import useSubmissionDataGridColumns from './useSubmissionDataGridColumns'; + +const wrapper = ({ children }: PropsWithChildren) => {children}; + +describe('useSubmissionDataGridColumns', () => { + describe('mounting conditions', () => { + it('should mount', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSubmissionDataGridColumns('test'), { + wrapper + }); + await waitForNextUpdate(); + expect(result.current.length).toBeDefined(); + }); + }); +}); diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx similarity index 77% rename from app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx rename to app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx index 96ee693d..e2333112 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/useSubmisssionDataGridColumns.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx @@ -4,7 +4,8 @@ import { Button, Stack } from '@mui/material'; import { Box } from '@mui/system'; import { GridColDef, GridRenderCellParams, GridValueGetterParams } from '@mui/x-data-grid'; import { useApi } from 'hooks/useApi'; -import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { useCodesContext } from 'hooks/useContext'; +import useDownload from 'hooks/useDownload'; import { IFeatureTypeProperties } from 'interfaces/useCodesApi.interface'; import React from 'react'; @@ -17,7 +18,7 @@ import React from 'react'; const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => { const api = useApi(); const codesContext = useCodesContext(); - const dialogContext = useDialogContext(); + const { downloadSignedUrl } = useDownload(); const featureTypesWithProperties = codesContext.codesDataLoader.data?.feature_type_with_properties; @@ -41,23 +42,13 @@ const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => variant="outlined" size="small" onClick={async () => { - try { - const signedUrl = await api.submissions.getSubmissionFeatureSignedUrl({ - submissionId: params.row.submission_id, - submissionFeatureId: params.row.submission_feature_id, - submissionFeatureKey: featureType.type, - submissionFeatureValue: params.value - }); - window.open(signedUrl, '_blank'); - } catch (err) { - dialogContext.setErrorDialog({ - onOk: () => dialogContext.setErrorDialog({ open: false }), - onClose: () => dialogContext.setErrorDialog({ open: false }), - dialogTitle: 'Download Error', - dialogText: err.message, - open: true - }); - } + const signedUrl = api.submissions.getSubmissionFeatureSignedUrl({ + submissionId: params.row.submission_id, + submissionFeatureId: params.row.submission_feature_id, + submissionFeatureKey: featureType.type, + submissionFeatureValue: params.value + }); + await downloadSignedUrl(signedUrl); }}> Download diff --git a/app/src/features/submissions/list/SubmissionsListPage.tsx b/app/src/features/submissions/list/SubmissionsListPage.tsx index 0ca7dbc5..24df5fdf 100644 --- a/app/src/features/submissions/list/SubmissionsListPage.tsx +++ b/app/src/features/submissions/list/SubmissionsListPage.tsx @@ -11,7 +11,7 @@ import SubmissionsListSortMenu from 'features/submissions/list/SubmissionsListSo import { FuseResult } from 'fuse.js'; import { useApi } from 'hooks/useApi'; import useDataLoader from 'hooks/useDataLoader'; -import useDownloadJSON from 'hooks/useDownloadJSON'; +import useDownload from 'hooks/useDownload'; import useFuzzySearch from 'hooks/useFuzzySearch'; import { SubmissionRecordPublished } from 'interfaces/useSubmissionsApi.interface'; import { useState } from 'react'; @@ -24,7 +24,7 @@ import { pluralize as p } from 'utils/Utils'; */ const SubmissionsListPage = () => { const biohubApi = useApi(); - const download = useDownloadJSON(); + const { downloadJSON } = useDownload(); const reviewedSubmissionsDataLoader = useDataLoader(() => biohubApi.submissions.getPublishedSubmissions()); reviewedSubmissionsDataLoader.load(); @@ -40,7 +40,7 @@ const SubmissionsListPage = () => { // make request here for JSON data of submission and children const data = await biohubApi.submissions.getSubmissionPublishedDownloadPackage(submission.item.submission_id); const fileName = `${submission.item.name.toLowerCase().replace(/ /g, '-')}-${submission.item.submission_id}`; - download(data, fileName); + downloadJSON(data, fileName); }; return ( diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx new file mode 100644 index 00000000..d596f4b5 --- /dev/null +++ b/app/src/hooks/useDownload.tsx @@ -0,0 +1,52 @@ +import { useDialogContext } from './useContext'; + +const useDownload = () => { + const dialogContext = useDialogContext(); + /** + * handler for downloading raw data as JSON + * Note: currently this does not zip the file. Can be modified if needed. + * + * @param {any} data - to download + * @param {string} fileName - name of file excluding file extension ie: file1 + */ + const downloadJSON = (data: any, fileName: string) => { + const blob = new Blob([JSON.stringify(data, undefined, 2)], { type: 'application/json' }); + + const link = document.createElement('a'); + + link.download = `${fileName}.json`; + + link.href = URL.createObjectURL(blob); + + link.click(); + + URL.revokeObjectURL(link.href); + }; + + /** + * Downloads / views a signed url + * displays error dialog if signedUrlService throws error + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} params + * @returns {Promise<[void]>} + */ + const downloadSignedUrl = async (signedUrlService: Promise) => { + try { + const signedUrl = await signedUrlService; + + window.open(signedUrl, '_blank'); + } catch (err) { + dialogContext.setErrorDialog({ + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: 'Download Error', + dialogText: err.message, + open: true + }); + } + }; + return { downloadJSON, downloadSignedUrl }; +}; + +export default useDownload; diff --git a/app/src/hooks/useDownloadJSON.tsx b/app/src/hooks/useDownloadJSON.tsx deleted file mode 100644 index 69314ff6..00000000 --- a/app/src/hooks/useDownloadJSON.tsx +++ /dev/null @@ -1,25 +0,0 @@ -const useDownloadJSON = () => { - /** - * hook to handle downloading raw data as JSON - * Note: currently this does not zip the file. Can be modified if needed. - * - * @param {any} data - to download - * @param {string} fileName - name of file excluding file extension ie: file1 - */ - const download = (data: any, fileName: string) => { - const blob = new Blob([JSON.stringify(data, undefined, 2)], { type: 'application/json' }); - - const link = document.createElement('a'); - - link.download = `${fileName}.json`; - - link.href = URL.createObjectURL(blob); - - link.click(); - - URL.revokeObjectURL(link.href); - }; - return download; -}; - -export default useDownloadJSON; From 55b3522425e18de49325fe0974a9e7190e956fba Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Wed, 10 Jan 2024 16:28:42 -0800 Subject: [PATCH 08/13] updated logic for submissionFeatureArtifactKey repository --- api/src/repositories/submission-repository.ts | 7 +++--- api/src/services/submission-service.test.ts | 4 ++-- .../useSubmissionDataGridColumns.tsx | 22 +++++++++---------- app/src/hooks/useDownload.tsx | 7 +++--- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 3d217432..4686d517 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -1783,11 +1783,12 @@ export class SubmissionRepository extends BaseRepository { WHERE sfs.submission_feature_id = ss.submission_feature_id ) AND ss.value = ${payload.submissionFeatureObj.value} - AND fp.name = ${payload.submissionFeatureObj.key};`; + AND fp.name = ${payload.submissionFeatureObj.key} + RETURNING ss.value;`; const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (!response.rows?.[0]?.value) { + if (response.rowCount === 0 || !response.rows[0]?.value) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `submissionFeature is secure or matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getSubmissionFeatureArtifactKey' @@ -1818,7 +1819,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (!response.rows?.[0]?.value) { + if (!response.rowCount || !response.rows[0]?.value) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getAdminSubmissionFeatureArtifactKey' diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index 785a93b2..c4461b7c 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -1144,7 +1144,7 @@ describe('SubmissionService', () => { submissionFeatureObj: { key: 'a', value: 'b' } }; - it.only('should call admin repository when isAdmin == true', async () => { + it('should call admin repository when isAdmin == true', async () => { const mockDBConnection = getMockDBConnection(); const getAdminSubmissionFeatureSignedUrlStub = sinon @@ -1200,7 +1200,7 @@ describe('SubmissionService', () => { expect(response).to.be.eql('S3KEY'); }); - it.only('should throw error if getS3SignedURL fails to generate (null)', async () => { + it('should throw error if getS3SignedURL fails to generate (null)', async () => { const mockDBConnection = getMockDBConnection(); const getSubmissionFeatureSignedUrlStub = sinon diff --git a/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx index e2333112..35bc61f6 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx @@ -37,19 +37,17 @@ const useSubmissionDataGridColumns = (featureTypeName: string): GridColDef[] => hideSortIcons: true, valueGetter: (params: GridValueGetterParams) => params.row.data[featureType.name] ?? null, renderCell: (params: GridRenderCellParams) => { + const download = async () => { + const signedUrlPromise = api.submissions.getSubmissionFeatureSignedUrl({ + submissionId: params.row.submission_id, + submissionFeatureId: params.row.submission_feature_id, + submissionFeatureKey: featureType.type, + submissionFeatureValue: params.value + }); + await downloadSignedUrl(signedUrlPromise); + }; return ( - ); diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx index d596f4b5..f7985a55 100644 --- a/app/src/hooks/useDownload.tsx +++ b/app/src/hooks/useDownload.tsx @@ -26,16 +26,17 @@ const useDownload = () => { /** * Downloads / views a signed url * displays error dialog if signedUrlService throws error + * Note: allows a promise to be passed to handle different api services * * @async * @param {SubmissionFeatureSignedUrlPayload} params * @returns {Promise<[void]>} */ - const downloadSignedUrl = async (signedUrlService: Promise) => { + const downloadSignedUrl = async (signedUrl: Promise | string) => { try { - const signedUrl = await signedUrlService; + const url = await signedUrl; - window.open(signedUrl, '_blank'); + window.open(url, '_blank'); } catch (err) { dialogContext.setErrorDialog({ onOk: () => dialogContext.setErrorDialog({ open: false }), From 4555a10d5a90ea8426574752589cf76916c7e9e6 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Thu, 11 Jan 2024 09:16:19 -0800 Subject: [PATCH 09/13] submission repository updates --- .../submission-repository.test.ts | 42 ++++++++++++++++++- api/src/repositories/submission-repository.ts | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts index c3be053f..43b05e2d 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -1710,7 +1710,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it('should throw an error when insert sql fails', async () => { + it('should throw an error when insert sql fails (rowCount 0)', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); @@ -1729,6 +1729,25 @@ describe('SubmissionRepository', () => { } }); + it('should throw an error when insert sql fails (missing value property)', async () => { + const mockQueryResponse = { rowCount: 1, rows: [{ test: 'blah' }] } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + try { + await submissionRepository.getAdminSubmissionFeatureArtifactKey({ + isAdmin: true, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + expect.fail(); + } catch (actualError) { + expect((actualError as ApiGeneralError).message).to.equal('Failed to get key for signed URL'); + } + }); + it('should succeed with valid data', async () => { const mockResponse = { value: 'KEY' @@ -1755,7 +1774,7 @@ describe('SubmissionRepository', () => { sinon.restore(); }); - it('should throw an error when insert sql fails', async () => { + it('should throw an error when insert sql fails (rowCount 0)', async () => { const mockQueryResponse = { rowCount: 0 } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); @@ -1774,6 +1793,25 @@ describe('SubmissionRepository', () => { } }); + it('should throw an error when insert sql fails (missing value prop)', async () => { + const mockQueryResponse = { rowCount: 1, rows: [{ test: 'blah' }] } as any as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + try { + await submissionRepository.getSubmissionFeatureArtifactKey({ + isAdmin: false, + submissionFeatureId: 0, + submissionFeatureObj: { key: 'a', value: 'b' } + }); + expect.fail(); + } catch (actualError) { + expect((actualError as ApiGeneralError).message).to.equal('Failed to get key for signed URL'); + } + }); + it('should succeed with valid data', async () => { const mockResponse = { value: 'KEY' diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 4686d517..08a08b74 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -1819,7 +1819,7 @@ export class SubmissionRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); - if (!response.rowCount || !response.rows[0]?.value) { + if (response.rowCount === 0 || !response.rows[0]?.value) { throw new ApiExecuteSQLError('Failed to get key for signed URL', [ `matching key value pair does not exist for submissionFeatureId: ${payload.submissionFeatureId}`, 'SubmissionRepository->getAdminSubmissionFeatureArtifactKey' From 35b15e0a24ff9bb43e9db68c0dd0775b159bdf62 Mon Sep 17 00:00:00 2001 From: Alfred Rosenthal Date: Fri, 12 Jan 2024 12:36:44 -0800 Subject: [PATCH 10/13] fixed download hook --- .../dashboard/components/PublishedSubmissionsTable.tsx | 6 +++--- app/src/hooks/useDownload.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx index d2826006..fa74dd64 100644 --- a/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx +++ b/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx @@ -18,14 +18,14 @@ import { DATE_FORMAT } from 'constants/dateTimeFormats'; import SubmissionsListSortMenu from 'features/submissions/list/SubmissionsListSortMenu'; import { useApi } from 'hooks/useApi'; import useDataLoader from 'hooks/useDataLoader'; -import useDownloadJSON from 'hooks/useDownloadJSON'; +import useDownload from 'hooks/useDownload'; import { SubmissionRecordWithSecurityAndRootFeature } from 'interfaces/useSubmissionsApi.interface'; import { Link as RouterLink } from 'react-router-dom'; import { getFormattedDate, pluralize as p } from 'utils/Utils'; const PublishedSubmissionsTable = () => { const biohubApi = useApi(); - const download = useDownloadJSON(); + const download = useDownload(); const publishedSubmissionsDataLoader = useDataLoader(() => biohubApi.submissions.getPublishedSubmissionsForAdmins()); @@ -36,7 +36,7 @@ const PublishedSubmissionsTable = () => { const onDownload = async (submission: SubmissionRecordWithSecurityAndRootFeature) => { // make request here for JSON data of submission and children const data = await biohubApi.submissions.getSubmissionDownloadPackage(submission.submission_id); - download(data, `${submission.name.toLowerCase().replace(/ /g, '-')}-${submission.submission_id}`); + download.downloadJSON(data, `${submission.name.toLowerCase().replace(/ /g, '-')}-${submission.submission_id}`); }; const handleSortSubmissions = (submissions: SubmissionRecordWithSecurityAndRootFeature[]) => { diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx index f7985a55..2cab52e2 100644 --- a/app/src/hooks/useDownload.tsx +++ b/app/src/hooks/useDownload.tsx @@ -37,7 +37,7 @@ const useDownload = () => { const url = await signedUrl; window.open(url, '_blank'); - } catch (err) { + } catch (err: any) { dialogContext.setErrorDialog({ onOk: () => dialogContext.setErrorDialog({ open: false }), onClose: () => dialogContext.setErrorDialog({ open: false }), From 1695222feb6c1d7325ac0f1f53cd64334de41730 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Fri, 12 Jan 2024 16:33:21 -0800 Subject: [PATCH 11/13] fixed nit picks --- .../{submissionFeatureId}/signed-url.ts | 7 +++--- .../SubmissionDataGrid/SubmissionDataGrid.tsx | 7 +++--- app/src/hooks/api/useSubmissionsApi.ts | 7 ++++++ app/src/hooks/useDownload.test.tsx | 11 ++++++++ app/src/hooks/useDownload.tsx | 25 ++++++++++--------- 5 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 app/src/hooks/useDownload.test.tsx diff --git a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts index 7ea1272d..eaec2742 100644 --- a/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts @@ -64,8 +64,7 @@ GET.apiDoc = { content: { 'application/json': { schema: { - type: 'string', - nullable: true + type: 'string' } } } @@ -97,7 +96,7 @@ export function getSubmissionFeatureSignedUrl(): RequestHandler { const isAdmin = await userService.isSystemUserAdmin(); - const result = await submissionService.getSubmissionFeatureSignedUrl({ + const signedUrl = await submissionService.getSubmissionFeatureSignedUrl({ submissionFeatureId, submissionFeatureObj: { key: submissionFeatureDataKey, value: submissionFeatureDataValue }, isAdmin @@ -105,7 +104,7 @@ export function getSubmissionFeatureSignedUrl(): RequestHandler { await connection.commit(); - res.status(200).json(result); + res.status(200).json(signedUrl); } catch (error) { defaultLog.error({ label: 'getSubmissionFeatureSignedUrl', message: 'error', error }); await connection.rollback(); diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx index db0a20c8..db02b536 100644 --- a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx +++ b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx @@ -4,7 +4,8 @@ import { Box } from '@mui/system'; import { DataGrid, GridRowSelectionModel } from '@mui/x-data-grid'; import { SubmissionFeatureRecordWithTypeAndSecurity } from 'interfaces/useSubmissionsApi.interface'; import React, { useState } from 'react'; -import useColumns from './useSubmissionDataGridColumns'; +import { pluralize } from 'utils/Utils'; +import useSubmissionDataGridColumns from './useSubmissionDataGridColumns'; export interface ISubmissionDataGridProps { feature_type_display_name: string; @@ -21,7 +22,7 @@ export interface ISubmissionDataGridProps { export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { const { submissionFeatures, feature_type_display_name, feature_type_name } = props; - const columns = useColumns(feature_type_name); + const columns = useSubmissionDataGridColumns(feature_type_name); const [rowSelectionModel, setRowSelectionModel] = useState([]); @@ -29,7 +30,7 @@ export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { - {`${feature_type_display_name} Records`} + {`${feature_type_display_name} ${pluralize(submissionFeatures.length, 'Record')}`} ({submissionFeatures.length}) diff --git a/app/src/hooks/api/useSubmissionsApi.ts b/app/src/hooks/api/useSubmissionsApi.ts index 45affb59..096b1bfb 100644 --- a/app/src/hooks/api/useSubmissionsApi.ts +++ b/app/src/hooks/api/useSubmissionsApi.ts @@ -151,6 +151,13 @@ const useSubmissionsApi = (axios: AxiosInstance) => { return data; }; + /** + * Fetch signed URL for a submission_feature (artifact) key value pair + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} params + * @returns {Promise} signed URL + */ const getSubmissionFeatureSignedUrl = async (params: SubmissionFeatureSignedUrlPayload): Promise => { const { submissionFeatureKey, submissionFeatureValue, submissionId, submissionFeatureId } = params; diff --git a/app/src/hooks/useDownload.test.tsx b/app/src/hooks/useDownload.test.tsx new file mode 100644 index 00000000..22877322 --- /dev/null +++ b/app/src/hooks/useDownload.test.tsx @@ -0,0 +1,11 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as download from './useDownload'; +describe('useDownload', () => { + describe('mounting', () => { + const { result } = renderHook(() => download.useDownload()); + it('should mount with both downloadJSON and downloadSignedURl', () => { + expect(result.current.downloadJSON).toBeDefined(); + expect(result.current.downloadSignedUrl).toBeDefined(); + }); + }); +}); diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx index 2cab52e2..f360d4f8 100644 --- a/app/src/hooks/useDownload.tsx +++ b/app/src/hooks/useDownload.tsx @@ -1,20 +1,23 @@ import { useDialogContext } from './useContext'; -const useDownload = () => { +export const useDownload = () => { const dialogContext = useDialogContext(); + /** - * handler for downloading raw data as JSON + * Handler for downloading raw data as JSON. * Note: currently this does not zip the file. Can be modified if needed. * - * @param {any} data - to download - * @param {string} fileName - name of file excluding file extension ie: file1 + * @param {any} data - Data to download. + * @param {string} fileName - Name of file excluding file extension ie: file1. */ const downloadJSON = (data: any, fileName: string) => { const blob = new Blob([JSON.stringify(data, undefined, 2)], { type: 'application/json' }); const link = document.createElement('a'); - link.download = `${fileName}.json`; + const sanitizedFileName = fileName.replace(/[^a-zA-Z ]/g, ''); + + link.download = `Biohub-${sanitizedFileName}.json`; link.href = URL.createObjectURL(blob); @@ -24,13 +27,13 @@ const useDownload = () => { }; /** - * Downloads / views a signed url - * displays error dialog if signedUrlService throws error - * Note: allows a promise to be passed to handle different api services + * Downloads or views a signed url. + * Displays error dialog if signedUrlService throws error. + * Note: Allows a promise to be passed to handle different api services. * * @async - * @param {SubmissionFeatureSignedUrlPayload} params - * @returns {Promise<[void]>} + * @param {Promise | string} params + * @returns {Promise} */ const downloadSignedUrl = async (signedUrl: Promise | string) => { try { @@ -49,5 +52,3 @@ const useDownload = () => { }; return { downloadJSON, downloadSignedUrl }; }; - -export default useDownload; From e0e574fc75ff8eb485152a4027004294fa0337c9 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Fri, 12 Jan 2024 16:41:57 -0800 Subject: [PATCH 12/13] fixed broken tests --- app/src/hooks/useDownload.test.tsx | 4 ++-- app/src/hooks/useDownload.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/hooks/useDownload.test.tsx b/app/src/hooks/useDownload.test.tsx index 22877322..15804d3f 100644 --- a/app/src/hooks/useDownload.test.tsx +++ b/app/src/hooks/useDownload.test.tsx @@ -1,8 +1,8 @@ import { renderHook } from '@testing-library/react-hooks'; -import * as download from './useDownload'; +import useDownload from './useDownload'; describe('useDownload', () => { describe('mounting', () => { - const { result } = renderHook(() => download.useDownload()); + const { result } = renderHook(() => useDownload()); it('should mount with both downloadJSON and downloadSignedURl', () => { expect(result.current.downloadJSON).toBeDefined(); expect(result.current.downloadSignedUrl).toBeDefined(); diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx index f360d4f8..59380af1 100644 --- a/app/src/hooks/useDownload.tsx +++ b/app/src/hooks/useDownload.tsx @@ -1,6 +1,6 @@ import { useDialogContext } from './useContext'; -export const useDownload = () => { +const useDownload = () => { const dialogContext = useDialogContext(); /** @@ -52,3 +52,5 @@ export const useDownload = () => { }; return { downloadJSON, downloadSignedUrl }; }; + +export default useDownload; From 0113aa0df27be9bd1235069c5e3ca78f110be69e Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Mon, 15 Jan 2024 09:31:17 -0800 Subject: [PATCH 13/13] ignore-skip --- app/src/hooks/useDownload.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/hooks/useDownload.tsx b/app/src/hooks/useDownload.tsx index 59380af1..0d467cb7 100644 --- a/app/src/hooks/useDownload.tsx +++ b/app/src/hooks/useDownload.tsx @@ -2,7 +2,6 @@ import { useDialogContext } from './useContext'; const useDownload = () => { const dialogContext = useDialogContext(); - /** * Handler for downloading raw data as JSON. * Note: currently this does not zip the file. Can be modified if needed.