diff --git a/src/api/identities/address-document-verifications.js b/src/api/identities/address-document-verifications.js index 82bae04f..b8879424 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,17 @@ 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 + * @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) { + 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 +190,36 @@ 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 + * @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) { + 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..c79988eb 100644 --- a/src/api/identities/face-authentications.js +++ b/src/api/identities/face-authentications.js @@ -72,11 +72,17 @@ 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 + * @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) { + 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, @@ -120,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 16871570..a5305e9e 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,17 @@ 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 + * @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) { + 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 +192,36 @@ 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 + * @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) { + 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..f51ca57d 100644 --- a/src/api/identities/identities.js +++ b/src/api/identities/identities.js @@ -59,14 +59,24 @@ 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) { 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); } @@ -88,8 +98,22 @@ 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); + } + + /** + * 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); } async getIDDocumentVerificationAttempt(id_document_verification_id, attempt_id) { @@ -117,8 +141,26 @@ 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); + } + + /** + * 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, + attempt_id, + params + ); } async getAddressDocumentVerificationAttempt(address_document_verification_id, attempt_id) { @@ -158,14 +200,24 @@ 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) { 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 433097e6..8bd8322e 100644 --- a/src/api/identities/identity-verifications.js +++ b/src/api/identities/identity-verifications.js @@ -146,11 +146,17 @@ 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 + * @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) { + 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, @@ -195,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) { @@ -224,7 +232,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 +241,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..5752f1ab 100644 --- a/src/api/issuing/cards.js +++ b/src/api/issuing/cards.js @@ -71,17 +71,41 @@ 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 + * 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} 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) { + 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/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/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 608404d4..e2261061 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 () => { @@ -288,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 faf8d97c..67d8cd02 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 () => { @@ -423,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/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 dafff147..357da683 100644 --- a/types/dist/api/identities/address-document-verifications.d.ts +++ b/types/dist/api/identities/address-document-verifications.d.ts @@ -5,9 +5,26 @@ 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` (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` (default 0) and `limit` (default 10) 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..e4e6c4f4 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` (default 0) and + * `limit` (default 10) 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..d93d37b9 100644 --- a/types/dist/api/identities/id-document-verifications.d.ts +++ b/types/dist/api/identities/id-document-verifications.d.ts @@ -5,9 +5,26 @@ 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` (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` (default 0) and `limit` (default 10) 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..bd75eb7d 100644 --- a/types/dist/api/identities/identity-verifications.d.ts +++ b/types/dist/api/identities/identity-verifications.d.ts @@ -7,9 +7,24 @@ 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` (default 0) and + * `limit` (default 10) 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..68da8f6c 100644 --- a/types/dist/api/issuing/cards.d.ts +++ b/types/dist/api/issuing/cards.d.ts @@ -5,7 +5,18 @@ export default class Cards { createCard(body: object, idempotencyKey?: string): Promise; getCardDetails(id: string): Promise; - updateCard(id: string, body: object): Promise; + /** + * 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; 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..0778615c 100644 --- a/types/dist/api/issuing/issuing.d.ts +++ b/types/dist/api/issuing/issuing.d.ts @@ -34,7 +34,18 @@ 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. + * + * 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; updateThreeDS(id: string, body: object): Promise; getThreeDSDetails(id: string): Promise;