From 0c39db068e06e093e130ae79c001e7938577a7b2 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 20 May 2026 08:07:50 -0500 Subject: [PATCH 1/2] feat: validate credential fields on POST /credentials/issue Adds field-level validation for @context, type, issuer, and credentialSubject so the mock returns 400 on malformed input, matching the behavior required by the vc-api-issuer-test-suite. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/credentials.js | 43 ++++++++++++++++++ test/integration/credentials.test.js | 68 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/routes/credentials.js b/src/routes/credentials.js index 8444fb0..1693275 100644 --- a/src/routes/credentials.js +++ b/src/routes/credentials.js @@ -6,6 +6,42 @@ import {Router} from 'express'; /** @typedef {import('../types.js').VerifiableCredential} VerifiableCredential */ +/** + * Validates required credential fields per the VC-API issuer test suite. + * Returns an error message string, or null if valid. + * + * @param {Record} credential + * @returns {string | null} + */ +function validateCredentialFields(credential) { + if(!Array.isArray(credential['@context'])) { + return 'credential "@context" must be an array.'; + } + if(!credential['@context'].every(item => typeof item === 'string')) { + return 'credential "@context" items must be strings.'; + } + if(!Array.isArray(credential.type)) { + return 'credential "type" must be an array.'; + } + if(!credential.type.every(item => typeof item === 'string')) { + return 'credential "type" items must be strings.'; + } + if(credential.issuer === undefined || credential.issuer === null) { + return 'credential must have property "issuer".'; + } + if(typeof credential.issuer !== 'string' && + (typeof credential.issuer !== 'object' || + Array.isArray(credential.issuer))) { + return 'credential "issuer" must be a string or an object.'; + } + if(!credential.credentialSubject || + typeof credential.credentialSubject !== 'object' || + Array.isArray(credential.credentialSubject)) { + return 'credential "credentialSubject" must be an object.'; + } + return null; +} + /** * @param {import('../store/index.js').createStore extends * (...args: any[]) => infer R ? R : never} store @@ -25,6 +61,13 @@ export function credentialsRouter(store) { ); } + const invalid = validateCredentialFields(credential); + if(invalid) { + return res.status(400).json( + problemDetails('invalid-input', 'Invalid Input', 400, invalid) + ); + } + const credentialId = options.credentialId ?? credential.id ?? randomUUID(); const mandatoryPointers = options.mandatoryPointers ?? []; diff --git a/test/integration/credentials.test.js b/test/integration/credentials.test.js index 211eb5a..f204b13 100644 --- a/test/integration/credentials.test.js +++ b/test/integration/credentials.test.js @@ -29,6 +29,74 @@ describe('POST /credentials/issue', () => { assert.ok(res.body.type.includes('invalid-input')); }); + // vc-api-issuer-test-suite conformance checks + /** @type {Array<{label: string, mutate: (c: Record) => void}>} */ + const invalidInputCases = [ + { + label: 'missing @context', + mutate: c => { delete c['@context']; } + }, + { + label: '@context not an array', + mutate: c => { c['@context'] = 4; } + }, + { + label: '@context item not a string', + mutate: c => { c['@context'] = [{foo: true}]; } + }, + { + label: 'missing type', + mutate: c => { delete c.type; } + }, + { + label: 'type not an array', + mutate: c => { c.type = 4; } + }, + { + label: 'type item not a string', + mutate: c => { c.type = [null]; } + }, + { + label: 'missing issuer', + mutate: c => { delete c.issuer; } + }, + { + label: 'issuer invalid type', + mutate: c => { c.issuer = 4; } + }, + { + label: 'missing credentialSubject', + mutate: c => { delete c.credentialSubject; } + }, + { + label: 'credentialSubject not an object', + mutate: c => { c.credentialSubject = 'did:example:1234'; } + }, + ]; + + for(const {label, mutate} of invalidInputCases) { + it(`should return 400 when credential has ${label}`, async () => { + const credential = unsignedCredential(); + mutate(credential); + const res = await request(app) + .post('/credentials/issue') + .send({credential}); + assert.equal(res.status, 400, `Expected 400 for: ${label}`); + assert.ok(res.body.type.includes('invalid-input')); + }); + } + + it('should return 201 when credential has expirationDate', async () => { + const credential = /** @type {Record} */ (unsignedCredential()); + credential.expirationDate = + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() + .replace(/\.\d+Z$/, 'Z'); + const res = await request(app) + .post('/credentials/issue') + .send({credential}); + assert.equal(res.status, 201); + }); + it('should store the credential by credentialId option', async () => { const credentialId = 'test-cred-123'; await request(app) From 76a2b3dfc14498c428663bbfc25cfb2f4155c93b Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 20 May 2026 08:12:21 -0500 Subject: [PATCH 2/2] fix: lint errors in credentials.test.js Expand single-line arrow function bodies and split long cast line to satisfy @stylistic/brace-style and @stylistic/max-len rules. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/credentials.test.js | 45 ++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/test/integration/credentials.test.js b/test/integration/credentials.test.js index f204b13..70a62dc 100644 --- a/test/integration/credentials.test.js +++ b/test/integration/credentials.test.js @@ -34,44 +34,64 @@ describe('POST /credentials/issue', () => { const invalidInputCases = [ { label: 'missing @context', - mutate: c => { delete c['@context']; } + mutate: c => { + delete c['@context']; + } }, { label: '@context not an array', - mutate: c => { c['@context'] = 4; } + mutate: c => { + c['@context'] = 4; + } }, { label: '@context item not a string', - mutate: c => { c['@context'] = [{foo: true}]; } + mutate: c => { + c['@context'] = [{foo: true}]; + } }, { label: 'missing type', - mutate: c => { delete c.type; } + mutate: c => { + delete c.type; + } }, { label: 'type not an array', - mutate: c => { c.type = 4; } + mutate: c => { + c.type = 4; + } }, { label: 'type item not a string', - mutate: c => { c.type = [null]; } + mutate: c => { + c.type = [null]; + } }, { label: 'missing issuer', - mutate: c => { delete c.issuer; } + mutate: c => { + delete c.issuer; + } }, { label: 'issuer invalid type', - mutate: c => { c.issuer = 4; } + mutate: c => { + c.issuer = 4; + } }, { label: 'missing credentialSubject', - mutate: c => { delete c.credentialSubject; } + mutate: c => { + delete c.credentialSubject; + } }, { label: 'credentialSubject not an object', - mutate: c => { c.credentialSubject = 'did:example:1234'; } - }, + mutate: c => { + c.credentialSubject = 'did:example:1234'; + } + } ]; for(const {label, mutate} of invalidInputCases) { @@ -87,7 +107,8 @@ describe('POST /credentials/issue', () => { } it('should return 201 when credential has expirationDate', async () => { - const credential = /** @type {Record} */ (unsignedCredential()); + const cred = unsignedCredential(); + const credential = /** @type {Record} */ (cred); credential.expirationDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() .replace(/\.\d+Z$/, 'Z');