mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(ai-builder): Paginate list-credentials tool and drop unused fields (#28108)
This commit is contained in:
@@ -26,8 +26,6 @@ function makeCredentialDetail(overrides?: Partial<CredentialDetail>): Credential
|
||||
id: 'cred-123',
|
||||
name: 'My Slack Token',
|
||||
type: 'slackApi',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-06-15T12:00:00.000Z',
|
||||
nodesWithAccess: [{ nodeType: 'n8n-nodes-base.slack' }],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import type { InstanceAiContext, CredentialSummary } from '../../../types';
|
||||
import { createListCredentialsTool, listCredentialsInputSchema } from '../list-credentials.tool';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockContext(): InstanceAiContext {
|
||||
return {
|
||||
userId: 'test-user',
|
||||
workflowService: {} as InstanceAiContext['workflowService'],
|
||||
executionService: {} as InstanceAiContext['executionService'],
|
||||
credentialService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
},
|
||||
nodeService: {} as InstanceAiContext['nodeService'],
|
||||
dataTableService: {} as InstanceAiContext['dataTableService'],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCredential(overrides?: Partial<CredentialSummary>): CredentialSummary {
|
||||
return {
|
||||
id: 'cred-1',
|
||||
name: 'Gmail OAuth',
|
||||
type: 'gmailOAuth2',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('list-credentials tool', () => {
|
||||
describe('schema validation', () => {
|
||||
it('accepts empty input', () => {
|
||||
const result = listCredentialsInputSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a type filter', () => {
|
||||
const result = listCredentialsInputSchema.safeParse({ type: 'gmailOAuth2' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts pagination params', () => {
|
||||
const result = listCredentialsInputSchema.safeParse({ limit: 10, offset: 20 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns credentials with total count', async () => {
|
||||
const context = createMockContext();
|
||||
const credentials = [makeCredential()];
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createListCredentialsTool(context);
|
||||
const result = (await tool.execute!({}, {} as never)) as {
|
||||
credentials: Array<{ id: string; name: string; type: string }>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.credentials).toEqual([
|
||||
{ id: 'cred-1', name: 'Gmail OAuth', type: 'gmailOAuth2' },
|
||||
]);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('passes type filter to the list call', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const tool = createListCredentialsTool(context);
|
||||
await tool.execute!({ type: 'gmailOAuth2' }, {} as never);
|
||||
|
||||
expect(context.credentialService.list).toHaveBeenCalledWith({ type: 'gmailOAuth2' });
|
||||
});
|
||||
|
||||
it('paginates results with limit and offset', async () => {
|
||||
const context = createMockContext();
|
||||
const credentials = Array.from({ length: 5 }, (_, i) =>
|
||||
makeCredential({ id: `cred-${i}`, name: `Cred ${i}` }),
|
||||
);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createListCredentialsTool(context);
|
||||
const result = (await tool.execute!({ limit: 2, offset: 1 }, {} as never)) as {
|
||||
credentials: Array<{ id: string }>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.credentials).toHaveLength(2);
|
||||
expect(result.credentials[0].id).toBe('cred-1');
|
||||
expect(result.credentials[1].id).toBe('cred-2');
|
||||
});
|
||||
|
||||
it('uses default limit of 50', async () => {
|
||||
const context = createMockContext();
|
||||
const credentials = Array.from({ length: 60 }, (_, i) =>
|
||||
makeCredential({ id: `cred-${i}`, name: `Cred ${i}` }),
|
||||
);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createListCredentialsTool(context);
|
||||
const result = (await tool.execute!({}, {} as never)) as {
|
||||
credentials: Array<{ id: string }>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.total).toBe(60);
|
||||
expect(result.credentials).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,6 @@ export function createGetCredentialTool(context: InstanceAiContext) {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
nodesWithAccess: z.array(z.object({ nodeType: z.string() })).optional(),
|
||||
}),
|
||||
execute: async (inputData: z.infer<typeof getCredentialInputSchema>) => {
|
||||
|
||||
@@ -3,14 +3,33 @@ import { z } from 'zod';
|
||||
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
export const listCredentialsInputSchema = z.object({
|
||||
type: z.string().optional().describe('Filter by credential type (e.g. "notionApi")'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe(
|
||||
`Max credentials to return (default ${DEFAULT_LIMIT}, max 200). Use with offset to paginate.`,
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Number of credentials to skip (default 0). Use with limit to paginate.'),
|
||||
});
|
||||
|
||||
export function createListCredentialsTool(context: InstanceAiContext) {
|
||||
return createTool({
|
||||
id: 'list-credentials',
|
||||
description: 'List credentials accessible to the current user. Never exposes secret data.',
|
||||
description:
|
||||
'List credentials accessible to the current user. Never exposes secret data. ' +
|
||||
'Results are paginated — use limit/offset to page through large sets, or filter by type to narrow results.',
|
||||
inputSchema: listCredentialsInputSchema,
|
||||
outputSchema: z.object({
|
||||
credentials: z.array(
|
||||
@@ -18,16 +37,24 @@ export function createListCredentialsTool(context: InstanceAiContext) {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}),
|
||||
),
|
||||
total: z.number().describe('Total number of credentials matching the query'),
|
||||
}),
|
||||
execute: async (inputData: z.infer<typeof listCredentialsInputSchema>) => {
|
||||
const credentials = await context.credentialService.list({
|
||||
const allCredentials = await context.credentialService.list({
|
||||
type: inputData.type,
|
||||
});
|
||||
return { credentials };
|
||||
|
||||
const total = allCredentials.length;
|
||||
const offset = inputData.offset ?? 0;
|
||||
const limit = inputData.limit ?? DEFAULT_LIMIT;
|
||||
const page = allCredentials.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
credentials: page.map(({ id, name, type }) => ({ id, name, type })),
|
||||
total,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { InstanceAiContext } from '../../types';
|
||||
/** Cache for deduplicating credential fetches across nodes with the same types. */
|
||||
export interface CredentialCache {
|
||||
/** Credential list promises, keyed by credential type. */
|
||||
lists: Map<string, Promise<Array<{ id: string; name: string; updatedAt: string }>>>;
|
||||
lists: Map<string, Promise<Array<{ id: string; name: string }>>>;
|
||||
/** Testability check promises, keyed by credential type. */
|
||||
testability: Map<string, Promise<boolean>>;
|
||||
/** Credential test result promises, keyed by credential ID. */
|
||||
@@ -136,11 +136,7 @@ export async function buildSetupRequests(
|
||||
if (!listPromise) {
|
||||
listPromise = context.credentialService
|
||||
.list({ type: credentialType })
|
||||
.then((creds) =>
|
||||
creds
|
||||
.map((c) => ({ id: c.id, name: c.name, updatedAt: c.updatedAt }))
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
|
||||
);
|
||||
.then((creds) => creds.map((c) => ({ id: c.id, name: c.name })));
|
||||
cache?.lists.set(credentialType, listPromise);
|
||||
}
|
||||
const sortedCreds = await listPromise;
|
||||
|
||||
@@ -88,8 +88,6 @@ export interface CredentialSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialDetail extends CredentialSummary {
|
||||
|
||||
@@ -824,8 +824,6 @@ export class InstanceAiAdapterService {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -836,8 +834,6 @@ export class InstanceAiAdapterService {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
createdAt: credential.createdAt.toISOString(),
|
||||
updatedAt: credential.updatedAt.toISOString(),
|
||||
} satisfies CredentialDetail;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user