diff --git a/api/package-lock.json b/api/package-lock.json index d991a84ba..06b241c08 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 000000000..f98a28780 --- /dev/null +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.test.ts @@ -0,0 +1,99 @@ +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('throws error if submissionService throws error', async () => { + const dbConnectionObj = getMockDBConnection(); + + const getDBConnectionStub = 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['keycloak_token'] = 'TOKEN'; + + mockReq.params = { + submissionId: '1', + submissionFeatureId: '2' + }; + + mockReq.query = { + key: 'KEY', + value: 'VALUE' + }; + + try { + await requestHandler(mockReq, mockRes, mockNext); + + 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); + expect((error as HTTPError).message).to.equal('Error'); + } + }); + + it('should return 200 on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + const getAPIUserDBConnectionStub = sinon.stub(db, 'getAPIUserDBConnection').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(getAPIUserDBConnectionStub).to.have.been.calledOnce; + 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 new file mode 100644 index 000000000..eaec2742d --- /dev/null +++ b/api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/signed-url.ts @@ -0,0 +1,116 @@ +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' + } + } + } + }, + ...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 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 signedUrl = await submissionService.getSubmissionFeatureSignedUrl({ + submissionFeatureId, + submissionFeatureObj: { key: submissionFeatureDataKey, value: submissionFeatureDataValue }, + isAdmin + }); + + await connection.commit(); + + res.status(200).json(signedUrl); + } 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.test.ts b/api/src/repositories/submission-repository.test.ts index daec8f4fc..41febdb7a 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -1754,4 +1754,132 @@ describe('SubmissionRepository', () => { expect(response).to.eql([mockResponse]); }); }); + + describe('getAdminSubmissionFeatureAritifactKey', () => { + afterEach(() => { + sinon.restore(); + }); + + 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 }); + + 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 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' + }; + + 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 (rowCount 0)', 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.equal('Failed to get key for signed URL'); + } + }); + + 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' + }; + + 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 d2e06698a..f3eec0e7f 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. * @@ -1815,4 +1821,73 @@ 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} + RETURNING ss.value;`; + + const response = await this.connection.sql(sqlStatement, z.object({ value: z.string() })); + + 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' + ]); + } + + 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.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' + ]); + } + + return response.rows[0].value; + } } diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index 591afd95f..eb577498f 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'; @@ -1190,4 +1192,88 @@ 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 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 () => { + const mockDBConnection = getMockDBConnection(); + + const getSubmissionFeatureSignedUrlStub = sinon + .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 () => { + 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.equal(`Failed to generate signed URL for "a":"b"`); + } + }); + }); }); diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index c49dbb071..3c4592cdb 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'; @@ -725,4 +727,33 @@ export class SubmissionService extends DBService { async downloadPublishedSubmission(submissionId: number): Promise { return this.submissionRepository.downloadPublishedSubmission(submissionId); } + + /** + * Generates a signed URL for a submission_feature's (artifact) key value pair + * ie: "s3_key": "artifact/test-file.txt" + * + * Note: admin's can generate signed urls for secure submission_features + * + * @async + * @param {SubmissionFeatureSignedUrlPayload} payload + * @throws {ApiGeneralError} + * @memberof SubmissionService + * @returns {Promise} signed URL + */ + async getSubmissionFeatureSignedUrl(payload: SubmissionFeatureSignedUrlPayload): Promise { + const artifactKey = payload.isAdmin + ? await this.submissionRepository.getAdminSubmissionFeatureArtifactKey(payload) + : 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/admin/dashboard/components/PublishedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx index d28260063..fa74dd647 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/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx index 0568660cf..300862df8 100644 --- a/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx +++ b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.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 ReviewedSubmissionsTable = () => { const biohubApi = useApi(); - const download = useDownloadJSON(); + const { downloadJSON } = useDownload(); const reviewedSubmissionsDataLoader = useDataLoader(() => biohubApi.submissions.getReviewedSubmissionsForAdmins()); @@ -36,7 +36,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/AdminSubmissionPage.tsx b/app/src/features/submissions/AdminSubmissionPage.tsx index cdf02b4a9..fe20dbc36 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.tsx deleted file mode 100644 index 93e6c91f2..000000000 --- a/app/src/features/submissions/components/SubmissionDataGrid.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { mdiLock, mdiLockOpenOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Divider, Paper, Stack, 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 { useCodesContext } from 'hooks/useContext'; -import { IFeatureTypeProperties } from 'interfaces/useCodesApi.interface'; -import { SubmissionFeatureRecordWithTypeAndSecurity } from 'interfaces/useSubmissionsApi.interface'; -import { useState } from 'react'; - -export interface ISubmissionDataGridProps { - feature_type_display_name: string; - feature_type_name: string; - submissionFeatures: SubmissionFeatureRecordWithTypeAndSecurity[]; -} - -/** - * SubmissionDataGrid component for displaying submission data. - * - * @param {ISubmissionDataGridProps} props - * @return {*} - */ -export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { - const codesContext = useCodesContext(); - 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 [rowSelectionModel, setRowSelectionModel] = useState([]); - - const fieldColumns = featureTypeWithProperties.map((featureType: IFeatureTypeProperties) => { - 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)} - - ); - } - }; - }); - - 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 ( - - - - {`${feature_type_display_name} Records`} - - ({submissionFeatures.length}) - - - - - - - row.submission_feature_id} - autoHeight - rows={submissionFeatures} - columns={columns} - pageSizeOptions={[5]} - editMode="row" - rowSelectionModel={rowSelectionModel} - onRowSelectionModelChange={(model) => { - setRowSelectionModel(model); - }} - disableRowSelectionOnClick - disableColumnSelector - disableColumnMenu - sortingOrder={['asc', 'desc']} - initialState={{ - sorting: { sortModel: [{ field: 'submission_feature_id', sort: 'asc' }] }, - pagination: { - paginationModel: { - pageSize: 10 - } - } - }} - sx={{ - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 700, - textTransform: 'uppercase', - color: 'text.secondary' - } - }} - /> - - - ); -}; - -export default SubmissionDataGrid; diff --git a/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx new file mode 100644 index 000000000..db02b536d --- /dev/null +++ b/app/src/features/submissions/components/SubmissionDataGrid/SubmissionDataGrid.tsx @@ -0,0 +1,79 @@ +import { Divider, Paper, Toolbar } from '@mui/material'; +import Typography from '@mui/material/Typography'; +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 { pluralize } from 'utils/Utils'; +import useSubmissionDataGridColumns from './useSubmissionDataGridColumns'; + +export interface ISubmissionDataGridProps { + feature_type_display_name: string; + feature_type_name: string; + submissionFeatures: SubmissionFeatureRecordWithTypeAndSecurity[]; +} + +/** + * SubmissionDataGrid component for displaying submission data. + * + * @param {ISubmissionDataGridProps} props + * @return {*} + */ +export const SubmissionDataGrid = (props: ISubmissionDataGridProps) => { + const { submissionFeatures, feature_type_display_name, feature_type_name } = props; + + const columns = useSubmissionDataGridColumns(feature_type_name); + + const [rowSelectionModel, setRowSelectionModel] = useState([]); + + return ( + + + + {`${feature_type_display_name} ${pluralize(submissionFeatures.length, 'Record')}`} + + ({submissionFeatures.length}) + + + + + + + row.submission_feature_id} + autoHeight + rows={submissionFeatures} + columns={columns} + pageSizeOptions={[5]} + editMode="row" + rowSelectionModel={rowSelectionModel} + onRowSelectionModelChange={(model) => { + setRowSelectionModel(model); + }} + disableRowSelectionOnClick + disableColumnSelector + disableColumnMenu + sortingOrder={['asc', 'desc']} + initialState={{ + sorting: { sortModel: [{ field: 'submission_feature_id', sort: 'asc' }] }, + pagination: { + paginationModel: { + pageSize: 10 + } + } + }} + sx={{ + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + textTransform: 'uppercase', + color: 'text.secondary' + } + }} + /> + + + ); +}; + +export default SubmissionDataGrid; 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 000000000..cf397e769 --- /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/useSubmissionDataGridColumns.tsx b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx new file mode 100644 index 000000000..35bc61f62 --- /dev/null +++ b/app/src/features/submissions/components/SubmissionDataGrid/useSubmissionDataGridColumns.tsx @@ -0,0 +1,125 @@ +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 } from 'hooks/useContext'; +import useDownload from 'hooks/useDownload'; +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 { downloadSignedUrl } = useDownload(); + + 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, + disableReorder: true, + 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 ( + + ); + } + }; + } + 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; diff --git a/app/src/features/submissions/list/SubmissionsListPage.tsx b/app/src/features/submissions/list/SubmissionsListPage.tsx index 0ca7dbc55..24df5fdf0 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/api/useSubmissionsApi.test.ts b/app/src/hooks/api/useSubmissionsApi.test.ts index 56cb76a57..81d4869b8 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'); + }); + }); }); diff --git a/app/src/hooks/api/useSubmissionsApi.ts b/app/src/hooks/api/useSubmissionsApi.ts index 5b4154cbb..096b1bfbd 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 @@ -150,6 +151,23 @@ 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; + + const { data } = await axios.get( + `api/submission/${submissionId}/features/${submissionFeatureId}/signed-url?key=${submissionFeatureKey}&value=${submissionFeatureValue}` + ); + + return data; + }; + return { listSubmissions, getSignedUrl, @@ -161,7 +179,8 @@ const useSubmissionsApi = (axios: AxiosInstance) => { getReviewedSubmissionsForAdmins, getPublishedSubmissionsForAdmins, updateSubmissionRecord, - getPublishedSubmissions + getPublishedSubmissions, + getSubmissionFeatureSignedUrl }; }; diff --git a/app/src/hooks/useDownload.test.tsx b/app/src/hooks/useDownload.test.tsx new file mode 100644 index 000000000..15804d3f7 --- /dev/null +++ b/app/src/hooks/useDownload.test.tsx @@ -0,0 +1,11 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useDownload from './useDownload'; +describe('useDownload', () => { + describe('mounting', () => { + 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 new file mode 100644 index 000000000..0d467cb7a --- /dev/null +++ b/app/src/hooks/useDownload.tsx @@ -0,0 +1,55 @@ +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 - 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'); + + const sanitizedFileName = fileName.replace(/[^a-zA-Z ]/g, ''); + + link.download = `Biohub-${sanitizedFileName}.json`; + + link.href = URL.createObjectURL(blob); + + link.click(); + + URL.revokeObjectURL(link.href); + }; + + /** + * 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 {Promise | string} params + * @returns {Promise} + */ + const downloadSignedUrl = async (signedUrl: Promise | string) => { + try { + const url = await signedUrl; + + window.open(url, '_blank'); + } catch (err: any) { + 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 69314ff69..000000000 --- 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; diff --git a/app/src/interfaces/useSubmissionsApi.interface.ts b/app/src/interfaces/useSubmissionsApi.interface.ts index 7d89fafda..1ce158439 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/02_populate_feature_tables.ts b/database/src/seeds/02_populate_feature_tables.ts index 5480dbbe1..16df68209 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 89d5756e1..e150b5c77 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 ba3abf8f7..7097301b8 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 = 'dev-artifacts/artifact.txt'; + + 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;