From 7d1860629a330c9c4d4a3a9931f5512343736647 Mon Sep 17 00:00:00 2001 From: Morgan Brown Date: Mon, 17 Nov 2025 15:21:37 +0100 Subject: [PATCH 1/3] isOrcidId allows sandbox-prefixed orcids --- src/utils/assert.ts | 31 +++++++++++++++++++++++++++++-- tests/utils/assert.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) 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', () => { From 38585048e91497ae22c9e6189b0c36760ad4545a Mon Sep 17 00:00:00 2001 From: Morgan Brown Date: Mon, 17 Nov 2025 18:04:59 +0100 Subject: [PATCH 2/3] Ensure orcidLinkedIdentityByOrcid supporting functions can handle sandbox prefixed orcids --- .../linkedIdentityResolvers.ts | 1 + src/orcid-account/orcidApi.ts | 25 +++++++++++++------ src/orcid-account/validateOrcidExists.ts | 15 ++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) 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..2a9a539 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,7 +112,10 @@ export default async function fetchOrcidProfile( }); } - const cacheKey = buildCacheKey(orcidId); + // Strip sandbox- prefix for API call since ORCID API doesn't recognize it in the URL + const unprefixedOrcidId = unprefixOrcidId(orcidId); + + const cacheKey = buildCacheKey(unprefixedOrcidId); const { value: cached } = await getCache(cacheKey); if (cached) { try { @@ -123,7 +126,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: unprefixedOrcidId, + }); return null; } } catch (error) { @@ -132,13 +137,15 @@ export default async function fetchOrcidProfile( } 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 +173,7 @@ export default async function fetchOrcidProfile( } const profile: OrcidProfile = { - orcidId, + orcidId: unprefixedOrcidId, givenName: nameParts.givenName, familyName: nameParts.familyName, }; @@ -178,7 +185,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; } From c71de4a985f59d6edf27d69629f0b3fc8706fd17 Mon Sep 17 00:00:00 2001 From: Morgan Brown Date: Mon, 17 Nov 2025 19:12:28 +0100 Subject: [PATCH 3/3] Cache prefixed orcid --- src/orcid-account/orcidApi.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/orcid-account/orcidApi.ts b/src/orcid-account/orcidApi.ts index 2a9a539..4f35c87 100644 --- a/src/orcid-account/orcidApi.ts +++ b/src/orcid-account/orcidApi.ts @@ -112,10 +112,8 @@ 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); - - const cacheKey = buildCacheKey(unprefixedOrcidId); + // Sandbox ORCIDs are cached prefixed to avoid conflicts with production ORCIDs + const cacheKey = buildCacheKey(orcidId); const { value: cached } = await getCache(cacheKey); if (cached) { try { @@ -127,7 +125,7 @@ export default async function fetchOrcidProfile( if (parsedJson === null) { // Cached 404 console.log('ORCID cache hit with null entry.', { - orcidId: unprefixedOrcidId, + orcidId, }); return null; } @@ -136,6 +134,9 @@ 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: unprefixedOrcidId,