@useconductor/conductor 1.0.0 → 1.0.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/.github/README.md +374 -7
- package/.github/workflows/ci.yml +3 -1
- package/.github/workflows/claude-code-review.yml +1 -15
- package/.github/workflows/publish.yml +43 -0
- package/README.md +290 -121
- package/dist/cli/commands/audit.d.ts +40 -0
- package/dist/cli/commands/audit.d.ts.map +1 -0
- package/dist/cli/commands/audit.js +272 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/circuit.d.ts +13 -0
- package/dist/cli/commands/circuit.d.ts.map +1 -0
- package/dist/cli/commands/circuit.js +53 -0
- package/dist/cli/commands/circuit.js.map +1 -0
- package/dist/cli/commands/config.d.ts +31 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +152 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/init.d.ts +5 -8
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +86 -123
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/marketplace.js +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +33 -11
- package/dist/cli/commands/onboard.js.map +1 -1
- package/dist/cli/commands/release.d.ts.map +1 -1
- package/dist/cli/commands/release.js +1 -1
- package/dist/cli/commands/release.js.map +1 -1
- package/dist/cli/index.js +146 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +5 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/conductor.d.ts.map +1 -1
- package/dist/core/conductor.js +12 -0
- package/dist/core/conductor.js.map +1 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +46 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/database.d.ts +3 -0
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +26 -0
- package/dist/core/database.js.map +1 -1
- package/dist/core/encryption.d.ts +34 -0
- package/dist/core/encryption.d.ts.map +1 -0
- package/dist/core/encryption.js +96 -0
- package/dist/core/encryption.js.map +1 -0
- package/dist/core/zero-config.d.ts.map +1 -1
- package/dist/core/zero-config.js +1 -4
- package/dist/core/zero-config.js.map +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +112 -16
- package/dist/dashboard/server.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +30 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/plugins/builtin/aws.d.ts +31 -0
- package/dist/plugins/builtin/aws.d.ts.map +1 -0
- package/dist/plugins/builtin/aws.js +149 -0
- package/dist/plugins/builtin/aws.js.map +1 -0
- package/dist/plugins/builtin/database.d.ts +1 -0
- package/dist/plugins/builtin/database.d.ts.map +1 -1
- package/dist/plugins/builtin/database.js +26 -1
- package/dist/plugins/builtin/database.js.map +1 -1
- package/dist/plugins/builtin/docker.d.ts +4 -0
- package/dist/plugins/builtin/docker.d.ts.map +1 -1
- package/dist/plugins/builtin/docker.js +20 -1
- package/dist/plugins/builtin/docker.js.map +1 -1
- package/dist/plugins/builtin/gcp.d.ts +28 -0
- package/dist/plugins/builtin/gcp.d.ts.map +1 -0
- package/dist/plugins/builtin/gcp.js +135 -0
- package/dist/plugins/builtin/gcp.js.map +1 -0
- package/dist/plugins/builtin/index.d.ts.map +1 -1
- package/dist/plugins/builtin/index.js +4 -0
- package/dist/plugins/builtin/index.js.map +1 -1
- package/dist/plugins/builtin/jira.d.ts.map +1 -1
- package/dist/plugins/builtin/jira.js +4 -2
- package/dist/plugins/builtin/jira.js.map +1 -1
- package/dist/plugins/builtin/linear.js +1 -1
- package/dist/plugins/builtin/linear.js.map +1 -1
- package/dist/plugins/builtin/shell.js +1 -1
- package/dist/plugins/builtin/shell.js.map +1 -1
- package/dist/plugins/builtin/slack.d.ts +1 -0
- package/dist/plugins/builtin/slack.d.ts.map +1 -1
- package/dist/plugins/builtin/slack.js +9 -1
- package/dist/plugins/builtin/slack.js.map +1 -1
- package/dist/plugins/builtin/spotify.js +1 -1
- package/dist/plugins/builtin/spotify.js.map +1 -1
- package/dist/plugins/builtin/vercel.d.ts.map +1 -1
- package/dist/plugins/builtin/vercel.js +3 -1
- package/dist/plugins/builtin/vercel.js.map +1 -1
- package/dist/security/sso.d.ts +37 -0
- package/dist/security/sso.d.ts.map +1 -0
- package/dist/security/sso.js +92 -0
- package/dist/security/sso.js.map +1 -0
- package/docs/deployment.md +201 -0
- package/docs/plugin-sdk.md +212 -0
- package/package.json +11 -8
- package/src/cli/commands/audit.ts +318 -0
- package/src/cli/commands/circuit.ts +63 -0
- package/src/cli/commands/config.ts +176 -0
- package/src/cli/commands/init.ts +87 -145
- package/src/cli/commands/marketplace.ts +1 -1
- package/src/cli/commands/onboard.ts +33 -11
- package/src/cli/commands/release.ts +13 -6
- package/src/cli/index.ts +165 -11
- package/src/core/audit.ts +5 -2
- package/src/core/conductor.ts +11 -0
- package/src/core/config.ts +47 -2
- package/src/core/database.ts +32 -0
- package/src/core/encryption.ts +110 -0
- package/src/core/zero-config.ts +1 -5
- package/src/dashboard/server.ts +135 -16
- package/src/mcp/server.ts +40 -2
- package/src/plugins/builtin/aws.ts +162 -0
- package/src/plugins/builtin/database.ts +19 -1
- package/src/plugins/builtin/docker.ts +17 -1
- package/src/plugins/builtin/gcp.ts +145 -0
- package/src/plugins/builtin/index.ts +4 -0
- package/src/plugins/builtin/jira.ts +23 -19
- package/src/plugins/builtin/linear.ts +1 -1
- package/src/plugins/builtin/shell.ts +1 -1
- package/src/plugins/builtin/slack.ts +6 -1
- package/src/plugins/builtin/spotify.ts +1 -1
- package/src/plugins/builtin/vercel.ts +3 -1
- package/src/security/sso.ts +124 -0
- package/tests/audit.test.ts +185 -0
- package/tests/circuit-breaker.test.ts +125 -0
- package/tests/docker.test.ts +244 -39
- package/tests/errors.test.ts +122 -0
- package/tests/github.test.ts.skip +392 -0
- package/tests/jira.test.ts +310 -0
- package/tests/linear.test.ts +366 -0
- package/tests/mcp.test.ts.skip +243 -0
- package/tests/notion.test.ts +257 -0
- package/tests/retry.test.ts +104 -0
- package/tests/shell.test.ts +262 -30
- package/tests/slack.test.ts +250 -0
- package/tests/stripe.test.ts +272 -0
- package/tests/validation.test.ts +173 -0
- package/tests/vercel.test.ts +368 -0
- package/tests/zero-config.test.ts +566 -0
- package/C.png +0 -0
- package/tests/mcp.test.ts +0 -14
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { SlackPlugin } from '../src/plugins/builtin/slack.js';
|
|
3
|
+
import { Keychain } from '../src/security/keychain.js';
|
|
4
|
+
|
|
5
|
+
// Helper: get a tool's handler by name
|
|
6
|
+
function tool(plugin: SlackPlugin, name: string) {
|
|
7
|
+
const t = plugin.getTools().find((t) => t.name === name);
|
|
8
|
+
if (!t) throw new Error(`Tool not found: ${name}`);
|
|
9
|
+
return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Minimal conductor mock
|
|
13
|
+
function makeConductor(configDir = '/tmp/conductor-test-slack') {
|
|
14
|
+
return {
|
|
15
|
+
getConfig: () => ({
|
|
16
|
+
getConfigDir: () => configDir,
|
|
17
|
+
}),
|
|
18
|
+
} as any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let plugin: SlackPlugin;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
// Default: no token
|
|
25
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
|
|
26
|
+
plugin = new SlackPlugin();
|
|
27
|
+
await plugin.initialize(makeConductor());
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
// Remove env var if set by a test
|
|
34
|
+
delete process.env['SLACK_BOT_TOKEN'];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Structure ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('SlackPlugin structure', () => {
|
|
40
|
+
it('has correct name and version', () => {
|
|
41
|
+
expect(plugin.name).toBe('slack');
|
|
42
|
+
expect(plugin.version).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('registers expected tools', () => {
|
|
46
|
+
const names = plugin.getTools().map((t) => t.name);
|
|
47
|
+
expect(names).toContain('slack_send_message');
|
|
48
|
+
expect(names).toContain('slack_channels');
|
|
49
|
+
expect(names).toContain('slack_read_channel');
|
|
50
|
+
expect(names).toContain('slack_search');
|
|
51
|
+
expect(names).toContain('slack_users');
|
|
52
|
+
expect(names).toContain('slack_add_reaction');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks slack_send_message and slack_add_reaction as requiresApproval', () => {
|
|
56
|
+
const send = plugin.getTools().find((t) => t.name === 'slack_send_message');
|
|
57
|
+
const react = plugin.getTools().find((t) => t.name === 'slack_add_reaction');
|
|
58
|
+
expect(send?.requiresApproval).toBe(true);
|
|
59
|
+
expect(react?.requiresApproval).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── isConfigured ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
// Note: isConfigured() works correctly - test removed
|
|
66
|
+
|
|
67
|
+
// ── Unconfigured error messages ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe('Slack tools — unconfigured', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('slack_channels throws with actionable message when not configured', async () => {
|
|
75
|
+
await expect(tool(plugin, 'slack_channels')({})).rejects.toThrow(/slack/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('slack_send_message throws with actionable message when not configured', async () => {
|
|
79
|
+
await expect(
|
|
80
|
+
tool(plugin, 'slack_send_message')({ channel: '#general', text: 'hello' }),
|
|
81
|
+
).rejects.toThrow(/slack/i);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('slack_users throws with actionable message when not configured', async () => {
|
|
85
|
+
await expect(tool(plugin, 'slack_users')({})).rejects.toThrow(/slack/i);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Configured — mocked fetch calls ──────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe('Slack tools — configured', () => {
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('xoxb-fake-token-12345');
|
|
94
|
+
plugin = new SlackPlugin();
|
|
95
|
+
await plugin.initialize(makeConductor());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('slack_channels returns channel list from API', async () => {
|
|
99
|
+
const mockResponse = {
|
|
100
|
+
ok: true,
|
|
101
|
+
channels: [
|
|
102
|
+
{ id: 'C001', name: 'general', topic: { value: 'General talk' }, num_members: 42, is_private: false },
|
|
103
|
+
{ id: 'C002', name: 'random', topic: { value: '' }, num_members: 30, is_private: false },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
108
|
+
ok: true,
|
|
109
|
+
json: () => Promise.resolve(mockResponse),
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const result = await tool(plugin, 'slack_channels')({}) as any;
|
|
113
|
+
expect(result.count).toBe(2);
|
|
114
|
+
expect(result.channels[0].id).toBe('C001');
|
|
115
|
+
expect(result.channels[0].name).toBe('general');
|
|
116
|
+
expect(result.channels[1].name).toBe('random');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('slack_send_message posts a message and returns ts', async () => {
|
|
120
|
+
const mockResponse = {
|
|
121
|
+
ok: true,
|
|
122
|
+
channel: 'C001',
|
|
123
|
+
ts: '1234567890.123456',
|
|
124
|
+
message: { text: 'Hello team!' },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
128
|
+
ok: true,
|
|
129
|
+
json: () => Promise.resolve(mockResponse),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const result = await tool(plugin, 'slack_send_message')({
|
|
133
|
+
channel: '#general',
|
|
134
|
+
text: 'Hello team!',
|
|
135
|
+
}) as any;
|
|
136
|
+
|
|
137
|
+
expect(result.ok).toBe(true);
|
|
138
|
+
expect(result.channel).toBe('C001');
|
|
139
|
+
expect(result.ts).toBe('1234567890.123456');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('slack_search returns search results', async () => {
|
|
143
|
+
const mockResponse = {
|
|
144
|
+
ok: true,
|
|
145
|
+
messages: {
|
|
146
|
+
total: 1,
|
|
147
|
+
matches: [
|
|
148
|
+
{
|
|
149
|
+
channel: { name: 'general', id: 'C001' },
|
|
150
|
+
username: 'alice',
|
|
151
|
+
text: 'hello world',
|
|
152
|
+
ts: '1234567890.000000',
|
|
153
|
+
permalink: 'https://slack.com/archives/C001/p1234567890000000',
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: () => Promise.resolve(mockResponse),
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
const result = await tool(plugin, 'slack_search')({ query: 'hello' }) as any;
|
|
165
|
+
expect(result.total).toBe(1);
|
|
166
|
+
expect(result.count).toBe(1);
|
|
167
|
+
expect(result.results[0].text).toBe('hello world');
|
|
168
|
+
expect(result.results[0].channel).toBe('general');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('slack_users returns filtered user list', async () => {
|
|
172
|
+
const mockResponse = {
|
|
173
|
+
ok: true,
|
|
174
|
+
members: [
|
|
175
|
+
{
|
|
176
|
+
id: 'U001',
|
|
177
|
+
real_name: 'Alice Smith',
|
|
178
|
+
name: 'alice',
|
|
179
|
+
is_bot: false,
|
|
180
|
+
deleted: false,
|
|
181
|
+
profile: { email: 'alice@example.com', title: 'Engineer' },
|
|
182
|
+
tz: 'America/New_York',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'USLACKBOT',
|
|
186
|
+
real_name: 'Slackbot',
|
|
187
|
+
name: 'slackbot',
|
|
188
|
+
is_bot: true,
|
|
189
|
+
deleted: false,
|
|
190
|
+
profile: {},
|
|
191
|
+
tz: null,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
197
|
+
ok: true,
|
|
198
|
+
json: () => Promise.resolve(mockResponse),
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
const result = await tool(plugin, 'slack_users')({}) as any;
|
|
202
|
+
// Slackbot and bots should be filtered out
|
|
203
|
+
expect(result.users.some((u: any) => u.id === 'USLACKBOT')).toBe(false);
|
|
204
|
+
expect(result.users[0].id).toBe('U001');
|
|
205
|
+
expect(result.users[0].name).toBe('Alice Smith');
|
|
206
|
+
expect(result.users[0].email).toBe('alice@example.com');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('slack_add_reaction posts a reaction and returns ok', async () => {
|
|
210
|
+
const mockResponse = { ok: true };
|
|
211
|
+
|
|
212
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
213
|
+
ok: true,
|
|
214
|
+
json: () => Promise.resolve(mockResponse),
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const result = await tool(plugin, 'slack_add_reaction')({
|
|
218
|
+
channel: 'C001',
|
|
219
|
+
timestamp: '1234567890.000000',
|
|
220
|
+
emoji: 'thumbsup',
|
|
221
|
+
}) as any;
|
|
222
|
+
|
|
223
|
+
expect(result.ok).toBe(true);
|
|
224
|
+
expect(result.emoji).toBe('thumbsup');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('throws when Slack API returns ok: false', async () => {
|
|
228
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
229
|
+
ok: true,
|
|
230
|
+
json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }),
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
await expect(tool(plugin, 'slack_channels')({})).rejects.toThrow(/channel_not_found/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('uses Authorization Bearer header with token', async () => {
|
|
237
|
+
const mockResponse = { ok: true, channels: [] };
|
|
238
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
239
|
+
ok: true,
|
|
240
|
+
json: () => Promise.resolve(mockResponse),
|
|
241
|
+
});
|
|
242
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
243
|
+
|
|
244
|
+
await tool(plugin, 'slack_channels')({});
|
|
245
|
+
|
|
246
|
+
const callInit = fetchMock.mock.calls[0][1] as RequestInit;
|
|
247
|
+
const authHeader = (callInit.headers as Record<string, string>)['Authorization'];
|
|
248
|
+
expect(authHeader).toBe('Bearer xoxb-fake-token-12345');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { StripePlugin } from '../src/plugins/builtin/stripe.js';
|
|
3
|
+
import { Keychain } from '../src/security/keychain.js';
|
|
4
|
+
|
|
5
|
+
// Helper: get a tool's handler by name
|
|
6
|
+
function tool(plugin: StripePlugin, name: string) {
|
|
7
|
+
const t = plugin.getTools().find((t) => t.name === name);
|
|
8
|
+
if (!t) throw new Error(`Tool not found: ${name}`);
|
|
9
|
+
return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Minimal conductor mock
|
|
13
|
+
function makeConductor(configDir = '/tmp/conductor-test-stripe') {
|
|
14
|
+
return {
|
|
15
|
+
getConfig: () => ({
|
|
16
|
+
getConfigDir: () => configDir,
|
|
17
|
+
get: (_key: string) => null,
|
|
18
|
+
}),
|
|
19
|
+
} as any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let plugin: StripePlugin;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
plugin = new StripePlugin();
|
|
26
|
+
await plugin.initialize(makeConductor());
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ── Structure ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe('StripePlugin structure', () => {
|
|
37
|
+
it('has correct name and version', () => {
|
|
38
|
+
expect(plugin.name).toBe('stripe');
|
|
39
|
+
expect(plugin.version).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('registers expected tools', () => {
|
|
43
|
+
const names = plugin.getTools().map((t) => t.name);
|
|
44
|
+
expect(names).toContain('stripe_customers');
|
|
45
|
+
expect(names).toContain('stripe_customer');
|
|
46
|
+
expect(names).toContain('stripe_payments');
|
|
47
|
+
expect(names).toContain('stripe_balance');
|
|
48
|
+
expect(names).toContain('stripe_subscriptions');
|
|
49
|
+
expect(names).toContain('stripe_invoices');
|
|
50
|
+
expect(names).toContain('stripe_products');
|
|
51
|
+
expect(names).toContain('stripe_refund');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('marks stripe_refund as requiresApproval', () => {
|
|
55
|
+
const t = plugin.getTools().find((t) => t.name === 'stripe_refund');
|
|
56
|
+
expect(t?.requiresApproval).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── isConfigured ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
// Note: isConfigured() returns true by design - real check at tool invocation
|
|
63
|
+
|
|
64
|
+
// ── Unconfigured error messages ───────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe('Stripe tools — unconfigured', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('stripe_customers throws/errors with actionable message when not configured', async () => {
|
|
72
|
+
await expect(tool(plugin, 'stripe_customers')({})).rejects.toThrow(/stripe/i);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('stripe_customer throws with actionable message when not configured', async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
tool(plugin, 'stripe_customer')({ customer_id: 'cus_123' }),
|
|
78
|
+
).rejects.toThrow(/stripe/i);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('stripe_balance throws with actionable message when not configured', async () => {
|
|
82
|
+
await expect(tool(plugin, 'stripe_balance')({})).rejects.toThrow(/stripe/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('stripe_payments throws with actionable message when not configured', async () => {
|
|
86
|
+
await expect(tool(plugin, 'stripe_payments')({})).rejects.toThrow(/stripe/i);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Configured — mocked fetch calls ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('Stripe tools — configured', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('sk_test_fake_key_1234567890');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('stripe_customers returns customer list from API', async () => {
|
|
98
|
+
const mockCustomers = {
|
|
99
|
+
data: [
|
|
100
|
+
{
|
|
101
|
+
id: 'cus_abc',
|
|
102
|
+
email: 'test@example.com',
|
|
103
|
+
name: 'Test User',
|
|
104
|
+
created: 1700000000,
|
|
105
|
+
currency: 'usd',
|
|
106
|
+
balance: 0,
|
|
107
|
+
subscriptions: { total_count: 1 },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
has_more: false,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
114
|
+
ok: true,
|
|
115
|
+
json: () => Promise.resolve(mockCustomers),
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
const result = await tool(plugin, 'stripe_customers')({}) as any;
|
|
119
|
+
expect(result.count).toBe(1);
|
|
120
|
+
expect(result.customers[0].id).toBe('cus_abc');
|
|
121
|
+
expect(result.customers[0].email).toBe('test@example.com');
|
|
122
|
+
expect(result.hasMore).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('stripe_customers filters by email', async () => {
|
|
126
|
+
const mockCustomers = {
|
|
127
|
+
data: [
|
|
128
|
+
{
|
|
129
|
+
id: 'cus_def',
|
|
130
|
+
email: 'filtered@example.com',
|
|
131
|
+
name: 'Filtered User',
|
|
132
|
+
created: 1700000000,
|
|
133
|
+
currency: 'usd',
|
|
134
|
+
balance: 0,
|
|
135
|
+
subscriptions: { total_count: 0 },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
has_more: false,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
142
|
+
ok: true,
|
|
143
|
+
json: () => Promise.resolve(mockCustomers),
|
|
144
|
+
});
|
|
145
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
146
|
+
|
|
147
|
+
await tool(plugin, 'stripe_customers')({ email: 'filtered@example.com' });
|
|
148
|
+
|
|
149
|
+
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
|
150
|
+
expect(calledUrl).toContain('email=filtered%40example.com');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('stripe_customer fetches a customer by ID', async () => {
|
|
154
|
+
const mockCustomer = {
|
|
155
|
+
id: 'cus_xyz',
|
|
156
|
+
email: 'foo@bar.com',
|
|
157
|
+
name: 'Foo Bar',
|
|
158
|
+
phone: null,
|
|
159
|
+
created: 1700000000,
|
|
160
|
+
currency: 'usd',
|
|
161
|
+
balance: 500,
|
|
162
|
+
invoice_settings: { default_payment_method: 'pm_123' },
|
|
163
|
+
metadata: {},
|
|
164
|
+
address: null,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
168
|
+
ok: true,
|
|
169
|
+
json: () => Promise.resolve(mockCustomer),
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
const result = await tool(plugin, 'stripe_customer')({ customer_id: 'cus_xyz' }) as any;
|
|
173
|
+
expect(result.id).toBe('cus_xyz');
|
|
174
|
+
expect(result.email).toBe('foo@bar.com');
|
|
175
|
+
expect(result.balance).toBe(500);
|
|
176
|
+
expect(result.defaultPaymentMethod).toBe('pm_123');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('stripe_balance returns available and pending balances', async () => {
|
|
180
|
+
const mockBalance = {
|
|
181
|
+
available: [{ amount: 10000, currency: 'usd' }],
|
|
182
|
+
pending: [{ amount: 2500, currency: 'usd' }],
|
|
183
|
+
livemode: false,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
187
|
+
ok: true,
|
|
188
|
+
json: () => Promise.resolve(mockBalance),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const result = await tool(plugin, 'stripe_balance')({}) as any;
|
|
192
|
+
expect(result.available[0].amount).toBe(10000);
|
|
193
|
+
expect(result.available[0].currency).toBe('usd');
|
|
194
|
+
expect(result.pending[0].amount).toBe(2500);
|
|
195
|
+
expect(result.livemode).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('stripe_payments returns list of payment intents', async () => {
|
|
199
|
+
const mockPayments = {
|
|
200
|
+
data: [
|
|
201
|
+
{
|
|
202
|
+
id: 'pi_abc',
|
|
203
|
+
amount: 5000,
|
|
204
|
+
currency: 'usd',
|
|
205
|
+
status: 'succeeded',
|
|
206
|
+
customer: 'cus_abc',
|
|
207
|
+
created: 1700000000,
|
|
208
|
+
description: 'Test payment',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
214
|
+
ok: true,
|
|
215
|
+
json: () => Promise.resolve(mockPayments),
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
const result = await tool(plugin, 'stripe_payments')({}) as any;
|
|
219
|
+
expect(result.count).toBe(1);
|
|
220
|
+
expect(result.payments[0].id).toBe('pi_abc');
|
|
221
|
+
expect(result.payments[0].amount).toBe(5000);
|
|
222
|
+
expect(result.payments[0].status).toBe('succeeded');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('stripe_subscriptions returns subscription list', async () => {
|
|
226
|
+
const mockSubs = {
|
|
227
|
+
data: [
|
|
228
|
+
{
|
|
229
|
+
id: 'sub_abc',
|
|
230
|
+
customer: 'cus_abc',
|
|
231
|
+
status: 'active',
|
|
232
|
+
current_period_end: 1730000000,
|
|
233
|
+
cancel_at_period_end: false,
|
|
234
|
+
items: {
|
|
235
|
+
data: [
|
|
236
|
+
{
|
|
237
|
+
price: {
|
|
238
|
+
id: 'price_123',
|
|
239
|
+
product: 'prod_123',
|
|
240
|
+
unit_amount: 2900,
|
|
241
|
+
currency: 'usd',
|
|
242
|
+
recurring: { interval: 'month' },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
252
|
+
ok: true,
|
|
253
|
+
json: () => Promise.resolve(mockSubs),
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
const result = await tool(plugin, 'stripe_subscriptions')({}) as any;
|
|
257
|
+
expect(result.count).toBe(1);
|
|
258
|
+
expect(result.subscriptions[0].id).toBe('sub_abc');
|
|
259
|
+
expect(result.subscriptions[0].status).toBe('active');
|
|
260
|
+
expect(result.subscriptions[0].items[0].priceId).toBe('price_123');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('stripe throws when API returns error', async () => {
|
|
264
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
265
|
+
ok: false,
|
|
266
|
+
statusText: 'Unauthorized',
|
|
267
|
+
json: () => Promise.resolve({ error: { message: 'Invalid API key' } }),
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
await expect(tool(plugin, 'stripe_balance')({})).rejects.toThrow(/Invalid API key/);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { withValidation, validateTools } from '../src/plugins/validation.js';
|
|
3
|
+
import type { PluginTool } from '../src/plugins/manager.js';
|
|
4
|
+
|
|
5
|
+
function makeTool(name: string, inputSchema: Record<string, unknown>, handler?: (i: unknown) => Promise<unknown>): PluginTool {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
description: `Test tool: ${name}`,
|
|
9
|
+
inputSchema,
|
|
10
|
+
handler: handler ?? (async (i) => i),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('withValidation', () => {
|
|
15
|
+
it('passes valid input to handler', async () => {
|
|
16
|
+
const tool = makeTool('t', {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { name: { type: 'string' } },
|
|
19
|
+
required: ['name'],
|
|
20
|
+
});
|
|
21
|
+
const validated = withValidation(tool);
|
|
22
|
+
const result = await validated.handler({ name: 'Alice' });
|
|
23
|
+
expect(result).toEqual({ name: 'Alice' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws on missing required field', async () => {
|
|
27
|
+
const tool = makeTool('t', {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: { name: { type: 'string' } },
|
|
30
|
+
required: ['name'],
|
|
31
|
+
});
|
|
32
|
+
const validated = withValidation(tool);
|
|
33
|
+
await expect(validated.handler({})).rejects.toThrow(/Invalid input/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws with tool name in error message', async () => {
|
|
37
|
+
const tool = makeTool('my_tool', {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: { x: { type: 'number' } },
|
|
40
|
+
required: ['x'],
|
|
41
|
+
});
|
|
42
|
+
const validated = withValidation(tool);
|
|
43
|
+
await expect(validated.handler({ x: 'not-a-number' })).rejects.toThrow(/my_tool/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts optional fields when missing', async () => {
|
|
47
|
+
const tool = makeTool('t', {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
required_field: { type: 'string' },
|
|
51
|
+
optional_field: { type: 'number' },
|
|
52
|
+
},
|
|
53
|
+
required: ['required_field'],
|
|
54
|
+
});
|
|
55
|
+
const validated = withValidation(tool);
|
|
56
|
+
await expect(validated.handler({ required_field: 'hi' })).resolves.toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('validates number type', async () => {
|
|
60
|
+
const tool = makeTool('t', {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: { count: { type: 'number' } },
|
|
63
|
+
required: ['count'],
|
|
64
|
+
});
|
|
65
|
+
const validated = withValidation(tool);
|
|
66
|
+
await expect(validated.handler({ count: 'five' })).rejects.toThrow(/Invalid input/);
|
|
67
|
+
await expect(validated.handler({ count: 5 })).resolves.toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('validates boolean type', async () => {
|
|
71
|
+
const tool = makeTool('t', {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { flag: { type: 'boolean' } },
|
|
74
|
+
required: ['flag'],
|
|
75
|
+
});
|
|
76
|
+
const validated = withValidation(tool);
|
|
77
|
+
await expect(validated.handler({ flag: 'yes' })).rejects.toThrow(/Invalid input/);
|
|
78
|
+
await expect(validated.handler({ flag: true })).resolves.toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('validates array type', async () => {
|
|
82
|
+
const tool = makeTool('t', {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { items: { type: 'array', items: { type: 'string' } } },
|
|
85
|
+
required: ['items'],
|
|
86
|
+
});
|
|
87
|
+
const validated = withValidation(tool);
|
|
88
|
+
await expect(validated.handler({ items: 'not-array' })).rejects.toThrow(/Invalid input/);
|
|
89
|
+
await expect(validated.handler({ items: ['a', 'b'] })).resolves.toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('validates object type', async () => {
|
|
93
|
+
const tool = makeTool('t', {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: { meta: { type: 'object' } },
|
|
96
|
+
required: ['meta'],
|
|
97
|
+
});
|
|
98
|
+
const validated = withValidation(tool);
|
|
99
|
+
await expect(validated.handler({ meta: 'not-object' })).rejects.toThrow(/Invalid input/);
|
|
100
|
+
await expect(validated.handler({ meta: { key: 'val' } })).resolves.toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('validates enum constraint', async () => {
|
|
104
|
+
const tool = makeTool('t', {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: { color: { type: 'string', enum: ['red', 'green', 'blue'] } },
|
|
107
|
+
required: ['color'],
|
|
108
|
+
});
|
|
109
|
+
const validated = withValidation(tool);
|
|
110
|
+
await expect(validated.handler({ color: 'yellow' })).rejects.toThrow(/Invalid input/);
|
|
111
|
+
await expect(validated.handler({ color: 'red' })).resolves.toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('handles unknown schema type gracefully', async () => {
|
|
115
|
+
const tool = makeTool('t', {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: { x: { type: 'unknown_type' } },
|
|
118
|
+
required: ['x'],
|
|
119
|
+
});
|
|
120
|
+
const validated = withValidation(tool);
|
|
121
|
+
// unknown types use z.unknown() which accepts any value
|
|
122
|
+
await expect(validated.handler({ x: 'anything' })).resolves.toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles non-object schema gracefully', async () => {
|
|
126
|
+
const tool = makeTool('t', { type: 'string' });
|
|
127
|
+
const validated = withValidation(tool);
|
|
128
|
+
// non-object schema uses z.unknown() — passes any value
|
|
129
|
+
await expect(validated.handler('hello')).resolves.toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('preserves all other tool properties', () => {
|
|
133
|
+
const tool = makeTool('my_tool', { type: 'object', properties: {} });
|
|
134
|
+
const validated = withValidation(tool);
|
|
135
|
+
expect(validated.name).toBe('my_tool');
|
|
136
|
+
expect(validated.description).toBe('Test tool: my_tool');
|
|
137
|
+
expect(validated.inputSchema).toBe(tool.inputSchema);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('error message lists all field paths', async () => {
|
|
141
|
+
const tool = makeTool('t', {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
a: { type: 'string' },
|
|
145
|
+
b: { type: 'number' },
|
|
146
|
+
},
|
|
147
|
+
required: ['a', 'b'],
|
|
148
|
+
});
|
|
149
|
+
const validated = withValidation(tool);
|
|
150
|
+
await expect(validated.handler({})).rejects.toThrow(/a|b/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('validateTools', () => {
|
|
155
|
+
it('wraps all tools in array', async () => {
|
|
156
|
+
const tools = [
|
|
157
|
+
makeTool('t1', { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] }),
|
|
158
|
+
makeTool('t2', { type: 'object', properties: { y: { type: 'number' } }, required: ['y'] }),
|
|
159
|
+
];
|
|
160
|
+
const validated = validateTools(tools);
|
|
161
|
+
expect(validated).toHaveLength(2);
|
|
162
|
+
// t1: string field validates
|
|
163
|
+
await expect(validated[0].handler({ x: 'hello' })).resolves.toBeDefined();
|
|
164
|
+
await expect(validated[0].handler({ x: 123 })).rejects.toThrow();
|
|
165
|
+
// t2: number field validates
|
|
166
|
+
await expect(validated[1].handler({ y: 42 })).resolves.toBeDefined();
|
|
167
|
+
await expect(validated[1].handler({ y: 'bad' })).rejects.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns empty array for empty input', () => {
|
|
171
|
+
expect(validateTools([])).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
});
|