diff --git a/src/linked-identity/linkedIdentityResolvers.ts b/src/linked-identity/linkedIdentityResolvers.ts index f9e789d..1a29b95 100644 --- a/src/linked-identity/linkedIdentityResolvers.ts +++ b/src/linked-identity/linkedIdentityResolvers.ts @@ -101,6 +101,7 @@ const linkedIdentityResolvers = { const exists = await validateOrcidExists(orcid); if (!exists) return null; + // Try to find the account with the ORCID as provided (which may include sandbox- prefix) const orcidAccountId: RepoDriverId = await getCrossChainOrcidAccountIdByOrcidId(orcid, [ chainToDbSchema[chain], diff --git a/src/orcid-account/orcidApi.ts b/src/orcid-account/orcidApi.ts index a24b361..4f35c87 100644 --- a/src/orcid-account/orcidApi.ts +++ b/src/orcid-account/orcidApi.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import { GraphQLError } from 'graphql'; import { getCache, setCacheWithJitter } from '../cache/redis'; -import { isOrcidId } from '../utils/assert'; +import { isOrcidId, unprefixOrcidId } from '../utils/assert'; import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout, @@ -112,6 +112,7 @@ export default async function fetchOrcidProfile( }); } + // Sandbox ORCIDs are cached prefixed to avoid conflicts with production ORCIDs const cacheKey = buildCacheKey(orcidId); const { value: cached } = await getCache(cacheKey); if (cached) { @@ -123,7 +124,9 @@ export default async function fetchOrcidProfile( } if (parsedJson === null) { // Cached 404 - console.log('ORCID cache hit with null entry.', { orcidId }); + console.log('ORCID cache hit with null entry.', { + orcidId, + }); return null; } } catch (error) { @@ -131,14 +134,19 @@ export default async function fetchOrcidProfile( } } + // Strip sandbox- prefix for API call since ORCID API doesn't recognize it in the URL + const unprefixedOrcidId = unprefixOrcidId(orcidId); + try { - console.log('Fetching ORCID profile from API.', { orcidId }); + console.log('Fetching ORCID profile from API.', { + orcidId: unprefixedOrcidId, + }); - let response = await requestProfile(orcidId); + let response = await requestProfile(unprefixedOrcidId); if (response.status === 401) { resetOrcidAccessToken(); - response = await requestProfile(orcidId); + response = await requestProfile(unprefixedOrcidId); } if (!response.ok) { @@ -166,7 +174,7 @@ export default async function fetchOrcidProfile( } const profile: OrcidProfile = { - orcidId, + orcidId: unprefixedOrcidId, givenName: nameParts.givenName, familyName: nameParts.familyName, }; @@ -178,7 +186,9 @@ export default async function fetchOrcidProfile( appSettings.cacheSettings.ttlJitterRatio, ); - console.log('ORCID profile fetched from API.', { orcidId }); + console.log('ORCID profile fetched from API.', { + orcidId: unprefixedOrcidId, + }); return profile; } catch (error) { diff --git a/src/orcid-account/validateOrcidExists.ts b/src/orcid-account/validateOrcidExists.ts index 000c0e8..1cfb74c 100644 --- a/src/orcid-account/validateOrcidExists.ts +++ b/src/orcid-account/validateOrcidExists.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import appSettings from '../common/appSettings'; +import { unprefixOrcidId } from '../utils/assert'; import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout, @@ -58,30 +59,32 @@ const validationCache = new OrcidValidationCache(); export default async function validateOrcidExists( orcidId: string, ): Promise { - const cachedResult = validationCache.get(orcidId); + const unprefixedOrcidId = unprefixOrcidId(orcidId); + + const cachedResult = validationCache.get(unprefixedOrcidId); if (cachedResult !== undefined) { return cachedResult; } try { - let response = await requestValidation(orcidId); + let response = await requestValidation(unprefixedOrcidId); if (response.status === 401) { resetOrcidAccessToken(); - response = await requestValidation(orcidId); + response = await requestValidation(unprefixedOrcidId); } const exists = response.status === 200; - validationCache.set(orcidId, exists); + validationCache.set(unprefixedOrcidId, exists); if (!exists && response.status === 404) { - console.log(`ORCID ${orcidId} does not exist on orcid.org`); + console.log(`ORCID ${unprefixedOrcidId} does not exist on orcid.org`); } return exists; } catch (error) { console.error( - `Failed to validate ORCID ${orcidId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Failed to validate ORCID ${unprefixedOrcidId}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); return false; } diff --git a/src/utils/assert.ts b/src/utils/assert.ts index 2e8f241..71f4286 100644 --- a/src/utils/assert.ts +++ b/src/utils/assert.ts @@ -313,13 +313,40 @@ export function assertIsLinkedIdentityId( } } -// ORCID +/** + * ORCID iDs in the sandbox environment should start with this prefix. + */ +export const ORCID_SANDBOX_PREFIX = 'sandbox-'; + +/** + * Regex to match the sandbox prefix at the start of an ORCID iD. + */ +const ORCID_SANDBOX_PREFIX_REGEX = new RegExp(`^${ORCID_SANDBOX_PREFIX}`); + +/** + * Removes the sandbox prefix from an ORCID iD, if present. + * + * @param orcidId An ORCID iD, possibly with the sandbox prefix. + * @returns The ORCID iD without the sandbox prefix. + */ +export function unprefixOrcidId(orcidId: string): string { + return orcidId.replace(ORCID_SANDBOX_PREFIX_REGEX, ''); +} + +/** + * Determine if a given string is a valid ORCID iD. ORCID iDs can be + * prefixed with "sandbox-". + * + * @param orcidId An ORCID iD + * @returns true if the ORCID iD is valid, false otherwise. + */ export function isOrcidId(orcidId: string): boolean { if (typeof orcidId !== 'string') { return false; } - const baseStr: string = orcidId.replace(/[-\s]/g, ''); + const unprefixedOrcidId = unprefixOrcidId(orcidId); + const baseStr: string = unprefixedOrcidId.replace(/[-\s]/g, ''); const orcidPattern: RegExp = /^\d{15}[\dX]$/; if (!orcidPattern.test(baseStr.toUpperCase())) { diff --git a/tests/utils/assert.test.ts b/tests/utils/assert.test.ts index b9def0d..ca1195a 100644 --- a/tests/utils/assert.test.ts +++ b/tests/utils/assert.test.ts @@ -1,16 +1,35 @@ import { describe, test, expect } from 'vitest'; import { isOrcidId, + unprefixOrcidId, + ORCID_SANDBOX_PREFIX, isLinkedIdentityId, assertIsLinkedIdentityId, } from '../../src/utils/assert'; +describe('unprefixOrcidId', () => { + test('should remove sandbox prefix from ORCID ID', () => { + expect(unprefixOrcidId(`${ORCID_SANDBOX_PREFIX}0009-0007-1106-8413`)).toBe( + '0009-0007-1106-8413', + ); + }); + + test('should return ORCID ID unchanged if no sandbox prefix', () => { + expect(unprefixOrcidId('0009-0007-1106-8413')).toBe('0009-0007-1106-8413'); + }); +}); + describe('isOrcidId', () => { test('should return true for valid ORCID IDs', () => { // Valid ORCID ID patterns with correct checksums expect(isOrcidId('0000-0003-1527-0030')).toBe(true); }); + test('should return true for valid sandbox-prefixed ORCID IDs', () => { + // Valid sandbox ORCID ID with correct checksum after removing prefix + expect(isOrcidId(`${ORCID_SANDBOX_PREFIX}0009-0007-1106-8413`)).toBe(true); + }); + test('should return false for invalid ORCID IDs', () => { // Invalid patterns expect(isOrcidId('0000-0002-1825-009X')).toBe(false); // Invalid checksum @@ -33,6 +52,11 @@ describe('isOrcidId', () => { expect(isOrcidId('abc-def-ghi-jkl')).toBe(false); // Letters instead of digits expect(isOrcidId('0000-0000-0000-000@')).toBe(false); // Invalid special character }); + + test('should return false for sandbox-prefixed invalid ORCID IDs', () => { + // Invalid checksum even with sandbox prefix + expect(isOrcidId('sandbox-0000-0002-1825-009X')).toBe(false); + }); }); describe('isLinkedIdentityId', () => {