From bab42369c5c1c2214dd3320d3cecc677af935c62 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 21 Sep 2026 18:37:46 +0200 Subject: [PATCH 1/2] Add verification attempt-assets endpoints and card scheduled_activation_date --- .../address-document-verifications.js | 40 ++- src/api/identities/face-authentications.js | 8 +- .../identities/id-document-verifications.js | 40 ++- src/api/identities/identities.js | 28 +- src/api/identities/identity-verifications.js | 15 +- src/api/issuing/cards.js | 21 +- src/api/issuing/issuing.js | 4 +- src/services/utils.js | 30 ++- .../attempt-assets-pagination-unit.js | 247 ++++++++++++++++++ .../id-document-verifications-unit.js | 7 +- .../identity-verifications-unit.js | 10 +- .../issuing/cards/card-update-headers-unit.js | 167 ++++++++++++ .../address-document-verifications.d.ts | 20 +- .../api/identities/face-authentications.d.ts | 6 +- .../identities/id-document-verifications.d.ts | 20 +- types/dist/api/identities/identities.d.ts | 16 +- .../identities/identity-verifications.d.ts | 18 +- types/dist/api/issuing/cards.d.ts | 6 +- types/dist/api/issuing/issuing.d.ts | 7 +- 19 files changed, 656 insertions(+), 54 deletions(-) create mode 100644 test/identities/attempt-assets-pagination-unit.js create mode 100644 test/issuing/cards/card-update-headers-unit.js diff --git a/src/api/identities/address-document-verifications.js b/src/api/identities/address-document-verifications.js index 82bae04f..57eb547d 100644 --- a/src/api/identities/address-document-verifications.js +++ b/src/api/identities/address-document-verifications.js @@ -1,9 +1,11 @@ import { determineError } from '../../services/errors.js'; +import { buildQueryParams } from '../../services/utils.js'; import { get, post } from '../../services/http.js'; // Path segments appended to the API base (config.identityVerificationUrl). const ADDRESS_DOCUMENT_VERIFICATIONS_PATH = 'address-document-verifications'; const ANONYMIZE_PATH = 'anonymize'; +const ASSETS_PATH = 'assets'; const ATTEMPTS_PATH = 'attempts'; const PDF_REPORT_PATH = 'pdf-report'; @@ -71,11 +73,15 @@ export default class AddressDocumentVerifications { * Get the details of all attempts for a specific address document verification. * @method listAttempts * @param {string} address_document_verification_id - The address document verification's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) * @returns {Promise} A promise to the Get address document verification attempts response */ - async listAttempts(address_document_verification_id) { + async listAttempts(address_document_verification_id, params) { try { - const url = `${this.config.identityVerificationUrl}/${ADDRESS_DOCUMENT_VERIFICATIONS_PATH}/${address_document_verification_id}/${ATTEMPTS_PATH}`; + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${ADDRESS_DOCUMENT_VERIFICATIONS_PATH}/${address_document_verification_id}/${ATTEMPTS_PATH}`, + params + ); const response = await get( this.config.httpClient, url, @@ -182,4 +188,34 @@ export default class AddressDocumentVerifications { throw await determineError(error); } } + + /** + * Get address document verification attempt assets + * [BETA] + * Get the assets (the document image) uploaded for an address document verification attempt. + * Results are paginated. + * @method getAttemptAssets + * @param {string} address_document_verification_id - The address document verification's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @returns {Promise} A promise to the Get address document verification attempt assets response + */ + async getAttemptAssets(address_document_verification_id, attempt_id, params) { + try { + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${ADDRESS_DOCUMENT_VERIFICATIONS_PATH}/${address_document_verification_id}/${ATTEMPTS_PATH}/${attempt_id}/${ASSETS_PATH}`, + params + ); + + const response = await get( + this.config.httpClient, + url, + this.config, + this.config.sk + ); + return await response.json; + } catch (error) { + throw await determineError(error); + } + } } diff --git a/src/api/identities/face-authentications.js b/src/api/identities/face-authentications.js index 0c7d43c6..91c8aa97 100644 --- a/src/api/identities/face-authentications.js +++ b/src/api/identities/face-authentications.js @@ -72,11 +72,15 @@ export default class FaceAuthentications { * Get the details of all attempts for a specific face authentication. * @method listAttempts * @param {string} face_authentication_id - The face authentication's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) * @returns {Promise} A promise to the Get face authentication attempts response */ - async listAttempts(face_authentication_id) { + async listAttempts(face_authentication_id, params) { try { - const url = `${this.config.identityVerificationUrl}/${FACE_AUTHENTICATIONS_PATH}/${face_authentication_id}/${ATTEMPTS_PATH}`; + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${FACE_AUTHENTICATIONS_PATH}/${face_authentication_id}/${ATTEMPTS_PATH}`, + params + ); const response = await get( this.config.httpClient, url, diff --git a/src/api/identities/id-document-verifications.js b/src/api/identities/id-document-verifications.js index 16871570..7749b53c 100644 --- a/src/api/identities/id-document-verifications.js +++ b/src/api/identities/id-document-verifications.js @@ -1,8 +1,10 @@ import { determineError } from '../../services/errors.js'; +import { buildQueryParams } from '../../services/utils.js'; import { get, post } from '../../services/http.js'; // Path segments appended to the API base (config.identityVerificationUrl). const ANONYMIZE_PATH = 'anonymize'; +const ASSETS_PATH = 'assets'; const ATTEMPTS_PATH = 'attempts'; const ID_DOCUMENT_VERIFICATIONS_PATH = 'id-document-verifications'; const PDF_REPORT_PATH = 'pdf-report'; @@ -71,11 +73,15 @@ export default class IDDocumentVerifications { * Get the details of all attempts for a specific ID document verification. * @method listAttempts * @param {string} id_document_verification_id - The ID document verification's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) * @returns {Promise} A promise to the Get ID document verification attempts response */ - async listAttempts(id_document_verification_id) { + async listAttempts(id_document_verification_id, params) { try { - const url = `${this.config.identityVerificationUrl}/${ID_DOCUMENT_VERIFICATIONS_PATH}/${id_document_verification_id}/${ATTEMPTS_PATH}`; + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${ID_DOCUMENT_VERIFICATIONS_PATH}/${id_document_verification_id}/${ATTEMPTS_PATH}`, + params + ); const response = await get( this.config.httpClient, url, @@ -184,4 +190,34 @@ export default class IDDocumentVerifications { throw await determineError(error); } } + + /** + * Get ID document verification attempt assets + * [BETA] + * Get the assets (the front and back images of the document) uploaded for an ID document verification attempt. + * Results are paginated. + * @method getAttemptAssets + * @param {string} id_document_verification_id - The ID document verification's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @returns {Promise} A promise to the Get ID document verification attempt assets response + */ + async getAttemptAssets(id_document_verification_id, attempt_id, params) { + try { + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${ID_DOCUMENT_VERIFICATIONS_PATH}/${id_document_verification_id}/${ATTEMPTS_PATH}/${attempt_id}/${ASSETS_PATH}`, + params + ); + + const response = await get( + this.config.httpClient, + url, + this.config, + this.config.sk + ); + return await response.json; + } catch (error) { + throw await determineError(error); + } + } } diff --git a/src/api/identities/identities.js b/src/api/identities/identities.js index 254b72c2..cf5b7f1d 100644 --- a/src/api/identities/identities.js +++ b/src/api/identities/identities.js @@ -59,8 +59,8 @@ export default class Identities { return this.faceAuthentications.getFaceAuthentication(face_authentication_id); } - async listFaceAuthenticationAttempts(face_authentication_id) { - return this.faceAuthentications.listAttempts(face_authentication_id); + async listFaceAuthenticationAttempts(face_authentication_id, params) { + return this.faceAuthentications.listAttempts(face_authentication_id, params); } async getFaceAuthenticationAttempt(face_authentication_id, attempt_id) { @@ -88,8 +88,12 @@ export default class Identities { return this.idDocumentVerifications.getIDDocumentVerification(id_document_verification_id); } - async listIDDocumentVerificationAttempts(id_document_verification_id) { - return this.idDocumentVerifications.listAttempts(id_document_verification_id); + async listIDDocumentVerificationAttempts(id_document_verification_id, params) { + return this.idDocumentVerifications.listAttempts(id_document_verification_id, params); + } + + async getIDDocumentVerificationAttemptAssets(id_document_verification_id, attempt_id, params) { + return this.idDocumentVerifications.getAttemptAssets(id_document_verification_id, attempt_id, params); } async getIDDocumentVerificationAttempt(id_document_verification_id, attempt_id) { @@ -117,8 +121,16 @@ export default class Identities { return this.addressDocumentVerifications.getAddressDocumentVerification(address_document_verification_id); } - async listAddressDocumentVerificationAttempts(address_document_verification_id) { - return this.addressDocumentVerifications.listAttempts(address_document_verification_id); + async listAddressDocumentVerificationAttempts(address_document_verification_id, params) { + return this.addressDocumentVerifications.listAttempts(address_document_verification_id, params); + } + + async getAddressDocumentVerificationAttemptAssets(address_document_verification_id, attempt_id, params) { + return this.addressDocumentVerifications.getAttemptAssets( + address_document_verification_id, + attempt_id, + params + ); } async getAddressDocumentVerificationAttempt(address_document_verification_id, attempt_id) { @@ -158,8 +170,8 @@ export default class Identities { return this.identityVerifications.createAttempt(identity_verification_id, body); } - async listIdentityVerificationAttempts(identity_verification_id) { - return this.identityVerifications.listAttempts(identity_verification_id); + async listIdentityVerificationAttempts(identity_verification_id, params) { + return this.identityVerifications.listAttempts(identity_verification_id, params); } async getIdentityVerificationAttempt(identity_verification_id, attempt_id) { diff --git a/src/api/identities/identity-verifications.js b/src/api/identities/identity-verifications.js index 433097e6..ac4360a7 100644 --- a/src/api/identities/identity-verifications.js +++ b/src/api/identities/identity-verifications.js @@ -146,11 +146,15 @@ export default class IdentityVerifications { * Get all the attempts for a specific identity verification. * @method listAttempts * @param {string} identity_verification_id - The identity verification's unique identifier + * @param {Object} [params] - Optional pagination query parameters (skip and limit) * @returns {Promise} A promise to the Get identity verification attempts response */ - async listAttempts(identity_verification_id) { + async listAttempts(identity_verification_id, params) { try { - const url = `${this.config.identityVerificationUrl}/${IDENTITY_VERIFICATIONS_PATH}/${identity_verification_id}/${ATTEMPTS_PATH}`; + const url = buildQueryParams( + `${this.config.identityVerificationUrl}/${IDENTITY_VERIFICATIONS_PATH}/${identity_verification_id}/${ATTEMPTS_PATH}`, + params + ); const response = await get( this.config.httpClient, url, @@ -224,7 +228,8 @@ export default class IdentityVerifications { * The report is only available when the verification status is approved or declined. * @method getPDFReport * @param {string} identity_verification_id - The identity verification's unique identifier - * @returns {Promise} A promise to the PDF report as a Buffer + * @returns {Promise} A promise to the Get identity verification report response, + * carrying `pdf_report`, the pre-signed URL to the PDF */ async getPDFReport(identity_verification_id) { try { @@ -232,10 +237,10 @@ export default class IdentityVerifications { const response = await get( this.config.httpClient, url, - { ...this.config, csv: true }, + this.config, this.config.sk ); - return await response.csv; + return await response.json; } catch (error) { throw await determineError(error); } diff --git a/src/api/issuing/cards.js b/src/api/issuing/cards.js index 14e8b4a5..ea96dfa4 100644 --- a/src/api/issuing/cards.js +++ b/src/api/issuing/cards.js @@ -71,17 +71,32 @@ export default class Cards { /** * Updates a card you issued previously. * + * Pass headers to request the card's encrypted credentials in the response. Set + * `return-encrypted-cvv` to "true" together with an `Encryption-Key`; supplying the flag + * without the key returns a 422 with error code `encryption_key_required`. + * + * The headers travel on a copy of the config rather than on the body, which is how + * getRequestHeaders picks them up for every verb. Putting them on the body would also send + * them as JSON fields. + * * @memberof Cards * @param {string} id Card id. * @param {Object} body Card params to update. - * @return {Promise} A promise to the card update response. + * @param {Object} [headers] Optional HTTP headers. Supports `return-encrypted-cvv` and + * `Encryption-Key`. + * @return {Promise} A promise to the card update response, carrying `encrypted_cvv` + * when requested. */ - async updateCard(id, body) { + async updateCard(id, body, headers) { try { + const config = headers + ? { ...this.config, headers: { ...(this.config.headers || {}), ...headers } } + : this.config; + const response = await patch( this.config.httpClient, `${this.config.host}/${ISSUING_PATH}/${CARDS_PATH}/${id}`, - this.config, + config, this.config.sk, body ); diff --git a/src/api/issuing/issuing.js b/src/api/issuing/issuing.js index 20345646..6560e08d 100644 --- a/src/api/issuing/issuing.js +++ b/src/api/issuing/issuing.js @@ -58,8 +58,8 @@ export default class Issuing { return this.cards.getCardDetails(id); } - async updateCard(id, body) { - return this.cards.updateCard(id, body); + async updateCard(id, body, headers) { + return this.cards.updateCard(id, body, headers); } async enrollThreeDS(id, body) { diff --git a/src/services/utils.js b/src/services/utils.js index b1caaf39..02ae8dad 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -1,11 +1,27 @@ +/** + * Append params to a path as a query string. + * + * An empty params object returns the path unchanged rather than appending a bare "?". That matters + * for the endpoints whose query is optional: before the 2026-09-02 pass the list-attempts + * endpoints took no params at all, and a caller passing {} would otherwise start getting + * ".../attempts?" instead of ".../attempts". A trailing question mark with no query carries no + * information, so no caller loses anything. + * + * Note that a zero value is kept, so skip=0 is sent. That is deliberate: 0 is a meaningful value + * for a pagination offset. + * + * @param {string} path The path to append to. + * @param {Object} [params] The query parameters. + * @return {string} The path, with a query string when there is one to add. + */ export const buildQueryParams = (path, params) => { - let url = path; - if (params) { - const queryString = Object.keys(params) - .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) - .join('&'); - url += `?${queryString}`; + if (!params) { + return path; } - return url; + const queryString = Object.keys(params) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&'); + + return queryString ? `${path}?${queryString}` : path; }; diff --git a/test/identities/attempt-assets-pagination-unit.js b/test/identities/attempt-assets-pagination-unit.js new file mode 100644 index 00000000..8ad3b843 --- /dev/null +++ b/test/identities/attempt-assets-pagination-unit.js @@ -0,0 +1,247 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import Checkout from '../../src/Checkout.js'; +import { buildQueryParams } from '../../src/services/utils.js'; + +// Part A (the two new attempt-assets endpoints) and part F M1 (pagination on all four +// list-attempts endpoints) of the 2026-09-02 swagger delta. +// +// The assets payloads are the swagger examples verbatim, from components.examples, apart from +// shortened hrefs. asset_url is the only link AdvAttemptAsset and IddvAttemptAsset declare, and it +// is required, so using the spec's own example rather than a hand-written fixture is the point. + +const SK = 'sk_sbox_o2nulev2arguvyf6w7sc5fkznas'; +const BASE = 'https://identity-verification.sandbox.checkout.com'; + +const ADV_ID = 'adv_tkoi5db4hryu5cei5vwoabr7we'; +const ADV_ATTEMPT_ID = 'adva_tkoi5db4hryu5cei5vwoabr7we'; +const IDDV_ID = 'iddv_tkoi5db4hryu5cei5vwoabr7we'; +const IDDV_ATTEMPT_ID = 'datp_tkoi5db4hryu5cei5vwoabraio'; + +const ADV_ASSETS = { + total_count: 1, + skip: 0, + limit: 10, + data: [ + { + type: 'document', + _links: { + asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/address_document.png' } + } + } + ], + _links: { + self: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets` }, + next: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets?skip=10` }, + previous: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets?skip=0` } + } +}; + +const IDDV_ASSETS = { + total_count: 2, + skip: 0, + limit: 10, + data: [ + { + type: 'document_front_image', + _links: { asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_front.png' } } + }, + { + type: 'document_back_image', + _links: { asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_back.png' } } + } + ], + _links: { + self: { href: `${BASE}/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets` } + } +}; + +const cko = () => new Checkout(SK, { subdomain: 'test' }); + +afterEach(() => { + nock.cleanAll(); +}); + +describe('Unit::Identities attempt assets', () => { + it('should get the address document verification attempt assets', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) + .query({ skip: 0, limit: 10 }) + .reply(200, ADV_ASSETS); + + const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ADV_ATTEMPT_ID, + { skip: 0, limit: 10 } + ); + + expect(result.total_count).to.equal(1); + expect(result.data).to.have.lengthOf(1); + expect(result.data[0].type).to.equal('document'); + expect(result.data[0]._links.asset_url.href).to.contain('address_document.png'); + expect(result.data[0]._links.download).to.be.undefined; + expect(result._links.next.href).to.contain('skip=10'); + }); + + it('should get the address document verification attempt assets without a query', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) + .reply(200, ADV_ASSETS); + + const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ADV_ATTEMPT_ID + ); + + expect(result.total_count).to.equal(1); + }); + + it('should get the ID document verification attempt assets, both types', async () => { + nock(BASE) + .get(`/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets`) + .query({ limit: 10 }) + .reply(200, IDDV_ASSETS); + + const result = await cko().identities.idDocumentVerifications.getAttemptAssets( + IDDV_ID, + IDDV_ATTEMPT_ID, + { limit: 10 } + ); + + expect(result.total_count).to.equal(2); + expect(result.data.map((asset) => asset.type)).to.deep.equal([ + 'document_front_image', + 'document_back_image' + ]); + expect(result.data[0]._links.asset_url.href).to.contain('document_front.png'); + expect(result.data[1]._links.asset_url.href).to.contain('document_back.png'); + expect(result.data[0]._links.download).to.be.undefined; + }); + + // data declares minItems 0, so an attempt with no assets yet is a legal page. + it('should handle an empty assets page', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) + .reply(200, { total_count: 0, skip: 0, limit: 10, data: [], _links: { self: { href: 'x' } } }); + + const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ADV_ATTEMPT_ID + ); + + expect(result.total_count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + + it('should expose the assets through the backwards-compatible aggregate methods', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) + .reply(200, ADV_ASSETS) + .get(`/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets`) + .reply(200, IDDV_ASSETS); + + const client = cko(); + const adv = await client.identities.getAddressDocumentVerificationAttemptAssets(ADV_ID, ADV_ATTEMPT_ID); + const iddv = await client.identities.getIDDocumentVerificationAttemptAssets(IDDV_ID, IDDV_ATTEMPT_ID); + + expect(adv.data[0].type).to.equal('document'); + expect(iddv.data).to.have.lengthOf(2); + }); +}); + +describe('Unit::Identities attempts pagination', () => { + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('sends skip and limit on the address document verification attempts', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const result = await cko().identities.addressDocumentVerifications.listAttempts(ADV_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('sends skip and limit on the ID document verification attempts', async () => { + nock(BASE) + .get(`/id-document-verifications/${IDDV_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const result = await cko().identities.idDocumentVerifications.listAttempts(IDDV_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('sends skip and limit on the identity verification attempts', async () => { + nock(BASE) + .get('/identity-verifications/idv_1/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const result = await cko().identities.identityVerifications.listAttempts('idv_1', { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('sends skip and limit on the face authentication attempts', async () => { + nock(BASE) + .get('/face-authentications/fav_1/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const result = await cko().identities.faceAuthentications.listAttempts('fav_1', { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('sends no query string when no params are passed', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts`) + .reply(200, ATTEMPTS); + + const result = await cko().identities.addressDocumentVerifications.listAttempts(ADV_ID); + + expect(result.total_count).to.equal(25); + }); + + it('forwards params through the backwards-compatible aggregate methods', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts`) + .query({ limit: 1 }) + .reply(200, ATTEMPTS); + + const result = await cko().identities.listAddressDocumentVerificationAttempts(ADV_ID, { limit: 1 }); + + expect(result.total_count).to.equal(25); + }); +}); + +describe('Unit::buildQueryParams', () => { + it('keeps an explicit zero skip, unlike the PHP and Go filters', () => { + expect(buildQueryParams('u', { skip: 0, limit: 10 })).to.equal('u?skip=0&limit=10'); + }); + + it('adds no query string for undefined params', () => { + expect(buildQueryParams('u', undefined)).to.equal('u'); + }); + + // An empty object used to produce a bare "u?", which the list-attempts endpoints would have + // started emitting once they began routing through this helper. + it('adds no bare question mark for an empty params object', () => { + expect(buildQueryParams('u', {})).to.equal('u'); + }); +}); diff --git a/test/identities/id-document-verifications/id-document-verifications-unit.js b/test/identities/id-document-verifications/id-document-verifications-unit.js index 608404d4..f3a91373 100644 --- a/test/identities/id-document-verifications/id-document-verifications-unit.js +++ b/test/identities/id-document-verifications/id-document-verifications-unit.js @@ -206,8 +206,7 @@ describe('Unit::ID Document Verifications', () => { nock('https://identity-verification.sandbox.checkout.com') .get('/id-document-verifications/iddv_tkoi5db4hryu5cei5vwoabr7we/pdf-report') .reply(200, { - pdf_report: 'https://www.example.com/pdf', - signed_url: 'https://www.example.com/signed/pdf' + pdf_report: 'https://www.example.com/pdf' }); const cko = new Checkout(SK, { subdomain: 'test' }); @@ -216,7 +215,9 @@ describe('Unit::ID Document Verifications', () => { ); expect(result.pdf_report).to.equal('https://www.example.com/pdf'); - expect(result.signed_url).to.be.a('string'); + // Part C: IdvPdf declares pdf_report only. signed_url does not appear anywhere in the + // 2026-09-02 spec, so the fixture no longer returns it and nothing should read it. + expect(result.signed_url).to.be.undefined; }); it('should anonymize an ID document verification', async () => { diff --git a/test/identities/identity-verifications/identity-verifications-unit.js b/test/identities/identity-verifications/identity-verifications-unit.js index faf8d97c..e7705f23 100644 --- a/test/identities/identity-verifications/identity-verifications-unit.js +++ b/test/identities/identity-verifications/identity-verifications-unit.js @@ -341,16 +341,18 @@ describe('Unit::Identity Verifications', () => { it('should get identity verification PDF report', async () => { nock('https://identity-verification.sandbox.checkout.com') .get('/identity-verifications/idv_tkoi5db4hryu5cei5vwoabr7we/pdf-report') - .reply(200, Buffer.from('PDF content'), { - 'content-type': 'application/pdf' - }); + .reply(200, { pdf_report: 'https://www.example.com/reports/idv.pdf' }); const cko = new Checkout(SK, { subdomain: 'test' }); const result = await cko.identities.identityVerifications.getPDFReport( 'idv_tkoi5db4hryu5cei5vwoabr7we' ); - expect(result).to.be.an.instanceOf(Buffer); + // The endpoint answers application/json with IdvPdf, so the report arrives as a URL in + // pdf_report, not as PDF bytes. This previously asserted a Buffer, which was asserting + // the csv: true defect rather than the documented contract. + expect(result.pdf_report).to.equal('https://www.example.com/reports/idv.pdf'); + expect(result.signed_url).to.be.undefined; }); it('should get identity verification attempt assets', async () => { diff --git a/test/issuing/cards/card-update-headers-unit.js b/test/issuing/cards/card-update-headers-unit.js new file mode 100644 index 00000000..a6a779fb --- /dev/null +++ b/test/issuing/cards/card-update-headers-unit.js @@ -0,0 +1,167 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import Checkout from '../../../src/Checkout.js'; +import { ValidationError } from '../../../src/services/errors.js'; + +// Part D of the 2026-09-02 swagger delta: the return-encrypted-cvv and Encryption-Key headers on +// PATCH /issuing/cards/{cardId}, and the encrypted_cvv the response then carries. +// +// These assert the headers on the outgoing request, because the header names are case sensitive in +// the spec (return-encrypted-cvv lower case, Encryption-Key title case) and they travel on a copy +// of the config rather than on the body. + +const SUBDOMAIN = '123456789'; +const ACCESS_BASE = `https://${SUBDOMAIN}.access.sandbox.checkout.com`; +const BASE = `https://${SUBDOMAIN}.api.sandbox.checkout.com`; +const CARD_ID = 'crd_fa6psq42dc0uxl3ct3jryhdo2m'; + +// Issuing uses OAuth, so every call needs the token endpoint stubbed first. +const cko = () => { + nock(ACCESS_BASE).post('/connect/token').reply(200, { + access_token: 'test_access_token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'issuing:card-management-write issuing:card-management-read' + }); + + return new Checkout('test_client_secret', { + client: 'ack_testclie123456', + scope: ['issuing:card-management-write', 'issuing:card-management-read'], + environment: 'sandbox', + subdomain: SUBDOMAIN + }); +}; + +afterEach(() => { + nock.cleanAll(); +}); + +describe('Unit::Issuing card update headers', () => { + it('sends both headers with their exact swagger spelling', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z', encrypted_cvv: 'oJMoNMEEUiQKYOsQ4Zd' }; + }); + + const result = await cko().issuing.cards.updateCard( + CARD_ID, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': 'true', 'Encryption-Key': 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' } + ); + + // nock lowercases header names when it records them, which is what HTTP does on the wire. + expect(seen['return-encrypted-cvv']).to.equal('true'); + expect(seen['encryption-key']).to.equal('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'); + expect(result.encrypted_cvv).to.equal('oJMoNMEEUiQKYOsQ4Zd'); + }); + + it('does not leak the headers into the request body', async () => { + let body; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`, (received) => { + body = received; + return true; + }) + .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); + + await cko().issuing.cards.updateCard( + CARD_ID, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': 'true' } + ); + + expect(body).to.deep.equal({ reference: 'X-123456-N11' }); + expect(body['return-encrypted-cvv']).to.be.undefined; + expect(body.headers).to.be.undefined; + }); + + it('sends no card headers when none are supplied', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + const result = await cko().issuing.cards.updateCard(CARD_ID, { reference: 'X-123456-N11' }); + + expect(seen['return-encrypted-cvv']).to.be.undefined; + expect(seen['encryption-key']).to.be.undefined; + expect(result.encrypted_cvv).to.be.undefined; + }); + + it('sends the encryption key on its own', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + await cko().issuing.cards.updateCard(CARD_ID, {}, { 'Encryption-Key': 'MIIBIjAN' }); + + expect(seen['encryption-key']).to.equal('MIIBIjAN'); + expect(seen['return-encrypted-cvv']).to.be.undefined; + }); + + it('forwards the headers through the backwards-compatible aggregate method', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + await cko().issuing.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': 'true' }); + + expect(seen['return-encrypted-cvv']).to.equal('true'); + }); + + // The API answers 422 with error code encryption_key_required when the flag is set without a + // key. The SDK must surface the error body rather than swallowing it. + it('surfaces the 422 encryption_key_required error', async () => { + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(422, { + request_id: '0HLHPN8802NUF:00000003', + error_type: 'request_invalid', + error_codes: ['encryption_key_required'] + }); + + try { + await cko().issuing.cards.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': 'true' }); + throw new Error('expected the update to reject'); + } catch (error) { + expect(error).to.be.instanceOf(ValidationError); + expect(error.http_code).to.equal(422); + expect(error.body.error_codes).to.contain('encryption_key_required'); + } + }); + + // Part B: the renamed field is a plain passthrough here, since node models no card fields, but + // the key still has to reach the wire under its new name. + it('passes scheduled_activation_date through to the request body', async () => { + let body; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`, (received) => { + body = received; + return true; + }) + .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); + + await cko().issuing.cards.updateCard(CARD_ID, { + scheduled_activation_date: '2026-06-01T10:00Z', + revocation_date: '2027-03-12' + }); + + expect(body.scheduled_activation_date).to.equal('2026-06-01T10:00Z'); + expect(body.revocation_date).to.equal('2027-03-12'); + expect(body.activation_date).to.be.undefined; + }); +}); diff --git a/types/dist/api/identities/address-document-verifications.d.ts b/types/dist/api/identities/address-document-verifications.d.ts index dafff147..ec02811e 100644 --- a/types/dist/api/identities/address-document-verifications.d.ts +++ b/types/dist/api/identities/address-document-verifications.d.ts @@ -5,9 +5,25 @@ export default class AddressDocumentVerifications { createAddressDocumentVerification(body: object): Promise; getAddressDocumentVerification(addressDocumentVerificationId: string): Promise; - listAttempts(addressDocumentVerificationId: string): Promise; + /** + * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + */ + listAttempts(addressDocumentVerificationId: string, params?: object): Promise; getAttempt(addressDocumentVerificationId: string, attemptId: string): Promise; + /** + * Get the assets (the document image) uploaded for an attempt. Results are paginated: pass + * `skip` and `limit` on `params`. + */ + getAttemptAssets(addressDocumentVerificationId: string, attemptId: string, params?: object): Promise; createAttempt(addressDocumentVerificationId: string, body: object): Promise; anonymizeAddressDocumentVerification(addressDocumentVerificationId: string): Promise; - getPDFReport(addressDocumentVerificationId: string): Promise; + /** + * Get the PDF report. + * + * Resolves to the JSON body, which carries `pdf_report`, the pre-signed URL to the PDF. The + * 2026-09-02 spec renamed that property from `signed_url`. Declared `Promise` and not + * `Promise`: the implementation returns `response.json`, so the previous Buffer + * signature never matched what a caller actually received. + */ + getPDFReport(addressDocumentVerificationId: string): Promise; } diff --git a/types/dist/api/identities/face-authentications.d.ts b/types/dist/api/identities/face-authentications.d.ts index f5f01b44..d78aa273 100644 --- a/types/dist/api/identities/face-authentications.d.ts +++ b/types/dist/api/identities/face-authentications.d.ts @@ -5,7 +5,11 @@ export default class FaceAuthentications { createFaceAuthentication(body: object): Promise; getFaceAuthentication(faceAuthenticationId: string): Promise; - listAttempts(faceAuthenticationId: string): Promise; + /** + * List the authentication attempts. Results are paginated: pass `skip` and `limit` on + * `params`. + */ + listAttempts(faceAuthenticationId: string, params?: object): Promise; getAttempt(faceAuthenticationId: string, attemptId: string): Promise; getAttemptAssets(faceAuthenticationId: string, attemptId: string, params?: object): Promise; createAttempt(faceAuthenticationId: string, body: object): Promise; diff --git a/types/dist/api/identities/id-document-verifications.d.ts b/types/dist/api/identities/id-document-verifications.d.ts index b16b6532..6759fae4 100644 --- a/types/dist/api/identities/id-document-verifications.d.ts +++ b/types/dist/api/identities/id-document-verifications.d.ts @@ -5,9 +5,25 @@ export default class IDDocumentVerifications { createIDDocumentVerification(body: object): Promise; getIDDocumentVerification(idDocumentVerificationId: string): Promise; - listAttempts(idDocumentVerificationId: string): Promise; + /** + * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + */ + listAttempts(idDocumentVerificationId: string, params?: object): Promise; getAttempt(idDocumentVerificationId: string, attemptId: string): Promise; + /** + * Get the assets (the front and back images of the document) uploaded for an attempt. Results + * are paginated: pass `skip` and `limit` on `params`. + */ + getAttemptAssets(idDocumentVerificationId: string, attemptId: string, params?: object): Promise; createAttempt(idDocumentVerificationId: string, body: object): Promise; anonymizeIDDocumentVerification(idDocumentVerificationId: string): Promise; - getPDFReport(idDocumentVerificationId: string): Promise; + /** + * Get the PDF report. + * + * Resolves to the JSON body, which carries `pdf_report`, the pre-signed URL to the PDF. The + * 2026-09-02 spec renamed that property from `signed_url`. Declared `Promise` and not + * `Promise`: the implementation returns `response.json`, so the previous Buffer + * signature never matched what a caller actually received. + */ + getPDFReport(idDocumentVerificationId: string): Promise; } diff --git a/types/dist/api/identities/identities.d.ts b/types/dist/api/identities/identities.d.ts index df490e08..d24f1d63 100644 --- a/types/dist/api/identities/identities.d.ts +++ b/types/dist/api/identities/identities.d.ts @@ -29,7 +29,7 @@ export default class Identities { // Backwards compatibility - Face Authentications createFaceAuthentication(body: object): Promise; getFaceAuthentication(faceAuthenticationId: string): Promise; - listFaceAuthenticationAttempts(faceAuthenticationId: string): Promise; + listFaceAuthenticationAttempts(faceAuthenticationId: string, params?: object): Promise; getFaceAuthenticationAttempt(faceAuthenticationId: string, attemptId: string): Promise; getFaceAuthenticationAttemptAssets(faceAuthenticationId: string, attemptId: string, params?: object): Promise; createFaceAuthenticationAttempt(faceAuthenticationId: string, body: object): Promise; @@ -38,29 +38,31 @@ export default class Identities { // Backwards compatibility - ID Document Verifications createIDDocumentVerification(body: object): Promise; getIDDocumentVerification(idDocumentVerificationId: string): Promise; - listIDDocumentVerificationAttempts(idDocumentVerificationId: string): Promise; + listIDDocumentVerificationAttempts(idDocumentVerificationId: string, params?: object): Promise; getIDDocumentVerificationAttempt(idDocumentVerificationId: string, attemptId: string): Promise; + getIDDocumentVerificationAttemptAssets(idDocumentVerificationId: string, attemptId: string, params?: object): Promise; createIDDocumentVerificationAttempt(idDocumentVerificationId: string, body: object): Promise; anonymizeIDDocumentVerification(idDocumentVerificationId: string): Promise; - getIDDocumentVerificationPDFReport(idDocumentVerificationId: string): Promise; + getIDDocumentVerificationPDFReport(idDocumentVerificationId: string): Promise; // Backwards compatibility - Address Document Verifications createAddressDocumentVerification(body: object): Promise; getAddressDocumentVerification(addressDocumentVerificationId: string): Promise; - listAddressDocumentVerificationAttempts(addressDocumentVerificationId: string): Promise; + listAddressDocumentVerificationAttempts(addressDocumentVerificationId: string, params?: object): Promise; getAddressDocumentVerificationAttempt(addressDocumentVerificationId: string, attemptId: string): Promise; + getAddressDocumentVerificationAttemptAssets(addressDocumentVerificationId: string, attemptId: string, params?: object): Promise; createAddressDocumentVerificationAttempt(addressDocumentVerificationId: string, body: object): Promise; anonymizeAddressDocumentVerification(addressDocumentVerificationId: string): Promise; - getAddressDocumentVerificationPDFReport(addressDocumentVerificationId: string): Promise; + getAddressDocumentVerificationPDFReport(addressDocumentVerificationId: string): Promise; // Backwards compatibility - Identity Verifications createIdentityVerification(body: object): Promise; createAndStartIdentityVerification(body: object): Promise; getIdentityVerification(identityVerificationId: string): Promise; createIdentityVerificationAttempt(identityVerificationId: string, body: object): Promise; - listIdentityVerificationAttempts(identityVerificationId: string): Promise; + listIdentityVerificationAttempts(identityVerificationId: string, params?: object): Promise; getIdentityVerificationAttempt(identityVerificationId: string, attemptId: string): Promise; getIdentityVerificationAttemptAssets(identityVerificationId: string, attemptId: string, params?: object): Promise; anonymizeIdentityVerification(identityVerificationId: string): Promise; - getIdentityVerificationPDFReport(identityVerificationId: string): Promise; + getIdentityVerificationPDFReport(identityVerificationId: string): Promise; } diff --git a/types/dist/api/identities/identity-verifications.d.ts b/types/dist/api/identities/identity-verifications.d.ts index 3614814d..5c11518d 100644 --- a/types/dist/api/identities/identity-verifications.d.ts +++ b/types/dist/api/identities/identity-verifications.d.ts @@ -7,9 +7,23 @@ export default class IdentityVerifications { createAndStartIdentityVerification(body: object): Promise; getIdentityVerification(identityVerificationId: string): Promise; createAttempt(identityVerificationId: string, body: object): Promise; - listAttempts(identityVerificationId: string): Promise; + /** + * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + */ + listAttempts(identityVerificationId: string, params?: object): Promise; getAttempt(identityVerificationId: string, attemptId: string): Promise; getAttemptAssets(identityVerificationId: string, attemptId: string, params?: object): Promise; anonymizeIdentityVerification(identityVerificationId: string): Promise; - getPDFReport(identityVerificationId: string): Promise; + /** + * Get the PDF report. + * + * Resolves to the JSON body, which carries `pdf_report`, the pre-signed URL to the PDF. The + * 2026-09-02 spec renamed that property from `signed_url`. + * + * This used to be `Promise`, and the implementation really did return one: it passed + * `csv: true`, which wraps the body in a Buffer and sends `Content-Type: text/csv`. The + * endpoint answers `application/json`, so a caller could never reach `pdf_report`. Both the + * method and this signature now follow the spec. + */ + getPDFReport(identityVerificationId: string): Promise; } diff --git a/types/dist/api/issuing/cards.d.ts b/types/dist/api/issuing/cards.d.ts index c8034260..3e3a542c 100644 --- a/types/dist/api/issuing/cards.d.ts +++ b/types/dist/api/issuing/cards.d.ts @@ -5,7 +5,11 @@ export default class Cards { createCard(body: object, idempotencyKey?: string): Promise; getCardDetails(id: string): Promise; - updateCard(id: string, body: object): Promise; + /** + * Update a card. Pass `headers` to request the encrypted credentials: set + * `return-encrypted-cvv` to "true" together with an `Encryption-Key`. + */ + updateCard(id: string, body: object, headers?: object): Promise; enrollThreeDS(id: string, body: object): Promise; updateThreeDS(id: string, body: object): Promise; getThreeDSDetails(id: string): Promise; diff --git a/types/dist/api/issuing/issuing.d.ts b/types/dist/api/issuing/issuing.d.ts index 73f7d52c..2ea1cee1 100644 --- a/types/dist/api/issuing/issuing.d.ts +++ b/types/dist/api/issuing/issuing.d.ts @@ -34,7 +34,12 @@ export default class Issuing { // Backwards compatibility - Cards createCard(body: object, idempotencyKey?: string): Promise; getCardDetails(id: string): Promise; - updateCard(id: string, body: object): Promise; + /** + * Update a card. Pass `headers` to request the encrypted credentials: set + * `return-encrypted-cvv` to "true" together with an `Encryption-Key`. Supplying the flag + * without the key returns a 422 with error code `encryption_key_required`. + */ + updateCard(id: string, body: object, headers?: object): Promise; enrollThreeDS(id: string, body: object): Promise; updateThreeDS(id: string, body: object): Promise; getThreeDSDetails(id: string): Promise; From 4f8d0d33edca4a581ab2c4efaece5575a5175d0f Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 21 Sep 2026 19:35:38 +0200 Subject: [PATCH 2/2] Extended documentation and tests --- .../address-document-verifications.js | 8 +- src/api/identities/face-authentications.js | 8 +- .../identities/id-document-verifications.js | 8 +- src/api/identities/identities.js | 40 +++ src/api/identities/identity-verifications.js | 8 +- src/api/issuing/cards.js | 21 +- .../address-document-verifications-it.js | 90 +++++++ .../address-document-verifications-unit.js | 128 ++++++++- .../attempt-assets-pagination-unit.js | 247 ------------------ test/identities/delegation/delegation-unit.js | 118 +++++++++ .../face-authentications-it.js | 18 ++ .../face-authentications-unit.js | 32 +++ .../id-document-verifications-it.js | 39 +++ .../id-document-verifications-unit.js | 129 +++++++++ .../identity-verifications-it.js | 19 ++ .../identity-verifications-unit.js | 32 +++ .../issuing/cards/card-update-headers-unit.js | 167 ------------ test/issuing/cards/cards-it.js | 49 ++++ test/issuing/cards/cards-unit.js | 179 +++++++++++++ test/services/utils.js | 28 ++ .../address-document-verifications.d.ts | 5 +- .../api/identities/face-authentications.d.ts | 4 +- .../identities/id-document-verifications.d.ts | 7 +- .../identities/identity-verifications.d.ts | 3 +- types/dist/api/issuing/cards.d.ts | 11 +- types/dist/api/issuing/issuing.d.ts | 12 +- 26 files changed, 967 insertions(+), 443 deletions(-) create mode 100644 test/identities/address-document-verifications/address-document-verifications-it.js delete mode 100644 test/identities/attempt-assets-pagination-unit.js delete mode 100644 test/issuing/cards/card-update-headers-unit.js create mode 100644 test/services/utils.js diff --git a/src/api/identities/address-document-verifications.js b/src/api/identities/address-document-verifications.js index 57eb547d..b8879424 100644 --- a/src/api/identities/address-document-verifications.js +++ b/src/api/identities/address-document-verifications.js @@ -73,7 +73,9 @@ export default class AddressDocumentVerifications { * Get the details of all attempts for a specific address document verification. * @method listAttempts * @param {string} address_document_verification_id - The address document verification's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of attempts to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of attempts to return. Defaults to 10 * @returns {Promise} A promise to the Get address document verification attempts response */ async listAttempts(address_document_verification_id, params) { @@ -197,7 +199,9 @@ export default class AddressDocumentVerifications { * @method getAttemptAssets * @param {string} address_document_verification_id - The address document verification's unique identifier * @param {string} attempt_id - The attempt's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 * @returns {Promise} A promise to the Get address document verification attempt assets response */ async getAttemptAssets(address_document_verification_id, attempt_id, params) { diff --git a/src/api/identities/face-authentications.js b/src/api/identities/face-authentications.js index 91c8aa97..c79988eb 100644 --- a/src/api/identities/face-authentications.js +++ b/src/api/identities/face-authentications.js @@ -72,7 +72,9 @@ export default class FaceAuthentications { * Get the details of all attempts for a specific face authentication. * @method listAttempts * @param {string} face_authentication_id - The face authentication's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of attempts to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of attempts to return. Defaults to 10 * @returns {Promise} A promise to the Get face authentication attempts response */ async listAttempts(face_authentication_id, params) { @@ -124,7 +126,9 @@ export default class FaceAuthentications { * @method getAttemptAssets * @param {string} face_authentication_id - The face authentication's unique identifier * @param {string} attempt_id - The attempt's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 * @returns {Promise} A promise to the Get face authentication attempt assets response */ async getAttemptAssets(face_authentication_id, attempt_id, params) { diff --git a/src/api/identities/id-document-verifications.js b/src/api/identities/id-document-verifications.js index 7749b53c..a5305e9e 100644 --- a/src/api/identities/id-document-verifications.js +++ b/src/api/identities/id-document-verifications.js @@ -73,7 +73,9 @@ export default class IDDocumentVerifications { * Get the details of all attempts for a specific ID document verification. * @method listAttempts * @param {string} id_document_verification_id - The ID document verification's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of attempts to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of attempts to return. Defaults to 10 * @returns {Promise} A promise to the Get ID document verification attempts response */ async listAttempts(id_document_verification_id, params) { @@ -199,7 +201,9 @@ export default class IDDocumentVerifications { * @method getAttemptAssets * @param {string} id_document_verification_id - The ID document verification's unique identifier * @param {string} attempt_id - The attempt's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 * @returns {Promise} A promise to the Get ID document verification attempt assets response */ async getAttemptAssets(id_document_verification_id, attempt_id, params) { diff --git a/src/api/identities/identities.js b/src/api/identities/identities.js index cf5b7f1d..f51ca57d 100644 --- a/src/api/identities/identities.js +++ b/src/api/identities/identities.js @@ -67,6 +67,16 @@ export default class Identities { return this.faceAuthentications.getAttempt(face_authentication_id, attempt_id); } + /** + * Get the assets (the face image or video) uploaded for a face authentication attempt. + * Delegates to `faceAuthentications.getAttemptAssets`. Results are paginated. + * @param {string} face_authentication_id - The face authentication's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 + * @returns {Promise} A promise to the Get face authentication attempt assets response + */ async getFaceAuthenticationAttemptAssets(face_authentication_id, attempt_id, params) { return this.faceAuthentications.getAttemptAssets(face_authentication_id, attempt_id, params); } @@ -92,6 +102,16 @@ export default class Identities { return this.idDocumentVerifications.listAttempts(id_document_verification_id, params); } + /** + * Get the assets (the front and back images of the document) uploaded for an ID document verification attempt. + * Delegates to `idDocumentVerifications.getAttemptAssets`. Results are paginated. + * @param {string} id_document_verification_id - The ID document verification's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 + * @returns {Promise} A promise to the Get ID document verification attempt assets response + */ async getIDDocumentVerificationAttemptAssets(id_document_verification_id, attempt_id, params) { return this.idDocumentVerifications.getAttemptAssets(id_document_verification_id, attempt_id, params); } @@ -125,6 +145,16 @@ export default class Identities { return this.addressDocumentVerifications.listAttempts(address_document_verification_id, params); } + /** + * Get the assets (the document image) uploaded for a address document verification attempt. + * Delegates to `addressDocumentVerifications.getAttemptAssets`. Results are paginated. + * @param {string} address_document_verification_id - The address document verification's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 + * @returns {Promise} A promise to the Get address document verification attempt assets response + */ async getAddressDocumentVerificationAttemptAssets(address_document_verification_id, attempt_id, params) { return this.addressDocumentVerifications.getAttemptAssets( address_document_verification_id, @@ -178,6 +208,16 @@ export default class Identities { return this.identityVerifications.getAttempt(identity_verification_id, attempt_id); } + /** + * Get the assets (the captured images and videos) uploaded for a identity verification attempt. + * Delegates to `identityVerifications.getAttemptAssets`. Results are paginated. + * @param {string} identity_verification_id - The identity verification's unique identifier + * @param {string} attempt_id - The attempt's unique identifier + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 + * @returns {Promise} A promise to the Get identity verification attempt assets response + */ async getIdentityVerificationAttemptAssets(identity_verification_id, attempt_id, params) { return this.identityVerifications.getAttemptAssets(identity_verification_id, attempt_id, params); } diff --git a/src/api/identities/identity-verifications.js b/src/api/identities/identity-verifications.js index ac4360a7..8bd8322e 100644 --- a/src/api/identities/identity-verifications.js +++ b/src/api/identities/identity-verifications.js @@ -146,7 +146,9 @@ export default class IdentityVerifications { * Get all the attempts for a specific identity verification. * @method listAttempts * @param {string} identity_verification_id - The identity verification's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of attempts to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of attempts to return. Defaults to 10 * @returns {Promise} A promise to the Get identity verification attempts response */ async listAttempts(identity_verification_id, params) { @@ -199,7 +201,9 @@ export default class IdentityVerifications { * @method getAttemptAssets * @param {string} identity_verification_id - The identity verification's unique identifier * @param {string} attempt_id - The attempt's unique identifier - * @param {Object} [params] - Optional pagination query parameters (skip and limit) + * @param {Object} [params] - Optional pagination query parameters + * @param {number} [params.skip] - The number of assets to skip. Defaults to 0 + * @param {number} [params.limit] - The maximum number of assets to return. Defaults to 10 * @returns {Promise} A promise to the Get identity verification attempt assets response */ async getAttemptAssets(identity_verification_id, attempt_id, params) { diff --git a/src/api/issuing/cards.js b/src/api/issuing/cards.js index ea96dfa4..5752f1ab 100644 --- a/src/api/issuing/cards.js +++ b/src/api/issuing/cards.js @@ -71,8 +71,11 @@ export default class Cards { /** * Updates a card you issued previously. * + * Only the fields you provide values for are updated. Passing `null` for a field **removes** + * its existing value, so omit a field to leave it untouched rather than sending null. + * * Pass headers to request the card's encrypted credentials in the response. Set - * `return-encrypted-cvv` to "true" together with an `Encryption-Key`; supplying the flag + * `return-encrypted-cvv` to `true` together with an `Encryption-Key`; supplying the flag * without the key returns a 422 with error code `encryption_key_required`. * * The headers travel on a copy of the config rather than on the body, which is how @@ -81,11 +84,17 @@ export default class Cards { * * @memberof Cards * @param {string} id Card id. - * @param {Object} body Card params to update. - * @param {Object} [headers] Optional HTTP headers. Supports `return-encrypted-cvv` and - * `Encryption-Key`. - * @return {Promise} A promise to the card update response, carrying `encrypted_cvv` - * when requested. + * @param {Object} body Card params to update: `reference`, `metadata`, `expiry_month`, + * `expiry_year`, `revocation_date` and `scheduled_activation_date`. + * @param {Object} [headers] Optional HTTP headers. + * @param {boolean} [headers.return-encrypted-cvv] Set to `true` to retrieve the card's + * encrypted credentials in the response. Requires `Encryption-Key`. + * @param {string} [headers.Encryption-Key] The RSA public key used to encrypt the returned + * credentials. Required when `return-encrypted-cvv` is `true`. Provide the key with the + * `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----` markers and every newline + * removed, encoded as Base64. + * @return {Promise} A promise to the card update response, carrying + * `last_modified_date`, `_links` and, when the headers requested it, `encrypted_cvv`. */ async updateCard(id, body, headers) { try { diff --git a/test/identities/address-document-verifications/address-document-verifications-it.js b/test/identities/address-document-verifications/address-document-verifications-it.js new file mode 100644 index 00000000..45e688c8 --- /dev/null +++ b/test/identities/address-document-verifications/address-document-verifications-it.js @@ -0,0 +1,90 @@ +/** + * Integration tests for Identities Address Document Verifications API. + * + * This family had no integration coverage before the 2026-09-02 row, unlike the other three. + */ +import nock from 'nock'; +import { expect } from 'chai'; +import { NotFoundError } from '../../../src/services/errors.js'; +import { cko } from '../identities-common.js'; + +afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); +}); + +describe.skip('Integration::Identities::AddressDocumentVerifications', () => { + it('should create an address document verification', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.addressdoc@example.com', + external_applicant_name: 'Test Address Doc', + }); + const addressDoc = await cko.identities.createAddressDocumentVerification({ + applicant_id: applicant.id, + }); + expect(addressDoc.id).to.not.be.null; + }); + + it('should throw NotFoundError when getting non-existent address document verification', async () => { + try { + await cko.identities.getAddressDocumentVerification('adv_nonexistent'); + expect.fail('Should have thrown NotFoundError'); + } catch (err) { + expect(err).to.be.instanceOf(NotFoundError); + } + }); + + // Part A: the new attempt-assets endpoint. AdvAttemptAsset requires type and an + // _links.asset_url, and type is always document for this family. + it('should get address document verification attempt assets', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.addressdoc.assets@example.com', + external_applicant_name: 'Test Address Doc Assets', + }); + const addressDoc = await cko.identities.createAddressDocumentVerification({ + applicant_id: applicant.id, + }); + const attempt = await cko.identities.createAddressDocumentVerificationAttempt(addressDoc.id, {}); + const assets = await cko.identities.getAddressDocumentVerificationAttemptAssets( + addressDoc.id, + attempt.id, + { skip: 0, limit: 10 } + ); + expect(assets).to.not.be.null; + expect(assets.data).to.be.an('array'); + }); + + // Part F M1: list-attempts became paginated in the 2026-09-02 row. + it('should list address document verification attempts with skip and limit', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.addressdoc.paging@example.com', + external_applicant_name: 'Test Address Doc Paging', + }); + const addressDoc = await cko.identities.createAddressDocumentVerification({ + applicant_id: applicant.id, + }); + const attempts = await cko.identities.listAddressDocumentVerificationAttempts(addressDoc.id, { + skip: 0, + limit: 5, + }); + expect(attempts.data).to.be.an('array'); + expect(attempts.limit).to.equal(5); + }); + + // Part C: IdvPdf declares pdf_report and nothing else; signed_url is gone from the spec. + it('should get an address document verification pdf report', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.addressdoc.pdf@example.com', + external_applicant_name: 'Test Address Doc Pdf', + }); + const addressDoc = await cko.identities.createAddressDocumentVerification({ + applicant_id: applicant.id, + }); + const report = await cko.identities.getAddressDocumentVerificationPDFReport(addressDoc.id); + expect(report.pdf_report).to.be.a('string'); + }); +}); diff --git a/test/identities/address-document-verifications/address-document-verifications-unit.js b/test/identities/address-document-verifications/address-document-verifications-unit.js index 60a1f271..89929783 100644 --- a/test/identities/address-document-verifications/address-document-verifications-unit.js +++ b/test/identities/address-document-verifications/address-document-verifications-unit.js @@ -92,10 +92,134 @@ describe('Unit::Address Document Verifications', () => { it('should get an address document verification pdf report', async () => { nock(BASE) .get(`/address-document-verifications/${ADV_ID}/pdf-report`) - .reply(200, { id: ADV_ID }); + .reply(200, { pdf_report: 'https://www.example.com/reports/adv.pdf' }); const cko = new Checkout(SK, { subdomain: 'test' }); const result = await cko.identities.addressDocumentVerifications.getPDFReport(ADV_ID); - expect(result.id).to.equal(ADV_ID); + + // IdvPdf declares exactly one property, pdf_report, and it is required. The fixture used + // to return an id, which the schema has no equivalent for, so nothing exercised the + // property this row renamed. + expect(result.pdf_report).to.equal('https://www.example.com/reports/adv.pdf'); + expect(result.signed_url).to.be.undefined; + }); + + // The new attempt-assets endpoint. + // + // The payload is the swagger example verbatim (components.examples + // adv_attempt_assets_response_body), apart from shortened hrefs. asset_url is the only link + // AdvAttemptAsset declares, and it is required, so using the spec's own example rather than a + // hand-written fixture is the point. + describe('attempt assets', () => { + const ASSETS = { + total_count: 1, + skip: 0, + limit: 10, + data: [ + { + type: 'document', + _links: { + asset_url: { + href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/address_document.png' + } + } + } + ], + _links: { + self: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets` }, + next: { + href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets?skip=10` + }, + previous: { + href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets?skip=0` + } + } + }; + + it('should get the attempt assets with skip and limit', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets`) + .query({ skip: 0, limit: 10 }) + .reply(200, ASSETS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ATTEMPT_ID, + { skip: 0, limit: 10 } + ); + + expect(result.total_count).to.equal(1); + expect(result.data).to.have.lengthOf(1); + expect(result.data[0].type).to.equal('document'); + expect(result.data[0]._links.asset_url.href).to.contain('address_document.png'); + expect(result.data[0]._links.download).to.be.undefined; + }); + + // The schema types _links as IdvSelfLink, which declares self only, but the endpoint's own + // example returns next and previous too. Reported internally; the example is what a caller + // actually has to page through, so that is what this asserts. + it('should expose the pagination links from the example', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets`) + .reply(200, ASSETS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ATTEMPT_ID + ); + + expect(result._links.self.href).to.contain('/assets'); + expect(result._links.next.href).to.contain('skip=10'); + expect(result._links.previous.href).to.contain('skip=0'); + }); + + // data declares minItems 0, so an attempt with no assets yet is a legal page. + it('should handle an empty assets page', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts/${ATTEMPT_ID}/assets`) + .reply(200, { total_count: 0, skip: 0, limit: 10, data: [], _links: { self: { href: 'x' } } }); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.addressDocumentVerifications.getAttemptAssets( + ADV_ID, + ATTEMPT_ID + ); + + expect(result.total_count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + }); + + // Part F M1: skip and limit on list-attempts, which took no query parameters before this row. + describe('attempts pagination', () => { + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('should send skip and limit', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.addressDocumentVerifications.listAttempts(ADV_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('should send no query string when no params are passed', async () => { + nock(BASE) + .get(`/address-document-verifications/${ADV_ID}/attempts`) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.addressDocumentVerifications.listAttempts(ADV_ID); + + expect(result.total_count).to.equal(25); + }); }); }); diff --git a/test/identities/attempt-assets-pagination-unit.js b/test/identities/attempt-assets-pagination-unit.js deleted file mode 100644 index 8ad3b843..00000000 --- a/test/identities/attempt-assets-pagination-unit.js +++ /dev/null @@ -1,247 +0,0 @@ -import { expect } from 'chai'; -import nock from 'nock'; -import Checkout from '../../src/Checkout.js'; -import { buildQueryParams } from '../../src/services/utils.js'; - -// Part A (the two new attempt-assets endpoints) and part F M1 (pagination on all four -// list-attempts endpoints) of the 2026-09-02 swagger delta. -// -// The assets payloads are the swagger examples verbatim, from components.examples, apart from -// shortened hrefs. asset_url is the only link AdvAttemptAsset and IddvAttemptAsset declare, and it -// is required, so using the spec's own example rather than a hand-written fixture is the point. - -const SK = 'sk_sbox_o2nulev2arguvyf6w7sc5fkznas'; -const BASE = 'https://identity-verification.sandbox.checkout.com'; - -const ADV_ID = 'adv_tkoi5db4hryu5cei5vwoabr7we'; -const ADV_ATTEMPT_ID = 'adva_tkoi5db4hryu5cei5vwoabr7we'; -const IDDV_ID = 'iddv_tkoi5db4hryu5cei5vwoabr7we'; -const IDDV_ATTEMPT_ID = 'datp_tkoi5db4hryu5cei5vwoabraio'; - -const ADV_ASSETS = { - total_count: 1, - skip: 0, - limit: 10, - data: [ - { - type: 'document', - _links: { - asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/address_document.png' } - } - } - ], - _links: { - self: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets` }, - next: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets?skip=10` }, - previous: { href: `${BASE}/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets?skip=0` } - } -}; - -const IDDV_ASSETS = { - total_count: 2, - skip: 0, - limit: 10, - data: [ - { - type: 'document_front_image', - _links: { asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_front.png' } } - }, - { - type: 'document_back_image', - _links: { asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_back.png' } } - } - ], - _links: { - self: { href: `${BASE}/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets` } - } -}; - -const cko = () => new Checkout(SK, { subdomain: 'test' }); - -afterEach(() => { - nock.cleanAll(); -}); - -describe('Unit::Identities attempt assets', () => { - it('should get the address document verification attempt assets', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) - .query({ skip: 0, limit: 10 }) - .reply(200, ADV_ASSETS); - - const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( - ADV_ID, - ADV_ATTEMPT_ID, - { skip: 0, limit: 10 } - ); - - expect(result.total_count).to.equal(1); - expect(result.data).to.have.lengthOf(1); - expect(result.data[0].type).to.equal('document'); - expect(result.data[0]._links.asset_url.href).to.contain('address_document.png'); - expect(result.data[0]._links.download).to.be.undefined; - expect(result._links.next.href).to.contain('skip=10'); - }); - - it('should get the address document verification attempt assets without a query', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) - .reply(200, ADV_ASSETS); - - const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( - ADV_ID, - ADV_ATTEMPT_ID - ); - - expect(result.total_count).to.equal(1); - }); - - it('should get the ID document verification attempt assets, both types', async () => { - nock(BASE) - .get(`/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets`) - .query({ limit: 10 }) - .reply(200, IDDV_ASSETS); - - const result = await cko().identities.idDocumentVerifications.getAttemptAssets( - IDDV_ID, - IDDV_ATTEMPT_ID, - { limit: 10 } - ); - - expect(result.total_count).to.equal(2); - expect(result.data.map((asset) => asset.type)).to.deep.equal([ - 'document_front_image', - 'document_back_image' - ]); - expect(result.data[0]._links.asset_url.href).to.contain('document_front.png'); - expect(result.data[1]._links.asset_url.href).to.contain('document_back.png'); - expect(result.data[0]._links.download).to.be.undefined; - }); - - // data declares minItems 0, so an attempt with no assets yet is a legal page. - it('should handle an empty assets page', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) - .reply(200, { total_count: 0, skip: 0, limit: 10, data: [], _links: { self: { href: 'x' } } }); - - const result = await cko().identities.addressDocumentVerifications.getAttemptAssets( - ADV_ID, - ADV_ATTEMPT_ID - ); - - expect(result.total_count).to.equal(0); - expect(result.data).to.deep.equal([]); - }); - - it('should expose the assets through the backwards-compatible aggregate methods', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts/${ADV_ATTEMPT_ID}/assets`) - .reply(200, ADV_ASSETS) - .get(`/id-document-verifications/${IDDV_ID}/attempts/${IDDV_ATTEMPT_ID}/assets`) - .reply(200, IDDV_ASSETS); - - const client = cko(); - const adv = await client.identities.getAddressDocumentVerificationAttemptAssets(ADV_ID, ADV_ATTEMPT_ID); - const iddv = await client.identities.getIDDocumentVerificationAttemptAssets(IDDV_ID, IDDV_ATTEMPT_ID); - - expect(adv.data[0].type).to.equal('document'); - expect(iddv.data).to.have.lengthOf(2); - }); -}); - -describe('Unit::Identities attempts pagination', () => { - const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; - - it('sends skip and limit on the address document verification attempts', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts`) - .query({ skip: 5, limit: 25 }) - .reply(200, ATTEMPTS); - - const result = await cko().identities.addressDocumentVerifications.listAttempts(ADV_ID, { - skip: 5, - limit: 25 - }); - - expect(result.total_count).to.equal(25); - }); - - it('sends skip and limit on the ID document verification attempts', async () => { - nock(BASE) - .get(`/id-document-verifications/${IDDV_ID}/attempts`) - .query({ skip: 5, limit: 25 }) - .reply(200, ATTEMPTS); - - const result = await cko().identities.idDocumentVerifications.listAttempts(IDDV_ID, { - skip: 5, - limit: 25 - }); - - expect(result.total_count).to.equal(25); - }); - - it('sends skip and limit on the identity verification attempts', async () => { - nock(BASE) - .get('/identity-verifications/idv_1/attempts') - .query({ skip: 5, limit: 25 }) - .reply(200, ATTEMPTS); - - const result = await cko().identities.identityVerifications.listAttempts('idv_1', { - skip: 5, - limit: 25 - }); - - expect(result.total_count).to.equal(25); - }); - - it('sends skip and limit on the face authentication attempts', async () => { - nock(BASE) - .get('/face-authentications/fav_1/attempts') - .query({ skip: 5, limit: 25 }) - .reply(200, ATTEMPTS); - - const result = await cko().identities.faceAuthentications.listAttempts('fav_1', { - skip: 5, - limit: 25 - }); - - expect(result.total_count).to.equal(25); - }); - - it('sends no query string when no params are passed', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts`) - .reply(200, ATTEMPTS); - - const result = await cko().identities.addressDocumentVerifications.listAttempts(ADV_ID); - - expect(result.total_count).to.equal(25); - }); - - it('forwards params through the backwards-compatible aggregate methods', async () => { - nock(BASE) - .get(`/address-document-verifications/${ADV_ID}/attempts`) - .query({ limit: 1 }) - .reply(200, ATTEMPTS); - - const result = await cko().identities.listAddressDocumentVerificationAttempts(ADV_ID, { limit: 1 }); - - expect(result.total_count).to.equal(25); - }); -}); - -describe('Unit::buildQueryParams', () => { - it('keeps an explicit zero skip, unlike the PHP and Go filters', () => { - expect(buildQueryParams('u', { skip: 0, limit: 10 })).to.equal('u?skip=0&limit=10'); - }); - - it('adds no query string for undefined params', () => { - expect(buildQueryParams('u', undefined)).to.equal('u'); - }); - - // An empty object used to produce a bare "u?", which the list-attempts endpoints would have - // started emitting once they began routing through this helper. - it('adds no bare question mark for an empty params object', () => { - expect(buildQueryParams('u', {})).to.equal('u'); - }); -}); diff --git a/test/identities/delegation/delegation-unit.js b/test/identities/delegation/delegation-unit.js index 97eedcde..afb7740c 100644 --- a/test/identities/delegation/delegation-unit.js +++ b/test/identities/delegation/delegation-unit.js @@ -211,4 +211,122 @@ describe('Identities - Backwards Compatibility Delegation', () => { const result = await cko.identities.getIdentityVerificationPDFReport('idv_123'); expect(result).to.not.be.null; }); + + // Attempt-assets delegation. The two document-verification delegates are new in the + // 2026-09-02 row; the face-authentication and identity-verification ones predate it and had no + // delegate coverage either. + const ASSETS = (type) => ({ + total_count: 1, + skip: 0, + limit: 10, + data: [{ type, _links: { asset_url: { href: `https://storage.example.com/${type}.png` } } }], + _links: { self: { href: 'https://identity-verification.sandbox.checkout.com/assets' } }, + }); + + it('should delegate getAddressDocumentVerificationAttemptAssets to addressDocumentVerifications submodule', async () => { + nock(BASE) + .get('/address-document-verifications/adv_123/attempts/adva_123/assets') + .reply(200, ASSETS('document')); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.getAddressDocumentVerificationAttemptAssets('adv_123', 'adva_123'); + expect(result.data[0].type).to.equal('document'); + expect(result.data[0]._links.asset_url.href).to.contain('document.png'); + }); + + it('should delegate getIDDocumentVerificationAttemptAssets to idDocumentVerifications submodule', async () => { + nock(BASE) + .get('/id-document-verifications/iddv_123/attempts/datp_123/assets') + .reply(200, ASSETS('document_front_image')); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.getIDDocumentVerificationAttemptAssets('iddv_123', 'datp_123'); + expect(result.data[0].type).to.equal('document_front_image'); + }); + + it('should delegate getFaceAuthenticationAttemptAssets to faceAuthentications submodule', async () => { + nock(BASE) + .get('/face-authentications/fav_123/attempts/fatp_123/assets') + .reply(200, ASSETS('face_image')); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.getFaceAuthenticationAttemptAssets('fav_123', 'fatp_123'); + expect(result.data[0].type).to.equal('face_image'); + }); + + it('should delegate getIdentityVerificationAttemptAssets to identityVerifications submodule', async () => { + nock(BASE) + .get('/identity-verifications/idv_123/attempts/iatp_123/assets') + .reply(200, ASSETS('face_video')); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.getIdentityVerificationAttemptAssets('idv_123', 'iatp_123'); + expect(result.data[0].type).to.equal('face_video'); + }); + + it('should forward skip and limit through the attempt-assets delegates', async () => { + nock(BASE) + .get('/address-document-verifications/adv_123/attempts/adva_123/assets') + .query({ skip: 10, limit: 5 }) + .reply(200, ASSETS('document')); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.getAddressDocumentVerificationAttemptAssets( + 'adv_123', + 'adva_123', + { skip: 10, limit: 5 } + ); + expect(result.total_count).to.equal(1); + }); + + // The four list-attempts delegates gained a params argument, so each one has to + // pass it through rather than silently drop it. + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('should forward skip and limit through listAddressDocumentVerificationAttempts', async () => { + nock(BASE) + .get('/address-document-verifications/adv_123/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.listAddressDocumentVerificationAttempts('adv_123', { + skip: 5, + limit: 25, + }); + expect(result.total_count).to.equal(25); + }); + + it('should forward skip and limit through listIDDocumentVerificationAttempts', async () => { + nock(BASE) + .get('/id-document-verifications/iddv_123/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.listIDDocumentVerificationAttempts('iddv_123', { + skip: 5, + limit: 25, + }); + expect(result.total_count).to.equal(25); + }); + + it('should forward skip and limit through listFaceAuthenticationAttempts', async () => { + nock(BASE) + .get('/face-authentications/fav_123/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.listFaceAuthenticationAttempts('fav_123', { + skip: 5, + limit: 25, + }); + expect(result.total_count).to.equal(25); + }); + + it('should forward skip and limit through listIdentityVerificationAttempts', async () => { + nock(BASE) + .get('/identity-verifications/idv_123/attempts') + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.listIdentityVerificationAttempts('idv_123', { + skip: 5, + limit: 25, + }); + expect(result.total_count).to.equal(25); + }); }); diff --git a/test/identities/face-authentications/face-authentications-it.js b/test/identities/face-authentications/face-authentications-it.js index baf20faf..4e6623e6 100644 --- a/test/identities/face-authentications/face-authentications-it.js +++ b/test/identities/face-authentications/face-authentications-it.js @@ -51,4 +51,22 @@ describe.skip('Integration::Identities::FaceAuthentications', () => { expect(assets).to.not.be.null; expect(assets.data).to.be.an('array'); }); + + // list-attempts became paginated in the 2026-09-02 row. + it('should list face authentication attempts with skip and limit', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.face.paging@example.com', + external_applicant_name: 'Test Face Paging', + }); + const faceAuth = await cko.identities.createFaceAuthentication({ + applicant_id: applicant.id, + }); + const attempts = await cko.identities.listFaceAuthenticationAttempts(faceAuth.id, { + skip: 0, + limit: 5, + }); + expect(attempts.data).to.be.an('array'); + expect(attempts.limit).to.equal(5); + }); }); diff --git a/test/identities/face-authentications/face-authentications-unit.js b/test/identities/face-authentications/face-authentications-unit.js index 26c1e264..f46adaa0 100644 --- a/test/identities/face-authentications/face-authentications-unit.js +++ b/test/identities/face-authentications/face-authentications-unit.js @@ -314,4 +314,36 @@ describe('Unit::Face Authentications', () => { expect(err).to.be.instanceOf(NotFoundError); } }); + + // Skip and limit on list-attempts, which took no query parameters before this row. + describe('attempts pagination', () => { + const RESOURCE_ID = 'fav_tkoi5db4hryu5cei5vwoabr7we'; + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('should send skip and limit', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/face-authentications/${RESOURCE_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.faceAuthentications.listAttempts(RESOURCE_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('should send no query string when no params are passed', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/face-authentications/${RESOURCE_ID}/attempts`) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.faceAuthentications.listAttempts(RESOURCE_ID); + + expect(result.total_count).to.equal(25); + }); + }); }); diff --git a/test/identities/id-document-verifications/id-document-verifications-it.js b/test/identities/id-document-verifications/id-document-verifications-it.js index fa056c28..f30dbe2e 100644 --- a/test/identities/id-document-verifications/id-document-verifications-it.js +++ b/test/identities/id-document-verifications/id-document-verifications-it.js @@ -32,4 +32,43 @@ describe.skip('Integration::Identities::IDDocumentVerifications', () => { expect(err).to.be.instanceOf(NotFoundError); } }); + + // The new attempt-assets endpoint. IddvAttemptAsset requires type and an + // _links.asset_url, and type is one of document_front_image or document_back_image. + it('should get ID document verification attempt assets', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.iddoc.assets@example.com', + external_applicant_name: 'Test ID Doc Assets', + }); + const idDoc = await cko.identities.createIDDocumentVerification({ + applicant_id: applicant.id, + }); + const attempt = await cko.identities.createIDDocumentVerificationAttempt(idDoc.id, {}); + const assets = await cko.identities.getIDDocumentVerificationAttemptAssets( + idDoc.id, + attempt.id, + { skip: 0, limit: 10 } + ); + expect(assets).to.not.be.null; + expect(assets.data).to.be.an('array'); + }); + + // List-attempts became paginated in the 2026-09-02 row. + it('should list ID document verification attempts with skip and limit', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.iddoc.paging@example.com', + external_applicant_name: 'Test ID Doc Paging', + }); + const idDoc = await cko.identities.createIDDocumentVerification({ + applicant_id: applicant.id, + }); + const attempts = await cko.identities.listIDDocumentVerificationAttempts(idDoc.id, { + skip: 0, + limit: 5, + }); + expect(attempts.data).to.be.an('array'); + expect(attempts.limit).to.equal(5); + }); }); diff --git a/test/identities/id-document-verifications/id-document-verifications-unit.js b/test/identities/id-document-verifications/id-document-verifications-unit.js index f3a91373..e2261061 100644 --- a/test/identities/id-document-verifications/id-document-verifications-unit.js +++ b/test/identities/id-document-verifications/id-document-verifications-unit.js @@ -289,4 +289,133 @@ describe('Unit::ID Document Verifications', () => { expect(err).to.be.instanceOf(NotFoundError); } }); + + // The new attempt-assets endpoint. + // + // The payload is the swagger example verbatim (components.examples + // iddv_attempt_assets_response_body), apart from shortened hrefs. asset_url is the only link + // IddvAttemptAsset declares, and it is required, so using the spec's own example rather than a + // hand-written fixture is the point. + describe('attempt assets', () => { + const IDDV_ID = 'iddv_tkoi5db4hryu5cei5vwoabr7we'; + const ATTEMPT_ID = 'datp_tkoi5db4hryu5cei5vwoabraio'; + const ASSETS = { + total_count: 2, + skip: 0, + limit: 10, + data: [ + { + type: 'document_front_image', + _links: { + asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_front.png' } + } + }, + { + type: 'document_back_image', + _links: { + asset_url: { href: 'https://storage-b.env.ubble.ai/ubble-ai/NDY/document_back.png' } + } + } + ], + _links: { + self: { href: `https://identity-verification.sandbox.checkout.com/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets` }, + next: { + href: `https://identity-verification.sandbox.checkout.com/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets?skip=10` + }, + previous: { + href: `https://identity-verification.sandbox.checkout.com/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets?skip=0` + } + } + }; + + it('should get the attempt assets, both document types', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets`) + .query({ limit: 10 }) + .reply(200, ASSETS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.idDocumentVerifications.getAttemptAssets( + IDDV_ID, + ATTEMPT_ID, + { limit: 10 } + ); + + expect(result.total_count).to.equal(2); + expect(result.data.map((asset) => asset.type)).to.deep.equal([ + 'document_front_image', + 'document_back_image' + ]); + expect(result.data[0]._links.asset_url.href).to.contain('document_front.png'); + expect(result.data[1]._links.asset_url.href).to.contain('document_back.png'); + expect(result.data[0]._links.download).to.be.undefined; + }); + + // The schema types _links as IdvSelfLink, which declares self only, but the endpoint's own + // example returns next and previous too. Reported internally; the example is what a caller + // actually has to page through, so that is what this asserts. + it('should expose the pagination links from the example', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets`) + .reply(200, ASSETS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.idDocumentVerifications.getAttemptAssets( + IDDV_ID, + ATTEMPT_ID + ); + + expect(result._links.self.href).to.contain('/assets'); + expect(result._links.next.href).to.contain('skip=10'); + expect(result._links.previous.href).to.contain('skip=0'); + }); + + // data declares minItems 0, so an attempt with no assets yet is a legal page. + it('should handle an empty assets page', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/id-document-verifications/${IDDV_ID}/attempts/${ATTEMPT_ID}/assets`) + .reply(200, { total_count: 0, skip: 0, limit: 10, data: [], _links: { self: { href: 'x' } } }); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.idDocumentVerifications.getAttemptAssets( + IDDV_ID, + ATTEMPT_ID + ); + + expect(result.total_count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + }); + + // Part F M1: skip and limit on list-attempts, which took no query parameters before this row. + describe('attempts pagination', () => { + const IDDV_ID = 'iddv_tkoi5db4hryu5cei5vwoabr7we'; + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('should send skip and limit', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/id-document-verifications/${IDDV_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.idDocumentVerifications.listAttempts(IDDV_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('should send no query string when no params are passed', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/id-document-verifications/${IDDV_ID}/attempts`) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.idDocumentVerifications.listAttempts(IDDV_ID); + + expect(result.total_count).to.equal(25); + }); + }); }); diff --git a/test/identities/identity-verifications/identity-verifications-it.js b/test/identities/identity-verifications/identity-verifications-it.js index 4b2f6818..4c868433 100644 --- a/test/identities/identity-verifications/identity-verifications-it.js +++ b/test/identities/identity-verifications/identity-verifications-it.js @@ -69,4 +69,23 @@ describe.skip('Integration::Identities::IdentityVerifications', () => { expect(assets).to.not.be.null; expect(assets.data).to.be.an('array'); }); + + // list-attempts became paginated in the 2026-09-02 row. + it('should list identity verification attempts with skip and limit', async () => { + const applicant = await cko.identities.createApplicant({ + external_applicant_id: `ext_${Date.now()}`, + email: 'test.verification.paging@example.com', + external_applicant_name: 'Test Verification Paging', + }); + const created = await cko.identities.createIdentityVerification({ + applicant_id: applicant.id, + declared_data: { name: 'Test Verification Paging' }, + }); + const attempts = await cko.identities.listIdentityVerificationAttempts(created.id, { + skip: 0, + limit: 5, + }); + expect(attempts.data).to.be.an('array'); + expect(attempts.limit).to.equal(5); + }); }); diff --git a/test/identities/identity-verifications/identity-verifications-unit.js b/test/identities/identity-verifications/identity-verifications-unit.js index e7705f23..67d8cd02 100644 --- a/test/identities/identity-verifications/identity-verifications-unit.js +++ b/test/identities/identity-verifications/identity-verifications-unit.js @@ -425,4 +425,36 @@ describe('Unit::Identity Verifications', () => { expect(err).to.be.instanceOf(NotFoundError); } }); + + // skip and limit on list-attempts, which took no query parameters before this row. + describe('attempts pagination', () => { + const RESOURCE_ID = 'idv_tkoi5db4hryu5cei5vwoabr7we'; + const ATTEMPTS = { total_count: 25, skip: 5, limit: 25, data: [], _links: { self: { href: 'x' } } }; + + it('should send skip and limit', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/identity-verifications/${RESOURCE_ID}/attempts`) + .query({ skip: 5, limit: 25 }) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.identityVerifications.listAttempts(RESOURCE_ID, { + skip: 5, + limit: 25 + }); + + expect(result.total_count).to.equal(25); + }); + + it('should send no query string when no params are passed', async () => { + nock('https://identity-verification.sandbox.checkout.com') + .get(`/identity-verifications/${RESOURCE_ID}/attempts`) + .reply(200, ATTEMPTS); + + const cko = new Checkout(SK, { subdomain: 'test' }); + const result = await cko.identities.identityVerifications.listAttempts(RESOURCE_ID); + + expect(result.total_count).to.equal(25); + }); + }); }); diff --git a/test/issuing/cards/card-update-headers-unit.js b/test/issuing/cards/card-update-headers-unit.js deleted file mode 100644 index a6a779fb..00000000 --- a/test/issuing/cards/card-update-headers-unit.js +++ /dev/null @@ -1,167 +0,0 @@ -import { expect } from 'chai'; -import nock from 'nock'; -import Checkout from '../../../src/Checkout.js'; -import { ValidationError } from '../../../src/services/errors.js'; - -// Part D of the 2026-09-02 swagger delta: the return-encrypted-cvv and Encryption-Key headers on -// PATCH /issuing/cards/{cardId}, and the encrypted_cvv the response then carries. -// -// These assert the headers on the outgoing request, because the header names are case sensitive in -// the spec (return-encrypted-cvv lower case, Encryption-Key title case) and they travel on a copy -// of the config rather than on the body. - -const SUBDOMAIN = '123456789'; -const ACCESS_BASE = `https://${SUBDOMAIN}.access.sandbox.checkout.com`; -const BASE = `https://${SUBDOMAIN}.api.sandbox.checkout.com`; -const CARD_ID = 'crd_fa6psq42dc0uxl3ct3jryhdo2m'; - -// Issuing uses OAuth, so every call needs the token endpoint stubbed first. -const cko = () => { - nock(ACCESS_BASE).post('/connect/token').reply(200, { - access_token: 'test_access_token', - expires_in: 3600, - token_type: 'Bearer', - scope: 'issuing:card-management-write issuing:card-management-read' - }); - - return new Checkout('test_client_secret', { - client: 'ack_testclie123456', - scope: ['issuing:card-management-write', 'issuing:card-management-read'], - environment: 'sandbox', - subdomain: SUBDOMAIN - }); -}; - -afterEach(() => { - nock.cleanAll(); -}); - -describe('Unit::Issuing card update headers', () => { - it('sends both headers with their exact swagger spelling', async () => { - let seen; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`) - .reply(200, function () { - seen = this.req.headers; - return { last_modified_date: '2026-06-01T10:00:00Z', encrypted_cvv: 'oJMoNMEEUiQKYOsQ4Zd' }; - }); - - const result = await cko().issuing.cards.updateCard( - CARD_ID, - { reference: 'X-123456-N11' }, - { 'return-encrypted-cvv': 'true', 'Encryption-Key': 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' } - ); - - // nock lowercases header names when it records them, which is what HTTP does on the wire. - expect(seen['return-encrypted-cvv']).to.equal('true'); - expect(seen['encryption-key']).to.equal('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'); - expect(result.encrypted_cvv).to.equal('oJMoNMEEUiQKYOsQ4Zd'); - }); - - it('does not leak the headers into the request body', async () => { - let body; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`, (received) => { - body = received; - return true; - }) - .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); - - await cko().issuing.cards.updateCard( - CARD_ID, - { reference: 'X-123456-N11' }, - { 'return-encrypted-cvv': 'true' } - ); - - expect(body).to.deep.equal({ reference: 'X-123456-N11' }); - expect(body['return-encrypted-cvv']).to.be.undefined; - expect(body.headers).to.be.undefined; - }); - - it('sends no card headers when none are supplied', async () => { - let seen; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`) - .reply(200, function () { - seen = this.req.headers; - return { last_modified_date: '2026-06-01T10:00:00Z' }; - }); - - const result = await cko().issuing.cards.updateCard(CARD_ID, { reference: 'X-123456-N11' }); - - expect(seen['return-encrypted-cvv']).to.be.undefined; - expect(seen['encryption-key']).to.be.undefined; - expect(result.encrypted_cvv).to.be.undefined; - }); - - it('sends the encryption key on its own', async () => { - let seen; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`) - .reply(200, function () { - seen = this.req.headers; - return { last_modified_date: '2026-06-01T10:00:00Z' }; - }); - - await cko().issuing.cards.updateCard(CARD_ID, {}, { 'Encryption-Key': 'MIIBIjAN' }); - - expect(seen['encryption-key']).to.equal('MIIBIjAN'); - expect(seen['return-encrypted-cvv']).to.be.undefined; - }); - - it('forwards the headers through the backwards-compatible aggregate method', async () => { - let seen; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`) - .reply(200, function () { - seen = this.req.headers; - return { last_modified_date: '2026-06-01T10:00:00Z' }; - }); - - await cko().issuing.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': 'true' }); - - expect(seen['return-encrypted-cvv']).to.equal('true'); - }); - - // The API answers 422 with error code encryption_key_required when the flag is set without a - // key. The SDK must surface the error body rather than swallowing it. - it('surfaces the 422 encryption_key_required error', async () => { - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`) - .reply(422, { - request_id: '0HLHPN8802NUF:00000003', - error_type: 'request_invalid', - error_codes: ['encryption_key_required'] - }); - - try { - await cko().issuing.cards.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': 'true' }); - throw new Error('expected the update to reject'); - } catch (error) { - expect(error).to.be.instanceOf(ValidationError); - expect(error.http_code).to.equal(422); - expect(error.body.error_codes).to.contain('encryption_key_required'); - } - }); - - // Part B: the renamed field is a plain passthrough here, since node models no card fields, but - // the key still has to reach the wire under its new name. - it('passes scheduled_activation_date through to the request body', async () => { - let body; - nock(BASE) - .patch(`/issuing/cards/${CARD_ID}`, (received) => { - body = received; - return true; - }) - .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); - - await cko().issuing.cards.updateCard(CARD_ID, { - scheduled_activation_date: '2026-06-01T10:00Z', - revocation_date: '2027-03-12' - }); - - expect(body.scheduled_activation_date).to.equal('2026-06-01T10:00Z'); - expect(body.revocation_date).to.equal('2027-03-12'); - expect(body.activation_date).to.be.undefined; - }); -}); diff --git a/test/issuing/cards/cards-it.js b/test/issuing/cards/cards-it.js index 07527f1e..693ce6de 100644 --- a/test/issuing/cards/cards-it.js +++ b/test/issuing/cards/cards-it.js @@ -132,4 +132,53 @@ describe.skip('Integration::Issuing::Cards - AuthenticationError: Requires CHECK expect(response.id).to.equal(card.id); expect(response.status).to.equal('revoked'); }); + + // update-card-request replaced activation_date with + // scheduled_activation_date. IssuingScheduledActivationDate only accepts a date, or a date + // with a round hour, and the value has to be at least the next round hour after the request. + it('should update a card with a scheduled activation date', async () => { + const nextRoundHour = new Date(Date.now() + 2 * 60 * 60 * 1000); + nextRoundHour.setUTCMinutes(0, 0, 0); + const scheduled = `${nextRoundHour.toISOString().slice(0, 16)}Z`; + + const response = await cko_issuing.issuing.updateCard(card.id, { + reference: 'X-123456-N11', + scheduled_activation_date: scheduled, + }); + + expect(response.last_modified_date).to.not.be.null; + expect(response._links.self.href).to.contain(card.id); + }); + + // return-encrypted-cvv plus an Encryption-Key returns the encrypted credentials. The + // key must be Base64 with the BEGIN and END PUBLIC KEY markers and every newline removed. + it('should update a card and return the encrypted cvv', async function () { + const publicKey = process.env.CHECKOUT_ISSUING_ENCRYPTION_PUBLIC_KEY; + if (!publicKey) { + this.skip(); + } + + const response = await cko_issuing.issuing.updateCard( + card.id, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': true, 'Encryption-Key': publicKey } + ); + + expect(response.encrypted_cvv).to.be.a('string'); + }); + + // The flag without the key is a documented 422 carrying encryption_key_required. + it('should throw ValidationError when requesting the cvv without an encryption key', async () => { + try { + await cko_issuing.issuing.updateCard( + card.id, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': true } + ); + expect.fail('Should have thrown ValidationError'); + } catch (err) { + expect(err).to.be.instanceOf(ValidationError); + expect(err.body.error_codes).to.contain('encryption_key_required'); + } + }); }); diff --git a/test/issuing/cards/cards-unit.js b/test/issuing/cards/cards-unit.js index 940eaf50..9754f4c6 100644 --- a/test/issuing/cards/cards-unit.js +++ b/test/issuing/cards/cards-unit.js @@ -779,4 +779,183 @@ describe('Unit::Issuing::Cards', () => { expect(err).to.be.instanceOf(NotFoundError); } }); + + // The return-encrypted-cvv and Encryption-Key headers + // on PATCH /issuing/cards/{cardId}, and the encrypted_cvv the response then carries. + // + // These assert the headers on the outgoing request, because the spec spells them case + // sensitively (return-encrypted-cvv lower case, Encryption-Key title case) and because they + // travel on a copy of the config rather than on the body. + describe('update headers', () => { + const SUBDOMAIN = "123456789"; + const ACCESS_BASE = `https://${SUBDOMAIN}.access.sandbox.checkout.com`; + const BASE = `https://${SUBDOMAIN}.api.sandbox.checkout.com`; + const CARD_ID = "crd_fa6psq42dc0uxl3ct3jryhdo2m"; + + // Issuing uses OAuth, so every call needs the token endpoint stubbed first. + const client = () => { + nock(ACCESS_BASE).post('/connect/token').reply(200, { + access_token: 'test_access_token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'issuing:card-management-write issuing:card-management-read' + }); + + return new Checkout('test_client_secret', { + client: 'ack_testclie123456', + scope: ['issuing:card-management-write', 'issuing:card-management-read'], + environment: 'sandbox', + subdomain: SUBDOMAIN + }); + }; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should send both headers with their exact swagger spelling', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { + last_modified_date: '2026-06-01T10:00:00Z', + encrypted_cvv: 'oJMoNMEEUiQKYOsQ4Zd', + _links: { + self: { + href: `https://${SUBDOMAIN}.api.checkout.com/issuing/cards/${CARD_ID}`, + actions: ['GET'], + types: ['application/json'] + } + } + }; + }); + + const result = await client().issuing.cards.updateCard( + CARD_ID, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': true, 'Encryption-Key': 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' } + ); + + // nock lowercases header names when it records them, which is what HTTP does on the wire. + expect(seen['return-encrypted-cvv']).to.equal('true'); + expect(seen['encryption-key']).to.equal('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'); + expect(result.encrypted_cvv).to.equal('oJMoNMEEUiQKYOsQ4Zd'); + + // last_modified_date is the only required property of update-card-response; _links is + // a CardSelfLink, whose self carries href, actions and types. + expect(result.last_modified_date).to.equal('2026-06-01T10:00:00Z'); + expect(result._links.self.href).to.contain(`/issuing/cards/${CARD_ID}`); + expect(result._links.self.actions).to.deep.equal(['GET']); + expect(result._links.self.types).to.deep.equal(['application/json']); + }); + + it('should not leak the headers into the request body', async () => { + let body; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`, (received) => { + body = received; + return true; + }) + .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); + + await client().issuing.cards.updateCard( + CARD_ID, + { reference: 'X-123456-N11' }, + { 'return-encrypted-cvv': true } + ); + + expect(body).to.deep.equal({ reference: 'X-123456-N11' }); + expect(body['return-encrypted-cvv']).to.be.undefined; + expect(body.headers).to.be.undefined; + }); + + it('should send no card headers when none are supplied', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + const result = await client().issuing.cards.updateCard(CARD_ID, { reference: 'X-123456-N11' }); + + expect(seen['return-encrypted-cvv']).to.be.undefined; + expect(seen['encryption-key']).to.be.undefined; + expect(result.encrypted_cvv).to.be.undefined; + }); + + it('should send the encryption key on its own', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + await client().issuing.cards.updateCard(CARD_ID, {}, { 'Encryption-Key': 'MIIBIjAN' }); + + expect(seen['encryption-key']).to.equal('MIIBIjAN'); + expect(seen['return-encrypted-cvv']).to.be.undefined; + }); + + it('should forward the headers through the backwards-compatible aggregate method', async () => { + let seen; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, function () { + seen = this.req.headers; + return { last_modified_date: '2026-06-01T10:00:00Z' }; + }); + + await client().issuing.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': true }); + + expect(seen['return-encrypted-cvv']).to.equal('true'); + }); + + // The API answers 422 with error code encryption_key_required when the flag is set without + // a key. The SDK must surface the error body rather than swallowing it. + it('should surface the 422 encryption_key_required error', async () => { + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(422, { + request_id: '0HLHPN8802NUF:00000003', + error_type: 'request_invalid', + error_codes: ['encryption_key_required'] + }); + + try { + await client().issuing.cards.updateCard(CARD_ID, {}, { 'return-encrypted-cvv': true }); + throw new Error('expected the update to reject'); + } catch (error) { + expect(error).to.be.instanceOf(ValidationError); + expect(error.http_code).to.equal(422); + expect(error.body.error_codes).to.contain('encryption_key_required'); + } + }); + + // The renamed field is a plain passthrough here, since node models no card fields, + // but the key still has to reach the wire under its new name. + it('should pass scheduled_activation_date through to the request body', async () => { + let body; + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`, (received) => { + body = received; + return true; + }) + .reply(200, { last_modified_date: '2026-06-01T10:00:00Z' }); + + await client().issuing.cards.updateCard(CARD_ID, { + scheduled_activation_date: '2026-06-01T10:00Z', + revocation_date: '2027-03-12' + }); + + expect(body.scheduled_activation_date).to.equal('2026-06-01T10:00Z'); + expect(body.revocation_date).to.equal('2027-03-12'); + expect(body.activation_date).to.be.undefined; + }); + }); }); diff --git a/test/services/utils.js b/test/services/utils.js new file mode 100644 index 00000000..ba08b13c --- /dev/null +++ b/test/services/utils.js @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { buildQueryParams } from '../../src/services/utils.js'; + +describe('Service Utils - buildQueryParams', () => { + it('appends the params as a query string', () => { + expect(buildQueryParams('u', { skip: 10, limit: 5 })).to.equal('u?skip=10&limit=5'); + }); + + // skip defaults to 0 server side, but an explicit 0 is a meaningful offset and has to survive. + it('keeps an explicit zero skip', () => { + expect(buildQueryParams('u', { skip: 0, limit: 10 })).to.equal('u?skip=0&limit=10'); + }); + + it('adds no query string for undefined params', () => { + expect(buildQueryParams('u', undefined)).to.equal('u'); + }); + + // An empty object used to produce a bare "u?". Nothing reached it before the 2026-09-02 row, + // because every caller either passed real params or passed nothing, but routing the four + // list-attempts endpoints through this helper made it reachable. + it('adds no bare question mark for an empty params object', () => { + expect(buildQueryParams('u', {})).to.equal('u'); + }); + + it('encodes keys and values', () => { + expect(buildQueryParams('u', { 'a b': 'c&d' })).to.equal('u?a%20b=c%26d'); + }); +}); diff --git a/types/dist/api/identities/address-document-verifications.d.ts b/types/dist/api/identities/address-document-verifications.d.ts index ec02811e..357da683 100644 --- a/types/dist/api/identities/address-document-verifications.d.ts +++ b/types/dist/api/identities/address-document-verifications.d.ts @@ -6,13 +6,14 @@ export default class AddressDocumentVerifications { createAddressDocumentVerification(body: object): Promise; getAddressDocumentVerification(addressDocumentVerificationId: string): Promise; /** - * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + * List the verification attempts. Results are paginated: pass `skip` (default 0) and + * `limit` (default 10) on `params`. */ listAttempts(addressDocumentVerificationId: string, params?: object): Promise; getAttempt(addressDocumentVerificationId: string, attemptId: string): Promise; /** * Get the assets (the document image) uploaded for an attempt. Results are paginated: pass - * `skip` and `limit` on `params`. + * `skip` (default 0) and `limit` (default 10) on `params`. */ getAttemptAssets(addressDocumentVerificationId: string, attemptId: string, params?: object): Promise; createAttempt(addressDocumentVerificationId: string, body: object): Promise; diff --git a/types/dist/api/identities/face-authentications.d.ts b/types/dist/api/identities/face-authentications.d.ts index d78aa273..e4e6c4f4 100644 --- a/types/dist/api/identities/face-authentications.d.ts +++ b/types/dist/api/identities/face-authentications.d.ts @@ -6,8 +6,8 @@ export default class FaceAuthentications { createFaceAuthentication(body: object): Promise; getFaceAuthentication(faceAuthenticationId: string): Promise; /** - * List the authentication attempts. Results are paginated: pass `skip` and `limit` on - * `params`. + * List the authentication attempts. Results are paginated: pass `skip` (default 0) and + * `limit` (default 10) on `params`. */ listAttempts(faceAuthenticationId: string, params?: object): Promise; getAttempt(faceAuthenticationId: string, attemptId: string): Promise; diff --git a/types/dist/api/identities/id-document-verifications.d.ts b/types/dist/api/identities/id-document-verifications.d.ts index 6759fae4..d93d37b9 100644 --- a/types/dist/api/identities/id-document-verifications.d.ts +++ b/types/dist/api/identities/id-document-verifications.d.ts @@ -6,13 +6,14 @@ export default class IDDocumentVerifications { createIDDocumentVerification(body: object): Promise; getIDDocumentVerification(idDocumentVerificationId: string): Promise; /** - * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + * List the verification attempts. Results are paginated: pass `skip` (default 0) and + * `limit` (default 10) on `params`. */ listAttempts(idDocumentVerificationId: string, params?: object): Promise; getAttempt(idDocumentVerificationId: string, attemptId: string): Promise; /** - * Get the assets (the front and back images of the document) uploaded for an attempt. Results - * are paginated: pass `skip` and `limit` on `params`. + * Get the assets (the front and back images of the document) uploaded for an attempt. + * Results are paginated: pass `skip` (default 0) and `limit` (default 10) on `params`. */ getAttemptAssets(idDocumentVerificationId: string, attemptId: string, params?: object): Promise; createAttempt(idDocumentVerificationId: string, body: object): Promise; diff --git a/types/dist/api/identities/identity-verifications.d.ts b/types/dist/api/identities/identity-verifications.d.ts index 5c11518d..bd75eb7d 100644 --- a/types/dist/api/identities/identity-verifications.d.ts +++ b/types/dist/api/identities/identity-verifications.d.ts @@ -8,7 +8,8 @@ export default class IdentityVerifications { getIdentityVerification(identityVerificationId: string): Promise; createAttempt(identityVerificationId: string, body: object): Promise; /** - * List the verification attempts. Results are paginated: pass `skip` and `limit` on `params`. + * List the verification attempts. Results are paginated: pass `skip` (default 0) and + * `limit` (default 10) on `params`. */ listAttempts(identityVerificationId: string, params?: object): Promise; getAttempt(identityVerificationId: string, attemptId: string): Promise; diff --git a/types/dist/api/issuing/cards.d.ts b/types/dist/api/issuing/cards.d.ts index 3e3a542c..68da8f6c 100644 --- a/types/dist/api/issuing/cards.d.ts +++ b/types/dist/api/issuing/cards.d.ts @@ -6,8 +6,15 @@ export default class Cards { createCard(body: object, idempotencyKey?: string): Promise; getCardDetails(id: string): Promise; /** - * Update a card. Pass `headers` to request the encrypted credentials: set - * `return-encrypted-cvv` to "true" together with an `Encryption-Key`. + * Update a card. + * + * Only the fields you provide values for are updated. Passing `null` for a field removes its + * existing value. + * + * Pass `headers` to request the encrypted credentials: set `return-encrypted-cvv` to `true` + * together with an `Encryption-Key`. The key must have its `-----BEGIN PUBLIC KEY-----` and + * `-----END PUBLIC KEY-----` markers and every newline removed, encoded as Base64. Supplying + * the flag without the key returns a 422 with error code `encryption_key_required`. */ updateCard(id: string, body: object, headers?: object): Promise; enrollThreeDS(id: string, body: object): Promise; diff --git a/types/dist/api/issuing/issuing.d.ts b/types/dist/api/issuing/issuing.d.ts index 2ea1cee1..0778615c 100644 --- a/types/dist/api/issuing/issuing.d.ts +++ b/types/dist/api/issuing/issuing.d.ts @@ -35,9 +35,15 @@ export default class Issuing { createCard(body: object, idempotencyKey?: string): Promise; getCardDetails(id: string): Promise; /** - * Update a card. Pass `headers` to request the encrypted credentials: set - * `return-encrypted-cvv` to "true" together with an `Encryption-Key`. Supplying the flag - * without the key returns a 422 with error code `encryption_key_required`. + * Update a card. + * + * Only the fields you provide values for are updated. Passing `null` for a field removes its + * existing value. + * + * Pass `headers` to request the encrypted credentials: set `return-encrypted-cvv` to `true` + * together with an `Encryption-Key`. The key must have its `-----BEGIN PUBLIC KEY-----` and + * `-----END PUBLIC KEY-----` markers and every newline removed, encoded as Base64. Supplying + * the flag without the key returns a 422 with error code `encryption_key_required`. */ updateCard(id: string, body: object, headers?: object): Promise; enrollThreeDS(id: string, body: object): Promise;