diff --git a/src/inspectors/checkCredentialSchemaConformity.ts b/src/inspectors/checkCredentialSchemaConformity.ts index e98fd9b6..7a35a8e9 100644 --- a/src/inspectors/checkCredentialSchemaConformity.ts +++ b/src/inspectors/checkCredentialSchemaConformity.ts @@ -5,11 +5,15 @@ import { SUB_STEPS } from '../domain/verifier/entities/verificationSteps'; import { getText } from '../domain/i18n/useCases'; import type { VCCredentialSchema } from '../models/BlockcertsV3'; -export default async function checkCredentialSchemaConformity (credentialSubject: any, credentialSchema: VCCredentialSchema | VCCredentialSchema[]): Promise { +export default async function checkCredentialSchemaConformity (credentialSubject: any | any[], credentialSchema: VCCredentialSchema | VCCredentialSchema[]): Promise { if (!Array.isArray(credentialSchema)) { credentialSchema = [credentialSchema]; } + if (!Array.isArray(credentialSubject)) { + credentialSubject = [credentialSubject]; + } + for (const schemaInfo of credentialSchema) { const rawSchema = await request({ url: schemaInfo.id }); let schema; @@ -21,9 +25,11 @@ export default async function checkCredentialSchemaConformity (credentialSubject } const validate = validator(schema); - const result = validate(credentialSubject); - if (!result) { - throw new VerifierError(SUB_STEPS.checkCredentialSchemaConformity, getText('errors', 'checkCredentialSchemaConformity')); + for (const subject of credentialSubject) { + const result = validate(subject); + if (!result) { + throw new VerifierError(SUB_STEPS.checkCredentialSchemaConformity, getText('errors', 'checkCredentialSchemaConformity')); + } } } } diff --git a/src/models/BlockcertsV3.ts b/src/models/BlockcertsV3.ts index 026df350..db1e4093 100644 --- a/src/models/BlockcertsV3.ts +++ b/src/models/BlockcertsV3.ts @@ -34,7 +34,7 @@ export interface VerifiableCredential { credentialStatus?: VCCredentialStatus | VCCredentialStatus[]; credentialSchema?: VCCredentialSchema | VCCredentialSchema[]; issuer: string | Issuer; - credentialSubject?: any; + credentialSubject?: any | any[]; expirationDate?: string; evidence?: Array<{ type: string[]; @@ -83,20 +83,23 @@ export interface VCCredentialSchema { id: string; } -export interface BlockcertsV3 extends VerifiableCredential { - credentialSubject: { +// CredentialSubject can technically be anything the issuer wants, but we define a basic structure +export interface BlockcertsV3BasicCredentialSubject { + id?: string; + publicKey?: string; + name?: string; + type?: string; + claim?: { + type?: string; id?: string; - publicKey?: string; name?: string; - type?: string; - claim?: { - type?: string; - id?: string; - name?: string; - description?: string; - criteria?: string; - }; + description?: string; + criteria?: string; }; +} + +export interface BlockcertsV3 extends VerifiableCredential { + credentialSubject: BlockcertsV3BasicCredentialSubject | BlockcertsV3BasicCredentialSubject[]; metadata?: string; display?: BlockcertsV3Display; nonce?: string; diff --git a/src/parsers/parseV3.ts b/src/parsers/parseV3.ts index 57685a05..f8be22d8 100644 --- a/src/parsers/parseV3.ts +++ b/src/parsers/parseV3.ts @@ -1,23 +1,27 @@ import domain from '../domain'; import type { Issuer } from '../models/Issuer'; -import type { BlockcertsV3, MultilingualVcField, VCProof } from '../models/BlockcertsV3'; +import type { BlockcertsV3, VCProof } from '../models/BlockcertsV3'; import type { ParsedCertificate } from './index'; -function getRecipientFullName (certificateJson): string { - const { credentialSubject } = certificateJson; - return credentialSubject.name || ''; -} +function getPropertyValueForCurrentLanguage (propertyName: string, field: any, locale: string): string { + if (typeof field === 'undefined') { + return ''; + } -function getPropertyValueForCurrentLanguage (property: string, field: string | MultilingualVcField[] = '', locale: string): string { if (typeof field === 'string') { return field; } + + if (!Array.isArray(field)) { + field = [field]; + } + return field // find value by exact match - .find((f) => f['@language'] === locale)?.['@value'] ?? + .find((f) => f['@language'] === locale)?.[propertyName] ?? // find value by shorthand (ie. 'en-US' -> 'en') - field.find((f) => f['@language'].split('-')[0] === locale.split('-')[0])?.['@value'] ?? - field[0]['@value']; + field.find((f) => f['@language']?.split('-')[0] === locale.split('-')[0])?.[propertyName] ?? + field[0][propertyName]; } export default async function parseV3 (certificateJson: BlockcertsV3, locale: string): Promise { @@ -31,7 +35,8 @@ export default async function parseV3 (certificateJson: BlockcertsV3, locale: st validUntil, proof, name, - description + description, + credentialSubject } = certificateJson; let { validFrom } = certificateJson; const certificateMetadata = metadata ?? metadataJson; @@ -50,10 +55,10 @@ export default async function parseV3 (certificateJson: BlockcertsV3, locale: st issuedOn: issuanceDate ?? validFrom, // maintain backwards compatibility id, issuer, - name: getPropertyValueForCurrentLanguage('name', name, locale), - description: getPropertyValueForCurrentLanguage('description', description, locale), + name: getPropertyValueForCurrentLanguage('@value', name, locale), + description: getPropertyValueForCurrentLanguage('@value', description, locale), metadataJson: certificateMetadata, - recipientFullName: getRecipientFullName(certificateJson), + recipientFullName: getPropertyValueForCurrentLanguage('name', credentialSubject, locale), recordLink: id }; } diff --git a/test/application/parsers/parser-v3.2.test.ts b/test/application/parsers/parser-v3.2.test.ts index 06589494..e1ffc495 100644 --- a/test/application/parsers/parser-v3.2.test.ts +++ b/test/application/parsers/parser-v3.2.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import parseJSON from '../../../src/parsers'; import MonolingualBlockcertsV3 from '../../fixtures/v3/mocknet-vc-v2-name-description.json'; import MultilingualBlockcertsV3 from '../../fixtures/v3/mocknet-vc-v2-name-description-multilingual.json'; +import MultipleCredentialSubjectsBlockcertsV3 from '../../fixtures/v3/mocknet-vc-v2-credential-subject-array.json'; import v3IssuerProfile from '../../fixtures/issuer-blockcerts.json'; describe('Parser v3 test suite', function () { - describe('given it is called with valid v3 certificate data', function () { + describe('given it is called with valid v3.2 certificate data', function () { beforeAll(function () { vi.mock('@blockcerts/explorer-lookup', async (importOriginal) => { const explorerLookup = await importOriginal(); @@ -102,5 +103,35 @@ describe('Parser v3 test suite', function () { }); }); }); + + describe('Getting the recipient full name', function () { + describe('when the credential subject is not an array', function () { + it('should return the recipient full name', async function () { + const parsedCertificate = await parseJSON(MonolingualBlockcertsV3); + expect(parsedCertificate.recipientFullName).toBe('John Smith'); + }); + }); + + describe('when the credential subject is an array', function () { + describe('and the current language is specified in the certificate', function () { + it('should return the recipient full name according to the language', async function () { + const parsedCertificate = await parseJSON(MultipleCredentialSubjectsBlockcertsV3, 'fr'); + expect(parsedCertificate.recipientFullName).toBe('Jean Forgeron'); + }); + + it('should return the recipient full name according to the language if a subset', async function () { + const parsedCertificate = await parseJSON(MultipleCredentialSubjectsBlockcertsV3, 'fr-CA'); + expect(parsedCertificate.recipientFullName).toBe('Jean Forgeron'); + }); + }); + + describe('and the current language is not specified in the certificate', function () { + it('should return the first recipient full name', async function () { + const parsedCertificate = await parseJSON(MultipleCredentialSubjectsBlockcertsV3, 'it'); + expect(parsedCertificate.recipientFullName).toBe('John Smith'); + }); + }); + }); + }); }); }); diff --git a/test/e2e/verifier/mocknet-vc-v2-credential-subject-array.test.ts b/test/e2e/verifier/mocknet-vc-v2-credential-subject-array.test.ts new file mode 100644 index 00000000..18bf2c50 --- /dev/null +++ b/test/e2e/verifier/mocknet-vc-v2-credential-subject-array.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll, vi, afterAll } from 'vitest'; +import { Certificate, VERIFICATION_STATUSES } from '../../../src'; +import MocknetVCV2CredentialSubjectArray from '../../fixtures/v3/mocknet-vc-v2-credential-subject-array.json'; +import fixtureBlockcertsIssuerProfile from '../../fixtures/issuer-blockcerts.json'; +import fixtureCredentialSchema from '../../fixtures/credential-schema-example-id-card.json'; + +describe('given the certificate is a valid mocknet (v3.2)', function () { + beforeAll(function () { + vi.mock('@blockcerts/explorer-lookup', async (importOriginal) => { + const explorerLookup = await importOriginal(); + return { + ...explorerLookup, + // replace some exports + request: async function ({ url }) { + if (url === 'https://www.blockcerts.org/samples/3.0/issuer-blockcerts.json') { + return JSON.stringify(fixtureBlockcertsIssuerProfile); + } + + if (url === 'https://www.blockcerts.org/samples/3.0/example-id-card-schema.json') { + return JSON.stringify(fixtureCredentialSchema); + } + } + }; + }); + }); + + afterAll(function () { + vi.restoreAllMocks(); + }); + + describe('and complies with its json schema definition', function () { + // this test will expire in 2039 + it('should verify successfully', async function () { + const certificate = new Certificate(MocknetVCV2CredentialSubjectArray); + await certificate.init(); + const result = await certificate.verify(); + expect(result.status).toBe(VERIFICATION_STATUSES.SUCCESS); + }); + }); +}); diff --git a/test/fixtures/v3/mocknet-vc-v2-credential-subject-array.json b/test/fixtures/v3/mocknet-vc-v2-credential-subject-array.json new file mode 100644 index 00000000..0cb5c19b --- /dev/null +++ b/test/fixtures/v3/mocknet-vc-v2-credential-subject-array.json @@ -0,0 +1 @@ +{"@context": ["https://www.w3.org/ns/credentials/v2", {"DOB": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/DOB", "@type": "https://schema.org/Text"}, "nationality": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/nationality", "@type": "https://schema.org/Text"}, "height": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/height", "@type": "https://schema.org/Text"}, "residentialAddressStreet": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/residentialAddressStreet", "@type": "https://schema.org/Text"}, "residentialAddressTown": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/residentialAddressTown", "@type": "https://schema.org/Text"}, "residentialAddressPostCode": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/residentialAddressPostCode", "@type": "https://schema.org/Text"}, "IdCardCredential": {"@id": "https://schemas.learningmachine.com/2017/blockcerts/IdCardCredential", "@type": "https://schema.org/DataType"}}, "https://w3id.org/security/data-integrity/v2", "https://w3id.org/blockcerts/v3.2"], "type": ["VerifiableCredential", "BlockcertsCredential", "IdCardCredential"], "name": [{"@value": "Canadian Id Card", "@language": "en"}, {"@value": "Carte d'Identit\u00e9 Canadienne", "@language": "fr"}, {"@value": "\u0628\u0637\u0627\u0642\u0629 \u0627\u0644\u0647\u0648\u064a\u0629 \u0627\u0644\u0643\u0646\u062f\u064a\u0629", "@language": "ar", "@direction": "rtl"}], "description": [{"@value": "A Blockcerts example (not an official document) highlighting various VC v2 spec items", "@language": "en"}, {"@value": "Un example Blockcerts (document non officiel) d\u00e9montrant diff\u00e9rents \u00e9l\u00e9ments de la sp\u00e9cification VC v2", "@language": "fr"}, {"@value": "\u0645\u062b\u0627\u0644 \u0639\u0644\u0649 Blockcerts (\u0644\u064a\u0633 \u0645\u0633\u062a\u0646\u062f\u064b\u0627 \u0631\u0633\u0645\u064a\u064b\u0627) \u064a\u0633\u0644\u0637 \u0627\u0644\u0636\u0648\u0621 \u0639\u0644\u0649 \u0639\u0646\u0627\u0635\u0631 \u0645\u0648\u0627\u0635\u0641\u0627\u062a VC v2 \u0627\u0644\u0645\u062e\u062a\u0644\u0641\u0629", "@language": "ar", "@direction": "rtl"}], "issuer": "https://www.blockcerts.org/samples/3.0/issuer-blockcerts.json", "validFrom": "2024-03-01T00:00:00Z", "validUntil": "2039-02-28T23:59:59Z", "id": "urn:uuid:4f5f0100-ccbf-4ca9-9cfc-4f5fc3052d28", "credentialSchema": [{"id": "https://www.blockcerts.org/samples/3.0/example-id-card-schema.json", "type": "JsonSchema"}], "credentialSubject": [{"id": "did:example:ebfeb1f712ebc6f1c276e12ec21", "@language": "en", "name": "John Smith", "nationality": "Canada", "DOB": "05/10/1983", "height": "1.80m", "residentialAddressStreet": "6 Maple Tree street", "residentialAddressTown": "Toronto", "residentialAddressPostCode": "YYZYUL"}, {"id": "did:example:ebfeb1f712ebc6f1c276e12ec21", "@language": "fr", "name": "Jean Forgeron", "nationality": "Canada", "DOB": "05/10/1983", "height": "1.80m", "residentialAddressStreet": "6 rue des \u00c9rables", "residentialAddressTown": "Montr\u00e9al", "residentialAddressPostCode": "YYZYUL"}], "display": {"contentMediaType": "text/html", "content": "
Yo
"}, "proof": {"id": "urn:uuid:277f7b2a-3567-4182-b0e1-199441fae5fb", "type": "DataIntegrityProof", "cryptosuite": "merkle-proof-2019", "proofPurpose": "assertionMethod", "created": "2024-11-07T15:51:05Z", "proofValue": "zEuZQLZTYrdsDv2EpwzQGUBe6PfabCRBgw919pBUVimNgpE5188vhyTySWTDvFLMDKMgLNdi3RQouwP5UrjLJkPSLb6JSBao3zi7nXAUFs8pKvv1AS4FC38QicsCL7oihbLLTG3dgkfjoVZAgNujrGfVTbJU3vw5QmViEEMFzupwA4vXF5zentFinyoELzpZGwDGTHVtgSEwGGFXaxDNNUMw6d3cbDNRvZT5mBneyG6GEZMtaccQ4f9PoPFi8Wac5rXje15UmMQfPuzoX6QyVvDZjCofhGBogYBSZAyG2rNqRT6", "verificationMethod": "https://www.blockcerts.org/samples/3.0/issuer-blockcerts.json#key-1"}} \ No newline at end of file