diff --git a/src/orcid-account/orcidAccountResolvers.ts b/src/orcid-account/orcidAccountResolvers.ts index 86613d3..a596478 100644 --- a/src/orcid-account/orcidAccountResolvers.ts +++ b/src/orcid-account/orcidAccountResolvers.ts @@ -73,7 +73,7 @@ const orcidAccountResolvers = { ): Promise => { if (!isOrcidId(id)) { throw new Error( - `Invalid ORCID identifier: '${id}'. Expected format: 0000-0000-0000-000X`, + `Invalid ORCID identifier: '${id}'. See https://support.orcid.org/hc/en-us/articles/360006897674-Structure-of-the-ORCID-Identifier.`, ); } diff --git a/src/utils/assert.ts b/src/utils/assert.ts index b6b7faf..f823d08 100644 --- a/src/utils/assert.ts +++ b/src/utils/assert.ts @@ -293,9 +293,53 @@ export function isAccountId(id: string): boolean { ); } -// ORCID -export function isOrcidId(id: string): boolean { - // ORCID ID format: 0000-0000-0000-000X where X can be 0-9 or X - const orcidPattern = /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/; - return orcidPattern.test(id); +/** + * Validates an ORCID iD. + * + * An ORCID iD is a 16-character string that follows a specific structure: + * - It consists of 15 digits followed by a check digit (0-9 or 'X'). + * - It is often formatted with hyphens, e.g., "0000-0002-1825-0097". + * - The validation uses the ISO 7064 11,2 checksum algorithm. + * + * @param {string} orcid The ORCID iD string to validate. + * @returns {boolean} True if the ORCID iD is valid, false otherwise. + */ +export function isOrcidId(orcidId: string): boolean { + if (typeof orcidId !== 'string') { + return false; + } + + // Remove hyphens and whitespace to get the base 16 characters. + const baseStr: string = orcidId.replace(/[-\s]/g, ''); + + // An ORCID must be 16 characters long and match the pattern: + // 15 digits followed by a final character that is a digit or 'X'. + const orcidPattern: RegExp = /^\d{15}[\dX]$/; + if (!orcidPattern.test(baseStr.toUpperCase())) { + return false; + } + + // --- Checksum Calculation (ISO 7064 11,2) --- + + let total: number = 0; + // Iterate over the first 15 digits of the ORCID. + for (let i = 0; i < 15; i++) { + const digit: number = parseInt(baseStr[i], 10); + total = (total + digit) * 2; + } + + // Calculate the remainder when divided by 11. + const remainder: number = total % 11; + // Subtract the remainder from 12. + const result: number = (12 - remainder) % 11; + + // Determine the correct check digit from the result. + // If the result is 10, the check digit is 'X'. Otherwise, it's the digit itself. + const calculatedCheckDigit: string = result === 10 ? 'X' : String(result); + + // Get the actual check digit from the input string. + const actualCheckDigit: string = baseStr.charAt(15).toUpperCase(); + + // Compare the calculated check digit with the actual one. + return calculatedCheckDigit === actualCheckDigit; } diff --git a/tests/utils/assert.test.ts b/tests/utils/assert.test.ts index e447958..e9143e4 100644 --- a/tests/utils/assert.test.ts +++ b/tests/utils/assert.test.ts @@ -4,11 +4,8 @@ import { isOrcidId } from '../../src/utils/assert'; describe('isOrcidId', () => { test('should return true for valid ORCID IDs', () => { // Valid ORCID ID patterns - expect(isOrcidId('0000-0000-0000-0000')).toBe(true); - expect(isOrcidId('0000-0000-0000-000X')).toBe(true); - expect(isOrcidId('1234-5678-9012-3456')).toBe(true); - expect(isOrcidId('0000-0002-1825-009X')).toBe(true); - expect(isOrcidId('0000-0003-1527-0030')).toBe(true); + expect(isOrcidId('0009-0007-1106-8413')).toBe(true); + expect(isOrcidId('0000-0002-9079-593X')).toBe(true); }); test('should return false for invalid ORCID IDs', () => { @@ -31,5 +28,7 @@ describe('isOrcidId', () => { expect(isOrcidId('')).toBe(false); // Empty string expect(isOrcidId('abc-def-ghi-jkl')).toBe(false); // Letters instead of digits expect(isOrcidId('0000-0000-0000-000@')).toBe(false); // Invalid special character + expect(isOrcidId('0000-0001-5109-370X')).toBe(false); // Incorrect X position + expect(isOrcidId('0000-0002-1825-0098')).toBe(false); // Fails checksum }); });