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..70a62dc 100644 --- a/test/integration/credentials.test.js +++ b/test/integration/credentials.test.js @@ -29,6 +29,95 @@ 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 cred = unsignedCredential(); + const credential = /** @type {Record} */ (cred); + 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)