diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d963b3..f889b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.3 + +- Add a credential test to the BatchData API credential (n8n Creator Portal review: "missing credential test"). It sends `GET /api/v2/property/search-sessions?take=1` with the Bearer token from `authenticate`: a valid token returns 200 and a bad token returns 401. The call is read-only and does not bill property records. 401, 403 and 429 get clear messages. +- Remove the node-level `testedBy` credential test methods, so the credential's own test is the single check for every node and the HTTP Request node. + + ## 0.2.2 - Pass `npx @n8n/scan-community-package`: move input-error throws out of `catch` blocks, prefer `httpRequest` in the credential test, and rename the Custom API Call operation to "Make" so the official scanner (which ignores eslint-disable comments) is clean. diff --git a/credentials/BatchDataApi.credentials.ts b/credentials/BatchDataApi.credentials.ts index fa85727..8e6523c 100644 --- a/credentials/BatchDataApi.credentials.ts +++ b/credentials/BatchDataApi.credentials.ts @@ -1,4 +1,10 @@ -import type { IAuthenticateGeneric, Icon, ICredentialType, INodeProperties } from 'n8n-workflow'; +import type { + IAuthenticateGeneric, + Icon, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; export class BatchDataApi implements ICredentialType { name = 'batchDataApi'; @@ -33,4 +39,37 @@ export class BatchDataApi implements ICredentialType { }, }, }; + + // Lists search sessions (read-only, does not bill property records): a valid token gets + // 200, a bad token gets 401. The Bearer header from `authenticate` is applied to this request. + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.batchdata.com/api', + url: '/v2/property/search-sessions', + method: 'GET', + headers: { Accept: 'application/json' }, + qs: { take: 1 }, + }, + rules: [ + { + type: 'responseCode', + properties: { value: 401, message: 'The API token was rejected (401). Check the token.' }, + }, + { + type: 'responseCode', + properties: { + value: 403, + message: + 'The API token was refused (403). The credential test lists search sessions, so the token needs the property search sessions permission. Questions: support@batchservice.com', + }, + }, + { + type: 'responseCode', + properties: { + value: 429, + message: 'Rate limit reached (429). Try the test again shortly.', + }, + }, + ], + }; } diff --git a/nodes/BatchData/BatchData.node.ts b/nodes/BatchData/BatchData.node.ts index c941e95..ee5a2e7 100644 --- a/nodes/BatchData/BatchData.node.ts +++ b/nodes/BatchData/BatchData.node.ts @@ -28,7 +28,6 @@ import { RESOURCE_VALUE, } from './catalogProperties'; import { API_HOST } from './constants'; -import { credentialTestMethods } from './credentialMethods'; import { addressFields, addressOperations, @@ -107,7 +106,6 @@ export class BatchData implements INodeType { { name: 'batchDataApi', required: true, - testedBy: 'batchDataApiTest', }, ], properties: [ @@ -153,7 +151,6 @@ export class BatchData implements INodeType { return await listForDropdown(this, '/api/v2/property-subscription', 'subscriptions'); }, }, - credentialTest: credentialTestMethods, }; async execute(this: IExecuteFunctions): Promise { diff --git a/nodes/BatchData/credentialMethods.ts b/nodes/BatchData/credentialMethods.ts deleted file mode 100644 index b303533..0000000 --- a/nodes/BatchData/credentialMethods.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - ICredentialsDecrypted, - ICredentialTestFunctions, - INodeCredentialTestResult, -} from 'n8n-workflow'; - -import { API_HOST } from './constants'; -import { evaluateCredentialProbe } from './credentialTest'; - -type ProbeResponse = { statusCode?: number; status?: number }; - -type CredentialHelpers = ICredentialTestFunctions['helpers'] & { - httpRequest?: (options: { - method: 'POST'; - url: string; - headers: Record; - body: unknown; - json: boolean; - returnFullResponse: boolean; - ignoreHttpStatusErrors: boolean; - }) => Promise; -}; - -/** Shared by every BatchData node so the credential can be tested from any of them. */ -export const credentialTestMethods = { - async batchDataApiTest( - this: ICredentialTestFunctions, - credential: ICredentialsDecrypted, - ): Promise { - const token = String(credential.data?.apiToken ?? '').trim(); - if (token === '') { - return { status: 'Error', message: 'API token is empty' }; - } - const url = `${API_HOST}/api/v1/address/verify`; - const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' }; - const body = { requests: [] }; - try { - const helpers = this.helpers as CredentialHelpers; - let response: ProbeResponse; - if (typeof helpers.httpRequest === 'function') { - response = await helpers.httpRequest({ - method: 'POST', - url, - headers, - body, - json: true, - returnFullResponse: true, - ignoreHttpStatusErrors: true, - }); - } else { - // ICredentialTestFunctions typings only expose the legacy request helper. - // Call it by key so the community-node scanner, which ignores - // eslint-disable comments, does not flag helpers.request. - const legacyRequest = helpers['request'].bind(helpers); - response = await legacyRequest({ - method: 'POST', - uri: url, - headers, - body, - json: true, - resolveWithFullResponse: true, - simple: false, - }); - } - return evaluateCredentialProbe(Number(response?.statusCode ?? response?.status ?? 0)); - } catch (error) { - return { - status: 'Error', - message: `Could not reach the BatchData API: ${(error as Error).message}`, - }; - } - }, -}; diff --git a/nodes/BatchData/credentialTest.ts b/nodes/BatchData/credentialTest.ts deleted file mode 100644 index ef3b978..0000000 --- a/nodes/BatchData/credentialTest.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { INodeCredentialTestResult } from 'n8n-workflow'; - -import { SUPPORT_EMAIL } from './constants'; - -/** - * The credential check posts an empty batch to address verify. A valid token gets a - * 400 (the batch is empty and nothing is billed); an invalid token gets a 401. - */ -export function evaluateCredentialProbe(statusCode: number): INodeCredentialTestResult { - if (statusCode === 400 || (statusCode >= 200 && statusCode < 300)) { - return { status: 'OK', message: 'Authentication successful' }; - } - if (statusCode === 401) { - return { status: 'Error', message: 'The API token was rejected (401). Check the token.' }; - } - if (statusCode === 403) { - return { - status: 'Error', - message: `The API token was refused (403). Check the token permissions. Questions: ${SUPPORT_EMAIL}`, - }; - } - if (statusCode === 429) { - return { status: 'Error', message: 'Rate limit reached (429). Try the test again shortly.' }; - } - return { - status: 'Error', - message: `Unexpected response from the BatchData API (HTTP ${statusCode || 'unknown'}).`, - }; -} diff --git a/nodes/BatchDataMonitorTrigger/BatchDataMonitorTrigger.node.ts b/nodes/BatchDataMonitorTrigger/BatchDataMonitorTrigger.node.ts index 800b77d..4558672 100644 --- a/nodes/BatchDataMonitorTrigger/BatchDataMonitorTrigger.node.ts +++ b/nodes/BatchDataMonitorTrigger/BatchDataMonitorTrigger.node.ts @@ -11,7 +11,6 @@ import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { collectInputWith } from '../BatchData/catalogExecute'; import { buildRequest, getPath, setPath, toBoolean } from '../BatchData/catalogEngine'; import { catalog, opProperties } from '../BatchData/catalogProperties'; -import { credentialTestMethods } from '../BatchData/credentialMethods'; import { batchDataRequest, toNodeApiError } from '../BatchData/GenericFunctions'; import { eventRecord, rememberIds, subscriptionIdFrom } from '../BatchData/triggerSupport'; @@ -30,7 +29,7 @@ export class BatchDataMonitorTrigger implements INodeType { defaults: { name: 'BatchData Monitor Trigger' }, inputs: [], outputs: [NodeConnectionTypes.Main], - credentials: [{ name: 'batchDataApi', required: true, testedBy: 'batchDataApiTest' }], + credentials: [{ name: 'batchDataApi', required: true }], webhooks: [ { name: 'default', @@ -50,8 +49,6 @@ export class BatchDataMonitorTrigger implements INodeType { ], }; - methods = { credentialTest: credentialTestMethods }; - webhookMethods = { default: { async checkExists(this: IHookFunctions): Promise { diff --git a/nodes/BatchDataTrigger/BatchDataTrigger.node.ts b/nodes/BatchDataTrigger/BatchDataTrigger.node.ts index 66a7769..fc22827 100644 --- a/nodes/BatchDataTrigger/BatchDataTrigger.node.ts +++ b/nodes/BatchDataTrigger/BatchDataTrigger.node.ts @@ -10,7 +10,6 @@ import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { collectInputWith } from '../BatchData/catalogExecute'; import { buildRequest, extractResult } from '../BatchData/catalogEngine'; import { catalog, opProperties } from '../BatchData/catalogProperties'; -import { credentialTestMethods } from '../BatchData/credentialMethods'; import { batchDataRequest, toNodeApiError } from '../BatchData/GenericFunctions'; import { pollRecords, recordId, rememberIds } from '../BatchData/triggerSupport'; @@ -30,7 +29,7 @@ export class BatchDataTrigger implements INodeType { polling: true, inputs: [], outputs: [NodeConnectionTypes.Main], - credentials: [{ name: 'batchDataApi', required: true, testedBy: 'batchDataApiTest' }], + credentials: [{ name: 'batchDataApi', required: true }], properties: [ { displayName: 'Event', @@ -48,8 +47,6 @@ export class BatchDataTrigger implements INodeType { ], }; - methods = { credentialTest: credentialTestMethods }; - async poll(this: IPollFunctions): Promise { const event = this.getNodeParameter('event') as string; const op = pollingOps.find((entry) => entry.key === event); diff --git a/package-lock.json b/package-lock.json index 4488532..06d8fb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-batchdata", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-batchdata", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "@n8n/node-cli": "*", diff --git a/package.json b/package.json index 5535c9d..e44636f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-batchdata", - "version": "0.2.2", + "version": "0.2.3", "description": "n8n community nodes for the BatchData API: property search and lookup, skip trace, reverse skip trace, permits, investors, address and phone checks, search sessions, property monitors, polling and instant triggers", "license": "MIT", "homepage": "https://github.com/batchdataco/n8n-nodes-batchdata#readme", diff --git a/test/catalog.test.mjs b/test/catalog.test.mjs index 15cdf9c..c97b8bc 100644 --- a/test/catalog.test.mjs +++ b/test/catalog.test.mjs @@ -409,9 +409,9 @@ test('row-level Your Reference ID reaches the request in batch modules', async ( } }); -test('package metadata points at this GitHub repo and is a 0.2.2 release', () => { +test('package metadata points at this GitHub repo and is a 0.2.3 release', () => { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); - assert.equal(pkg.version, '0.2.2'); + assert.equal(pkg.version, '0.2.3'); assert.equal(pkg.repository.url, 'git+https://github.com/batchdataco/n8n-nodes-batchdata.git'); assert.match(pkg.homepage, /github\.com\/batchdataco\/n8n-nodes-batchdata/); assert.ok(pkg.keywords.includes('n8n-community-node-package')); diff --git a/test/unit.test.mjs b/test/unit.test.mjs index 998067e..567d11f 100644 --- a/test/unit.test.mjs +++ b/test/unit.test.mjs @@ -3,47 +3,39 @@ import { test } from 'node:test'; import { BatchData } from '../dist/nodes/BatchData/BatchData.node.js'; import { BatchDataApi } from '../dist/credentials/BatchDataApi.credentials.js'; -import { evaluateCredentialProbe } from '../dist/nodes/BatchData/credentialTest.js'; import { QUICKLISTS } from '../dist/nodes/BatchData/constants.js'; -test('credential probe: 400 and 2xx are valid; 401/403 are not', () => { - assert.equal(evaluateCredentialProbe(400).status, 'OK'); - assert.equal(evaluateCredentialProbe(200).status, 'OK'); - assert.equal(evaluateCredentialProbe(401).status, 'Error'); - assert.equal(evaluateCredentialProbe(403).status, 'Error'); - assert.equal(evaluateCredentialProbe(429).status, 'Error'); - assert.equal(evaluateCredentialProbe(500).status, 'Error'); - assert.equal(evaluateCredentialProbe(0).status, 'Error'); +test('credential defines a free test request: GET search sessions, one result', () => { + const { test: credTest } = new BatchDataApi(); + assert.ok(credTest, 'credential has a test'); + const { request } = credTest; + assert.equal(request.method, 'GET'); + assert.equal( + `${request.baseURL}${request.url}`, + 'https://api.batchdata.com/api/v2/property/search-sessions', + ); + assert.deepEqual(request.qs, { take: 1 }); + assert.equal(request.body, undefined); + // Auth comes from `authenticate`, not a hard-coded header on the test request. + assert.equal(request.headers?.Authorization, undefined); + const codes = credTest.rules + .filter((r) => r.type === 'responseCode') + .map((r) => r.properties.value); + assert.deepEqual(codes.sort(), [401, 403, 429]); }); -test('credential test method posts an empty batch with the bearer token', async () => { - const node = new BatchData(); - const seen = []; - const make = (statusCode, helper = 'httpRequest') => ({ - helpers: { - [helper]: async (options) => { - seen.push(options); - return { statusCode }; - }, - }, - }); - const credential = { data: { apiToken: ' secret-token ' } }; - const ok = await node.methods.credentialTest.batchDataApiTest.call(make(400), credential); - assert.equal(ok.status, 'OK'); - assert.equal(seen[0].url, 'https://api.batchdata.com/api/v1/address/verify'); - assert.deepEqual(seen[0].body, { requests: [] }); - assert.equal(seen[0].headers.Authorization, 'Bearer secret-token'); - const bad = await node.methods.credentialTest.batchDataApiTest.call(make(401), credential); - assert.equal(bad.status, 'Error'); - const empty = await node.methods.credentialTest.batchDataApiTest.call(make(200), { data: {} }); - assert.equal(empty.status, 'Error'); - seen.length = 0; - const legacy = await node.methods.credentialTest.batchDataApiTest.call( - make(400, 'request'), - credential, +test('nodes rely on the credential test, not a node-level testedBy method', async () => { + const { BatchDataTrigger } = await import( + '../dist/nodes/BatchDataTrigger/BatchDataTrigger.node.js' + ); + const { BatchDataMonitorTrigger } = await import( + '../dist/nodes/BatchDataMonitorTrigger/BatchDataMonitorTrigger.node.js' ); - assert.equal(legacy.status, 'OK'); - assert.equal(seen[0].uri, 'https://api.batchdata.com/api/v1/address/verify'); + for (const node of [new BatchData(), new BatchDataTrigger(), new BatchDataMonitorTrigger()]) { + assert.equal(node.description.credentials[0].name, 'batchDataApi'); + assert.equal(node.description.credentials[0].testedBy, undefined); + assert.equal(node.methods?.credentialTest, undefined); + } }); test('credential injects a Bearer header and declares a password field', () => { @@ -74,7 +66,6 @@ test('node description exposes the planned resources and no wallet or webhook fi names.some((n) => n.includes('webhook') || n.includes('callback')), false, ); - assert.equal(description.credentials[0].testedBy, 'batchDataApiTest'); }); test('quicklist constants have no duplicates', () => {