Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/linked-identity/linkedIdentityResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
24 changes: 17 additions & 7 deletions src/orcid-account/orcidApi.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -123,22 +124,29 @@ 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) {
console.error('Failed to parse cached ORCID profile:', error);
}
}

// 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) {
Expand Down Expand Up @@ -166,7 +174,7 @@ export default async function fetchOrcidProfile(
}

const profile: OrcidProfile = {
orcidId,
orcidId: unprefixedOrcidId,
givenName: nameParts.givenName,
familyName: nameParts.familyName,
};
Expand All @@ -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) {
Expand Down
15 changes: 9 additions & 6 deletions src/orcid-account/validateOrcidExists.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import appSettings from '../common/appSettings';
import { unprefixOrcidId } from '../utils/assert';
import {
DEFAULT_FETCH_TIMEOUT_MS,
fetchWithTimeout,
Expand Down Expand Up @@ -58,30 +59,32 @@ const validationCache = new OrcidValidationCache();
export default async function validateOrcidExists(
orcidId: string,
): Promise<boolean> {
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;
}
Expand Down
31 changes: 29 additions & 2 deletions src/utils/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand Down
24 changes: 24 additions & 0 deletions tests/utils/assert.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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', () => {
Expand Down