@taodigital/connectors 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/azuredevops.d.ts +35 -0
- package/dist/azuredevops.js +116 -0
- package/dist/confluence.d.ts +22 -0
- package/dist/confluence.js +114 -0
- package/dist/connectors.test.d.ts +10 -0
- package/dist/connectors.test.js +407 -0
- package/dist/figma.d.ts +57 -0
- package/dist/figma.js +141 -0
- package/dist/github.d.ts +43 -0
- package/dist/github.js +120 -0
- package/dist/gitlab.d.ts +42 -0
- package/dist/gitlab.js +137 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +45 -0
- package/dist/jira.d.ts +93 -0
- package/dist/jira.js +354 -0
- package/dist/pagerduty.d.ts +25 -0
- package/dist/pagerduty.js +76 -0
- package/dist/salesforce.d.ts +35 -0
- package/dist/salesforce.js +93 -0
- package/dist/servicenow.d.ts +40 -0
- package/dist/servicenow.js +96 -0
- package/package.json +16 -4
- package/src/azuredevops.ts +141 -0
- package/src/confluence.ts +120 -0
- package/src/connectors.test.ts +424 -0
- package/src/figma.ts +194 -0
- package/src/github.ts +139 -0
- package/src/gitlab.ts +166 -0
- package/src/index.ts +9 -0
- package/src/jira.ts +372 -0
- package/src/pagerduty.ts +78 -0
- package/src/salesforce.ts +109 -0
- package/src/servicenow.ts +112 -0
- package/tsconfig.json +8 -0
- package/README.md +0 -3
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security-focused tests for all connectors.
|
|
3
|
+
* Tests SSRF guard, missing credentials, and happy-path API call shape.
|
|
4
|
+
* No real network calls are made — fetch is mocked globally.
|
|
5
|
+
*
|
|
6
|
+
* Requires DATABASE_URL and MIGRATION_DATABASE_URL env vars to satisfy Prisma's
|
|
7
|
+
* constructor validation (triggered transitively via @taodigital/core → @taodigital/database).
|
|
8
|
+
* The test script in package.json sets mock values for these.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test, mock, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
|
|
14
|
+
// --- helpers -----------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Make a minimal fetch mock that always returns ok with a JSON body. */
|
|
17
|
+
function mockFetch(body: unknown, status = 200) {
|
|
18
|
+
const stub = mock.fn(async (_url: string, _init?: any) => ({
|
|
19
|
+
ok: status >= 200 && status < 300,
|
|
20
|
+
status,
|
|
21
|
+
json: async () => body,
|
|
22
|
+
text: async () => JSON.stringify(body),
|
|
23
|
+
}));
|
|
24
|
+
(globalThis as any).fetch = stub;
|
|
25
|
+
return stub;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function restoreFetch(original: typeof globalThis.fetch) {
|
|
29
|
+
globalThis.fetch = original;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Private / IMDS addresses that the SSRF guard must block
|
|
33
|
+
const PRIVATE_URLS = [
|
|
34
|
+
'https://169.254.169.254/latest/meta-data',
|
|
35
|
+
'https://10.0.0.1',
|
|
36
|
+
'https://192.168.1.1',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Jira
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
import * as jira from './jira.js';
|
|
44
|
+
|
|
45
|
+
const JIRA_CREDS: jira.JiraCredentials = {
|
|
46
|
+
baseUrl: 'https://example.atlassian.net',
|
|
47
|
+
email: 'test@example.com',
|
|
48
|
+
apiToken: 'token123',
|
|
49
|
+
projectKey: 'PROJ',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test('jira.listProjects: SSRF guard fires for private baseUrl', async () => {
|
|
53
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
54
|
+
const creds = { ...JIRA_CREDS, baseUrl: privateUrl };
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
() => jira.listProjects(creds),
|
|
57
|
+
/private or internal|HTTPS/i,
|
|
58
|
+
`Expected SSRF rejection for ${privateUrl}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('jira.createBug: missing projectKey throws before network call', async () => {
|
|
64
|
+
const orig = globalThis.fetch;
|
|
65
|
+
const stub = mockFetch({});
|
|
66
|
+
try {
|
|
67
|
+
const creds = { ...JIRA_CREDS, projectKey: undefined };
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
() => jira.createBug(creds, { summary: 'Bug', description: 'Desc' }),
|
|
70
|
+
/projectKey required/i,
|
|
71
|
+
);
|
|
72
|
+
assert.equal(stub.mock.calls.length, 0, 'fetch should not be called when projectKey is missing');
|
|
73
|
+
} finally {
|
|
74
|
+
restoreFetch(orig);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('jira.listProjects: happy path — correct auth header and endpoint used', async () => {
|
|
79
|
+
const orig = globalThis.fetch;
|
|
80
|
+
const stub = mockFetch({ values: [{ key: 'PROJ', name: 'Project', id: '1' }] });
|
|
81
|
+
try {
|
|
82
|
+
const result = await jira.listProjects(JIRA_CREDS);
|
|
83
|
+
assert.equal(result.length, 1);
|
|
84
|
+
assert.equal(result[0].key, 'PROJ');
|
|
85
|
+
assert.equal(stub.mock.calls.length, 1);
|
|
86
|
+
const [url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
87
|
+
assert.ok(url.includes('/rest/api/3/project/search'), 'should call JIRA project search');
|
|
88
|
+
assert.ok(init.headers.Authorization.startsWith('Basic '), 'Basic auth when no bearerToken');
|
|
89
|
+
} finally {
|
|
90
|
+
restoreFetch(orig);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Confluence
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
import * as confluence from './confluence.js';
|
|
99
|
+
|
|
100
|
+
const CONFLUENCE_CREDS: confluence.ConfluenceCredentials = {
|
|
101
|
+
baseUrl: 'https://example.atlassian.net',
|
|
102
|
+
email: 'test@example.com',
|
|
103
|
+
apiToken: 'token123',
|
|
104
|
+
spaceKey: 'QA',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
test('confluence.listPages: SSRF guard fires for private baseUrl', async () => {
|
|
108
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
109
|
+
const creds = { ...CONFLUENCE_CREDS, baseUrl: privateUrl };
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
() => confluence.listPages(creds),
|
|
112
|
+
/private or internal|HTTPS/i,
|
|
113
|
+
`Expected SSRF rejection for ${privateUrl}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('confluence.testConnection: returns ok:false when server errors, does not throw', async () => {
|
|
119
|
+
const orig = globalThis.fetch;
|
|
120
|
+
mockFetch({}, 401);
|
|
121
|
+
try {
|
|
122
|
+
const result = await confluence.testConnection(CONFLUENCE_CREDS);
|
|
123
|
+
assert.equal(result.ok, false);
|
|
124
|
+
assert.ok(result.error?.includes('401'));
|
|
125
|
+
} finally {
|
|
126
|
+
restoreFetch(orig);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('confluence.listPages: happy path — calls correct endpoint with spaceKey', async () => {
|
|
131
|
+
const orig = globalThis.fetch;
|
|
132
|
+
const stub = mockFetch({ results: [{ id: '123', title: 'Page A' }] });
|
|
133
|
+
try {
|
|
134
|
+
const result = await confluence.listPages(CONFLUENCE_CREDS);
|
|
135
|
+
assert.equal(result.length, 1);
|
|
136
|
+
assert.equal(result[0].title, 'Page A');
|
|
137
|
+
const [url] = stub.mock.calls[0].arguments as [string, any];
|
|
138
|
+
assert.ok(url.includes('spaceKey=QA'), 'space key should be included in query');
|
|
139
|
+
} finally {
|
|
140
|
+
restoreFetch(orig);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// GitHub
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
import * as github from './github.js';
|
|
149
|
+
|
|
150
|
+
const GITHUB_CREDS: github.GitHubCredentials = {
|
|
151
|
+
token: 'ghp_test_token',
|
|
152
|
+
repo: 'owner/repo',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
test('github.testConnection: SSRF guard fires for http:// (non-HTTPS)', async () => {
|
|
156
|
+
// GitHub always calls api.github.com but assertResolvesToPublicAddress rejects http://
|
|
157
|
+
// We test this by verifying the module-level check works — since GitHub hardcodes
|
|
158
|
+
// the URL, the SSRF guard is exercised via assertResolvesToPublicAddress on a known public host.
|
|
159
|
+
// The guard rejects any http:// scheme, but api.github.com is always https — verify
|
|
160
|
+
// the normal path proceeds (the guard doesn't reject it as private).
|
|
161
|
+
const orig = globalThis.fetch;
|
|
162
|
+
mockFetch({ login: 'octocat' });
|
|
163
|
+
try {
|
|
164
|
+
const result = await github.testConnection(GITHUB_CREDS);
|
|
165
|
+
assert.equal(result.ok, true);
|
|
166
|
+
assert.equal(result.login, 'octocat');
|
|
167
|
+
} finally {
|
|
168
|
+
restoreFetch(orig);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('github.createIssue: missing token causes auth failure (401 treated as error)', async () => {
|
|
173
|
+
const orig = globalThis.fetch;
|
|
174
|
+
mockFetch({ message: 'Bad credentials' }, 401);
|
|
175
|
+
try {
|
|
176
|
+
await assert.rejects(
|
|
177
|
+
() => github.createIssue({ ...GITHUB_CREDS, token: '' }, 'title', 'body'),
|
|
178
|
+
/GitHub error: 401/,
|
|
179
|
+
);
|
|
180
|
+
} finally {
|
|
181
|
+
restoreFetch(orig);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('github.listRecentCommits: happy path — correct repo URL and Bearer header', async () => {
|
|
186
|
+
const orig = globalThis.fetch;
|
|
187
|
+
const stub = mockFetch([{ sha: 'abc1234', commit: { message: 'Initial', author: { name: 'Dev', date: '2025-01-01' } } }]);
|
|
188
|
+
try {
|
|
189
|
+
const result = await github.listRecentCommits(GITHUB_CREDS);
|
|
190
|
+
assert.equal(result.length, 1);
|
|
191
|
+
assert.equal(result[0].sha, 'abc1234');
|
|
192
|
+
const [url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
193
|
+
assert.ok(url.includes('/repos/owner/repo/commits'), 'URL must reference the correct repo');
|
|
194
|
+
assert.equal(init.headers.Authorization, 'Bearer ghp_test_token');
|
|
195
|
+
} finally {
|
|
196
|
+
restoreFetch(orig);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// Salesforce
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
204
|
+
import * as salesforce from './salesforce.js';
|
|
205
|
+
|
|
206
|
+
// Use a real, resolvable public hostname for SSRF-guarded connectors.
|
|
207
|
+
// The SSRF guard does actual DNS resolution — fake hostnames cause "Could not
|
|
208
|
+
// resolve destination hostname" before fetch is even reached.
|
|
209
|
+
const SF_CREDS: salesforce.SalesforceCredentials = {
|
|
210
|
+
instanceUrl: 'https://example.com',
|
|
211
|
+
clientId: 'clientId123',
|
|
212
|
+
clientSecret: 'secret123',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
test('salesforce.listObjects: SSRF guard fires for private instanceUrl', async () => {
|
|
216
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
217
|
+
const creds = { ...SF_CREDS, instanceUrl: privateUrl };
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
() => salesforce.listObjects(creds),
|
|
220
|
+
/private or internal|HTTPS/i,
|
|
221
|
+
`Expected SSRF rejection for ${privateUrl}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('salesforce.testConnection: returns ok:false when OAuth token request fails', async () => {
|
|
227
|
+
const orig = globalThis.fetch;
|
|
228
|
+
mockFetch({ error: 'invalid_client' }, 401);
|
|
229
|
+
try {
|
|
230
|
+
const result = await salesforce.testConnection(SF_CREDS);
|
|
231
|
+
assert.equal(result.ok, false);
|
|
232
|
+
// Error string contains the status code or OAuth error description
|
|
233
|
+
assert.ok(result.error != null, 'error field should be set on failure');
|
|
234
|
+
} finally {
|
|
235
|
+
restoreFetch(orig);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('salesforce.createCase: happy path — obtains token then POSTs case', async () => {
|
|
240
|
+
const orig = globalThis.fetch;
|
|
241
|
+
// First call = OAuth token endpoint, second call = create case
|
|
242
|
+
let callCount = 0;
|
|
243
|
+
(globalThis as any).fetch = async (_url: string, _init?: any) => {
|
|
244
|
+
callCount++;
|
|
245
|
+
if (callCount === 1) {
|
|
246
|
+
// Salesforce token endpoint
|
|
247
|
+
return { ok: true, status: 200, json: async () => ({ access_token: 'tok' }), text: async () => '' };
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, status: 200, json: async () => ({ id: 'case001' }), text: async () => '' };
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
const result = await salesforce.createCase(SF_CREDS, { subject: 'Test', description: 'Desc' });
|
|
253
|
+
assert.equal(result.id, 'case001');
|
|
254
|
+
assert.ok(result.url.includes('case001'));
|
|
255
|
+
assert.equal(callCount, 2, 'should make OAuth token call then case creation call');
|
|
256
|
+
} finally {
|
|
257
|
+
restoreFetch(orig);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// =============================================================================
|
|
262
|
+
// ServiceNow
|
|
263
|
+
// =============================================================================
|
|
264
|
+
|
|
265
|
+
import * as servicenow from './servicenow.js';
|
|
266
|
+
|
|
267
|
+
const SN_CREDS: servicenow.ServiceNowCredentials = {
|
|
268
|
+
instanceUrl: 'https://example.com',
|
|
269
|
+
username: 'admin',
|
|
270
|
+
password: 'pass123',
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
test('servicenow.createIncident: SSRF guard fires for private instanceUrl', async () => {
|
|
274
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
275
|
+
const creds = { ...SN_CREDS, instanceUrl: privateUrl };
|
|
276
|
+
await assert.rejects(
|
|
277
|
+
() => servicenow.createIncident(creds, { shortDescription: 'Test', description: 'Desc' }),
|
|
278
|
+
/private or internal|HTTPS/i,
|
|
279
|
+
`Expected SSRF rejection for ${privateUrl}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('servicenow.testConnection: returns ok:false when server returns 401', async () => {
|
|
285
|
+
const orig = globalThis.fetch;
|
|
286
|
+
mockFetch({ error: 'Unauthorized' }, 401);
|
|
287
|
+
try {
|
|
288
|
+
const result = await servicenow.testConnection(SN_CREDS);
|
|
289
|
+
assert.equal(result.ok, false);
|
|
290
|
+
assert.ok(result.error != null, 'error field should be set on failure');
|
|
291
|
+
} finally {
|
|
292
|
+
restoreFetch(orig);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('servicenow.createIncident: happy path — POSTs to incident table with correct fields', async () => {
|
|
297
|
+
const orig = globalThis.fetch;
|
|
298
|
+
const stub = mockFetch({ result: { sys_id: 'sys001', number: 'INC0001' } });
|
|
299
|
+
try {
|
|
300
|
+
const result = await servicenow.createIncident(SN_CREDS, {
|
|
301
|
+
shortDescription: 'Login broken',
|
|
302
|
+
description: 'Full description',
|
|
303
|
+
urgency: 1,
|
|
304
|
+
});
|
|
305
|
+
assert.equal(result.sysId, 'sys001');
|
|
306
|
+
assert.equal(result.number, 'INC0001');
|
|
307
|
+
const [url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
308
|
+
assert.ok(url.includes('/api/now/table/incident'), 'must POST to incident table');
|
|
309
|
+
const body = JSON.parse(init.body);
|
|
310
|
+
assert.equal(body.urgency, '1');
|
|
311
|
+
assert.equal(body.short_description, 'Login broken');
|
|
312
|
+
} finally {
|
|
313
|
+
restoreFetch(orig);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// =============================================================================
|
|
318
|
+
// Figma
|
|
319
|
+
// =============================================================================
|
|
320
|
+
|
|
321
|
+
import * as figma from './figma.js';
|
|
322
|
+
|
|
323
|
+
const FIGMA_CREDS: figma.FigmaCredentials = {
|
|
324
|
+
accessToken: 'figd_test_token',
|
|
325
|
+
fileKey: 'testFileKey123',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
test('figma.testConnection: returns ok:false when token is rejected (403)', async () => {
|
|
329
|
+
const orig = globalThis.fetch;
|
|
330
|
+
mockFetch({ message: 'Forbidden' }, 403);
|
|
331
|
+
try {
|
|
332
|
+
const result = await figma.testConnection(FIGMA_CREDS);
|
|
333
|
+
assert.equal(result.ok, false);
|
|
334
|
+
assert.ok(result.error?.includes('403'));
|
|
335
|
+
} finally {
|
|
336
|
+
restoreFetch(orig);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('figma.testConnection: uses X-Figma-Token header for PAT', async () => {
|
|
341
|
+
const orig = globalThis.fetch;
|
|
342
|
+
const stub = mockFetch({ handle: 'design_user', email: 'design@example.com' });
|
|
343
|
+
try {
|
|
344
|
+
const result = await figma.testConnection(FIGMA_CREDS);
|
|
345
|
+
assert.equal(result.ok, true);
|
|
346
|
+
assert.equal(result.name, 'design_user');
|
|
347
|
+
const [_url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
348
|
+
assert.equal(init.headers['X-Figma-Token'], 'figd_test_token', 'PAT must be sent as X-Figma-Token');
|
|
349
|
+
assert.equal(init.headers.Authorization, undefined, 'no Bearer header for PAT');
|
|
350
|
+
} finally {
|
|
351
|
+
restoreFetch(orig);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('figma.testConnection: uses Bearer header for OAuth token', async () => {
|
|
356
|
+
const orig = globalThis.fetch;
|
|
357
|
+
const stub = mockFetch({ handle: 'oauth_user' });
|
|
358
|
+
try {
|
|
359
|
+
await figma.testConnection({ ...FIGMA_CREDS, oauthToken: 'oauth_token_abc' });
|
|
360
|
+
const [_url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
361
|
+
assert.equal(init.headers.Authorization, 'Bearer oauth_token_abc');
|
|
362
|
+
assert.equal(init.headers['X-Figma-Token'], undefined, 'no PAT header when oauthToken is set');
|
|
363
|
+
} finally {
|
|
364
|
+
restoreFetch(orig);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// =============================================================================
|
|
369
|
+
// PagerDuty
|
|
370
|
+
// =============================================================================
|
|
371
|
+
|
|
372
|
+
import * as pagerduty from './pagerduty.js';
|
|
373
|
+
|
|
374
|
+
const PD_CREDS: pagerduty.PagerDutyCredentials = {
|
|
375
|
+
apiKey: 'pd_api_key_test',
|
|
376
|
+
serviceId: 'SVCABC',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
test('pagerduty.createIncident: missing serviceId throws before network call', async () => {
|
|
380
|
+
const orig = globalThis.fetch;
|
|
381
|
+
const stub = mockFetch({});
|
|
382
|
+
try {
|
|
383
|
+
const creds = { apiKey: 'pd_api_key_test' };
|
|
384
|
+
await assert.rejects(
|
|
385
|
+
() => pagerduty.createIncident(creds, { title: 'Alert', body: 'Details', severity: 'critical' }),
|
|
386
|
+
/serviceId is required/i,
|
|
387
|
+
);
|
|
388
|
+
assert.equal(stub.mock.calls.length, 0, 'fetch should not be called when serviceId is missing');
|
|
389
|
+
} finally {
|
|
390
|
+
restoreFetch(orig);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('pagerduty.testConnection: returns ok:false on 401', async () => {
|
|
395
|
+
const orig = globalThis.fetch;
|
|
396
|
+
mockFetch({ error: 'Invalid credentials' }, 401);
|
|
397
|
+
try {
|
|
398
|
+
const result = await pagerduty.testConnection(PD_CREDS);
|
|
399
|
+
assert.equal(result.ok, false);
|
|
400
|
+
assert.ok(result.error?.includes('401'));
|
|
401
|
+
} finally {
|
|
402
|
+
restoreFetch(orig);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('pagerduty.createIncident: happy path — correct token header and incident shape', async () => {
|
|
407
|
+
const orig = globalThis.fetch;
|
|
408
|
+
const stub = mockFetch({ incident: { id: 'INC001', html_url: 'https://example.pagerduty.com/incidents/INC001' } });
|
|
409
|
+
try {
|
|
410
|
+
const result = await pagerduty.createIncident(PD_CREDS, {
|
|
411
|
+
title: 'Critical failure',
|
|
412
|
+
body: 'System down',
|
|
413
|
+
severity: 'critical',
|
|
414
|
+
});
|
|
415
|
+
assert.equal(result.id, 'INC001');
|
|
416
|
+
const [url, init] = stub.mock.calls[0].arguments as [string, any];
|
|
417
|
+
assert.ok(url.includes('pagerduty.com/incidents'), 'must post to PD incidents endpoint');
|
|
418
|
+
assert.ok(init.headers.Authorization.startsWith('Token token='), 'PD uses Token auth scheme');
|
|
419
|
+
const body = JSON.parse(init.body);
|
|
420
|
+
assert.equal(body.incident.urgency, 'high', 'critical severity maps to high urgency');
|
|
421
|
+
} finally {
|
|
422
|
+
restoreFetch(orig);
|
|
423
|
+
}
|
|
424
|
+
});
|
package/src/figma.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma connector — reads design files and extracts screens + components.
|
|
3
|
+
* Access token stored encrypted per project.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface FigmaCredentials {
|
|
7
|
+
accessToken: string; // Personal Access Token (X-Figma-Token header)
|
|
8
|
+
fileKey?: string; // The key from the Figma URL: figma.com/file/{KEY}/...
|
|
9
|
+
oauthToken?: string; // OAuth 2.0 access token — uses Authorization: Bearer header
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function headers(creds: FigmaCredentials): Record<string, string> {
|
|
13
|
+
if (creds.oauthToken) {
|
|
14
|
+
return { Authorization: `Bearer ${creds.oauthToken}`, 'Content-Type': 'application/json' };
|
|
15
|
+
}
|
|
16
|
+
return { 'X-Figma-Token': creds.accessToken, 'Content-Type': 'application/json' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deriveSuggestedLocator(name: string, type: string): string {
|
|
20
|
+
const n = name.toLowerCase();
|
|
21
|
+
const label = name.split('/')[0].trim();
|
|
22
|
+
if (n.includes('button') || n.includes('btn') || n.includes('cta') || n.includes('submit') || n.includes('save')) {
|
|
23
|
+
return `getByRole('button', { name: /${label}/i })`;
|
|
24
|
+
}
|
|
25
|
+
if (n.includes('input') || n.includes('field') || n.includes('email') || n.includes('password') || n.includes('search')) {
|
|
26
|
+
return `getByLabel('${label}')`;
|
|
27
|
+
}
|
|
28
|
+
if (n.includes('checkbox')) return `getByRole('checkbox', { name: /${label}/i })`;
|
|
29
|
+
if (n.includes('select') || n.includes('dropdown')) return `getByRole('combobox', { name: /${label}/i })`;
|
|
30
|
+
if (n.includes('modal') || n.includes('dialog')) return `getByRole('dialog')`;
|
|
31
|
+
if (n.includes('link') || n.includes('nav')) return `getByRole('link', { name: /${label}/i })`;
|
|
32
|
+
return `getByText('${label}', { exact: false })`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function deriveSuggestedTestId(name: string): string {
|
|
36
|
+
return name.split('/')[0].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function testConnection(creds: FigmaCredentials): Promise<{ ok: boolean; name?: string; error?: string }> {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch('https://api.figma.com/v1/me', { headers: headers(creds) });
|
|
42
|
+
if (!res.ok) return { ok: false, error: `Figma returned ${res.status}` };
|
|
43
|
+
const data = (await res.json()) as any;
|
|
44
|
+
return { ok: true, name: data.handle };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { ok: false, error: String(err) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getFile(creds: FigmaCredentials, fileKey?: string) {
|
|
51
|
+
const key = fileKey ?? creds.fileKey;
|
|
52
|
+
if (!key) throw new Error('Figma file key is required.');
|
|
53
|
+
const res = await fetch(`https://api.figma.com/v1/files/${key}`, { headers: headers(creds) });
|
|
54
|
+
if (!res.ok) throw new Error(`Figma error: ${res.status}`);
|
|
55
|
+
return res.json() as Promise<any>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ListScreensOptions {
|
|
59
|
+
nodeTypes?: string[];
|
|
60
|
+
excludePrefixes?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Extract top-level frames (screens) from a Figma file.
|
|
64
|
+
* Only takes DIRECT children of each page — not recursively nested components.
|
|
65
|
+
* This gives you actual screens (what designers think of as a screen),
|
|
66
|
+
* not every sub-component and icon frame inside them (which can number in the hundreds).
|
|
67
|
+
*/
|
|
68
|
+
export async function listScreens(creds: FigmaCredentials, fileKey?: string, options: ListScreensOptions = {}) {
|
|
69
|
+
const file = await getFile(creds, fileKey);
|
|
70
|
+
const screens: Array<{ id: string; name: string; type: string; node: any }> = [];
|
|
71
|
+
const nodeTypes = options.nodeTypes ?? ['FRAME', 'COMPONENT', 'SECTION'];
|
|
72
|
+
const excludePrefixes = options.excludePrefixes ?? ['_', '.'];
|
|
73
|
+
|
|
74
|
+
(file.document?.children ?? []).forEach((page: any) => {
|
|
75
|
+
// Only direct children of pages = top-level frames = actual screens
|
|
76
|
+
(page.children ?? []).forEach((node: any) => {
|
|
77
|
+
if (nodeTypes.includes(node.type)) {
|
|
78
|
+
// Skip utility frames: those starting with _ or . (designer convention for hidden/utility)
|
|
79
|
+
if (!excludePrefixes.some((p) => node.name.startsWith(p))) {
|
|
80
|
+
screens.push({ id: node.id, name: node.name, type: node.type, node });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return screens;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GetScreenImagesOptions {
|
|
90
|
+
imageScale?: number;
|
|
91
|
+
chunkSize?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get image URLs for specific node IDs in a Figma file. */
|
|
95
|
+
export async function getScreenImages(creds: FigmaCredentials, fileKey: string, nodeIds: string[], scale?: number, options: GetScreenImagesOptions = {}) {
|
|
96
|
+
// Batch into chunks of 50 to avoid HTTP 414 (URI Too Long) on large files
|
|
97
|
+
const { getSystemConfig } = await import('@taodigital/core');
|
|
98
|
+
const figmaCfg = await getSystemConfig('connectors_figma');
|
|
99
|
+
const resolvedScale = options.imageScale ?? scale ?? parseInt(String(figmaCfg?.imageScale ?? '2'), 10);
|
|
100
|
+
const CHUNK = options.chunkSize ?? parseInt(String(figmaCfg?.chunkSize ?? '50'), 10);
|
|
101
|
+
const result: Record<string, string> = {};
|
|
102
|
+
for (let i = 0; i < nodeIds.length; i += CHUNK) {
|
|
103
|
+
const chunk = nodeIds.slice(i, i + CHUNK);
|
|
104
|
+
const ids = chunk.join(',');
|
|
105
|
+
const res = await fetch(
|
|
106
|
+
`https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(ids)}&scale=${resolvedScale}&format=png`,
|
|
107
|
+
{ headers: headers(creds) }
|
|
108
|
+
);
|
|
109
|
+
if (!res.ok) throw new Error(`Figma error: ${res.status}`);
|
|
110
|
+
const data = (await res.json()) as any;
|
|
111
|
+
Object.assign(result, data.images as Record<string, string>);
|
|
112
|
+
}
|
|
113
|
+
return result; // nodeId → image URL
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface FigmaElement {
|
|
117
|
+
name: string;
|
|
118
|
+
type: string;
|
|
119
|
+
description?: string;
|
|
120
|
+
isInteractive: boolean;
|
|
121
|
+
reactions?: any[];
|
|
122
|
+
destinationIds?: string[];
|
|
123
|
+
boundingBox?: { x: number; y: number; width: number; height: number };
|
|
124
|
+
componentId?: string;
|
|
125
|
+
suggestedLocator?: string;
|
|
126
|
+
suggestedTestId?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Extract interactive elements from a Figma frame node.
|
|
130
|
+
* Uses reactions/interactions as the primary interactivity signal.
|
|
131
|
+
* FRAME and GROUP are included — they cover buttons, cards, modals, navbars.
|
|
132
|
+
* Primitive types (RECTANGLE, TEXT, etc.) are only collected when they have explicit reactions.
|
|
133
|
+
*/
|
|
134
|
+
export function extractElements(node: any): FigmaElement[] {
|
|
135
|
+
const elements: FigmaElement[] = [];
|
|
136
|
+
|
|
137
|
+
const CONTAINER_TYPES = new Set(['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'COMPONENT_SET']);
|
|
138
|
+
const PRIMITIVE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'TEXT', 'VECTOR', 'STAR', 'POLYGON', 'LINE']);
|
|
139
|
+
|
|
140
|
+
function destinationIds(reactions: any[]): string[] {
|
|
141
|
+
const ids: string[] = [];
|
|
142
|
+
for (const r of reactions) {
|
|
143
|
+
const actions = r.actions ?? (r.action ? [r.action] : []);
|
|
144
|
+
for (const a of actions) {
|
|
145
|
+
if (a?.destinationId) ids.push(a.destinationId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return ids;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function walk(n: any) {
|
|
152
|
+
if (!n?.name || n.name.startsWith('_') || n.name.startsWith('.')) {
|
|
153
|
+
(n?.children ?? []).forEach(walk);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// REST API uses `interactions`; Plugin API uses `reactions` — support both.
|
|
157
|
+
const reactions: any[] = n.interactions ?? n.reactions ?? [];
|
|
158
|
+
const hasReactions = reactions.length > 0;
|
|
159
|
+
|
|
160
|
+
if (CONTAINER_TYPES.has(n.type)) {
|
|
161
|
+
elements.push({
|
|
162
|
+
name: n.name,
|
|
163
|
+
type: n.type,
|
|
164
|
+
description: n.description ?? undefined,
|
|
165
|
+
isInteractive: hasReactions,
|
|
166
|
+
...(hasReactions ? { reactions, destinationIds: destinationIds(reactions) } : {}),
|
|
167
|
+
boundingBox: n.absoluteBoundingBox ?? undefined,
|
|
168
|
+
componentId: n.componentId ?? undefined,
|
|
169
|
+
suggestedLocator: deriveSuggestedLocator(n.name ?? '', n.type ?? ''),
|
|
170
|
+
suggestedTestId: deriveSuggestedTestId(n.name ?? ''),
|
|
171
|
+
});
|
|
172
|
+
// Don't recurse into INSTANCE internals — the instance is the unit of interest.
|
|
173
|
+
if (n.type !== 'INSTANCE') (n.children ?? []).forEach(walk);
|
|
174
|
+
} else if (PRIMITIVE_TYPES.has(n.type) && hasReactions) {
|
|
175
|
+
// Only collect primitives that are explicitly interactive (e.g. clickable text links).
|
|
176
|
+
elements.push({
|
|
177
|
+
name: n.name,
|
|
178
|
+
type: n.type,
|
|
179
|
+
isInteractive: true,
|
|
180
|
+
reactions,
|
|
181
|
+
destinationIds: destinationIds(reactions),
|
|
182
|
+
boundingBox: n.absoluteBoundingBox ?? undefined,
|
|
183
|
+
suggestedLocator: deriveSuggestedLocator(n.name ?? '', n.type ?? ''),
|
|
184
|
+
suggestedTestId: deriveSuggestedTestId(n.name ?? ''),
|
|
185
|
+
});
|
|
186
|
+
(n.children ?? []).forEach(walk);
|
|
187
|
+
} else {
|
|
188
|
+
(n.children ?? []).forEach(walk);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
walk(node);
|
|
193
|
+
return elements;
|
|
194
|
+
}
|