From 601e177f053d9bdd753207760c890660232bcfe8 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 12:42:49 +0530 Subject: [PATCH 01/18] SK-2841: Fixed the workflow --- .github/workflows/common-release.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/common-release.yml b/.github/workflows/common-release.yml index 4007f06b..2c2027fd 100644 --- a/.github/workflows/common-release.yml +++ b/.github/workflows/common-release.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20.x' - # registry-url: "https://registry.npmjs.org" + registry-url: "https://registry.npmjs.org" - name: Install Packages run: npm install diff --git a/package.json b/package.json index 50b7072d..909357c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.1.0", + "version": "2.0.4-dev.857dc8b", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From 62f8104c5e3b2d960476e89641e55edd6fe85c5e Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 07:13:19 +0000 Subject: [PATCH 02/18] [AUTOMATED] Private Release 2.0.4-dev.601e177 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 909357c7..29c28c6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.857dc8b", + "version": "2.0.4-dev.601e177", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From 1cb46753f07e2ad4d6c753af02c1ff299b926784 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 13:14:25 +0530 Subject: [PATCH 03/18] SK-2841: Changed code cov to true --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3b4b511a..2bcd8050 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -24,6 +24,6 @@ jobs: ci-checks: uses: ./.github/workflows/common-ci.yml with: - upload-to-code-cov: false + upload-to-code-cov: true secrets: inherit From 659e6c4fed02ac49957c0344d38f458ff8a4f4e2 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 07:45:16 +0000 Subject: [PATCH 04/18] [AUTOMATED] Private Release 2.0.4-dev.bd17593 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 29c28c6e..80c86ce3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.601e177", + "version": "2.0.4-dev.bd17593", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From 095f0233df948be82fc036132f98b3930d8dd35a Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 13:21:47 +0530 Subject: [PATCH 05/18] SK-2841: fixed workflow for codecov --- .github/workflows/common-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/common-ci.yml b/.github/workflows/common-ci.yml index a52bf48c..fb48b9f5 100644 --- a/.github/workflows/common-ci.yml +++ b/.github/workflows/common-ci.yml @@ -29,7 +29,7 @@ jobs: run: npm run test - name: Upload Code Coverage To Codecov - if: ${{ inputs.upload-to-code-cov == 'true' }} + if: ${{ inputs.upload-to-code-cov == true }} uses: codecov/codecov-action@v2.1.0 with: token: ${{ secrets.CODECOV_REPO_UPLOAD_TOKEN }} From 1a22cc40bdb7461936bb835f244fd6bdef7e1de7 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 07:52:28 +0000 Subject: [PATCH 06/18] [AUTOMATED] Private Release 2.0.4-dev.95575dd --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 80c86ce3..e74522fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.bd17593", + "version": "2.0.4-dev.95575dd", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From f2de70225e41b4cfffd1faca59b3fcf317ba4174 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 13:42:57 +0530 Subject: [PATCH 07/18] SK-2841: Added test cases --- test/vault/controller/connection.test.js | 419 ++++++++++++++++++++++- test/vault/controller/detect.test.js | 400 ++++++++++++++++++++++ 2 files changed, 818 insertions(+), 1 deletion(-) diff --git a/test/vault/controller/connection.test.js b/test/vault/controller/connection.test.js index e0ffd2da..323c6c06 100644 --- a/test/vault/controller/connection.test.js +++ b/test/vault/controller/connection.test.js @@ -17,8 +17,83 @@ import ConnectionController from "../../../src/vault/controller/connections"; import SkyflowError from "../../../src/error"; import SKYFLOW_ERROR_CODE from "../../../src/error/codes"; -jest.mock("../../../src/utils"); +jest.mock("../../../src/utils", () => ({ + fillUrlWithPathAndQueryParams: jest.fn(), + generateSDKMetrics: jest.fn(), + getBearerToken: jest.fn(), + LogLevel: { WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', ERROR: 'ERROR', OFF: 'OFF' }, + MessageType: { LOG: 'LOG', WARN: 'WARN', ERROR: 'ERROR' }, + RequestMethod: { POST: 'POST', GET: 'GET', PUT: 'PUT', PATCH: 'PATCH' }, + parameterizedString: jest.fn((...args) => args.join(' ')), + printLog: jest.fn(), + SDK: { METRICS_HEADER_KEY: 'sky-metadata' }, + SKYFLOW: { ID: 'skyflowId', LEGACY_ID: 'skyflow_id', AUTH_HEADER_KEY: 'x-skyflow-authorization' }, + REQUEST: { ID_KEY: 'x-request-id' }, + TYPES: { + INSERT: 'INSERT', INSERT_BATCH: 'INSERT_BATCH', DETOKENIZE: 'DETOKENIZE', TOKENIZE: 'TOKENIZE', + DELETE: 'DELETE', UPDATE: 'UPDATE', GET: 'GET', FILE_UPLOAD: 'FILE_UPLOAD', QUERY: 'QUERY', + DETECT: 'DETECT', INVOKE_CONNECTION: 'INVOKE_CONNECTION', DEIDENTIFY_TEXT: 'DEIDENTIFY_TEXT', + REIDENTIFY_TEXT: 'REIDENTIFY_TEXT', DEIDENTIFY_FILE: 'DEIDENTIFY_FILE', DETECT_RUN: 'DETECT_RUN', + }, + HTTP_HEADER: { CONTENT_TYPE: 'Content-Type', CONTENT_TYPE_LOWER: 'content-type', X_REQUEST_ID: 'x-request-id', ERROR_FROM_CLIENT: 'error-from-client' }, + CONTENT_TYPE: { + APPLICATION_JSON: 'application/json', + APPLICATION_X_WWW_FORM_URLENCODED: 'application/x-www-form-urlencoded', + TEXT_PLAIN: 'text/plain', + MULTIPART_FORM_DATA: 'multipart/form-data', + TEXT_XML: 'text/xml', + APPLICATION_XML: 'application/xml', + TEXT_HTML: 'text/html', + }, + objectToXML: jest.fn((obj, root) => `<${root}>${JSON.stringify(obj)}`), +})); +jest.mock("../../../src/utils/logs", () => ({ + __esModule: true, + default: { + infoLogs: { + INVOKE_CONNECTION_TRIGGERED: 'invoke connection triggered', + VALIDATE_CONNECTION_CONFIG: 'validate connection config', + EMIT_REQUEST: 'emit request %s', + INVOKE_CONNECTION_REQUEST_RESOLVED: 'invoke connection request resolved', + }, + errorLogs: { + INVOKE_CONNECTION_REQUEST_REJECTED: 'invoke connection request rejected', + }, + warnLogs: { + DEPRECATED_REQUEST_ID_PROPERTY: 'deprecated request_ID property', + }, + }, +})); jest.mock("../../../src/utils/validations"); +jest.mock("../../../src/vault/client", () => { + return jest.fn().mockImplementation(() => ({})); +}); + +if (typeof global.FormData === 'undefined') { + global.FormData = class { + constructor() { this._data = {}; } + append(key, value) { this._data[key] = value; } + has(key) { return key in this._data; } + get(key) { return this._data[key]; } + }; +} +if (typeof global.File === 'undefined') { + global.File = class File { + constructor(parts, name, options = {}) { + this.name = name; + this.type = options.type || ''; + this._content = parts; + } + }; +} +if (typeof global.Blob === 'undefined') { + global.Blob = class Blob { + constructor(parts, options = {}) { + this.type = options.type || ''; + this._content = parts; + } + }; +} describe("ConnectionController Tests", () => { let mockClient; @@ -1108,4 +1183,346 @@ describe("ConnectionController Tests", () => { // The auth header is always set by the SDK expect(fetchCall.headers["x-skyflow-authorization"]).toBe("bearer_token"); }); + + // Line 93: FormData instance passed directly as body + it("should use FormData instance directly as body for multipart/form-data", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + const formData = new FormData(); + formData.append("key", "value"); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + return null; + }), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: formData, + method: RequestMethod.POST, + headers: { "Content-Type": "multipart/form-data" }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.body).toBe(formData); + }); + + // Line 100: nested object value in multipart body → JSON.stringify in FormData + it("should JSON.stringify nested object values when building FormData body", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + return null; + }), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { + simpleKey: "simpleValue", + nestedObject: { innerKey: "innerValue" }, + }, + method: RequestMethod.POST, + headers: { "Content-Type": "multipart/form-data" }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.body).toBeInstanceOf(FormData); + }); + + // Line 116: non-string non-object body with XML content type → SkyflowError + it("should throw SkyflowError when XML content type has a numeric body", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + const request = { + body: 12345, + method: RequestMethod.POST, + headers: { "Content-Type": "application/xml" }, + }; + + await expect(connectionController.invoke(request)).rejects.toBeInstanceOf(SkyflowError); + }); + + // Line 130: string body with unknown content type passthrough + it("should pass string body through for unknown content type", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + return null; + }), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: "raw binary string", + method: RequestMethod.POST, + headers: { "Content-Type": "application/octet-stream" }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.body).toBe("raw binary string"); + }); + + // Line 150: parseResponseBody JSON branch (case-insensitive Content-Type header) + it("should parse JSON response body via Content-Type header match", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockResolvedValue({ parsed: "json_data" }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toEqual({ parsed: "json_data" }); + }); + + // Line 155: parseResponseBody XML response branch + it("should parse XML response body as text via Content-Type header", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "application/xml"; + return null; + }), + }, + text: jest.fn().mockResolvedValue("value"), + }); + + const request = { + body: "sample", + method: RequestMethod.POST, + headers: { "Content-Type": "application/xml" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBe("value"); + }); + + // Line 160: parseResponseBody text/html response branch + it("should parse text/html response body as text via Content-Type header", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "text/html"; + return null; + }), + }, + text: jest.fn().mockResolvedValue("response"), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBe("response"); + }); + + // Line 167: parseResponseBody multipart/form-data response branch + it("should parse multipart/form-data response body as text via Content-Type header", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "multipart/form-data; boundary=boundary123"; + return null; + }), + }, + text: jest.fn().mockResolvedValue("--boundary123\r\npart data\r\n--boundary123--"), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(typeof result.data).toBe("string"); + }); + + // Lines 176-178: parseResponseBody outer catch — json() fails, text() returns value + it("should return { message: text } when json() rejects and text() resolves in parseResponseBody", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockRejectedValue(new Error("JSON parse error")), + text: jest.fn().mockResolvedValue("error occurred"), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toEqual({ message: "error occurred" }); + }); + + // Lines 179-181: parseResponseBody outer catch — both json() and text() reject + it("should return null when both json() and text() reject in parseResponseBody", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "request_id"; + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockRejectedValue(new Error("JSON parse error")), + text: jest.fn().mockRejectedValue(new Error("Text parse error")), + body: { cancel: jest.fn().mockResolvedValue(undefined) }, + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBeNull(); + }); + + // Line 270: deprecated request_ID getter on metadata + it("should trigger deprecation warning when accessing request_ID property on metadata", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key === "x-request-id") return "test-request-id"; + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + // Access deprecated getter — triggers printLog warning at line 270 + const deprecatedValue = result.metadata.request_ID; + expect(deprecatedValue).toBeDefined(); + expect(result.metadata.requestId).toBeDefined(); + }); }); diff --git a/test/vault/controller/detect.test.js b/test/vault/controller/detect.test.js index e98e0fb5..716a5bda 100644 --- a/test/vault/controller/detect.test.js +++ b/test/vault/controller/detect.test.js @@ -14,6 +14,14 @@ jest.mock('../../../src/utils', () => ({ MessageType: { LOG: 'LOG', ERROR: 'ERROR', + WARN: 'WARN', + }, + LogLevel: { + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + OFF: 'OFF', }, TYPES: { DETECT: 'DETECT', @@ -1102,4 +1110,396 @@ describe('deidentifyFile', () => { expect.any(Buffer) ); }); + + // Lines 101-119, 319, 621-631: TEXT file type via .text extension + test('should successfully deidentify a text file using deidentifyText API', async () => { + jest.clearAllMocks(); + const file = new File(['text content'], 'test.text', { type: 'text/plain' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyText.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'textRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('request-id-text') } }, + }), + })); + + mockVaultClient.filesAPI.getRun + .mockResolvedValueOnce({ status: 'IN_PROGRESS' }) + .mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ + processedFile: 'textProcessedFile', + processedFileType: 'text', + processedFileExtension: 'txt', + }], + wordCharacterCount: { wordCount: 3, characterCount: 30 }, + size: 128, + duration: 0, + pages: 0, + slides: 0, + run_id: 'textRunId', + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + const result = await promise; + expect(mockVaultClient.filesAPI.deidentifyText).toHaveBeenCalled(); + expect(result.fileBase64).toBe('textProcessedFile'); + expect(result.status).toBe('SUCCESS'); + }); + + // Line 483: entities array populated from output with processedFileType === 'entities' + test('should populate entities array when output contains entities type items', async () => { + jest.clearAllMocks(); + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'entRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-ent') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { + processedFile: 'mainProcessedFile', + processedFileType: 'pdf', + processedFileExtension: 'pdf', + }, + { + processedFile: 'entitiesData', + processedFileType: 'entities', + processedFileExtension: 'json', + }, + ], + wordCharacterCount: { wordCount: 5, characterCount: 50 }, + size: 512, + duration: 0, + pages: 1, + slides: 0, + run_id: 'entRunId', + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + const result = await promise; + expect(result.entities).toHaveLength(1); + expect(result.entities[0].file).toBe('entitiesData'); + expect(result.entities[0].extension).toBe('json'); + }); + + // Lines 347-348 + 720: maxWaitTime=1 reached on first poll → resolve IN_PROGRESS → early return + test('should return IN_PROGRESS response when maxWaitTime reached during first poll', async () => { + jest.clearAllMocks(); + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setWaitTime(1); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'pollRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-poll') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValue({ status: 'IN_PROGRESS' }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + const result = await promise; + expect(result.runId).toBe('pollRunId'); + expect(result.status).toBe('IN_PROGRESS'); + }); + + // Lines 353-354: nextWaitTime >= maxWaitTime → cap currentWaitTime then hit max on second poll + test('should cap wait time when nextWaitTime exceeds maxWaitTime', async () => { + jest.clearAllMocks(); + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setWaitTime(2); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'capRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-cap') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValue({ status: 'IN_PROGRESS' }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + const result = await promise; + expect(result.runId).toBe('capRunId'); + expect(result.status).toBe('IN_PROGRESS'); + }); + + // Lines 366-367: FAILED status via correct {file} constructor + test('should reject when polling returns FAILED status (correct request wrapper)', async () => { + jest.clearAllMocks(); + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'failRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-fail') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValue({ status: 'FAILED', message: 'Processing failed' }); + + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + }); + + // Lines 368-369: unknown status via correct {file} constructor + test('should reject when polling returns unknown status', async () => { + jest.clearAllMocks(); + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'unknownRunId' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-unknown') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValue({ status: 'UNKNOWN_STATUS', message: 'unexpected' }); + + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + }); + + // Line 285: null processedFile → continue (skip writing) + test('should skip output item when processedFile is null', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runNull' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-null') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: null, processedFileExtension: 'pdf' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runNull', + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + await promise; + expect(mkdirSpy).toHaveBeenCalled(); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + // Line 289: non-alphanumeric extension → throw SkyflowError + test('should throw when processedFileExtension contains non-alphanumeric characters', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runBadExt' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-badext') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: 'base64data', processedFileExtension: '../etc' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runBadExt', + }); + + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + }); + + // Lines 301-302: json extension → Buffer.from().toString(utf8) → writeFile with string + test('should write UTF-8 decoded content for json extension', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); + + const base64Json = Buffer.from('{"key":"value"}').toString('base64'); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runJson' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-json') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: base64Json, processedFileType: 'json', processedFileExtension: 'json' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runJson', + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + await promise; + expect(writeSpy).toHaveBeenCalledWith( + expect.stringContaining('processed-test.json'), + '{"key":"value"}' + ); + }); + + // Lines 303-305: mp3 extension → Buffer.from() → writeFile with binary encoding + test('should write binary content for mp3 extension', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); + + const base64Mp3 = Buffer.from('mp3 audio data').toString('base64'); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runMp3' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-mp3') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: base64Mp3, processedFileType: 'mp3', processedFileExtension: 'mp3' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runMp3', + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + + await promise; + expect(writeSpy).toHaveBeenCalledWith( + expect.stringContaining('processed-test.mp3'), + expect.any(Buffer), + { encoding: 'binary' } + ); + }); + + // Line 297: path traversal detected → throw SkyflowError + test('should throw when resolved output path escapes the output directory', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + const pathModule = require('path'); + const resolveSpy = jest.spyOn(pathModule, 'resolve') + .mockReturnValueOnce('/etc/malicious.pdf') + .mockReturnValueOnce('/mock/output'); + + jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runTraversal' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-traversal') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: 'base64data', processedFileType: 'pdf', processedFileExtension: 'pdf' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runTraversal', + }); + + try { + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + } finally { + resolveSpy.mockRestore(); + } + }); + + // Line 274: writeFile throws inside decodeBase64AndSaveToFile → SkyflowError + test('should throw SkyflowError when writeFile fails in decodeBase64AndSaveToFile', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory('/mock/output'); + + jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + jest.spyOn(fs.promises, 'writeFile').mockRejectedValue(new Error('Disk full')); + + const base64Data = Buffer.from('some content').toString('base64'); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runWrite' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-write') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [ + { processedFile: base64Data, processedFileType: 'bin', processedFileExtension: 'bin' }, + ], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + run_id: 'runWrite', + }); + + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + }); }); \ No newline at end of file From 17f8c346bb76648d7fc3589ae426f612367fbf62 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 08:13:51 +0000 Subject: [PATCH 08/18] [AUTOMATED] Private Release 2.0.4-dev.28b08fc --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e74522fa..1aeb5c54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.95575dd", + "version": "2.0.4-dev.28b08fc", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From dee6ce7c12af1f8051764c50fe7a417aea0eea8c Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 13:55:45 +0530 Subject: [PATCH 09/18] SK-2841: Updated coverage --- test/utils/validations.test.js | 107 ++++++++++++++++++++++++- test/vault/client/client.test.js | 130 +++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/test/utils/validations.test.js b/test/utils/validations.test.js index 3f15866a..d3049b11 100644 --- a/test/utils/validations.test.js +++ b/test/utils/validations.test.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const { validateDeidentifyFileRequest, validateSkyflowConfig, validateVaultConfig, validateUpdateVaultConfig, validateSkyflowCredentials, validateConnectionConfig, validateUpdateConnectionConfig, validateInsertRequest, validateUpdateRequest, validateGetRequest, validateDetokenizeRequest, validateTokenizeRequest, validateDeleteRequest, validateUploadFileRequest, validateQueryRequest, validateDeIdentifyTextRequest, validateReidentifyTextRequest, validateGetDetectRunRequest, validateInvokeConnectionRequest, validateUpdateOptions, validateGetColumnRequest, validateCredentialsWithId } = require('../../src/utils/validations'); +const { validateDeidentifyFileRequest, validateDeidentifyFileOptions, validateSkyflowConfig, validateVaultConfig, validateUpdateVaultConfig, validateSkyflowCredentials, validateConnectionConfig, validateUpdateConnectionConfig, validateInsertRequest, validateUpdateRequest, validateGetRequest, validateDetokenizeRequest, validateTokenizeRequest, validateDeleteRequest, validateUploadFileRequest, validateQueryRequest, validateDeIdentifyTextRequest, validateReidentifyTextRequest, validateGetDetectRunRequest, validateInvokeConnectionRequest, validateUpdateOptions, validateGetColumnRequest, validateCredentialsWithId } = require('../../src/utils/validations'); const DeidentifyFileRequest = require('../../src/vault/model/request/deidentify-file').default; const DeidentifyFileOptions = require('../../src/vault/model/options/deidentify-file').default; const SKYFLOW_ERROR_CODE = require('../../src/error/codes'); @@ -291,6 +291,64 @@ describe('validateDeidentifyFileRequest', () => { options.setAllowRegexList(['^test', 'pattern$']); expect(() => validateDeidentifyFileRequest(request, options)).not.toThrow(); }); + + test('should throw error when file base name is whitespace only', () => { + const invalidFile = new File(['content'], ' .txt', { type: 'text/plain' }); + const request = new DeidentifyFileRequest({ file: invalidFile }); + expect(() => validateDeidentifyFileRequest(request)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_FILE_TYPE); + }); + + test('should throw error when file path is whitespace only', () => { + const request = new DeidentifyFileRequest({ filePath: ' ' }); + expect(() => validateDeidentifyFileRequest(request)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_DEIDENTIFY_FILE_PATH); + }); + + test('should throw error when output directory is not a string', () => { + const request = new DeidentifyFileRequest({ file: mockFile }); + const options = new DeidentifyFileOptions(); + options.setOutputDirectory(123); + expect(() => validateDeidentifyFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_OUTPUT_DIRECTORY); + }); + + test('should throw error for invalid restrict regex list in file options', () => { + const request = new DeidentifyFileRequest({ file: mockFile }); + const options = new DeidentifyFileOptions(); + options.setRestrictRegexList('pattern'); + expect(() => validateDeidentifyFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_RESTRICT_REGEX_LIST); + }); + + test('should throw error when token format is not a TokenFormat instance in file options', () => { + const request = new DeidentifyFileRequest({ file: mockFile }); + const options = new DeidentifyFileOptions(); + options.setTokenFormat({}); + expect(() => validateDeidentifyFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_TOKEN_FORMAT); + }); + + test('should throw error for invalid output ocr text type', () => { + const request = new DeidentifyFileRequest({ file: mockFile }); + const options = new DeidentifyFileOptions(); + options.setOutputOcrText('true'); + expect(() => validateDeidentifyFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_OUTPUT_OCR_TEXT); + }); + + test('should throw error when bleep is not a Bleep instance', () => { + const request = new DeidentifyFileRequest({ file: mockFile }); + const options = new DeidentifyFileOptions(); + options.setBleep({}); + expect(() => validateDeidentifyFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_BLEEP); + }); + + test('should throw error when validateDeidentifyFileOptions receives null', () => { + expect(() => validateDeidentifyFileOptions(null)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_DEIDENTIFY_FILE_OPTIONS); + }); }); describe('validateSkyflowConfig', () => { @@ -2522,7 +2580,10 @@ describe('validateUploadFileRequest', () => { // Test invalid filePath test('should throw error when filePath is invalid', () => { const options = { - getFilePath: () => 123 // number instead of string + getSkyflowId: () => 'id1', + getFilePath: () => 123, // number instead of string + getBase64: () => null, + getFileObject: () => null }; expect(() => validateUploadFileRequest(validRequest, options)) .toThrow(SKYFLOW_ERROR_CODE.INVALID_FILE_PATH_IN_UPLOAD_FILE); @@ -2531,7 +2592,10 @@ describe('validateUploadFileRequest', () => { // Test invalid base64 test('should throw error when base64 is invalid', () => { const options = { - getBase64: () => 123 // number instead of string + getSkyflowId: () => 'id1', + getFilePath: () => null, + getBase64: () => 123, // number instead of string + getFileObject: () => null }; expect(() => validateUploadFileRequest(validRequest, options)) .toThrow(SKYFLOW_ERROR_CODE.INVALID_BASE64_IN_UPLOAD_FILE); @@ -2540,6 +2604,9 @@ describe('validateUploadFileRequest', () => { // Test invalid fileObject test('should throw error when fileObject is invalid', () => { const options = { + getSkyflowId: () => 'id1', + getFilePath: () => null, + getBase64: () => null, getFileObject: () => "not-a-file-object" // string instead of File object }; expect(() => validateUploadFileRequest(validRequest, options)) @@ -2549,6 +2616,7 @@ describe('validateUploadFileRequest', () => { // Test missing file source test('should throw error when no file source is provided', () => { const options = { + getSkyflowId: () => 'id1', getFilePath: () => null, getBase64: () => null, getFileObject: () => null @@ -2560,7 +2628,10 @@ describe('validateUploadFileRequest', () => { // Test missing fileName for base64 test('should throw error when fileName is missing for base64', () => { const options = { + getSkyflowId: () => 'id1', + getFilePath: () => null, getBase64: () => 'valid-base64', + getFileObject: () => null, getFileName: () => null }; expect(() => validateUploadFileRequest(validRequest, options)) @@ -2570,6 +2641,9 @@ describe('validateUploadFileRequest', () => { // Test invalid File object test('should throw error when File object is invalid', () => { const options = { + getSkyflowId: () => 'id1', + getFilePath: () => null, + getBase64: () => null, getFileObject: () => ({}) // not a File instance }; expect(() => validateUploadFileRequest(validRequest, options)) @@ -2580,6 +2654,9 @@ describe('validateUploadFileRequest', () => { test('should throw error when filename is missing in File object', () => { const mockFile = new File([], ''); // empty filename const options = { + getSkyflowId: () => 'id1', + getFilePath: () => null, + getBase64: () => null, getFileObject: () => mockFile }; expect(() => validateUploadFileRequest(validRequest, options)) @@ -2794,6 +2871,7 @@ describe('validateDeIdentifyTextRequest', () => { test('should throw error when allowRegexList is not an array', () => { const options = { + getEntities: () => null, getAllowRegexList: () => 'not-an-array' }; expect(() => validateDeIdentifyTextRequest(validRequest, options)) @@ -2802,6 +2880,8 @@ describe('validateDeIdentifyTextRequest', () => { test('should throw error when restrictRegexList is not an array', () => { const options = { + getEntities: () => null, + getAllowRegexList: () => null, getRestrictRegexList: () => 'not-an-array' }; expect(() => validateDeIdentifyTextRequest(validRequest, options)) @@ -2810,6 +2890,9 @@ describe('validateDeIdentifyTextRequest', () => { test('should throw error when tokenFormat is not TokenFormat instance', () => { const options = { + getEntities: () => null, + getAllowRegexList: () => null, + getRestrictRegexList: () => null, getTokenFormat: () => ({}) // not a TokenFormat instance }; expect(() => validateDeIdentifyTextRequest(validRequest, options)) @@ -2818,6 +2901,10 @@ describe('validateDeIdentifyTextRequest', () => { test('should throw error when transformations is not Transformations instance', () => { const options = { + getEntities: () => null, + getAllowRegexList: () => null, + getRestrictRegexList: () => null, + getTokenFormat: () => null, getTransformations: () => ({}) // not a Transformations instance }; expect(() => validateDeIdentifyTextRequest(validRequest, options)) @@ -2927,6 +3014,7 @@ describe('validateReidentifyTextRequest', () => { test('should throw error when maskedEntities is not an array', () => { const options = { + getRedactedEntities: () => null, getMaskedEntities: () => 'not-an-array' }; expect(() => validateReidentifyTextRequest(validRequest, options)) @@ -2935,6 +3023,8 @@ describe('validateReidentifyTextRequest', () => { test('should throw error when plainTextEntities is not an array', () => { const options = { + getRedactedEntities: () => null, + getMaskedEntities: () => null, getPlainTextEntities: () => 'not-an-array' }; expect(() => validateReidentifyTextRequest(validRequest, options)) @@ -3252,6 +3342,17 @@ describe('validateInvokeConnectionRequest', () => { }; expect(() => validateInvokeConnectionRequest(request)).not.toThrow(); }); + + test('should throw error for invalid headers', () => { + const request = { + method: 'GET', + headers: { + 'Content-Type': Symbol() // symbol value — not a string + } + }; + expect(() => validateInvokeConnectionRequest(request)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_HEADERS); + }); }); diff --git a/test/vault/client/client.test.js b/test/vault/client/client.test.js index 155dcc67..98eb5ea3 100644 --- a/test/vault/client/client.test.js +++ b/test/vault/client/client.test.js @@ -427,6 +427,136 @@ describe('VaultClient', () => { expect(err).toBeInstanceOf(SkyflowError); } }); + + test('should handle error response with non-JSON non-text content-type (rawResponse)', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/xml'], + ['x-request-id', 'abc-123'], + ]), + }, + body: { + error: { + message: 'XML error occurred', + grpc_code: 2, + }, + }, + statusCode: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); + + test('should handle error response with non-JSON non-text content-type (legacy)', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/xml'], + ['x-request-id', 'abc-123'], + ]), + data: { + error: { + message: 'XML error occurred', + }, + }, + status: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); + + test('should handle text error response with error-from-client header (rawResponse)', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'text/plain'], + ['x-request-id', 'abc-123'], + ['error-from-client', 'true'], + ]), + }, + body: { + error: { + message: 'Text error from client', + }, + }, + statusCode: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); + + test('should handle text error response with error-from-client header (legacy)', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'text/plain'], + ['x-request-id', 'abc-123'], + ['error-from-client', 'true'], + ]), + data: { + error: { + message: 'Text error from client', + }, + }, + status: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); + + test('should handle generic error response with error-from-client header (rawResponse)', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['x-request-id', 'abc-123'], + ['error-from-client', 'true'], + ]), + }, + body: { + error: { + message: 'Generic error from client', + grpc_code: 3, + }, + }, + statusCode: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); + + test('should handle generic error response with error-from-client header (legacy)', async () => { + const errorResponse = { + headers: new Map([ + ['x-request-id', 'abc-123'], + ['error-from-client', 'true'], + ]), + data: { + error: { + message: 'Generic error from client', + }, + }, + status: 400, + }; + try { + await vaultClient.failureResponse(errorResponse); + } catch (err) { + expect(err).toBeInstanceOf(SkyflowError); + } + }); }); describe('updateClientConfig', () => { From 40170a001f3e262f284ba3bb61e7a7e1a7c2cc58 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 08:26:28 +0000 Subject: [PATCH 10/18] [AUTOMATED] Private Release 2.0.4-dev.dff3e31 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1aeb5c54..a8cca024 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.28b08fc", + "version": "2.0.4-dev.dff3e31", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From c72a2c6dc5b46bd3f905b9eb770100a78ece252e Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 14:27:55 +0530 Subject: [PATCH 11/18] SK-2841: Updated detect test case --- test/vault/controller/detect.test.js | 361 +++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) diff --git a/test/vault/controller/detect.test.js b/test/vault/controller/detect.test.js index 716a5bda..6ea7f4e4 100644 --- a/test/vault/controller/detect.test.js +++ b/test/vault/controller/detect.test.js @@ -264,6 +264,23 @@ describe('deidentifyText', () => { ); expect(mockVaultClient.stringsAPI.deidentifyString).toHaveBeenCalled(); }); + + // Lines 418-432: buildDeidentifyTextRequest options?.xxx false branches (options undefined) + test('should deidentify text without options (covers options?.xxx false branches)', async () => { + validateDeIdentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'text to deidentify' }; + + mockVaultClient.stringsAPI.deidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { processed_text: 'processed text', entities: [], word_count: 1, character_count: 10 }, + rawResponse: null, // covers rawResponse?.headers false branch at line 389 + }), + })); + + const response = await detectController.deidentifyText(mockRequest); + expect(response).toBeInstanceOf(DeidentifyTextResponse); + expect(response.processedText).toBe('processed text'); + }); }); describe('reidentifyText', () => { @@ -371,6 +388,22 @@ describe('reidentifyText', () => { ); expect(mockVaultClient.stringsAPI.reidentifyString).toHaveBeenCalled(); }); + + test('should reidentify text without options (covers options?.xxx false branches)', async () => { + validateReidentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'redacted text' }; + + mockVaultClient.stringsAPI.reidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { text: 'plain text' }, + rawResponse: null, + }), + })); + + const response = await detectController.reidentifyText(mockRequest); + expect(response).toBeInstanceOf(ReidentifyTextResponse); + expect(response.processedText).toBe('plain text'); + }); }); describe('getDetectRun', () => { @@ -458,6 +491,15 @@ describe('getDetectRun', () => { await expect(detectController.getDetectRun(mockRequest)).rejects.toThrow('API error'); }); + + // Lines 580-583: catch block — if (error instanceof Error) false branch + test('should handle non-Error thrown in getDetectRun catch block', async () => { + validateGetDetectRunRequest.mockImplementation(() => { + throw 'non-error string exception'; + }); + const mockRequest = { runId: 'mockRunId' }; + await expect(detectController.getDetectRun(mockRequest)).rejects.toBe('non-error string exception'); + }); }); describe('deidentifyFile', () => { @@ -1502,4 +1544,323 @@ describe('deidentifyFile', () => { await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); }); + + // Lines 82-99: buildAudioRequest options?.xxx false branches (options undefined) + test('should deidentify audio file without options', async () => { + jest.clearAllMocks(); + const file = new File(['audio content'], 'test.mp3'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyAudio.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'audioNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-audio-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'audioData', processedFileType: 'mp3', processedFileExtension: 'mp3' }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 100, duration: 5, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 100-120: buildTextFileRequest options?.xxx false branches (options undefined) + test('should deidentify text file without options (covers buildTextFileRequest options?.xxx false branches)', async () => { + jest.clearAllMocks(); + // .text extension (contains 'text') routes to TEXT handler / buildTextFileRequest + const file = new File(['text content'], 'test.text'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyText.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'textNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-text-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'textData', processedFileType: 'text', processedFileExtension: 'text' }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 100, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 129-138: buildPdfRequest options?.xxx false branches (options undefined) + test('should deidentify pdf file without options', async () => { + jest.clearAllMocks(); + const file = new File(['pdf content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'pdfNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-pdf-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'pdfData', processedFileType: 'pdf', processedFileExtension: 'pdf' }], + wordCharacterCount: { wordCount: 5, characterCount: 50 }, + size: 512, duration: 0, pages: 1, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + expect(result.type).toBe('pdf'); + }); + + // Lines 151-161: buildImageRequest options?.xxx false branches (options undefined) + test('should deidentify image file without options', async () => { + jest.clearAllMocks(); + const file = new File(['image content'], 'test.png', { type: 'image/png' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyImage.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'imgNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-img-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'imgData', processedFileType: 'png', processedFileExtension: 'png' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 256, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 175-182: buildPptRequest options?.xxx false branches (options undefined) + test('should deidentify pptx file without options', async () => { + jest.clearAllMocks(); + const file = new File(['ppt content'], 'test.pptx'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyPresentation.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'pptNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-ppt-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'pptData', processedFileType: 'pptx', processedFileExtension: 'pptx' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 128, duration: 0, pages: 0, slides: 5, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 195-202: buildSpreadsheetRequest options?.xxx false branches (options undefined) + test('should deidentify xlsx file without options', async () => { + jest.clearAllMocks(); + const file = new File(['sheet content'], 'test.xlsx'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifySpreadsheet.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'xlsxNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-xlsx-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'xlsxData', processedFileType: 'xlsx', processedFileExtension: 'xlsx' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 128, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 215-223: buildStructuredTextRequest options?.xxx false branches (options undefined) + test('should deidentify json file without options', async () => { + jest.clearAllMocks(); + const file = new File(['{"key":"val"}'], 'test.json'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyStructuredText.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'jsonNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-json-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'jsonData', processedFileType: 'json', processedFileExtension: 'json' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 64, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 236-243: buildDocumentRequest options?.xxx false branches (options undefined) + test('should deidentify docx file without options', async () => { + jest.clearAllMocks(); + const file = new File(['doc content'], 'test.docx'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyDocument.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'docxNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-docx-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'docxData', processedFileType: 'docx', processedFileExtension: 'docx' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 64, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 257-264: buildGenericFileRequest options?.xxx false branches (options undefined) + test('should deidentify generic file without options', async () => { + jest.clearAllMocks(); + const file = new File(['generic content'], 'test.abc'); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + + mockVaultClient.filesAPI.deidentifyFile.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'abcNoOpts' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-abc-no') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ processedFile: 'abcData', processedFileType: 'abc', processedFileExtension: 'abc' }], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 64, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + }); + + // Lines 344, 363-366: response.status?.toUpperCase() false branch (status undefined) + test('should reject when getRun returns response with no status field', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runNoStatus' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-no-status') } }, + }), + })); + + // No status field — covers response.status?.toUpperCase() false branch + mockVaultClient.filesAPI.getRun.mockResolvedValue({}); + + await expect(detectController.deidentifyFile(deidentifyFileReq, options)).rejects.toThrow(); + }); + + // Lines 459-460, 473, 481: parseDeidentifyFileResponse with null output array + test('should handle SUCCESS response with null output array', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runNullOut' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-null-out') } }, + }), + })); + + // null output covers data.output?.[0]?.xxx false branches and data.output || [] true branch + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: null, + wordCharacterCount: null, + size: null, + duration: null, + pages: null, + slides: null, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + expect(result.fileBase64).toBe(''); + expect(result.entities).toHaveLength(0); + }); + + // Lines 459-460, 473: parseDeidentifyFileResponse with empty output array + test('should handle SUCCESS response with empty output array', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({file}); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'runEmptyOut' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-empty-out') } }, + }), + })); + + // empty output covers data.output[0] undefined (?.processedFile false branch) + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [], + wordCharacterCount: { wordCount: 0, characterCount: 0 }, + size: 0, duration: 0, pages: 0, slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + const result = await promise; + expect(result.status).toBe('SUCCESS'); + expect(result.fileBase64).toBe(''); + expect(result.entities).toHaveLength(0); + }); }); \ No newline at end of file From 9bc4bc1d492cec737c3c27a48665fd6954de74d1 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 08:58:22 +0000 Subject: [PATCH 12/18] [AUTOMATED] Private Release 2.0.4-dev.c72a2c6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8cca024..3942c5bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.dff3e31", + "version": "2.0.4-dev.c72a2c6", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From 1346fd64de61b00c0eea2c2a9ec430528b48fc57 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 14:56:15 +0530 Subject: [PATCH 13/18] SK-2841: Increase coverage to 100% --- src/vault/controller/connections/index.ts | 2 +- src/vault/controller/detect/index.ts | 8 +- test/vault/controller/connection.test.js | 386 +++++++++++++++++++++ test/vault/controller/detect.test.js | 398 ++++++++++++++++++++++ 4 files changed, 789 insertions(+), 5 deletions(-) diff --git a/src/vault/controller/connections/index.ts b/src/vault/controller/connections/index.ts index 281e3b03..0c3025fc 100644 --- a/src/vault/controller/connections/index.ts +++ b/src/vault/controller/connections/index.ts @@ -143,7 +143,7 @@ class ConnectionController { private async parseResponseBody(response: Response): Promise { const contentType = - response.headers.get(HTTP_HEADER.CONTENT_TYPE)?.toLowerCase() || ""; + response.headers?.get(HTTP_HEADER.CONTENT_TYPE)?.toLowerCase() || ""; try { if (contentType.includes(CONTENT_TYPE.APPLICATION_JSON)) { diff --git a/src/vault/controller/detect/index.ts b/src/vault/controller/detect/index.ts index e824d018..682b83f6 100644 --- a/src/vault/controller/detect/index.ts +++ b/src/vault/controller/detect/index.ts @@ -434,8 +434,8 @@ class DetectController { private parseDeidentifyTextResponse(records: DeidentifyStringResponse) { return { - processedText: records.processed_text, - entities: records?.entities.map((entity: DetectedEntity) => ({ + processedText: records?.processed_text, + entities: (records?.entities ?? []).map((entity: DetectedEntity) => ({ token: entity?.token, value: entity?.value, textIndex: { @@ -449,8 +449,8 @@ class DetectController { entity: entity?.entity_type, scores: entity?.entity_scores, })), - wordCount: records.word_count, - charCount: records.character_count, + wordCount: records?.word_count, + charCount: records?.character_count, errors: null, }; } diff --git a/test/vault/controller/connection.test.js b/test/vault/controller/connection.test.js index 323c6c06..af1441d6 100644 --- a/test/vault/controller/connection.test.js +++ b/test/vault/controller/connection.test.js @@ -1525,4 +1525,390 @@ describe("ConnectionController Tests", () => { expect(deprecatedValue).toBeDefined(); expect(result.metadata.requestId).toBeDefined(); }); + + it("should JSON.stringify non-string request header values", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { + "Content-Type": "application/json", + "X-Custom": { nested: "value" }, + }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.headers["X-Custom"]).toBe(JSON.stringify({ nested: "value" })); + }); + + it("should handle urlencoded body when body is null", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + text: jest.fn().mockResolvedValue(""), + }); + + const request = { + body: null, + method: RequestMethod.POST, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }; + + await connectionController.invoke(request); + + expect(global.fetch.mock.calls[0][1].body).toBe(""); + }); + + it("should stringify non-string body for text/plain", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + text: jest.fn().mockResolvedValue("ok"), + }); + + const request = { + body: 42, + method: RequestMethod.POST, + headers: { "Content-Type": "text/plain" }, + }; + + await connectionController.invoke(request); + + expect(global.fetch.mock.calls[0][1].body).toBe("42"); + }); + + it("should stringify non-string body for text/html", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + text: jest.fn().mockResolvedValue("ok"), + }); + + const request = { + body: 99, + method: RequestMethod.POST, + headers: { "Content-Type": "text/html" }, + }; + + await connectionController.invoke(request); + + expect(global.fetch.mock.calls[0][1].body).toBe("99"); + }); + + it("should build multipart FormData when body is null", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + text: jest.fn().mockResolvedValue("ok"), + }); + + const request = { + body: null, + method: RequestMethod.POST, + headers: { "Content-Type": "multipart/form-data" }, + }; + + await connectionController.invoke(request); + + expect(global.fetch.mock.calls[0][1].body).toBeInstanceOf(FormData); + }); + + it("should return null when response text is empty after json parse failure", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockRejectedValue(new Error("parse error")), + text: jest.fn().mockResolvedValue(""), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBeNull(); + }); + + it("should parse response when response headers object is undefined", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: undefined, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toEqual({ success: true }); + expect(result.metadata.requestId).toBe(""); + }); + + it("should default requestId to empty string when x-request-id header is missing", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.metadata.requestId).toBe(""); + }); + + it("should default to POST when method is not provided", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + headers: { "Content-Type": "application/json" }, + }; + + await connectionController.invoke(request); + + expect(global.fetch.mock.calls[0][1].method).toBe(RequestMethod.POST); + }); + + it("should invoke without request headers", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + }; + + await connectionController.invoke(request); + + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should return null when json and text parsing fail without response body", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockRejectedValue(new Error("parse error")), + text: jest.fn().mockRejectedValue(new Error("text error")), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBeNull(); + }); + + it("should cancel response body when json and text parsing both fail", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + const cancel = jest.fn().mockReturnValue(Promise.reject(new Error("cancel failed"))); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockImplementation((key) => { + if (key?.toLowerCase() === "content-type") return "application/json"; + return null; + }), + }, + json: jest.fn().mockRejectedValue(new Error("parse error")), + text: jest.fn().mockRejectedValue(new Error("text error")), + body: { cancel }, + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { "Content-Type": "application/json" }, + }; + + const result = await connectionController.invoke(request); + expect(result.data).toBeNull(); + expect(cancel).toHaveBeenCalled(); + }); + + it("should skip content-type header and stringify non-string values for multipart", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue(null) }, + text: jest.fn().mockResolvedValue("ok"), + }); + + const request = { + body: { field: "value" }, + method: RequestMethod.POST, + headers: { + "Content-Type": "multipart/form-data", + "X-Meta": ["a", "b"], + }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.headers["Content-Type"]).toBeUndefined(); + expect(fetchCall.headers["X-Meta"]).toBe(JSON.stringify(["a", "b"])); + }); + + it("should keep string header values when not removing content-type", async () => { + const token = { key: "bearer_token" }; + getBearerToken.mockResolvedValue(token); + fillUrlWithPathAndQueryParams.mockReturnValue("https://api.example.com/resource"); + generateSDKMetrics.mockReturnValue({ metric: "value" }); + validateInvokeConnectionRequest.mockImplementation(jest.fn()); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn().mockReturnValue("req-123") }, + json: jest.fn().mockResolvedValue({ success: true }), + }); + + const request = { + body: { data: "sample" }, + method: RequestMethod.POST, + headers: { + "Content-Type": "application/json", + "X-Plain": "plain-value", + }, + }; + + await connectionController.invoke(request); + + const fetchCall = global.fetch.mock.calls[0][1]; + expect(fetchCall.headers["X-Plain"]).toBe("plain-value"); + expect(fetchCall.headers["Content-Type"]).toBe("application/json"); + }); }); diff --git a/test/vault/controller/detect.test.js b/test/vault/controller/detect.test.js index 6ea7f4e4..c01dccb1 100644 --- a/test/vault/controller/detect.test.js +++ b/test/vault/controller/detect.test.js @@ -4,9 +4,44 @@ import DeidentifyTextResponse from '../../../src/vault/model/response/deidentify import ReidentifyTextResponse from '../../../src/vault/model/response/reidentify-text'; import DeidentifyFileRequest from '../../../src/vault/model/request/deidentify-file'; import DeidentifyFileOptions from '../../../src/vault/model/options/deidentify-file'; +import TokenFormat from '../../../src/vault/model/options/deidentify-text/token-format'; +import Transformations from '../../../src/vault/model/options/deidentify-text/transformations'; +import { Bleep } from '../../../src/vault/model/options/deidentify-file/bleep-audio'; import { DETECT_STATUS, ENCODING_TYPE, TYPES } from '../../../src/utils'; import fs from 'fs'; +function createFullDeidentifyFileOptions() { + const options = new DeidentifyFileOptions(); + const tokenFormat = new TokenFormat(); + tokenFormat.setDefault('entity_unique_counter'); + tokenFormat.setEntityUniqueCounter(['NAME']); + tokenFormat.setEntityOnly(['EMAIL']); + + const transformations = new Transformations(); + transformations.setShiftDays({ max: 10, min: 2, entities: ['DATE'] }); + + const bleep = new Bleep(); + bleep.setGain(0.5); + bleep.setFrequency(440); + bleep.setStartPadding(0.1); + bleep.setStopPadding(0.2); + + options.setEntities(['NAME', 'EMAIL']); + options.setAllowRegexList(['allow-regex']); + options.setRestrictRegexList(['restrict-regex']); + options.setTokenFormat(tokenFormat); + options.setTransformations(transformations); + options.setOutputTranscription('transcription'); + options.setOutputProcessedAudio(true); + options.setOutputProcessedImage(true); + options.setOutputOcrText(true); + options.setMaskingMethod('blackbox'); + options.setMaxResolution(1920); + options.setPixelDensity(300); + options.setBleep(bleep); + return options; +} + jest.mock('../../../src/utils', () => ({ printLog: jest.fn(), parameterizedString: jest.fn(), @@ -69,6 +104,11 @@ jest.mock('../../../src/utils', () => ({ BINARY: 'binary', UTF_8: 'utf-8', }, + TokenType: { + ENTITY_UNIQUE_COUNTER: 'entity_unq_counter', + ENTITY_ONLY: 'entity_only', + VAULT_TOKEN: 'vault_token', + }, generateSDKMetrics: jest.fn().mockReturnValue({ sdk: 'metrics' }), getBearerToken: jest.fn().mockResolvedValue(Promise.resolve('your-bearer-token')), })); @@ -208,6 +248,108 @@ describe('deidentifyText', () => { expect(response.errors).toBeNull(); }); + test('should map entities with missing optional fields', async () => { + validateDeIdentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'text with entity' }; + + mockVaultClient.stringsAPI.deidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { + processed_text: 'processed', + entities: [{}], + word_count: 1, + character_count: 9, + }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + const response = await detectController.deidentifyText(mockRequest); + expect(response.entities[0]).toEqual({ + token: undefined, + value: undefined, + textIndex: { start: undefined, end: undefined }, + processedIndex: { start: undefined, end: undefined }, + entity: undefined, + scores: undefined, + }); + }); + + test('should map entities with partial location fields', async () => { + validateDeIdentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'partial entity fields' }; + + mockVaultClient.stringsAPI.deidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { + processed_text: 'processed', + entities: [ + { token: 'tok', location: { start_index: 0, end_index: 1 } }, + { value: 'val', location: { start_index_processed: 2, end_index_processed: 3 } }, + ], + word_count: 2, + character_count: 20, + }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + const response = await detectController.deidentifyText(mockRequest); + expect(response.entities[0].textIndex).toEqual({ start: 0, end: 1 }); + expect(response.entities[0].processedIndex).toEqual({ + start: undefined, + end: undefined, + }); + expect(response.entities[1].processedIndex).toEqual({ start: 2, end: 3 }); + expect(response.entities[1].textIndex).toEqual({ + start: undefined, + end: undefined, + }); + }); + + test('should handle undefined records in deidentify text response', async () => { + validateDeIdentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'no records payload' }; + + mockVaultClient.stringsAPI.deidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: undefined, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + const response = await detectController.deidentifyText(mockRequest); + expect(response.processedText).toBeUndefined(); + expect(response.entities).toEqual([]); + }); + + test('should map null entity entries safely', async () => { + validateDeIdentifyTextRequest.mockImplementation(() => {}); + const mockRequest = { text: 'null entity entry' }; + + mockVaultClient.stringsAPI.deidentifyString.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { + processed_text: 'processed', + entities: [null], + word_count: 1, + character_count: 9, + }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + const response = await detectController.deidentifyText(mockRequest); + expect(response.entities[0]).toEqual({ + token: undefined, + value: undefined, + textIndex: { start: undefined, end: undefined }, + processedIndex: { start: undefined, end: undefined }, + entity: undefined, + scores: undefined, + }); + }); + test('should handle validation errors', async () => { const mockRequest = { text: 'Sensitive data to deidentify', @@ -467,6 +609,37 @@ describe('getDetectRun', () => { ); }); + test('should prefer runId from response data over request runId', async () => { + validateGetDetectRunRequest.mockImplementation(() => {}); + const mockRequest = { runId: 'request-run-id' }; + const mockResponseData = { + runId: 'response-run-id', + status: 'SUCCESS', + output: [ + { + processedFile: Buffer.from('file').toString(ENCODING_TYPE.BASE64), + processedFileType: 'pdf', + processedFileExtension: 'pdf', + }, + ], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 100, + duration: 0, + pages: 1, + slides: 0, + }; + + mockVaultClient.filesAPI.getRun.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: mockResponseData, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + const result = await detectController.getDetectRun(mockRequest); + expect(result.runId).toBe('response-run-id'); + }); + test('should handle validation errors', async () => { const mockRequest = { runId: 'mockRunId' }; validateGetDetectRunRequest.mockImplementation(() => { @@ -1863,4 +2036,229 @@ describe('deidentifyFile', () => { expect(result.fileBase64).toBe(''); expect(result.entities).toHaveLength(0); }); + + const fullOptionsFileCases = [ + { + name: 'audio', + fileName: 'test.mp3', + api: 'deidentifyAudio', + assertRequest: (req) => { + expect(req.token_type.default).toBe('entity_unique_counter'); + expect(req.bleep_gain).toBe(0.5); + expect(req.bleep_frequency).toBe(440); + expect(req.output_transcription).toBe('transcription'); + expect(req.output_processed_audio).toBe(true); + }, + }, + { + name: 'text', + fileName: 'test.text', + api: 'deidentifyText', + assertRequest: (req) => { + expect(req.token_type.entity_only).toEqual(['EMAIL']); + expect(req.transformations.shift_dates.max_days).toBe(10); + }, + }, + { + name: 'image', + fileName: 'test.png', + api: 'deidentifyImage', + assertRequest: (req) => { + expect(req.masking_method).toBe('blackbox'); + expect(req.output_ocr_text).toBe(true); + expect(req.output_processed_image).toBe(true); + }, + }, + { + name: 'ppt', + fileName: 'test.pptx', + api: 'deidentifyPresentation', + assertRequest: (req) => { + expect(req.allow_regex).toEqual(['allow-regex']); + expect(req.restrict_regex).toEqual(['restrict-regex']); + }, + }, + { + name: 'spreadsheet', + fileName: 'test.xlsx', + api: 'deidentifySpreadsheet', + assertRequest: (req) => { + expect(req.entity_types).toEqual(['NAME', 'EMAIL']); + }, + }, + { + name: 'structured text', + fileName: 'test.json', + api: 'deidentifyStructuredText', + assertRequest: (req) => { + expect(req.transformations.shift_dates.min_days).toBe(2); + }, + }, + { + name: 'document', + fileName: 'test.docx', + api: 'deidentifyDocument', + assertRequest: (req) => { + expect(req.token_type.entity_unq_counter).toEqual(['NAME']); + }, + }, + { + name: 'generic file', + fileName: 'test.abc', + api: 'deidentifyFile', + assertRequest: (req) => { + expect(req.entity_types).toEqual(['NAME', 'EMAIL']); + expect(req.transformations.shift_dates.entity_types).toEqual(['DATE']); + }, + }, + ]; + + test.each(fullOptionsFileCases)( + 'should pass all file options to %s deidentify API', + async ({ fileName, api, assertRequest }) => { + jest.clearAllMocks(); + const file = new File(['content'], fileName); + const deidentifyFileReq = new DeidentifyFileRequest({ file }); + const options = createFullDeidentifyFileOptions(); + + mockVaultClient.filesAPI[api].mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: `run-${api}` }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ + processedFile: 'data', + processedFileType: 'txt', + processedFileExtension: 'txt', + }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 10, + duration: 0, + pages: 0, + slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + await promise; + + expect(mockVaultClient.filesAPI[api]).toHaveBeenCalled(); + assertRequest(mockVaultClient.filesAPI[api].mock.calls[0][0]); + } + ); + + test('should poll when deidentify response has null data', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({ file }); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: null, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ + processedFile: 'pdfData', + processedFileType: 'pdf', + processedFileExtension: 'pdf', + }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 10, + duration: 0, + pages: 1, + slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + await promise; + + expect(mockVaultClient.filesAPI.getRun).toHaveBeenCalledWith( + undefined, + { vault_id: 'vault123' } + ); + }); + + test('should poll when deidentify response omits run_id', async () => { + jest.clearAllMocks(); + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({ file }); + const options = new DeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: {}, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ + processedFile: 'pdfData', + processedFileType: 'pdf', + processedFileExtension: 'pdf', + }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 10, + duration: 0, + pages: 1, + slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + await promise; + + expect(mockVaultClient.filesAPI.getRun).toHaveBeenCalledWith( + undefined, + { vault_id: 'vault123' } + ); + }); + + test('should pass all file options to pdf deidentify API', async () => { + jest.clearAllMocks(); + const file = new File(['pdf'], 'test.pdf', { type: 'application/pdf' }); + const deidentifyFileReq = new DeidentifyFileRequest({ file }); + const options = createFullDeidentifyFileOptions(); + + mockVaultClient.filesAPI.deidentifyPdf.mockImplementation(() => ({ + withRawResponse: jest.fn().mockResolvedValue({ + data: { run_id: 'run-pdf' }, + rawResponse: { headers: { get: jest.fn().mockReturnValue('req-id') } }, + }), + })); + + mockVaultClient.filesAPI.getRun.mockResolvedValueOnce({ + status: 'SUCCESS', + output: [{ + processedFile: 'pdfData', + processedFileType: 'pdf', + processedFileExtension: 'pdf', + }], + wordCharacterCount: { wordCount: 1, characterCount: 10 }, + size: 10, + duration: 0, + pages: 1, + slides: 0, + }); + + const promise = detectController.deidentifyFile(deidentifyFileReq, options); + await jest.runAllTimersAsync(); + await promise; + + const pdfReq = mockVaultClient.filesAPI.deidentifyPdf.mock.calls[0][0]; + expect(pdfReq.max_resolution).toBe(1920); + expect(pdfReq.density).toBe(300); + expect(pdfReq.token_type.default).toBe('entity_unique_counter'); + }); }); \ No newline at end of file From 80cd85ce240ea289044ccabd1658cfe93d42f5d8 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 09:27:01 +0000 Subject: [PATCH 14/18] [AUTOMATED] Private Release 2.0.4-dev.478d5b2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3942c5bc..e89a3a1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.c72a2c6", + "version": "2.0.4-dev.478d5b2", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From d7a2d9b83e91bab44218f9d34c7c29a3bb7cd503 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 15:27:12 +0530 Subject: [PATCH 15/18] SK-2841: Updated coverage --- test/service-account/client.test.js | 26 ++ test/service-account/token.test.js | 322 ++++++++++++++++++- test/utils/validations.test.js | 466 +++++++++++++++++++++++++++- test/vault/client/client.test.js | 463 +++++++++++++++++++++++++++ 4 files changed, 1274 insertions(+), 3 deletions(-) create mode 100644 test/service-account/client.test.js diff --git a/test/service-account/client.test.js b/test/service-account/client.test.js new file mode 100644 index 00000000..814b4cf2 --- /dev/null +++ b/test/service-account/client.test.js @@ -0,0 +1,26 @@ +jest.mock('../../src/ _generated_/rest/api/resources/authentication/client/Client', () => ({ + Authentication: jest.fn().mockImplementation((config) => ({ config })), +})); + +const Client = require('../../src/service-account/client').default; +const { + Authentication, +} = require('../../src/ _generated_/rest/api/resources/authentication/client/Client'); + +describe('Service Account Client', () => { + beforeEach(() => { + Authentication.mockClear(); + }); + + test('initializes Authentication with the token URI as baseUrl', () => { + const tokenUri = 'https://example.com/oauth/token'; + const client = new Client(tokenUri); + + expect(Authentication).toHaveBeenCalledWith({ + baseUrl: tokenUri, + token: '', + }); + expect(client.authApi).toBeDefined(); + expect(client.authApi.config.baseUrl).toBe(tokenUri); + }); +}); diff --git a/test/service-account/token.test.js b/test/service-account/token.test.js index 52cc183a..36ef4355 100644 --- a/test/service-account/token.test.js +++ b/test/service-account/token.test.js @@ -1,7 +1,6 @@ import { generateBearerToken, generateBearerTokenFromCreds, - generateToken, getToken, successResponse, failureResponse, @@ -9,6 +8,7 @@ import { generateSignedDataTokens, generateSignedDataTokensFromCreds, } from "../../src/service-account"; +import SKYFLOW_ERROR_CODE from '../../src/error/codes'; import SkyflowError from '../../src/error'; import errorMessages from '../../src/error/messages'; import jwt from 'jsonwebtoken'; @@ -775,3 +775,323 @@ describe('deprecated BearerTokenOptions.roleIDs normalization', () => { ); }); }); + +describe('service-account branch and line coverage', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + const writeTempCredentialsFile = (content) => { + const filePath = path.join( + os.tmpdir(), + `sa-creds-${Date.now()}-${Math.random()}.json`, + ); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('generateBearerToken rejects credential file with invalid JSON syntax', async () => { + const filePath = writeTempCredentialsFile('{not-valid-json'); + try { + await expect(generateBearerToken(filePath)).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.INVALID_JSON_FILE.httpCode, + }), + }); + } finally { + fs.unlinkSync(filePath); + } + }); + + test('generateBearerToken rejects empty credential file', async () => { + const filePath = writeTempCredentialsFile(''); + try { + await expect(generateBearerToken(filePath)).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.INVALID_JSON_FILE.httpCode, + }), + }); + } finally { + fs.unlinkSync(filePath); + } + }); + + test('getToken rejects credentials that are not valid JSON', async () => { + await expect(getToken('{not-valid-json')).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.INVALID_JSON_FORMAT.httpCode, + }), + }); + }); + + test('generateSignedDataTokens rejects credential file with invalid JSON syntax', async () => { + const filePath = writeTempCredentialsFile('{not-valid-json'); + try { + await expect( + generateSignedDataTokens(filePath, { dataTokens: ['token'] }), + ).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.INVALID_JSON_FILE.httpCode, + }), + }); + } finally { + fs.unlinkSync(filePath); + } + }); + + test('generateSignedDataTokensFromCreds rejects empty credentials string', async () => { + await expect( + generateSignedDataTokensFromCreds('', { dataTokens: ['token'] }), + ).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.EMPTY_CREDENTIALS_STRING.httpCode, + }), + }); + }); + + test('generateSignedDataTokensFromCreds rejects credentials missing key ID', async () => { + const creds = JSON.stringify({ + clientID: 'test-client-id', + tokenURI: 'https://test-token-uri.com', + privateKey: 'KEY', + }); + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + await expect( + generateSignedDataTokensFromCreds(creds, { dataTokens: ['token'] }), + ).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_KEY_ID.httpCode, + }), + }); + }); + + test('generateSignedDataTokensFromCreds outer catch when jwt.sign throws', async () => { + jest.spyOn(jwt, 'sign').mockImplementation(() => { + throw new Error('jwt sign failed'); + }); + const creds = JSON.stringify(validCredentials); + await expect( + generateSignedDataTokensFromCreds(creds, { dataTokens: ['token'] }), + ).rejects.toThrow('jwt sign failed'); + }); + + test('generateSignedDataTokensFromCreds succeeds with multiple tokens, ctx, and default TTL', async () => { + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + const creds = JSON.stringify(validCredentials); + const result = await generateSignedDataTokensFromCreds(creds, { + dataTokens: ['token-a', 'token-b'], + ctx: { scope: 'test' }, + }); + expect(result).toHaveLength(2); + expect(result[0].token).toBe('token-a'); + expect(result[0].signedToken).toContain('signed_token_'); + expect(result[1].token).toBe('token-b'); + }); + + test('failureResponse text/plain branch handles plain string body', async () => { + const err = { + rawResponse: { + headers: { + get: (key) => (key === 'content-type' ? 'text/plain' : 'req-id'), + }, + }, + body: 'plain error message', + }; + await expect(failureResponse(err)).rejects.toBeDefined(); + }); + + test('successResponse returns empty strings when token fields are missing', async () => { + const result = await successResponse({}); + expect(result).toEqual({ accessToken: '', tokenType: '' }); + }); + + test('generateBearerToken rejects missing credential file with logLevel', async () => { + await expect( + generateBearerToken('missing-credentials-file.json', { + logLevel: LogLevel.ERROR, + }), + ).rejects.toBeDefined(); + }); + + test('getToken rejects credentials missing clientId', async () => { + const creds = JSON.stringify({ + keyID: 'test-key-id', + tokenURI: 'https://test-token-uri.com', + privateKey: 'KEY', + }); + await expect(getToken(creds, { logLevel: LogLevel.ERROR })).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_CLIENT_ID.httpCode, + }), + }); + }); + + test('getToken rejects credentials missing keyId', async () => { + const creds = JSON.stringify({ + clientID: 'test-client-id', + tokenURI: 'https://test-token-uri.com', + privateKey: 'KEY', + }); + await expect(getToken(creds, { logLevel: LogLevel.ERROR })).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_KEY_ID.httpCode, + }), + }); + }); + + test('getToken rejects credentials missing tokenUri', async () => { + const creds = JSON.stringify({ + clientID: 'test-client-id', + keyID: 'test-key-id', + privateKey: 'KEY', + }); + await expect(getToken(creds, { logLevel: LogLevel.ERROR })).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_TOKEN_URI.httpCode, + }), + }); + }); + + test('getToken rejects credentials missing privateKey', async () => { + const creds = JSON.stringify({ + clientID: 'test-client-id', + keyID: 'test-key-id', + tokenURI: 'https://test-token-uri.com', + }); + await expect(getToken(creds, { logLevel: LogLevel.ERROR })).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_PRIVATE_KEY.httpCode, + }), + }); + }); + + test('getToken rejects when getBaseUrl returns empty string', async () => { + jest.spyOn(require('../../src/utils'), 'getBaseUrl').mockReturnValue(''); + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + await expect(getToken(JSON.stringify(validCredentials))).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.MISSING_TOKEN_URI.httpCode, + }), + }); + }); + + test('getToken rejects options with tokenUri property explicitly undefined', async () => { + await expect( + getToken(JSON.stringify(validCredentials), { tokenUri: undefined }), + ).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: SKYFLOW_ERROR_CODE.INVALID_TOKEN_URI.httpCode, + }), + }); + }); + + test('generateSignedDataTokens rejects missing file with logLevel', async () => { + await expect( + generateSignedDataTokens('missing-signed-creds.json', { + dataTokens: ['token'], + logLevel: LogLevel.ERROR, + }), + ).rejects.toBeDefined(); + }); + + test('generateSignedDataTokens rejects empty credential file with logLevel', async () => { + const filePath = writeTempCredentialsFile(''); + try { + await expect( + generateSignedDataTokens(filePath, { + dataTokens: ['token'], + logLevel: LogLevel.ERROR, + }), + ).rejects.toBeDefined(); + } finally { + fs.unlinkSync(filePath); + } + }); + + test('generateSignedDataTokensFromCreds uses custom timeToLive', async () => { + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + const result = await generateSignedDataTokensFromCreds( + JSON.stringify(validCredentials), + { dataTokens: ['token'], timeToLive: 120 }, + ); + expect(result).toHaveLength(1); + }); + + test('generateSignedDataTokensFromCreds rejects non-string credentials with logLevel', async () => { + await expect( + generateSignedDataTokensFromCreds(123, { + dataTokens: ['token'], + logLevel: LogLevel.ERROR, + }), + ).rejects.toBeDefined(); + }); + + test('generateSignedDataTokensFromCreds rejects missing dataTokens with logLevel', async () => { + await expect( + generateSignedDataTokensFromCreds(JSON.stringify(validCredentials), { + logLevel: LogLevel.ERROR, + }), + ).rejects.toBeDefined(); + }); + + test('failureResponse text/plain uses http_code from nested error when present', async () => { + const err = { + rawResponse: { + headers: { + get: (key) => (key === 'content-type' ? 'text/plain' : 'req-id'), + }, + }, + body: { error: { http_code: 503 } }, + }; + await expect(failureResponse(err, { logLevel: LogLevel.ERROR })).rejects.toBeDefined(); + }); + + test('generateSignedDataTokensFromCreds with null timeToLive uses default expiry', async () => { + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + const result = await generateSignedDataTokensFromCreds( + JSON.stringify(validCredentials), + { dataTokens: ['token'], timeToLive: null }, + ); + expect(result).toHaveLength(1); + }); + + test('error paths with logLevel cover optional logging branches', async () => { + const logOpts = { logLevel: LogLevel.DEBUG }; + const creds = JSON.stringify(validCredentials); + + await expect(generateBearerToken('missing.json', logOpts)).rejects.toBeDefined(); + await expect(getToken('{}', logOpts)).rejects.toBeDefined(); + await expect(getToken('not-json', logOpts)).rejects.toBeDefined(); + await expect( + getToken(creds, { ...logOpts, roleIds: [] }), + ).rejects.toBeDefined(); + await expect( + getToken(creds, { ...logOpts, tokenUri: 'not-a-url' }), + ).rejects.toBeDefined(); + await expect( + generateSignedDataTokensFromCreds('', { + dataTokens: ['token'], + ...logOpts, + }), + ).rejects.toBeDefined(); + await expect( + generateSignedDataTokens('missing.json', { + dataTokens: ['token'], + ...logOpts, + }), + ).rejects.toBeDefined(); + await expect( + generateSignedDataTokensFromCreds(creds, { + dataTokens: [], + ...logOpts, + }), + ).rejects.toBeDefined(); + await expect( + failureResponse({ message: 'network error' }, logOpts), + ).rejects.toBeDefined(); + }); +}); diff --git a/test/utils/validations.test.js b/test/utils/validations.test.js index d3049b11..04e108ef 100644 --- a/test/utils/validations.test.js +++ b/test/utils/validations.test.js @@ -1,11 +1,12 @@ const fs = require('fs'); const path = require('path'); -const { validateDeidentifyFileRequest, validateDeidentifyFileOptions, validateSkyflowConfig, validateVaultConfig, validateUpdateVaultConfig, validateSkyflowCredentials, validateConnectionConfig, validateUpdateConnectionConfig, validateInsertRequest, validateUpdateRequest, validateGetRequest, validateDetokenizeRequest, validateTokenizeRequest, validateDeleteRequest, validateUploadFileRequest, validateQueryRequest, validateDeIdentifyTextRequest, validateReidentifyTextRequest, validateGetDetectRunRequest, validateInvokeConnectionRequest, validateUpdateOptions, validateGetColumnRequest, validateCredentialsWithId } = require('../../src/utils/validations'); +const { validateDeidentifyFileRequest, validateDeidentifyFileOptions, validateSkyflowConfig, validateVaultConfig, validateUpdateVaultConfig, validateSkyflowCredentials, validateConnectionConfig, validateUpdateConnectionConfig, validateInsertRequest, validateUpdateRequest, validateGetRequest, validateDetokenizeRequest, validateTokenizeRequest, validateDeleteRequest, validateUploadFileRequest, validateQueryRequest, validateDeIdentifyTextRequest, validateReidentifyTextRequest, validateGetDetectRunRequest, validateInvokeConnectionRequest, validateUpdateOptions, validateGetColumnRequest, validateCredentialsWithId, isEnv, isRedactionType, isByot, isOrderBy, isMethod, isLogLevel, isValidAPIKey, validateInsertOptions, validateGetOptions, validateDetokenizeOptions } = require('../../src/utils/validations'); const DeidentifyFileRequest = require('../../src/vault/model/request/deidentify-file').default; const DeidentifyFileOptions = require('../../src/vault/model/options/deidentify-file').default; const SKYFLOW_ERROR_CODE = require('../../src/error/codes'); const { default: TokenFormat } = require('../../src/vault/model/options/deidentify-text/token-format'); -const { DetectEntities, Env, TokenMode, OrderByEnum } = require('../../src/utils'); +const { DetectEntities, Env, TokenMode, OrderByEnum, MaskingMethod } = require('../../src/utils'); +const { Bleep } = require('../../src/vault/model/options/deidentify-file/bleep-audio'); const { LogLevel } = require('../../src/utils'); const { default: Transformations } = require('../../src/vault/model/options/deidentify-text/transformations'); @@ -4366,3 +4367,464 @@ describe('validateCredentialsWithId and validateSkyflowCredentials - tokenUri va expect(() => validateSkyflowCredentials(credentials)).not.toThrow(); }); }); + +describe('validation helper functions', () => { + test('isEnv returns true for valid env and false otherwise', () => { + expect(isEnv(Env.SANDBOX)).toBe(true); + expect(isEnv(undefined)).toBe(false); + expect(isEnv('not-an-env')).toBe(false); + }); + + test('isRedactionType returns true for valid type and false otherwise', () => { + expect(isRedactionType('PLAIN_TEXT')).toBe(true); + expect(isRedactionType(undefined)).toBe(false); + expect(isRedactionType('invalid')).toBe(false); + }); + + test('isByot returns true for valid value and false otherwise', () => { + expect(isByot('ENABLE')).toBe(true); + expect(isByot(undefined)).toBe(false); + expect(isByot('invalid')).toBe(false); + }); + + test('isOrderBy returns true for valid value and false otherwise', () => { + expect(isOrderBy(OrderByEnum.ASCENDING)).toBe(true); + expect(isOrderBy(undefined)).toBe(false); + expect(isOrderBy('invalid')).toBe(false); + }); + + test('isMethod returns true for valid method and false otherwise', () => { + expect(isMethod('GET')).toBe(true); + expect(isMethod(undefined)).toBe(false); + expect(isMethod('INVALID')).toBe(false); + }); + + test('isLogLevel returns true for valid level and false otherwise', () => { + expect(isLogLevel(LogLevel.ERROR)).toBe(true); + expect(isLogLevel(undefined)).toBe(false); + expect(isLogLevel('INVALID')).toBe(false); + }); + + test('isValidAPIKey returns false for empty or invalid keys and true for sky- prefix', () => { + expect(isValidAPIKey('')).toBe(false); + expect(isValidAPIKey('bad-key')).toBe(false); + expect(isValidAPIKey('sky-key-abc')).toBe(true); + }); + + test('validateSkyflowCredentials validates credentials string payloads', () => { + expect(() => validateSkyflowCredentials({ credentialsString: '' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + expect(() => validateSkyflowCredentials({ credentialsString: 'not-json' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + expect(() => validateSkyflowCredentials({ + credentialsString: JSON.stringify({ keyID: 'only-key' }), + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + expect(() => validateSkyflowCredentials({ + credentialsString: JSON.stringify({ clientID: 'c', keyID: 'k' }), + })).not.toThrow(); + }); + + test('validateSkyflowCredentials validates credential file paths', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + expect(() => validateSkyflowCredentials({ path: '/exists.json' })).not.toThrow(); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => validateSkyflowCredentials({ path: '/missing.json' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_CREDENTIALS_FILE_PATH); + expect(() => validateSkyflowCredentials({ path: '' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_CREDENTIALS_FILE_PATH); + }); +}); + +describe('additional validation branch coverage', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('validateSkyflowCredentials rejects credentials string missing clientId', () => { + const credentials = { + credentialsString: JSON.stringify({ keyID: 'key-only' }), + }; + expect(() => validateSkyflowCredentials(credentials)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + }); + + test('validateSkyflowCredentials rejects null credentials string', () => { + expect(() => validateSkyflowCredentials({ credentialsString: null })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + }); + + test('validateSkyflowCredentials rejects credentials string that is not a string type', () => { + expect(() => validateSkyflowCredentials({ credentialsString: 12345 })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + }); + + test('validateSkyflowCredentials rejects path when file does not exist', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => validateSkyflowCredentials({ path: '/missing/file.json' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_CREDENTIALS_FILE_PATH); + }); + + test('validateCredentialsWithId rejects string credentials with numeric context', () => { + const credentials = { + credentialsString: JSON.stringify({ clientID: 'c', keyID: 'k' }), + context: 42, + }; + expect(() => validateCredentialsWithId(credentials, 'vault', 'vaultId', 'id')) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_CONTEXT); + }); + + test('validateUpdateConnectionConfig rejects whitespace-only connectionUrl', () => { + expect(() => validateUpdateConnectionConfig({ + connectionId: 'conn-1', + connectionUrl: ' ', + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_CONNECTION_URL); + }); + + test('validateInsertRequest rejects missing and invalid data with _table set', () => { + const base = { _table: 'users', table: 'users' }; + expect(() => validateInsertRequest({ ...base })).toThrow(SKYFLOW_ERROR_CODE.EMPTY_DATA_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: undefined })).toThrow(SKYFLOW_ERROR_CODE.EMPTY_DATA_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: { field: 'x' } })).toThrow(SKYFLOW_ERROR_CODE.INVALID_TYPE_OF_DATA_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [] })).toThrow(SKYFLOW_ERROR_CODE.EMPTY_DATA_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [null] })).toThrow(SKYFLOW_ERROR_CODE.EMPTY_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [false] })).toThrow(SKYFLOW_ERROR_CODE.EMPTY_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [123] })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [{ '': 'value' }] })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [[]] })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + }); + + test('validateUpdateRequest rejects array update data', () => { + expect(() => validateUpdateRequest({ + table: 'users', + data: [], + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_UPDATE); + }); + + test('validateUpdateRequest rejects update data with empty field name', () => { + expect(() => validateUpdateRequest({ + table: 'users', + data: { skyflow_id: 'id-1', '': 'value' }, + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_UPDATE); + }); + + test('validateUpdateOptions rejects token entry with non-string key', () => { + const entriesSpy = jest.spyOn(Object, 'entries').mockReturnValueOnce([[123, 'token']]); + const options = { getTokens: () => ({ placeholder: 'x' }) }; + expect(() => validateUpdateOptions(options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_TOKEN_IN_UPDATE); + entriesSpy.mockRestore(); + }); + + test('validateUploadFileRequest rejects non-string skyflowId from options', () => { + const request = { + _table: 'users', + table: 'users', + _columnName: 'file_column', + columnName: 'file_column', + }; + const options = { getSkyflowId: () => 123 }; + expect(() => validateUploadFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_SKYFLOW_ID_IN_UPLOAD_FILE); + }); + + test('validateUploadFileRequest rejects whitespace skyflowId from options', () => { + const request = { + _table: 'users', + table: 'users', + _columnName: 'file_column', + columnName: 'file_column', + }; + const options = { getSkyflowId: () => ' ' }; + expect(() => validateUploadFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_SKYFLOW_ID_IN_UPLOAD_FILE); + }); + + test('validateUploadFileRequest rejects missing columnName property', () => { + const request = { + _table: 'users', + table: 'users', + _skyflowId: 'id-1', + }; + const options = { getSkyflowId: () => 'id-1' }; + expect(() => validateUploadFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.MISSING_COLUMN_NAME_IN_UPLOAD_FILE); + }); + + test('validateUploadFileRequest rejects invalid columnName', () => { + const request = { + _table: 'users', + table: 'users', + _columnName: ' ', + columnName: ' ', + _skyflowId: 'id-1', + }; + const options = { getSkyflowId: () => 'id-1' }; + expect(() => validateUploadFileRequest(request, options)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_COLUMN_NAME_IN_UPLOAD_FILE); + }); + + test('validateSkyflowCredentials rejects empty credentials string', () => { + expect(() => validateSkyflowCredentials({ credentialsString: '' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + }); + + test('validateSkyflowCredentials rejects invalid JSON credentials string', () => { + expect(() => validateSkyflowCredentials({ credentialsString: 'not-json' })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + }); + + test('validateSkyflowCredentials rejects boolean context on string credentials', () => { + const credentials = { + credentialsString: JSON.stringify({ clientID: 'c', keyID: 'k' }), + context: true, + }; + expect(() => validateSkyflowCredentials(credentials)) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_CONTEXT); + }); + + test('validateInsertRequest rejects undefined data', () => { + expect(() => validateInsertRequest({ table: 'users', data: undefined })) + .toThrow(SKYFLOW_ERROR_CODE.EMPTY_DATA_IN_INSERT); + }); + + test('validateInsertRequest rejects falsy record in data array', () => { + expect(() => validateInsertRequest({ table: 'users', data: [false] })) + .toThrow(SKYFLOW_ERROR_CODE.EMPTY_RECORD_IN_INSERT); + }); + + test('validateInsertRequest rejects record with only null values and empty keys', () => { + const record = Object.create(null); + record[''] = 'value'; + expect(() => validateInsertRequest({ table: 'users', data: [record] })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + }); + + test('validateSkyflowCredentials accepts legacy credential field aliases', () => { + const payload = { + clientID: 'client', + keyID: 'key', + tokenURI: 'https://example.com/oauth/token', + }; + expect(() => validateSkyflowCredentials({ + credentialsString: JSON.stringify(payload), + })).not.toThrow(); + }); + + test('validateSkyflowConfig accepts connectionConfigs without vaultConfigs key', () => { + expect(() => validateSkyflowConfig({ connectionConfigs: [] })).not.toThrow(); + }); + + test('validateVaultConfig accepts config without env', () => { + expect(() => validateVaultConfig({ + vaultId: 'vault-1', + clusterId: 'cluster-1', + })).not.toThrow(); + }); + + test('validateUpdateVaultConfig accepts partial update without env or clusterId', () => { + expect(() => validateUpdateVaultConfig({ vaultId: 'vault-1' })).not.toThrow(); + }); + + test('validateUpdateVaultConfig rejects invalid env when provided', () => { + expect(() => validateUpdateVaultConfig({ + vaultId: 'vault-1', + env: 'NOT_ENV', + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_ENV); + }); + + test('validateUpdateVaultConfig rejects non-string clusterId when provided', () => { + expect(() => validateUpdateVaultConfig({ + vaultId: 'vault-1', + clusterId: 99, + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_CLUSTER_ID); + }); + + test('validateInsertRequest rejects empty object and invalid field keys in records', () => { + const base = { _table: 'users', table: 'users' }; + expect(() => validateInsertRequest({ ...base, data: [{}] })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [{ '': 'x' }] })) + .toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_INSERT); + expect(() => validateInsertRequest({ ...base, data: [{ name: 'ok' }] })) + .not.toThrow(); + }); + + test('validateUpdateRequest rejects array data and empty field keys', () => { + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: [], + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_UPDATE); + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: { skyflow_id: 'id-1' }, + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_UPDATE); + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: { skyflow_id: 'id-1', '': 'x' }, + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_RECORD_IN_UPDATE); + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: { skyflow_id: 'id-1', field: 'v' }, + })).not.toThrow(); + }); + + test('validateDeidentifyFileRequest accepts fully populated valid options', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'lstatSync').mockReturnValue({ isDirectory: () => true }); + const file = new File(['content'], 'sample.txt', { type: 'text/plain' }); + const request = new DeidentifyFileRequest({ file }); + const options = new DeidentifyFileOptions(); + const tokenFormat = new TokenFormat(); + tokenFormat.setDefault('entity_unique_counter'); + const transformations = new Transformations(); + transformations.setShiftDays({ max: 5, min: 1, entities: ['DATE'] }); + const bleep = new Bleep(); + bleep.setGain(0.5); + bleep.setFrequency(440); + options.setWaitTime(10); + options.setOutputDirectory('/valid/output/dir'); + options.setEntities(['NAME']); + options.setAllowRegexList(['allow']); + options.setRestrictRegexList(['restrict']); + options.setTokenFormat(tokenFormat); + options.setTransformations(transformations); + options.setOutputProcessedImage(true); + options.setOutputOcrText(true); + options.setMaskingMethod(MaskingMethod.Blur); + options.setPixelDensity(300); + options.setMaxResolution(1920); + options.setOutputProcessedAudio(true); + options.setOutputTranscription('transcription'); + options.setBleep(bleep); + expect(() => validateDeidentifyFileRequest(request, options)).not.toThrow(); + }); + + test('validateDeIdentifyTextRequest accepts valid options', () => { + const options = new (require('../../src/vault/model/options/deidentify-text').default)(); + const tokenFormat = new TokenFormat(); + const transformations = new Transformations(); + transformations.setShiftDays({ max: 3, min: 1, entities: ['DATE'] }); + options.setEntities(['NAME']); + options.setAllowRegexList(['a']); + options.setRestrictRegexList(['b']); + options.setTokenFormat(tokenFormat); + options.setTransformations(transformations); + expect(() => validateDeIdentifyTextRequest({ text: 'hello' }, options)).not.toThrow(); + }); + + test('validateReidentifyTextRequest accepts valid entity lists', () => { + const ReidentifyTextOptions = require('../../src/vault/model/options/reidentify-text').default; + const options = new ReidentifyTextOptions(); + options.setRedactedEntities(['NAME']); + options.setMaskedEntities(['EMAIL']); + options.setPlainTextEntities(['PHONE']); + expect(() => validateReidentifyTextRequest({ text: 'redacted' }, options)).not.toThrow(); + }); + + test('validateInvokeConnectionRequest accepts string body with content type', () => { + expect(() => validateInvokeConnectionRequest({ + method: 'POST', + body: 'raw body', + headers: { 'Content-Type': 'text/plain' }, + pathParams: { id: '1', meta: { nested: 'x' } }, + queryParams: { q: 'search' }, + })).not.toThrow(); + }); + + test('validateInsertRequest accepts ENABLE_STRICT token mode with matching tokens', () => { + expect(() => validateInsertRequest({ + _table: 'users', + table: 'users', + data: [{ name: 'value' }], + }, { + getTokenMode: () => TokenMode.ENABLE_STRICT, + getTokens: () => [{ name: 'token-value' }], + })).not.toThrow(); + }); + + test('validateInsertOptions short-circuits when optional getters return falsy', () => { + expect(() => validateInsertOptions({ + getReturnTokens: () => false, + getUpsertColumn: () => '', + getContinueOnError: () => null, + getHomogeneous: () => 0, + getTokenMode: () => undefined, + getTokens: () => [], + })).not.toThrow(); + }); + + test('validateUpdateOptions short-circuits when optional getters return falsy', () => { + expect(() => validateUpdateOptions({ + getReturnTokens: () => false, + getTokenMode: () => null, + getTokens: () => undefined, + })).not.toThrow(); + }); + + test('validateGetOptions short-circuits when optional getters return falsy', () => { + expect(() => validateGetOptions({ + getReturnTokens: () => false, + getRedactionType: () => undefined, + getOffset: () => 0, + getLimit: () => '', + getDownloadUrl: () => false, + getColumnName: () => null, + getOrderBy: () => undefined, + getFields: () => [], + getColumnValues: () => false, + })).not.toThrow(); + }); + + test('validateDetokenizeOptions short-circuits when optional getters return falsy', () => { + expect(() => validateDetokenizeOptions({ + getContinueOnError: () => false, + getDownloadUrl: () => null, + })).not.toThrow(); + }); + + test('validateSkyflowCredentials rejects null clientId and accepts canonical fields', () => { + expect(() => validateSkyflowCredentials({ + credentialsString: JSON.stringify({ clientId: null, keyId: 'k' }), + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_PARSED_CREDENTIALS_STRING); + expect(() => validateSkyflowCredentials({ + credentialsString: JSON.stringify({ + clientId: 'c', + keyId: 'k', + tokenUri: 'https://example.com', + }), + })).not.toThrow(); + }); + + test('validateUpdateConnectionConfig accepts connectionId without connectionUrl', () => { + expect(() => validateUpdateConnectionConfig({ + connectionId: 'conn-1', + })).not.toThrow(); + }); + + test('validateConnectionConfig rejects whitespace-only connectionUrl', () => { + expect(() => validateConnectionConfig({ + connectionId: 'conn-1', + connectionUrl: ' ', + })).toThrow(SKYFLOW_ERROR_CODE.INVALID_CONNECTION_URL); + }); + + test('validateUpdateRequest rejects update data missing skyflow id', () => { + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: { name: 'only-field' }, + })).toThrow(SKYFLOW_ERROR_CODE.MISSING_SKYFLOW_ID_IN_UPDATE); + }); + + test('validateUpdateRequest warns when legacy skyflow_id is used', () => { + expect(() => validateUpdateRequest({ + _table: 'users', + table: 'users', + data: { skyflow_id: 'legacy-id', name: 'value' }, + })).not.toThrow(); + }); +}); diff --git a/test/vault/client/client.test.js b/test/vault/client/client.test.js index 98eb5ea3..bfb02efd 100644 --- a/test/vault/client/client.test.js +++ b/test/vault/client/client.test.js @@ -557,6 +557,469 @@ describe('VaultClient', () => { expect(err).toBeInstanceOf(SkyflowError); } }); + + test('should handle JSON error with non-array details and error-from-client false (rawResponse)', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-1'], + ['error-from-client', 'false'], + ]), + }, + body: { + error: { + message: 'JSON error', + http_code: 400, + grpc_code: 3, + details: { field: 'issue' }, + }, + }, + statusCode: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle legacy JSON error without message using default description', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-legacy'], + ]), + body: { + error: { + http_code: 500, + grpc_code: 13, + details: [], + }, + }, + status: 500, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle legacy JSON error with non-array details and error-from-client', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-legacy-2'], + ['error-from-client', 'true'], + ]), + body: { + error: { + message: 'Legacy JSON', + http_code: 403, + details: 'not-an-array', + }, + }, + status: 403, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format text error using rawBody when message is missing', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'text/plain'], + ['x-request-id', 'req-text'], + ]), + }, + body: { + error: {}, + rawBody: 'plain text failure', + }, + statusCode: 500, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle legacy text error without message in body', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'text/plain'], + ['x-request-id', 'req-text-legacy'], + ]), + body: { + error: {}, + }, + status: 500, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format generic error using top-level message', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['x-request-id', 'req-generic'], + ]), + }, + message: 'Top-level API failure', + body: {}, + statusCode: 502, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format generic error with undefined headers', async () => { + const errorResponse = { + rawResponse: { + headers: undefined, + }, + body: { + error: { + message: 'No headers error', + grpc_code: 2, + details: [], + }, + }, + statusCode: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle legacy generic error with non-array details and error-from-client', async () => { + const errorResponse = { + headers: new Map([ + ['x-request-id', 'req-gen-legacy'], + ['error-from-client', 'true'], + ]), + body: { + error: { + message: 'Legacy generic', + details: { code: 'X' }, + }, + }, + status: 500, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format JSON error without error-from-client header', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-no-client-flag'], + ]), + }, + body: { + error: { + message: 'Server error', + http_code: 500, + grpc_code: 13, + details: [], + }, + }, + statusCode: 500, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format JSON error without message in error body', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-no-msg'], + ]), + }, + body: { + error: { + http_status: '400', + grpc_code: 3, + details: [], + }, + }, + statusCode: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format JSON error with array details and error-from-client', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-arr-details'], + ['error-from-client', 'true'], + ]), + }, + body: { + error: { + message: 'Validation failed', + http_code: 400, + grpc_code: 3, + details: [{ field: 'name' }], + }, + }, + statusCode: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle legacy JSON error without error-from-client header', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-legacy-no-flag'], + ]), + body: { + error: { + message: 'Legacy without flag', + http_code: 400, + details: [{ issue: 'bad' }], + }, + }, + status: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format text error with message in body', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'text/plain'], + ['x-request-id', 'req-text-msg'], + ]), + }, + body: { + error: { + message: 'Text error message', + http_code: 503, + grpc_code: 14, + }, + }, + statusCode: 503, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should handle new-format generic error with body error message', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([['x-request-id', 'req-gen-msg']]), + }, + body: { + error: { + message: 'Structured generic error', + grpc_code: 4, + details: ['detail-a'], + }, + }, + statusCode: 502, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should set errorFromClient to false when header is false (rawResponse)', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-false-flag'], + ['error-from-client', 'false'], + ]), + }, + body: { + error: { + message: 'Client flagged false', + http_code: 400, + details: [], + }, + }, + statusCode: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should set errorFromClient to false when header is false (legacy)', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-legacy-false-flag'], + ['error-from-client', 'false'], + ]), + body: { + error: { + message: 'Legacy client flagged false', + http_code: 400, + details: [], + }, + }, + status: 400, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should use statusCode on new-format error when body http_code is missing', async () => { + const errorResponse = { + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-status-only'], + ]), + }, + body: { + error: { + message: 'Status only', + grpc_code: 3, + }, + }, + statusCode: 418, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + + test('should use legacy http_code in logAndRejectError when isNewFormat is false', async () => { + const errorResponse = { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-legacy-http'], + ]), + body: { + error: { + message: 'Legacy http code', + http_code: 422, + grpc_code: 9, + }, + }, + status: 422, + }; + await expect(vaultClient.failureResponse(errorResponse)).rejects.toBeInstanceOf(SkyflowError); + }); + }); + + describe('error handler internals', () => { + const reject = jest.fn(); + + beforeEach(() => { + reject.mockClear(); + }); + + test('normalizeErrorMeta handles new and legacy header shapes', () => { + const newMeta = vaultClient['normalizeErrorMeta']({ + rawResponse: { + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-1'], + ['error-from-client', 'false'], + ]), + }, + }); + expect(newMeta.isNewFormat).toBe(true); + expect(newMeta.requestId).toBe('req-1'); + expect(newMeta.errorFromClient).toBe(false); + + const legacyMeta = vaultClient['normalizeErrorMeta']({ + headers: new Map([['x-request-id', 'legacy-req']]), + }); + expect(legacyMeta.isNewFormat).toBe(false); + expect(legacyMeta.requestId).toBe('legacy-req'); + expect(legacyMeta.errorFromClient).toBeUndefined(); + }); + + test('handleJsonError covers new and legacy branches', () => { + vaultClient['handleJsonError']({ + rawResponse: { headers: new Map() }, + body: { error: { message: 'new', http_status: '400', details: [] } }, + statusCode: 400, + }, { message: 'new' }, 'req', reject, true); + + vaultClient['handleJsonError']({ + headers: new Map([['content-type', 'application/json']]), + body: { error: { http_code: 422, details: 'x' } }, + }, { http_code: 422 }, 'legacy-req', reject, false); + + expect(reject).toHaveBeenCalled(); + }); + + test('handleTextError covers new and legacy branches', () => { + vaultClient['handleTextError']({ + rawResponse: { headers: new Map() }, + body: { error: { message: 'text' }, rawBody: 'fallback' }, + }, { message: 'text' }, 'req', reject, true); + + vaultClient['handleTextError']({ + headers: new Map([['content-type', 'text/plain']]), + body: { error: {} }, + }, {}, 'legacy-req', reject, false); + + expect(reject).toHaveBeenCalled(); + }); + + test('handleGenericError covers message fallbacks', () => { + vaultClient['handleGenericError']({ + rawResponse: { headers: new Map() }, + message: 'top-level', + body: { error: { grpc_code: 1 } }, + }, 'req', reject, undefined); + + vaultClient['handleGenericError']({ + rawResponse: { headers: new Map() }, + body: {}, + }, 'req-no-body', reject, undefined); + + vaultClient['handleGenericError']({ + headers: new Map(), + body: { error: { message: 'legacy generic' } }, + }, 'legacy-req', reject, true); + + expect(reject).toHaveBeenCalled(); + }); + + test('handleJsonError uses non-array details when errorFromClient is set', () => { + vaultClient['handleJsonError']({ + rawResponse: { headers: new Map() }, + body: { error: { message: 'err', details: { reason: 'x' } } }, + }, { message: 'err', details: { reason: 'x' } }, 'req', reject, true); + expect(reject).toHaveBeenCalled(); + }); + + test('handleTextError uses rawBody fallback for new-format errors', () => { + vaultClient['handleTextError']({ + rawResponse: { headers: new Map() }, + body: { error: {}, rawBody: 'raw failure text' }, + }, { rawBody: 'raw failure text' }, 'req', reject, true); + expect(reject).toHaveBeenCalled(); + }); + + test('logAndRejectError uses new and legacy http codes', () => { + vaultClient['logAndRejectError']( + 'desc', + { statusCode: 418, body: { error: {} } }, + 'req', + reject, + undefined, + 3, + [], + true + ); + vaultClient['logAndRejectError']( + 'legacy desc', + { body: { error: { http_code: 409 } } }, + 'legacy-req', + reject, + 409, + 7, + [], + false + ); + expect(reject).toHaveBeenCalledTimes(2); + }); + }); + + describe('getCredentials with updateTriggered', () => { + test('should skip token reuse when updateTriggered is true', () => { + isExpired.mockReturnValue(false); + vaultClient.authInfo = { key: token, type: AuthType.TOKEN }; + vaultClient.updateTriggered = true; + const credentials = vaultClient.getCredentials(); + expect(credentials).toEqual({ apiKey }); + expect(vaultClient.updateTriggered).toBe(false); + }); }); describe('updateClientConfig', () => { From 56cc9181d5a01c9bfbe420fabc066bf978acc1e2 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 09:57:57 +0000 Subject: [PATCH 16/18] [AUTOMATED] Private Release 2.0.4-dev.bd55be2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e89a3a1f..d09b3e26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.478d5b2", + "version": "2.0.4-dev.bd55be2", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js", From d7aca66b365499514ea24426a00cf4dd7f01c773 Mon Sep 17 00:00:00 2001 From: Aadarsh Date: Mon, 25 May 2026 15:45:52 +0530 Subject: [PATCH 17/18] SK-2841: Updated toekn coverage --- test/service-account/token.test.js | 63 +++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/test/service-account/token.test.js b/test/service-account/token.test.js index 36ef4355..9d73568f 100644 --- a/test/service-account/token.test.js +++ b/test/service-account/token.test.js @@ -317,11 +317,10 @@ describe('Signed Data Token Generation Test', () => { test("Valid credentials file", async () => { const filePath = 'test/demo-credentials/valid.json'; jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); - try { - await generateSignedDataTokens(filePath, defaultOptions); - } catch (err) { - expect(err.message).toBe(errorMessages.MISSING_TOKEN_URI); - } + const result = await generateSignedDataTokens(filePath, defaultOptions); + expect(result).toHaveLength(1); + expect(result[0].token).toBe('datatoken1'); + expect(result[0].signedToken).toContain('signed_token_'); }); test("File does not exist", async () => { @@ -603,6 +602,44 @@ describe('failureResponse with rawResponse', () => { await expect(failureResponse(err)).rejects.toBeDefined(); }); + test("handles application/json with error http_code and no request id", async () => { + const err = { + rawResponse: { + headers: { + get: (key) => + key === 'content-type' ? 'application/json' : undefined, + }, + }, + body: { error: { message: 'structured error', http_code: 422 } }, + }; + await expect(failureResponse(err)).rejects.toMatchObject({ + error: expect.objectContaining({ + httpCode: 422, + message: 'structured error', + }), + }); + }); + + test("handles application/json when body has no error.message", async () => { + const err = { + rawResponse: { headers: makeHeaders('application/json') }, + body: { code: 'ERR', detail: 'no nested message' }, + }; + await expect(failureResponse(err)).rejects.toMatchObject({ + error: expect.objectContaining({ + message: { code: 'ERR', detail: 'no nested message' }, + }), + }); + }); + + test("handles rawResponse without headers", async () => { + const err = { + rawResponse: {}, + body: { error: { message: 'no headers' } }, + }; + await expect(failureResponse(err)).rejects.toBeDefined(); + }); + test("should use tokenUri from options if provided and valid", async () => { const validCredsString = JSON.stringify(validCredentials); const validTokenOptions = { tokenUri: "https://override-token-uri.com" }; @@ -1047,7 +1084,21 @@ describe('service-account branch and line coverage', () => { }, body: { error: { http_code: 503 } }, }; - await expect(failureResponse(err, { logLevel: LogLevel.ERROR })).rejects.toBeDefined(); + await expect(failureResponse(err, { logLevel: LogLevel.ERROR })).rejects.toMatchObject({ + error: expect.objectContaining({ httpCode: 503 }), + }); + }); + + test('getToken accepts canonical clientId, keyId, and tokenUri credential fields', async () => { + jest.spyOn(jwt, 'sign').mockReturnValue('mocked_token'); + const creds = JSON.stringify({ + clientId: 'canonical-client', + keyId: 'canonical-key', + tokenUri: 'https://canonical-token-uri.com', + privateKey: 'KEY', + }); + const result = await getToken(creds); + expect(result.accessToken).toBe('mocked_access_token'); }); test('generateSignedDataTokensFromCreds with null timeToLive uses default expiry', async () => { From 636dd416e460645399cd8a5d1159cc76215834f6 Mon Sep 17 00:00:00 2001 From: aadarsh-st Date: Mon, 25 May 2026 10:16:22 +0000 Subject: [PATCH 18/18] [AUTOMATED] Private Release 2.0.4-dev.d7aca66 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d09b3e26..9f50fa3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skyflow-node", - "version": "2.0.4-dev.bd55be2", + "version": "2.0.4-dev.d7aca66", "description": "Skyflow SDK for Node.js", "main": "./lib/index.js", "module": "./lib/index.js",