diff --git a/.env.test b/.env.test index a20e598d..b13c5ffc 100644 --- a/.env.test +++ b/.env.test @@ -72,4 +72,5 @@ VSCODE_ACCESS_TOKEN= NODES_MEDIA_SERVER_URL=http://host.docker.internal:5454 REPO_SERVICE_SECRET_KEY="m8sIy5BPygBcX3+ZmMVuAA10k6w59BSCZd+Z5+VLYm4=" -REPO_SERVER_URL=http://host.docker.internal:5485 \ No newline at end of file +REPO_SERVER_URL=http://host.docker.internal:5485 +DEBUG=nock.desci-server \ No newline at end of file diff --git a/desci-server/package.json b/desci-server/package.json index a10510f9..de7bf984 100755 --- a/desci-server/package.json +++ b/desci-server/package.json @@ -144,6 +144,7 @@ "eslint-plugin-prettier": "^5.0.1", "lint-staged": "^11.1.2", "mocha": "^10.2.0", + "nock": "^13.5.4", "nodemon": "^2.0.22", "nyc": "^15.1.0", "prettier": "^3.1.1", diff --git a/desci-server/src/services/orcid.ts b/desci-server/src/services/orcid.ts index 286c6e3f..8b26a4bf 100644 --- a/desci-server/src/services/orcid.ts +++ b/desci-server/src/services/orcid.ts @@ -15,6 +15,11 @@ const ORCID_DOMAIN = process.env.ORCID_API_DOMAIN || 'sandbox.orcid.org'; type Claim = Awaited>[number]; const logger = parentLogger.child({ module: 'ORCIDApiService' }); +/** + * Service class for interfacing with ORCID /works API + * Handles updating orcid work profile entries for users with orcid + * linked to their profiles + */ class OrcidApiService { baseUrl: string; @@ -25,6 +30,12 @@ class OrcidApiService { logger.info({ url: this.baseUrl }, 'Init ORCID Service'); } + /** + * Query user orcid access token from the database and refreshes + * tokens if needed and update database entry valid token + * @param userId unique user identifier + * @returns a valid access token string + */ private async getAccessToken(userId: number) { const authTokens = await prisma.authToken.findMany({ where: { @@ -76,18 +87,28 @@ class OrcidApiService { }); logger.info({ status: response.status, statusText: response.statusText, data }, 'REFRESH TOKEN RESPONSE'); } else { - logger.info( + logger.error( { status: response.status, statusText: response.statusText, BODY: await response.json() }, 'REFRESH TOKEN ERROR', ); } } catch (err) { - logger.info({ err }, 'ORCID REFRESH TOKEN ERROR'); + logger.error({ err }, 'ORCID REFRESH TOKEN ERROR'); } return authToken.accessToken; } + /** + * Remove an attestation from user's ORCID work profile + * If user has no verified protected attestations, remove research node + * work entry + * @param {Object} argument - The claim argument to process + * @param {number} argument.claimId - The ID of the node attestation to remove + * @param {string} argument.nodeUuid - The uuid of the research node + * @param {string} argument.orcid - The ORCID identifier of the user + * @returns + */ async removeClaimRecord({ claimId, nodeUuid, orcid }: { claimId: number; nodeUuid: string; orcid: string }) { const putCode = await prisma.orcidPutCodes.findFirst({ where: { @@ -132,6 +153,15 @@ class OrcidApiService { logger.info({ userId: user.id, CLAIMS: claims.length, nodeUuid }, '[ORCID::DELETE]:: FINISH'); } + /** + * Execute http request to remove ORCID work entry + * and remove the associated putCode from the database + * @param {Object} argument - The claim argument to process + * @param {string} argument.orcid - The ORCID identifier of the user + * @param {number} argument.putCode - The ORCID /work record putCode + * @param {string} argument.authToken - A valid user orcid access token + * @returns + */ async removeWorkRecord({ putCode, authToken, orcid }: { orcid: string; putCode: OrcidPutCodes; authToken: string }) { const code = putCode.putcode; const url = `${this.baseUrl}/${orcid}/work${code ? '/' + code : ''}`; @@ -172,6 +202,14 @@ class OrcidApiService { ); } + /** + * Update ORCID work summary of a user + * Retrieve a validated protected attestations and post each as a work entry + * Retrieve Research Node with uuid {nodeUuid} and post a work entry + * @param nodeUuid - Research node uuid + * @param orcid - ORCID identifier + * @returns + */ async postWorkRecord(nodeUuid: string, orcid: string) { try { const user = await prisma.user.findUnique({ where: { orcid } }); @@ -225,6 +263,18 @@ class OrcidApiService { } } + /** + * Execute http request to post/update ORCID work entry for a node + * and insert/update the associated putCode in the database + * @param {Object} argument - The Research Node details object + * @param {Object} argument.manifest - The node's manifest + * @param {string} argument.publicationDate - The last publish datetime string in rfc3339 format + * @param {string} argument.uuid - Unique uuid identifier of the node to update + * @param {number} argument.userId - ID of the user (node owner) + * @param {string} argument.authToken - A valid user orcid access token + * @param {string} argument.orcid - The ORCID identifier of the user + * @param {number} argument.nodeVersion - The latest version of the research node + */ async putNodeWorkRecord({ manifest, publicationDate, @@ -248,7 +298,7 @@ class OrcidApiService { }); const putCode = orcidPutCode?.putcode; - let data = generateRootWorkRecord({ manifest, publicationDate, nodeVersion, putCode }); + let data = generateNodeWorkRecord({ manifest, publicationDate, nodeVersion, putCode }); data = data.replace(/\\"/g, '"'); const url = `${this.baseUrl}/${orcid}/work${putCode ? '/' + putCode : ''}`; @@ -312,10 +362,23 @@ class OrcidApiService { ); } } catch (err) { - logger.info({ err }, '[ORCID_API_SERVICE]::NODE API Error Response'); + logger.error({ err }, '[ORCID_API_SERVICE]::NODE API Error Response'); } } + /** + * Execute http request to post/update ORCID work entry for an attestation + * and insert/update the associated putCode in the database + * @param {Object} argument - The Research Node details object + * @param {string} argument.authToken - A valid user orcid access token + * @param {Object} argument.claim - The claim object retrieved from the database + * @param {Object} argument.manifest - The node's manifest + * @param {number} argument.nodeVersion - The latest version of the research node + * @param {string} argument.orcid - The ORCID identifier of the user + * @param {string} argument.publicationDate - The last publish datetime string in rfc3339 format + * @param {string} argument.uuid - Unique uuid identifier of the node to update + * @param {number} argument.userId - ID of the user (node owner) + */ async putClaimWorkRecord({ manifest, publicationDate, @@ -410,20 +473,27 @@ class OrcidApiService { 'ORCID CLAIM RECORD UPDATED', ); } else { - logger.info( + logger.error( { status: response.status, response, body: await response.text() }, '[ORCID_API_SERVICE]::ORCID CLAIM API ERROR', ); } } catch (err) { - logger.info({ err }, '[ORCID_API_SERVICE]::CLAIM API Error Response'); + logger.error({ err }, '[ORCID_API_SERVICE]::CLAIM API Error Response'); } } } -const orcidApiService = new OrcidApiService(); -export default orcidApiService; - +/** + * Generate an ORCID work summary xml string based for an attestation/claim + * Model Reference https://github.com/ORCID/orcid-model/blob/master/src/main/resources/record_3.0/work-3.0.xsd + * @param {Object} argument - The Research Node details object + * @param {Object} argument.claim - The claim object retrieved from the database + * @param {Object} argument.manifest - The node's manifest + * @param {number} argument.nodeVersion - The latest version of the research node + * @param {string} argument.publicationDate - The last publish datetime string in rfc3339 format + * @param {number=} argument.putCode - The ORCID /work record putCode + */ const generateClaimWorkRecord = ({ manifest, putCode, @@ -483,9 +553,20 @@ const generateClaimWorkRecord = ({ ` ); }; + const zeropad = (data: string) => (data.length < 2 ? `0${data}` : data); -const generateRootWorkRecord = ({ +/** + * Generate an ORCID work summary xml string based for a research Node + * Model Reference https://github.com/ORCID/orcid-model/blob/master/src/main/resources/record_3.0/work-3.0.xsd + * @param {Object} argument - The Research Node details object + * @param {Object} argument.manifest - The node's manifest + * @param {number} argument.nodeVersion - The latest version of the research node + * @param {string} argument.publicationDate - The last publish datetime string in rfc3339 format + * @param {number=} argument.putCode - The ORCID /work record putCode + * @returns {string} xml string of the constructed work summary data + */ +const generateNodeWorkRecord = ({ manifest, nodeVersion, putCode, @@ -495,7 +576,7 @@ const generateRootWorkRecord = ({ nodeVersion: number; putCode?: string; publicationDate: string; -}) => { +}): string => { const codeAttr = putCode ? 'put-code="' + putCode + '"' : ''; const workType = 'preprint'; const [month, day, year] = publicationDate.split('-'); @@ -530,7 +611,13 @@ const generateRootWorkRecord = ({ ); }; -const generateContributors = (authors: ResearchObjectV1Author[]) => { +/** + * Generate an ORCID work contributors xml string + * Model Reference https://github.com/ORCID/orcid-model/blob/master/src/main/resources/record_3.0/work-3.0.xsd#L160 + * @param authors[] - A list of ResearchObjectV1Author entries + * @returns {string} xml string of the constructed contributor data + */ +const generateContributors = (authors: ResearchObjectV1Author[]): string => { const contributors = authors?.length > 0 ? ` @@ -558,3 +645,6 @@ const generateContributors = (authors: ResearchObjectV1Author[]) => { : ``; return contributors; }; + +const orcidApiService = new OrcidApiService(); +export default orcidApiService; diff --git a/desci-server/test/integration/Attestation.test.ts b/desci-server/test/integration/Attestation.test.ts index 9a908ff7..a4d067b9 100644 --- a/desci-server/test/integration/Attestation.test.ts +++ b/desci-server/test/integration/Attestation.test.ts @@ -5,6 +5,7 @@ import { Annotation, Attestation, AttestationVersion, + AuthTokenSource, CommunityMember, CommunityMembershipRole, DesciCommunity, @@ -18,6 +19,7 @@ import { } from '@prisma/client'; import { assert, expect } from 'chai'; import jwt from 'jsonwebtoken'; +import nock from 'nock'; import request from 'supertest'; import { prisma } from '../../src/client.js'; @@ -2088,7 +2090,7 @@ describe('Attestations Service', async () => { }); }); - describe('Protected Attestation Verification', async () => { + describe.only('Protected Attestation Verification', async () => { let openCodeClaim: NodeAttestation; let openDataClaim: NodeAttestation; let node: Node; @@ -2105,6 +2107,9 @@ describe('Attestations Service', async () => { MemberJwtToken2: string, memberAuthHeaderVal1: string, memberAuthHeaderVal2: string; + const mockPutCode = '1926486'; + const ORCID_ID = '0000-0000-1111-090X'; + const NOCK_URL = new URL('https://api.sandbox.orcid.org'); before(async () => { node = nodes[0]; @@ -2121,6 +2126,26 @@ describe('Attestations Service', async () => { claimerId: author.id, }); + // add oricd to user profile + await prisma.user.update({ + where: { + id: author.id, + }, + data: { + orcid: ORCID_ID, + }, + }); + + // insert mock orcid auth token + await prisma.authToken.create({ + data: { + source: AuthTokenSource.ORCID, + accessToken: 'mock-access-token', + userId: author.id, + refreshToken: 'refresh-token', + }, + }); + members = await communityService.getAllMembers(desciCommunity.id); MemberJwtToken1 = jwt.sign({ email: members[0].user.email }, process.env.JWT_SECRET!, { expiresIn: '1y', @@ -2143,9 +2168,27 @@ describe('Attestations Service', async () => { await prisma.$queryRaw`TRUNCATE TABLE "Annotation" CASCADE;`; await prisma.$queryRaw`TRUNCATE TABLE "NodeAttestationVerification" CASCADE;`; // await prisma.$queryRaw`TRUNCATE TABLE "CommunityMember" CASCADE;`; + + nock.restore(); }); it('should allow only members verify a node attestation(claim)', async () => { + const scope1 = nock(NOCK_URL) + .post(`/v3.0/${ORCID_ID}/work`) + .once() + .reply(201, '', { location: `https://api.sandbox.orcid.org/v3.0/${ORCID_ID}/work/${mockPutCode}` }); + + const scope2 = nock(new URL('https://sandbox.orcid.org')).post(`/oauth/token`).twice().reply(200, { + access_token: 'access-token', + token_type: 'auth', + refresh_token: 'refresh-token', + expires_in: 3599, + scope: '', + name: '', + orcid: ORCID_ID, + }); + + console.log('ACTIVE MOCKS', nock.activeMocks()); let res = await request(app) .post(`/v1/attestations/verification`) .set('authorization', memberAuthHeaderVal1) @@ -2154,15 +2197,31 @@ describe('Attestations Service', async () => { }); expect(res.statusCode).to.equal(200); + // setTimeout(() => { + // }, 100); + + const scope3 = nock(NOCK_URL).put(`/v3.0/${ORCID_ID}/work/${mockPutCode}`).once().reply(200); + res = await request(app).post(`/v1/attestations/verification`).set('authorization', memberAuthHeaderVal2).send({ claimId: openCodeClaim.id, }); expect(res.statusCode).to.equal(200); + // setTimeout(() => { + // }, 100); + const verifications = await attestationService.getAllClaimVerfications(openCodeClaim.id); expect(verifications.length).to.equal(2); expect(verifications.some((v) => v.userId === members[0].userId)).to.equal(true); expect(verifications.some((v) => v.userId === members[1].userId)).to.equal(true); + + console.log('Pending MOCKS', nock.pendingMocks()); + scope1.done(); + scope2.done(); + scope3.done(); + console.log('INTERCEPTOR SCOPE', scope1); + console.log('INTERCEPTOR SCOPE', scope2); + console.log('INTERCEPTOR SCOPE', scope3); }); it('should prevent non-authorized users from verifying a protected attestation(claim)', async () => { diff --git a/desci-server/yarn.lock b/desci-server/yarn.lock index 60f309f2..41ed29f4 100644 --- a/desci-server/yarn.lock +++ b/desci-server/yarn.lock @@ -10116,7 +10116,7 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== @@ -10953,6 +10953,15 @@ next@14.1.0: "@next/swc-win32-ia32-msvc" "14.1.0" "@next/swc-win32-x64-msvc" "14.1.0" +nock@^13.5.4: + version "13.5.4" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.4.tgz#8918f0addc70a63736170fef7106a9721e0dc479" + integrity sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + propagate "^2.0.0" + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -11779,6 +11788,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"