fix(ai-builder): Tolerate id-less credential refs in eval mock execution (no-changelog) (#31428)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
José Braulio González Valido 2026-06-01 11:39:11 +01:00 committed by GitHub
parent 4188a7c4ed
commit b20d740bd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 78 additions and 23 deletions

View File

@ -415,11 +415,7 @@ describe('EvalMockedCredentialsHelper', () => {
});
describe('getDecrypted — schema synthesis when id is null', () => {
// Core's eval-mode bypass passes `{ id: null, name: type }` when a node
// has no credentials configured at all. The inner helper throws
// CredentialNotFoundError on a null id; the catch below schema-synthesizes
// (and applies the URL rewrite) so vendor SDK traffic stays inside the
// wire server instead of escaping to the real provider with 401.
// `{ id: null }` short-circuits to schema synthesis without delegating to the inner helper.
const propsSchema = [
{
name: 'apiKey',
@ -441,7 +437,7 @@ describe('EvalMockedCredentialsHelper', () => {
function makeSynthesizingInner(): ICredentialsHelper {
return makeInner({
getCredentialsProperties: jest.fn().mockReturnValue(propsSchema),
// Inner throws on a null-id lookup → catch fires → schema synthesis.
// Not reached for a null id (short-circuits first); left rejecting so a regression fails loudly.
getDecrypted: jest.fn().mockRejectedValue(new CredentialNotFoundError('null', 'openAiApi')),
});
}
@ -569,6 +565,65 @@ describe('EvalMockedCredentialsHelper', () => {
});
});
describe('no-id credential references — regression for "Found credential with no ID."', () => {
// Id-less refs (builder "set up later" placeholders, core's `{ id: null }`) make the real
// inner throw UnexpectedError, not CredentialNotFoundError — so the helper must synthesize, not delegate.
const propsSchema = [
{
name: 'apiKey',
displayName: 'API Key',
type: 'string' as const,
default: '',
typeOptions: { password: true },
},
];
it.each([
['null id (fully-unconfigured bypass)', { id: null, name: 'telegramApi' }],
['empty-string id (placeholder)', { id: '', name: 'Telegram cred' }],
['missing id (placeholder)', { name: 'Telegram cred' }],
])('synthesizes without delegating to inner — %s', async (_label, creds) => {
const inner = makeInner({
getCredentialsProperties: jest.fn().mockReturnValue(propsSchema),
// Stands in for core's UnexpectedError on a falsy id — fails loudly if the short-circuit regresses.
getDecrypted: jest.fn().mockRejectedValue(new Error('Found credential with no ID.')),
});
const helper = new EvalMockedCredentialsHelper(inner);
const result = await helper.getDecrypted(
fakeAdditionalData,
creds as INodeCredentialsDetails,
'telegramApi',
'manual',
{ node: fakeNode } as IExecuteData,
);
expect(result.__evalMockedCredential).toBe(true);
expect(inner.getDecrypted).not.toHaveBeenCalled();
expect(helper.mockedCredentials).toEqual([
{ nodeName: 'Telegram', credentialType: 'telegramApi', credentialId: undefined },
]);
});
it('still delegates (and surfaces the throw) when an id IS present', async () => {
// A present id whose lookup fails with a non-CredentialNotFoundError must still propagate.
const inner = makeInner({
getDecrypted: jest.fn().mockRejectedValue(new Error('database is down')),
});
const helper = new EvalMockedCredentialsHelper(inner);
await expect(
helper.getDecrypted(
fakeAdditionalData,
{ id: 'real-id', name: 'Telegram cred' },
'telegramApi',
'manual',
),
).rejects.toThrow('database is down');
expect(inner.getDecrypted).toHaveBeenCalled();
});
});
describe('authenticate', () => {
it('passes the request through unchanged for marker payloads', async () => {
const inner = makeInner();

View File

@ -104,6 +104,21 @@ export class EvalMockedCredentialsHelper extends ICredentialsHelper {
raw?: boolean,
expressionResolveValues?: ICredentialsExpressionResolveValues,
): Promise<ICredentialDataDecryptedObject> {
// Id-less refs make the inner helper throw UnexpectedError (not CredentialNotFoundError),
// which the catch below won't handle — synthesize a mock here instead of delegating.
if (!nodeCredentials.id) {
this.mockedCredentials.push({
nodeName: executeData?.node?.name ?? 'unknown',
credentialType: type,
credentialId: undefined,
});
const synthesized = {
...buildEvalMockCredentials(this.inner.getCredentialsProperties(type)),
[MOCK_MARKER]: true,
} as ICredentialDataDecryptedObject;
return this.applyServerUrlRewrite(synthesized, type, nodeCredentials, executeData);
}
let credentials: ICredentialDataDecryptedObject;
try {
credentials = await this.inner.getDecrypted(
@ -118,28 +133,13 @@ export class EvalMockedCredentialsHelper extends ICredentialsHelper {
} catch (error) {
if (!(error instanceof CredentialNotFoundError)) throw error;
// id present but absent from the DB — a bare marker stub is enough; URL rewrite still runs below.
this.mockedCredentials.push({
nodeName: executeData?.node?.name ?? 'unknown',
credentialType: type,
credentialId: nodeCredentials.id ?? undefined,
});
// When called with no credential id (eval-mode bypass for nodes
// with no credentials of any type configured), schema-synthesize
// so the wire-server URL rewrite below has a real `url` field to
// augment. Otherwise vendor SDK traffic would escape to the real
// provider with placeholder values and 401 at the wire layer.
// `buildEvalMockCredentials` is typed `Record<string, unknown>` —
// schema defaults can be richer than `CredentialInformation`, but
// at runtime emits only JSON-shaped values, which is what the
// rewrite path consumes.
credentials =
nodeCredentials.id === null
? ({
...buildEvalMockCredentials(this.inner.getCredentialsProperties(type)),
[MOCK_MARKER]: true,
} as ICredentialDataDecryptedObject)
: { [MOCK_MARKER]: true };
credentials = { [MOCK_MARKER]: true };
}
return this.applyServerUrlRewrite(credentials, type, nodeCredentials, executeData);