@openclaw/synology-chat 2026.2.22 → 2026.5.1-beta.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/api.ts +3 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +1 -0
- package/index.ts +11 -12
- package/openclaw.plugin.json +35 -1
- package/package.json +20 -5
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/src/accounts.ts +107 -43
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +22 -0
- package/src/channel.integration.test.ts +191 -0
- package/src/channel.test-mocks.ts +176 -0
- package/src/channel.test.ts +467 -174
- package/src/channel.ts +325 -278
- package/src/client.test.ts +296 -44
- package/src/client.ts +217 -24
- package/src/config-schema.ts +11 -0
- package/src/core.test.ts +373 -0
- package/src/gateway-runtime.ts +212 -0
- package/src/inbound-context.ts +10 -0
- package/src/inbound-turn.ts +171 -0
- package/src/runtime.ts +8 -20
- package/src/security-audit.test.ts +66 -0
- package/src/security-audit.ts +28 -0
- package/src/security.ts +67 -53
- package/src/session-key.ts +21 -0
- package/src/setup-surface.ts +338 -0
- package/src/test-http-utils.ts +75 -0
- package/src/types.ts +13 -14
- package/src/webhook-handler.test.ts +418 -75
- package/src/webhook-handler.ts +569 -138
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -133
- package/src/security.test.ts +0 -98
package/src/channel.test.ts
CHANGED
|
@@ -1,55 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
vi.mock("openclaw/plugin-sdk", () => ({
|
|
5
|
-
DEFAULT_ACCOUNT_ID: "default",
|
|
6
|
-
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
|
7
|
-
registerPluginHttpRoute: vi.fn(() => vi.fn()),
|
|
8
|
-
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
|
9
|
-
}));
|
|
1
|
+
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ResolvedSynologyChatAccount } from "./types.js";
|
|
10
4
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
const securityAccountDefaults: ResolvedSynologyChatAccount = {
|
|
6
|
+
accountId: "default",
|
|
7
|
+
enabled: true,
|
|
8
|
+
token: "t",
|
|
9
|
+
incomingUrl: "https://nas/incoming",
|
|
10
|
+
nasHost: "h",
|
|
11
|
+
webhookPath: "/w",
|
|
12
|
+
webhookPathSource: "default" as const,
|
|
13
|
+
dangerouslyAllowNameMatching: false,
|
|
14
|
+
dangerouslyAllowInheritedWebhookPath: false,
|
|
15
|
+
dmPolicy: "allowlist" as const,
|
|
16
|
+
allowedUserIds: [],
|
|
17
|
+
rateLimitPerMinute: 30,
|
|
18
|
+
botName: "Bot",
|
|
19
|
+
allowInsecureSsl: false,
|
|
20
|
+
};
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
function makeSecurityAccount(
|
|
23
|
+
overrides: Partial<ResolvedSynologyChatAccount> = {},
|
|
24
|
+
): ResolvedSynologyChatAccount {
|
|
25
|
+
return { ...securityAccountDefaults, ...overrides };
|
|
26
|
+
}
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
counts: {},
|
|
27
|
-
}),
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
})),
|
|
31
|
-
}));
|
|
28
|
+
const clientModule = await import("./client.js");
|
|
29
|
+
const gatewayRuntimeModule = await import("./gateway-runtime.js");
|
|
30
|
+
const mockSendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true);
|
|
31
|
+
const registerSynologyWebhookRouteMock = vi
|
|
32
|
+
.spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute")
|
|
33
|
+
.mockImplementation(() => vi.fn());
|
|
32
34
|
|
|
33
|
-
vi.mock("
|
|
34
|
-
|
|
35
|
-
object: vi.fn(() => ({
|
|
36
|
-
passthrough: vi.fn(() => ({ _type: "zod-schema" })),
|
|
37
|
-
})),
|
|
38
|
-
},
|
|
35
|
+
vi.mock("./webhook-handler.js", () => ({
|
|
36
|
+
createWebhookHandler: vi.fn(() => vi.fn()),
|
|
39
37
|
}));
|
|
40
38
|
|
|
41
|
-
const { createSynologyChatPlugin } = await import("./channel.js");
|
|
39
|
+
const { createSynologyChatPlugin, synologyChatPlugin } = await import("./channel.js");
|
|
40
|
+
const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlugin);
|
|
42
41
|
|
|
43
42
|
describe("createSynologyChatPlugin", () => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.stubEnv("SYNOLOGY_CHAT_TOKEN", "");
|
|
45
|
+
vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", "");
|
|
46
|
+
mockSendMessage.mockClear();
|
|
47
|
+
registerSynologyWebhookRouteMock.mockClear();
|
|
48
|
+
mockSendMessage.mockResolvedValue(true);
|
|
49
|
+
registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllEnvs();
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
describe("meta", () => {
|
|
@@ -71,22 +72,96 @@ describe("createSynologyChatPlugin", () => {
|
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
describe("config", () => {
|
|
74
|
-
it("listAccountIds
|
|
75
|
+
it("listAccountIds includes default and named accounts when configured", () => {
|
|
75
76
|
const plugin = createSynologyChatPlugin();
|
|
76
|
-
const result = plugin.config.listAccountIds({
|
|
77
|
-
|
|
77
|
+
const result = plugin.config.listAccountIds({
|
|
78
|
+
channels: {
|
|
79
|
+
"synology-chat": {
|
|
80
|
+
token: "base-token",
|
|
81
|
+
accounts: {
|
|
82
|
+
office: { token: "office-token" },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
expect(result).toEqual(["default", "office"]);
|
|
78
88
|
});
|
|
79
89
|
|
|
80
|
-
it("resolveAccount
|
|
81
|
-
const cfg = {
|
|
90
|
+
it("resolveAccount merges account overrides with base config defaults", () => {
|
|
91
|
+
const cfg = {
|
|
92
|
+
channels: {
|
|
93
|
+
"synology-chat": {
|
|
94
|
+
token: "base-token",
|
|
95
|
+
incomingUrl: "https://nas/base",
|
|
96
|
+
nasHost: "nas-base",
|
|
97
|
+
allowedUserIds: ["base-user"],
|
|
98
|
+
rateLimitPerMinute: 45,
|
|
99
|
+
botName: "Base Bot",
|
|
100
|
+
accounts: {
|
|
101
|
+
office: {
|
|
102
|
+
token: "office-token",
|
|
103
|
+
allowInsecureSsl: true,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
82
109
|
const plugin = createSynologyChatPlugin();
|
|
83
|
-
const account = plugin.config.resolveAccount(cfg, "
|
|
84
|
-
expect(account
|
|
110
|
+
const account = plugin.config.resolveAccount(cfg, "office");
|
|
111
|
+
expect(account).toMatchObject({
|
|
112
|
+
accountId: "office",
|
|
113
|
+
token: "office-token",
|
|
114
|
+
incomingUrl: "https://nas/base",
|
|
115
|
+
nasHost: "nas-base",
|
|
116
|
+
allowedUserIds: ["base-user"],
|
|
117
|
+
rateLimitPerMinute: 45,
|
|
118
|
+
botName: "Base Bot",
|
|
119
|
+
allowInsecureSsl: true,
|
|
120
|
+
});
|
|
85
121
|
});
|
|
86
122
|
|
|
87
123
|
it("defaultAccountId returns 'default'", () => {
|
|
88
124
|
const plugin = createSynologyChatPlugin();
|
|
89
|
-
expect(plugin.config.defaultAccountId({})).toBe("default");
|
|
125
|
+
expect(plugin.config.defaultAccountId?.({})).toBe("default");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("setup status honors the selected named account", async () => {
|
|
129
|
+
const status = await getSynologyChatSetupStatus({
|
|
130
|
+
cfg: {
|
|
131
|
+
channels: {
|
|
132
|
+
"synology-chat": {
|
|
133
|
+
accounts: {
|
|
134
|
+
ops: {
|
|
135
|
+
token: "ops-token",
|
|
136
|
+
incomingUrl: "https://nas/ops",
|
|
137
|
+
},
|
|
138
|
+
work: {
|
|
139
|
+
token: "work-token",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
accountOverrides: {
|
|
146
|
+
"synology-chat": "work",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(status.configured).toBe(false);
|
|
151
|
+
expect(status.statusLines).toEqual([
|
|
152
|
+
"Synology Chat: needs token + incoming webhook",
|
|
153
|
+
"Accounts: 2",
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("formats allowFrom entries through the shared adapter", () => {
|
|
158
|
+
const plugin = createSynologyChatPlugin();
|
|
159
|
+
expect(
|
|
160
|
+
plugin.config.formatAllowFrom?.({
|
|
161
|
+
cfg: {},
|
|
162
|
+
allowFrom: [" USER1 ", 42],
|
|
163
|
+
}),
|
|
164
|
+
).toEqual(["user1", "42"]);
|
|
90
165
|
});
|
|
91
166
|
});
|
|
92
167
|
|
|
@@ -100,6 +175,9 @@ describe("createSynologyChatPlugin", () => {
|
|
|
100
175
|
incomingUrl: "u",
|
|
101
176
|
nasHost: "h",
|
|
102
177
|
webhookPath: "/w",
|
|
178
|
+
webhookPathSource: "default" as const,
|
|
179
|
+
dangerouslyAllowNameMatching: false,
|
|
180
|
+
dangerouslyAllowInheritedWebhookPath: false,
|
|
103
181
|
dmPolicy: "allowlist" as const,
|
|
104
182
|
allowedUserIds: ["user1"],
|
|
105
183
|
rateLimitPerMinute: 30,
|
|
@@ -107,97 +185,158 @@ describe("createSynologyChatPlugin", () => {
|
|
|
107
185
|
allowInsecureSsl: true,
|
|
108
186
|
};
|
|
109
187
|
const result = plugin.security.resolveDmPolicy({ cfg: {}, account });
|
|
188
|
+
if (!result) {
|
|
189
|
+
throw new Error("resolveDmPolicy returned null");
|
|
190
|
+
}
|
|
110
191
|
expect(result.policy).toBe("allowlist");
|
|
111
192
|
expect(result.allowFrom).toEqual(["user1"]);
|
|
112
|
-
expect(
|
|
113
|
-
expect(result.normalizeEntry(" USER1 ")).toBe("user1");
|
|
193
|
+
expect(result.normalizeEntry?.(" USER1 ")).toBe("user1");
|
|
114
194
|
});
|
|
115
195
|
});
|
|
116
196
|
|
|
117
197
|
describe("pairing", () => {
|
|
118
|
-
it("
|
|
198
|
+
it("normalizes entries and notifies approved users", async () => {
|
|
119
199
|
const plugin = createSynologyChatPlugin();
|
|
120
200
|
expect(plugin.pairing.idLabel).toBe("synologyChatUserId");
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
const normalize = plugin.pairing.normalizeAllowEntry;
|
|
202
|
+
const notifyApproval = plugin.pairing.notifyApproval;
|
|
203
|
+
if (!normalize || !notifyApproval) {
|
|
204
|
+
throw new Error("synology-chat pairing helpers unavailable");
|
|
205
|
+
}
|
|
206
|
+
expect(normalize(" USER1 ")).toBe("user1");
|
|
207
|
+
|
|
208
|
+
await notifyApproval({
|
|
209
|
+
cfg: {
|
|
210
|
+
channels: {
|
|
211
|
+
"synology-chat": {
|
|
212
|
+
token: "t",
|
|
213
|
+
incomingUrl: "https://nas/incoming",
|
|
214
|
+
allowInsecureSsl: true,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
id: "USER1",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
222
|
+
"https://nas/incoming",
|
|
223
|
+
"OpenClaw: your access has been approved.",
|
|
224
|
+
"USER1",
|
|
225
|
+
true,
|
|
226
|
+
);
|
|
124
227
|
});
|
|
125
228
|
});
|
|
126
229
|
|
|
127
230
|
describe("security.collectWarnings", () => {
|
|
231
|
+
function makeSharedWebhookConfig(alertsOverrides: Record<string, unknown> = {}) {
|
|
232
|
+
return {
|
|
233
|
+
channels: {
|
|
234
|
+
"synology-chat": {
|
|
235
|
+
token: "base-token",
|
|
236
|
+
webhookPath: "/webhook/shared",
|
|
237
|
+
accounts: {
|
|
238
|
+
alerts: {
|
|
239
|
+
token: "alerts-token",
|
|
240
|
+
incomingUrl: "https://nas/alerts",
|
|
241
|
+
dmPolicy: "allowlist",
|
|
242
|
+
allowedUserIds: ["123"],
|
|
243
|
+
...alertsOverrides,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
128
251
|
it("warns when token is missing", () => {
|
|
129
252
|
const plugin = createSynologyChatPlugin();
|
|
130
|
-
const account = {
|
|
131
|
-
|
|
132
|
-
enabled: true,
|
|
133
|
-
token: "",
|
|
134
|
-
incomingUrl: "https://nas/incoming",
|
|
135
|
-
nasHost: "h",
|
|
136
|
-
webhookPath: "/w",
|
|
137
|
-
dmPolicy: "allowlist" as const,
|
|
138
|
-
allowedUserIds: [],
|
|
139
|
-
rateLimitPerMinute: 30,
|
|
140
|
-
botName: "Bot",
|
|
141
|
-
allowInsecureSsl: false,
|
|
142
|
-
};
|
|
143
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
253
|
+
const account = makeSecurityAccount({ token: "" });
|
|
254
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
144
255
|
expect(warnings.some((w: string) => w.includes("token"))).toBe(true);
|
|
145
256
|
});
|
|
146
257
|
|
|
147
258
|
it("warns when allowInsecureSsl is true", () => {
|
|
148
259
|
const plugin = createSynologyChatPlugin();
|
|
149
|
-
const account = {
|
|
150
|
-
|
|
151
|
-
enabled: true,
|
|
152
|
-
token: "t",
|
|
153
|
-
incomingUrl: "https://nas/incoming",
|
|
154
|
-
nasHost: "h",
|
|
155
|
-
webhookPath: "/w",
|
|
156
|
-
dmPolicy: "allowlist" as const,
|
|
157
|
-
allowedUserIds: [],
|
|
158
|
-
rateLimitPerMinute: 30,
|
|
159
|
-
botName: "Bot",
|
|
160
|
-
allowInsecureSsl: true,
|
|
161
|
-
};
|
|
162
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
260
|
+
const account = makeSecurityAccount({ allowInsecureSsl: true });
|
|
261
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
163
262
|
expect(warnings.some((w: string) => w.includes("SSL"))).toBe(true);
|
|
164
263
|
});
|
|
165
264
|
|
|
265
|
+
it("warns when dangerous name matching is enabled", () => {
|
|
266
|
+
const plugin = createSynologyChatPlugin();
|
|
267
|
+
const account = makeSecurityAccount({ dangerouslyAllowNameMatching: true });
|
|
268
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
269
|
+
expect(warnings.some((w: string) => w.includes("dangerouslyAllowNameMatching"))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("warns when inherited shared webhookPath is dangerously re-enabled", () => {
|
|
273
|
+
const plugin = createSynologyChatPlugin();
|
|
274
|
+
const account = makeSecurityAccount({
|
|
275
|
+
accountId: "alerts",
|
|
276
|
+
webhookPathSource: "inherited-base",
|
|
277
|
+
dangerouslyAllowInheritedWebhookPath: true,
|
|
278
|
+
});
|
|
279
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
280
|
+
expect(
|
|
281
|
+
warnings.some((w: string) => w.includes("dangerouslyAllowInheritedWebhookPath=true")),
|
|
282
|
+
).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
166
285
|
it("warns when dmPolicy is open", () => {
|
|
167
286
|
const plugin = createSynologyChatPlugin();
|
|
168
|
-
const account = {
|
|
169
|
-
|
|
170
|
-
enabled: true,
|
|
171
|
-
token: "t",
|
|
172
|
-
incomingUrl: "https://nas/incoming",
|
|
173
|
-
nasHost: "h",
|
|
174
|
-
webhookPath: "/w",
|
|
175
|
-
dmPolicy: "open" as const,
|
|
176
|
-
allowedUserIds: [],
|
|
177
|
-
rateLimitPerMinute: 30,
|
|
178
|
-
botName: "Bot",
|
|
179
|
-
allowInsecureSsl: false,
|
|
180
|
-
};
|
|
181
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
287
|
+
const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: ["*"] });
|
|
288
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
182
289
|
expect(warnings.some((w: string) => w.includes("open"))).toBe(true);
|
|
183
290
|
});
|
|
184
291
|
|
|
185
|
-
it("
|
|
292
|
+
it("warns when dmPolicy is open and allowedUserIds is empty", () => {
|
|
186
293
|
const plugin = createSynologyChatPlugin();
|
|
187
|
-
const account = {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
294
|
+
const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: [] });
|
|
295
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
296
|
+
expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => {
|
|
300
|
+
const plugin = createSynologyChatPlugin();
|
|
301
|
+
const account = makeSecurityAccount();
|
|
302
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
303
|
+
expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("warns when named multi-account routes inherit a shared webhookPath", () => {
|
|
307
|
+
const plugin = createSynologyChatPlugin();
|
|
308
|
+
const cfg = makeSharedWebhookConfig();
|
|
309
|
+
const account = plugin.config.resolveAccount(cfg, "alerts");
|
|
310
|
+
const warnings = plugin.security.collectWarnings({ cfg, account });
|
|
311
|
+
expect(warnings.some((w: string) => w.includes("must set an explicit webhookPath"))).toBe(
|
|
312
|
+
true,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("warns when enabled accounts share the same exact webhookPath", () => {
|
|
317
|
+
const plugin = createSynologyChatPlugin();
|
|
318
|
+
const base = makeSharedWebhookConfig({ webhookPath: "/webhook/shared" }).channels[
|
|
319
|
+
"synology-chat"
|
|
320
|
+
];
|
|
321
|
+
const cfg = {
|
|
322
|
+
channels: {
|
|
323
|
+
"synology-chat": {
|
|
324
|
+
...base,
|
|
325
|
+
incomingUrl: "https://nas/default",
|
|
326
|
+
dmPolicy: "allowlist",
|
|
327
|
+
allowedUserIds: ["123"],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
199
330
|
};
|
|
200
|
-
const
|
|
331
|
+
const account = plugin.config.resolveAccount(cfg, "alerts");
|
|
332
|
+
const warnings = plugin.security.collectWarnings({ cfg, account });
|
|
333
|
+
expect(warnings.some((w: string) => w.includes("conflicts on webhookPath"))).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("returns no warnings for fully configured account", () => {
|
|
337
|
+
const plugin = createSynologyChatPlugin();
|
|
338
|
+
const account = makeSecurityAccount({ allowedUserIds: ["user1"] });
|
|
339
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
201
340
|
expect(warnings).toHaveLength(0);
|
|
202
341
|
});
|
|
203
342
|
});
|
|
@@ -222,9 +361,10 @@ describe("createSynologyChatPlugin", () => {
|
|
|
222
361
|
describe("directory", () => {
|
|
223
362
|
it("returns empty stubs", async () => {
|
|
224
363
|
const plugin = createSynologyChatPlugin();
|
|
225
|
-
|
|
226
|
-
expect(await plugin.directory.
|
|
227
|
-
expect(await plugin.directory.
|
|
364
|
+
const params = { cfg: {}, runtime: {} as never };
|
|
365
|
+
expect(await plugin.directory.self?.(params)).toBeNull();
|
|
366
|
+
expect(await plugin.directory.listPeers?.(params)).toEqual([]);
|
|
367
|
+
expect(await plugin.directory.listGroups?.(params)).toEqual([]);
|
|
228
368
|
});
|
|
229
369
|
});
|
|
230
370
|
|
|
@@ -232,9 +372,9 @@ describe("createSynologyChatPlugin", () => {
|
|
|
232
372
|
it("returns formatting hints", () => {
|
|
233
373
|
const plugin = createSynologyChatPlugin();
|
|
234
374
|
const hints = plugin.agentPrompt.messageToolHints();
|
|
235
|
-
expect(
|
|
236
|
-
expect(hints
|
|
237
|
-
expect(hints.
|
|
375
|
+
expect(hints).toContain("### Synology Chat Formatting");
|
|
376
|
+
expect(hints).toContain("**Links**: Use `<URL|display text>` to create clickable links.");
|
|
377
|
+
expect(hints).toContain("- No buttons, cards, or interactive elements");
|
|
238
378
|
});
|
|
239
379
|
});
|
|
240
380
|
|
|
@@ -243,18 +383,10 @@ describe("createSynologyChatPlugin", () => {
|
|
|
243
383
|
const plugin = createSynologyChatPlugin();
|
|
244
384
|
await expect(
|
|
245
385
|
plugin.outbound.sendText({
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
incomingUrl: "",
|
|
251
|
-
nasHost: "h",
|
|
252
|
-
webhookPath: "/w",
|
|
253
|
-
dmPolicy: "open",
|
|
254
|
-
allowedUserIds: [],
|
|
255
|
-
rateLimitPerMinute: 30,
|
|
256
|
-
botName: "Bot",
|
|
257
|
-
allowInsecureSsl: true,
|
|
386
|
+
cfg: {
|
|
387
|
+
channels: {
|
|
388
|
+
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
|
389
|
+
},
|
|
258
390
|
},
|
|
259
391
|
text: "hello",
|
|
260
392
|
to: "user1",
|
|
@@ -265,43 +397,34 @@ describe("createSynologyChatPlugin", () => {
|
|
|
265
397
|
it("sendText returns OutboundDeliveryResult on success", async () => {
|
|
266
398
|
const plugin = createSynologyChatPlugin();
|
|
267
399
|
const result = await plugin.outbound.sendText({
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
rateLimitPerMinute: 30,
|
|
278
|
-
botName: "Bot",
|
|
279
|
-
allowInsecureSsl: true,
|
|
400
|
+
cfg: {
|
|
401
|
+
channels: {
|
|
402
|
+
"synology-chat": {
|
|
403
|
+
enabled: true,
|
|
404
|
+
token: "t",
|
|
405
|
+
incomingUrl: "https://nas/incoming",
|
|
406
|
+
allowInsecureSsl: true,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
280
409
|
},
|
|
281
410
|
text: "hello",
|
|
282
411
|
to: "user1",
|
|
283
412
|
});
|
|
284
|
-
expect(result
|
|
285
|
-
|
|
286
|
-
|
|
413
|
+
expect(result).toMatchObject({
|
|
414
|
+
channel: "synology-chat",
|
|
415
|
+
chatId: "user1",
|
|
416
|
+
});
|
|
417
|
+
expect(result.messageId).toMatch(/^sc-\d+$/);
|
|
287
418
|
});
|
|
288
419
|
|
|
289
420
|
it("sendMedia throws when missing incomingUrl", async () => {
|
|
290
421
|
const plugin = createSynologyChatPlugin();
|
|
291
422
|
await expect(
|
|
292
423
|
plugin.outbound.sendMedia({
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
incomingUrl: "",
|
|
298
|
-
nasHost: "h",
|
|
299
|
-
webhookPath: "/w",
|
|
300
|
-
dmPolicy: "open",
|
|
301
|
-
allowedUserIds: [],
|
|
302
|
-
rateLimitPerMinute: 30,
|
|
303
|
-
botName: "Bot",
|
|
304
|
-
allowInsecureSsl: true,
|
|
424
|
+
cfg: {
|
|
425
|
+
channels: {
|
|
426
|
+
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
|
427
|
+
},
|
|
305
428
|
},
|
|
306
429
|
mediaUrl: "https://example.com/img.png",
|
|
307
430
|
to: "user1",
|
|
@@ -311,30 +434,200 @@ describe("createSynologyChatPlugin", () => {
|
|
|
311
434
|
});
|
|
312
435
|
|
|
313
436
|
describe("gateway", () => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
437
|
+
function makeStartAccountCtx(
|
|
438
|
+
accountConfig: Record<string, unknown>,
|
|
439
|
+
abortController = new AbortController(),
|
|
440
|
+
) {
|
|
441
|
+
return {
|
|
442
|
+
abortController,
|
|
443
|
+
ctx: {
|
|
444
|
+
cfg: {
|
|
445
|
+
channels: { "synology-chat": accountConfig },
|
|
446
|
+
},
|
|
447
|
+
accountId: "default",
|
|
448
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
449
|
+
abortSignal: abortController.signal,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function makeNamedStartAccountCtx(
|
|
455
|
+
accountOverrides: Record<string, unknown>,
|
|
456
|
+
abortController = new AbortController(),
|
|
457
|
+
) {
|
|
458
|
+
return {
|
|
459
|
+
abortController,
|
|
460
|
+
ctx: {
|
|
461
|
+
cfg: {
|
|
462
|
+
channels: {
|
|
463
|
+
"synology-chat": {
|
|
464
|
+
enabled: true,
|
|
465
|
+
token: "default-token",
|
|
466
|
+
incomingUrl: "https://nas/default",
|
|
467
|
+
webhookPath: "/webhook/synology-shared",
|
|
468
|
+
dmPolicy: "allowlist",
|
|
469
|
+
allowedUserIds: ["123"],
|
|
470
|
+
accounts: {
|
|
471
|
+
alerts: {
|
|
472
|
+
enabled: true,
|
|
473
|
+
token: "alerts-token",
|
|
474
|
+
incomingUrl: "https://nas/alerts",
|
|
475
|
+
...accountOverrides,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
accountId: "alerts",
|
|
482
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
483
|
+
abortSignal: abortController.signal,
|
|
319
484
|
},
|
|
320
|
-
accountId: "default",
|
|
321
|
-
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
322
485
|
};
|
|
323
|
-
|
|
324
|
-
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function expectPendingStartAccountPromise(
|
|
489
|
+
result: Promise<unknown>,
|
|
490
|
+
abortController: AbortController,
|
|
491
|
+
) {
|
|
492
|
+
expect(result).toBeInstanceOf(Promise);
|
|
493
|
+
let settled = false;
|
|
494
|
+
void result.then(
|
|
495
|
+
() => {
|
|
496
|
+
settled = true;
|
|
497
|
+
},
|
|
498
|
+
() => {
|
|
499
|
+
settled = true;
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
await Promise.resolve();
|
|
503
|
+
expect(settled).toBe(false);
|
|
504
|
+
abortController.abort();
|
|
505
|
+
await result;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function expectPendingStartAccount(accountConfig: Record<string, unknown>) {
|
|
509
|
+
const plugin = createSynologyChatPlugin();
|
|
510
|
+
const { ctx, abortController } = makeStartAccountCtx(accountConfig);
|
|
511
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
512
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
it("startAccount returns pending promise for disabled account", async () => {
|
|
516
|
+
await expectPendingStartAccount({ enabled: false });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("startAccount returns pending promise for account without token", async () => {
|
|
520
|
+
await expectPendingStartAccount({ enabled: true });
|
|
325
521
|
});
|
|
326
522
|
|
|
327
|
-
it("startAccount
|
|
523
|
+
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
|
524
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
525
|
+
registerMock.mockClear();
|
|
328
526
|
const plugin = createSynologyChatPlugin();
|
|
329
|
-
const ctx = {
|
|
527
|
+
const { ctx, abortController } = makeStartAccountCtx({
|
|
528
|
+
enabled: true,
|
|
529
|
+
token: "t",
|
|
530
|
+
incomingUrl: "https://nas/incoming",
|
|
531
|
+
dmPolicy: "allowlist",
|
|
532
|
+
allowedUserIds: [],
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
536
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
537
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
|
|
538
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("startAccount refuses open accounts with empty allowedUserIds", async () => {
|
|
542
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
543
|
+
registerMock.mockClear();
|
|
544
|
+
const plugin = createSynologyChatPlugin();
|
|
545
|
+
const { ctx, abortController } = makeStartAccountCtx({
|
|
546
|
+
enabled: true,
|
|
547
|
+
token: "t",
|
|
548
|
+
incomingUrl: "https://nas/incoming",
|
|
549
|
+
dmPolicy: "open",
|
|
550
|
+
allowedUserIds: [],
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
554
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
555
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
556
|
+
expect.stringContaining("dmPolicy=open but empty allowedUserIds"),
|
|
557
|
+
);
|
|
558
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
|
|
562
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
563
|
+
const plugin = createSynologyChatPlugin();
|
|
564
|
+
const { ctx, abortController } = makeNamedStartAccountCtx({
|
|
565
|
+
dmPolicy: "allowlist",
|
|
566
|
+
allowedUserIds: ["123"],
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
570
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
571
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
572
|
+
expect.stringContaining("must set an explicit webhookPath"),
|
|
573
|
+
);
|
|
574
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
|
|
578
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
579
|
+
const plugin = createSynologyChatPlugin();
|
|
580
|
+
const { ctx, abortController } = makeNamedStartAccountCtx({
|
|
581
|
+
webhookPath: "/webhook/synology-shared",
|
|
582
|
+
dmPolicy: "open",
|
|
583
|
+
allowedUserIds: ["*"],
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
587
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
588
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
589
|
+
expect.stringContaining("conflicts on webhookPath"),
|
|
590
|
+
);
|
|
591
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("re-registers same account/path through the route registrar", async () => {
|
|
595
|
+
const unregisterFirst = vi.fn();
|
|
596
|
+
const unregisterSecond = vi.fn();
|
|
597
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
598
|
+
registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
|
|
599
|
+
|
|
600
|
+
const plugin = createSynologyChatPlugin();
|
|
601
|
+
const abortFirst = new AbortController();
|
|
602
|
+
const abortSecond = new AbortController();
|
|
603
|
+
const makeCtx = (abortCtrl: AbortController) => ({
|
|
330
604
|
cfg: {
|
|
331
|
-
channels: {
|
|
605
|
+
channels: {
|
|
606
|
+
"synology-chat": {
|
|
607
|
+
enabled: true,
|
|
608
|
+
token: "t",
|
|
609
|
+
incomingUrl: "https://nas/incoming",
|
|
610
|
+
webhookPath: "/webhook/synology",
|
|
611
|
+
dmPolicy: "allowlist",
|
|
612
|
+
allowedUserIds: ["123"],
|
|
613
|
+
},
|
|
614
|
+
},
|
|
332
615
|
},
|
|
333
616
|
accountId: "default",
|
|
334
617
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
618
|
+
abortSignal: abortCtrl.signal,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
|
|
622
|
+
const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
|
|
623
|
+
|
|
624
|
+
expect(registerMock).toHaveBeenCalledTimes(2);
|
|
625
|
+
expect(unregisterFirst).not.toHaveBeenCalled();
|
|
626
|
+
expect(unregisterSecond).not.toHaveBeenCalled();
|
|
627
|
+
|
|
628
|
+
abortFirst.abort();
|
|
629
|
+
abortSecond.abort();
|
|
630
|
+
await Promise.allSettled([firstPromise, secondPromise]);
|
|
338
631
|
});
|
|
339
632
|
});
|
|
340
633
|
});
|