moving
This commit is contained in:
@@ -7,8 +7,16 @@ import { GitLabConnector } from '../../src/connectors/gitlab-connector.js';
|
||||
import { BitbucketConnector } from '../../src/connectors/bitbucket-connector.js';
|
||||
import { DropboxConnector } from '../../src/connectors/dropbox-connector.js';
|
||||
import { PostgresConnector } from '../../src/connectors/postgres-connector.js';
|
||||
import { safeFetch } from '../../src/url-egress-guard.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
|
||||
vi.mock('../../src/url-egress-guard.js', () => ({
|
||||
safeFetch: vi.fn((url: string, init?: RequestInit) => globalThis.fetch(url, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMockVault(connectorId: string, cred?: { value: string; isExpired: boolean }, extras?: Record<string, string>): VaultStore {
|
||||
return {
|
||||
getConnectorCredential: vi.fn((id: string) => {
|
||||
@@ -95,6 +103,7 @@ describe('SalesforceConnector', () => {
|
||||
beforeEach(() => {
|
||||
connector = new SalesforceConnector();
|
||||
originalFetch = globalThis.fetch;
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -119,6 +128,10 @@ describe('SalesforceConnector', () => {
|
||||
expect(names).toContain('list_opportunities');
|
||||
});
|
||||
|
||||
it('marks arbitrary SOQL search as high risk', () => {
|
||||
expect(connector.actions.find(action => action.name === 'search')?.riskLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('execute returns error when not connected (no token)', async () => {
|
||||
const result = await connector.execute('search', { query: 'SELECT Id FROM Account' });
|
||||
expect(result.success).toBe(false);
|
||||
@@ -141,9 +154,13 @@ describe('SalesforceConnector', () => {
|
||||
expect(def.tools).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('execute(search) works with instance URL', async () => {
|
||||
it.each([
|
||||
'https://na123.salesforce.com',
|
||||
'https://acme.my.salesforce.com/',
|
||||
'https://acme--dev.sandbox.my.salesforce.com',
|
||||
])('execute(search) works with official instance origin %s without redirects', async (instanceUrl) => {
|
||||
const vault = createMockVault('salesforce', { value: 'token123', isExpired: false }, {
|
||||
'connector:salesforce:instance_url': 'https://myco.salesforce.com',
|
||||
'connector:salesforce:instance_url': instanceUrl,
|
||||
});
|
||||
await connector.connect(vault);
|
||||
|
||||
@@ -153,6 +170,126 @@ describe('SalesforceConnector', () => {
|
||||
const result = await connector.execute('search', { query: 'SELECT Id, Name FROM Account LIMIT 1' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(safeFetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^https:\/\/[a-z0-9.-]+\.salesforce\.com\/services\/data\/v59\.0\/query\?q=/),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer token123' }),
|
||||
}),
|
||||
{ maxRedirects: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
it('uses guarded no-redirect fetch for health checks', async () => {
|
||||
const vault = createMockVault('salesforce', { value: 'token123', isExpired: false }, {
|
||||
'connector:salesforce:instance_url': 'https://acme.my.salesforce.com',
|
||||
});
|
||||
await connector.connect(vault);
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch;
|
||||
|
||||
expect((await connector.healthCheck()).status).toBe('connected');
|
||||
expect(safeFetch).toHaveBeenCalledWith(
|
||||
'https://acme.my.salesforce.com/services/data/v59.0/limits',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer token123' }),
|
||||
}),
|
||||
{ maxRedirects: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://myco.salesforce.com',
|
||||
'https://salesforce.com',
|
||||
'https://salesforce.com.evil.test',
|
||||
'https://user:pass@myco.salesforce.com',
|
||||
'https://myco.salesforce.com:443',
|
||||
'https://myco.salesforce.com/services/data',
|
||||
'https://myco.salesforce.com/?redirect=https://evil.test',
|
||||
'https://myco.salesforce.com/#fragment',
|
||||
])('rejects unsafe instance URL %s before the bearer token reaches fetch', async (instanceUrl) => {
|
||||
const vault = createMockVault('salesforce', { value: 'secret-token', isExpired: false }, {
|
||||
'connector:salesforce:instance_url': instanceUrl,
|
||||
});
|
||||
const fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await connector.connect(vault);
|
||||
const health = await connector.healthCheck();
|
||||
const result = await connector.execute('search', { query: 'SELECT Id FROM Account' });
|
||||
|
||||
expect(health.status).toBe('disconnected');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).not.toContain('secret-token');
|
||||
expect(safeFetch).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['fractional list limit', 'list_contacts', { limit: 1.5 }],
|
||||
['zero list limit', 'list_contacts', { limit: 0 }],
|
||||
['oversized list limit', 'list_opportunities', { limit: 2001 }],
|
||||
['SOQL-injected field list', 'list_contacts', { fields: 'Id,Name FROM User' }],
|
||||
['path-like object type', 'get_record', { objectType: '../limits', recordId: '003000000000001AAA' }],
|
||||
['path-like record ID', 'get_record', { objectType: 'Contact', recordId: '../limits' }],
|
||||
['invalid create field', 'create_record', { objectType: 'Contact', fields: { 'Name,Id': 'test' } }],
|
||||
['invalid update record ID', 'update_record', { objectType: 'Contact', recordId: 'not-an-id', fields: { Name: 'test' } }],
|
||||
['empty SOQL query', 'search', { query: ' ' }],
|
||||
] as const)('rejects %s before any outbound request', async (_label, action, params) => {
|
||||
const vault = createMockVault('salesforce', { value: 'secret-token', isExpired: false }, {
|
||||
'connector:salesforce:instance_url': 'https://acme.my.salesforce.com',
|
||||
});
|
||||
const fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
await connector.connect(vault);
|
||||
|
||||
const result = await connector.execute(action, params);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).not.toContain('secret-token');
|
||||
expect(safeFetch).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves valid typed record and list operations with encoded paths', async () => {
|
||||
const vault = createMockVault('salesforce', { value: 'token123', isExpired: false }, {
|
||||
'connector:salesforce:instance_url': 'https://acme.my.salesforce.com',
|
||||
});
|
||||
await connector.connect(vault);
|
||||
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ records: [] }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ Id: '003000000000001AAA' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ Id: '003000000000001' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: '003000000000001AAA' }) })
|
||||
.mockResolvedValueOnce({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||
|
||||
expect((await connector.execute('list_contacts', {
|
||||
limit: 50,
|
||||
fields: 'Id,Account.Owner.Name,Custom_Field__c',
|
||||
})).success).toBe(true);
|
||||
expect((await connector.execute('get_record', {
|
||||
objectType: 'Contact',
|
||||
recordId: '003000000000001AAA',
|
||||
fields: 'Id,Account.Name',
|
||||
})).success).toBe(true);
|
||||
expect((await connector.execute('get_record', {
|
||||
objectType: 'Contact',
|
||||
recordId: '003000000000001',
|
||||
})).success).toBe(true);
|
||||
expect((await connector.execute('create_record', {
|
||||
objectType: 'Contact',
|
||||
fields: { LastName: 'Example', Custom_Field__c: 'value' },
|
||||
})).success).toBe(true);
|
||||
expect((await connector.execute('update_record', {
|
||||
objectType: 'Contact',
|
||||
recordId: '003000000000001AAA',
|
||||
fields: { LastName: 'Updated' },
|
||||
})).success).toBe(true);
|
||||
|
||||
expect(safeFetch).toHaveBeenCalledTimes(5);
|
||||
for (const [url, _init, options] of vi.mocked(safeFetch).mock.calls) {
|
||||
expect(url).toMatch(/^https:\/\/acme\.my\.salesforce\.com\/services\/data\/v59\.0\//);
|
||||
expect(options).toEqual({ maxRedirects: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -268,16 +268,19 @@ describe('ConfluenceConnector', () => {
|
||||
describe('ObsidianConnector', () => {
|
||||
let connector: ObsidianConnector;
|
||||
let tmpDir: string;
|
||||
let siblingDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new ObsidianConnector();
|
||||
// Create a temp directory as a mock Obsidian vault
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-obsidian-test-'));
|
||||
siblingDir = `${tmpDir}-evil`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(siblingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('has correct id, name, and actions', () => {
|
||||
@@ -503,6 +506,78 @@ describe('ObsidianConnector', () => {
|
||||
expect(result.error).toContain('path traversal');
|
||||
});
|
||||
|
||||
it('rejects sibling-prefix traversal outside the vault', async () => {
|
||||
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
|
||||
await connector.connect(vault);
|
||||
fs.mkdirSync(siblingDir);
|
||||
fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret');
|
||||
|
||||
const siblingPath = path.relative(tmpDir, path.join(siblingDir, 'secret.md'));
|
||||
const result = await connector.execute('get_note', { path: siblingPath });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('path traversal');
|
||||
});
|
||||
|
||||
it('rejects absolute, drive-qualified, UNC, and mixed-separator paths', async () => {
|
||||
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
|
||||
await connector.connect(vault);
|
||||
fs.mkdirSync(siblingDir);
|
||||
fs.writeFileSync(path.join(tmpDir, 'inside.md'), 'inside');
|
||||
fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret');
|
||||
|
||||
const invalidPaths = [
|
||||
path.join(tmpDir, 'inside.md'),
|
||||
'C:relative.md',
|
||||
'\\\\server\\share\\secret.md',
|
||||
'/absolute/secret.md',
|
||||
`..\\${path.basename(siblingDir)}/secret.md`,
|
||||
];
|
||||
|
||||
for (const invalidPath of invalidPaths) {
|
||||
const result = await connector.execute('get_note', { path: invalidPath });
|
||||
expect(result.success, invalidPath).toBe(false);
|
||||
expect(result.error, invalidPath).toContain('path traversal');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects reads through an out-of-vault symlink or Windows junction', async () => {
|
||||
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
|
||||
await connector.connect(vault);
|
||||
fs.mkdirSync(siblingDir);
|
||||
fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret');
|
||||
fs.symlinkSync(
|
||||
siblingDir,
|
||||
path.join(tmpDir, 'linked-out'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
const result = await connector.execute('get_note', { path: 'linked-out/secret.md' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('path traversal');
|
||||
});
|
||||
|
||||
it('rejects writes below a dangling link', async () => {
|
||||
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
|
||||
await connector.connect(vault);
|
||||
fs.mkdirSync(siblingDir);
|
||||
fs.symlinkSync(
|
||||
siblingDir,
|
||||
path.join(tmpDir, 'dangling-out'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
fs.rmSync(siblingDir, { recursive: true, force: true });
|
||||
|
||||
const blocked = await connector.execute('create_note', {
|
||||
path: 'dangling-out/blocked.md',
|
||||
content: 'must not escape',
|
||||
});
|
||||
|
||||
expect(blocked.success).toBe(false);
|
||||
expect(blocked.error).toContain('path traversal');
|
||||
});
|
||||
|
||||
it('toDefinition() maps correctly', () => {
|
||||
const def = connector.toDefinition('connected');
|
||||
expect(def.id).toBe('obsidian');
|
||||
|
||||
@@ -106,6 +106,64 @@ describe('LinearConnector', () => {
|
||||
expect(result.data).toEqual(mockData.data);
|
||||
});
|
||||
|
||||
it('binds list_issues filters as GraphQL variables', async () => {
|
||||
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
const teamInjection = '__WAGGLE_TEAM__" } }) { viewer { id } } #';
|
||||
const stateInjection = '__WAGGLE_STATE__" } }) { viewer { name } } #';
|
||||
const firstInjection = '__WAGGLE_FIRST__) { viewer { id } } #';
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { issues: { nodes: [] } } }),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await connector.execute('list_issues', {
|
||||
teamId: teamInjection,
|
||||
state: stateInjection,
|
||||
first: firstInjection,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.query).not.toContain('__WAGGLE_');
|
||||
expect(body.variables).toEqual({
|
||||
first: firstInjection,
|
||||
filter: {
|
||||
team: { id: { eq: teamInjection } },
|
||||
state: { name: { eq: stateInjection } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('binds list limits and preserves default issue filters', async () => {
|
||||
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: {} }),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const calls: Array<[string, Record<string, unknown>, Record<string, unknown>]> = [
|
||||
['list_issues', {}, { first: 50 }],
|
||||
['list_projects', {}, { first: 50 }],
|
||||
['list_teams', {}, { first: 50 }],
|
||||
['list_projects', { first: '__WAGGLE_PROJECT_FIRST__' }, { first: '__WAGGLE_PROJECT_FIRST__' }],
|
||||
['list_teams', { first: '__WAGGLE_TEAM_FIRST__' }, { first: '__WAGGLE_TEAM_FIRST__' }],
|
||||
];
|
||||
|
||||
for (const [action, params, expectedVariables] of calls) {
|
||||
const callIndex = fetchMock.mock.calls.length;
|
||||
const result = await connector.execute(action, params);
|
||||
expect(result.success).toBe(true);
|
||||
const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string);
|
||||
expect(body.query).not.toContain('__WAGGLE_');
|
||||
expect(body.variables).toEqual(expectedVariables);
|
||||
if (action === 'list_issues') expect(body.query).not.toContain('$filter');
|
||||
}
|
||||
});
|
||||
|
||||
it('execute returns error for unknown action', async () => {
|
||||
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
@@ -420,6 +478,172 @@ describe('MondayConnector', () => {
|
||||
expect(result.data).toEqual(mockData.data);
|
||||
});
|
||||
|
||||
it('binds every action value as an exact GraphQL variable', async () => {
|
||||
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) });
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const cases: Array<[
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
]> = [
|
||||
[
|
||||
'list_boards',
|
||||
{
|
||||
limit: '__WAGGLE_BOARD_LIMIT__) { users { id } } #',
|
||||
page: '__WAGGLE_BOARD_PAGE__) { users { email } } #',
|
||||
board_kind: 'private',
|
||||
},
|
||||
{
|
||||
limit: '__WAGGLE_BOARD_LIMIT__) { users { id } } #',
|
||||
page: '__WAGGLE_BOARD_PAGE__) { users { email } } #',
|
||||
boardKind: 'private',
|
||||
},
|
||||
],
|
||||
[
|
||||
'list_items',
|
||||
{
|
||||
boardId: '__WAGGLE_BOARD_ID__]) { users { id } } #',
|
||||
groupId: '__WAGGLE_GROUP_ID__"]) { users { email } } #',
|
||||
limit: '__WAGGLE_ITEM_LIMIT__) { users { id } } #',
|
||||
},
|
||||
{
|
||||
boardId: '__WAGGLE_BOARD_ID__]) { users { id } } #',
|
||||
groupId: '__WAGGLE_GROUP_ID__"]) { users { email } } #',
|
||||
limit: '__WAGGLE_ITEM_LIMIT__) { users { id } } #',
|
||||
},
|
||||
],
|
||||
[
|
||||
'create_item',
|
||||
{
|
||||
boardId: '__WAGGLE_CREATE_BOARD__',
|
||||
itemName: '__WAGGLE_ITEM_NAME__") { users { id } } #',
|
||||
groupId: '__WAGGLE_CREATE_GROUP__") { users { email } } #',
|
||||
columnValues: '__WAGGLE_CREATE_COLUMNS__") { users { id } } #',
|
||||
},
|
||||
{
|
||||
boardId: '__WAGGLE_CREATE_BOARD__',
|
||||
itemName: '__WAGGLE_ITEM_NAME__") { users { id } } #',
|
||||
groupId: '__WAGGLE_CREATE_GROUP__") { users { email } } #',
|
||||
columnValues: '__WAGGLE_CREATE_COLUMNS__") { users { id } } #',
|
||||
},
|
||||
],
|
||||
[
|
||||
'update_item',
|
||||
{
|
||||
boardId: '__WAGGLE_UPDATE_BOARD__',
|
||||
itemId: '__WAGGLE_UPDATE_ITEM__',
|
||||
columnValues: '__WAGGLE_UPDATE_COLUMNS__") { users { id } } #',
|
||||
},
|
||||
{
|
||||
boardId: '__WAGGLE_UPDATE_BOARD__',
|
||||
itemId: '__WAGGLE_UPDATE_ITEM__',
|
||||
columnValues: '__WAGGLE_UPDATE_COLUMNS__") { users { id } } #',
|
||||
},
|
||||
],
|
||||
[
|
||||
'search_items',
|
||||
{
|
||||
query: '__WAGGLE_SEARCH_QUERY__"]) { users { email } } #',
|
||||
limit: '__WAGGLE_SEARCH_LIMIT__) { users { id } } #',
|
||||
},
|
||||
{
|
||||
query: '__WAGGLE_SEARCH_QUERY__"]) { users { email } } #',
|
||||
limit: '__WAGGLE_SEARCH_LIMIT__) { users { id } } #',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [action, params, expectedVariables] of cases) {
|
||||
const callIndex = fetchMock.mock.calls.length;
|
||||
const result = await connector.execute(action, params);
|
||||
expect(result.success).toBe(true);
|
||||
const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string);
|
||||
expect(body.query).not.toContain('__WAGGLE_');
|
||||
expect(body.query).not.toContain('private');
|
||||
expect(body.variables).toEqual(expectedVariables);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves defaults and optional Monday action branches', async () => {
|
||||
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) });
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const cases: Array<[
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
string[],
|
||||
]> = [
|
||||
['list_boards', {}, { limit: 25, page: 1 }, ['$boardKind']],
|
||||
[
|
||||
'list_items',
|
||||
{ boardId: '__WAGGLE_NO_GROUP_BOARD__' },
|
||||
{ boardId: '__WAGGLE_NO_GROUP_BOARD__', limit: 50 },
|
||||
['$groupId'],
|
||||
],
|
||||
[
|
||||
'create_item',
|
||||
{ boardId: '__WAGGLE_REQUIRED_BOARD__', itemName: '__WAGGLE_REQUIRED_NAME__' },
|
||||
{ boardId: '__WAGGLE_REQUIRED_BOARD__', itemName: '__WAGGLE_REQUIRED_NAME__' },
|
||||
['$groupId', '$columnValues'],
|
||||
],
|
||||
[
|
||||
'search_items',
|
||||
{ query: '__WAGGLE_DEFAULT_SEARCH__' },
|
||||
{ limit: 25, query: '__WAGGLE_DEFAULT_SEARCH__' },
|
||||
[],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [action, params, expectedVariables, omittedDefinitions] of cases) {
|
||||
const callIndex = fetchMock.mock.calls.length;
|
||||
const result = await connector.execute(action, params);
|
||||
expect(result.success).toBe(true);
|
||||
const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string);
|
||||
expect(body.query).not.toContain('__WAGGLE_');
|
||||
expect(body.variables).toEqual(expectedVariables);
|
||||
for (const omitted of omittedDefinitions) expect(body.query).not.toContain(omitted);
|
||||
if (action === 'list_boards') {
|
||||
expect(body.query).toContain('limit: $limit, page: $page');
|
||||
expect(body.query).not.toContain('limit: 25');
|
||||
expect(body.query).not.toContain('page: 1');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('binds valid board kinds and rejects all other values before issuing GraphQL', async () => {
|
||||
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
for (const boardKind of ['public', 'private', 'share']) {
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: {} }) });
|
||||
const callIndex = fetchMock.mock.calls.length;
|
||||
const result = await connector.execute('list_boards', { board_kind: boardKind });
|
||||
expect(result.success).toBe(true);
|
||||
const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string);
|
||||
expect(body.query).not.toContain(boardKind);
|
||||
expect(body.variables).toEqual({ limit: 25, page: 1, boardKind });
|
||||
}
|
||||
|
||||
for (const invalidKind of [
|
||||
'private) { users { id email } } #',
|
||||
'workspace',
|
||||
42,
|
||||
null,
|
||||
{ toString: 1 },
|
||||
]) {
|
||||
const result = await connector.execute('list_boards', { board_kind: invalidKind });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid board_kind');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('execute returns error for unknown action', async () => {
|
||||
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
|
||||
await connector.connect(vault);
|
||||
|
||||
Reference in New Issue
Block a user