@openclaw/synology-chat 2026.2.22 → 2026.5.2-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.
@@ -1,55 +1,56 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- // Mock external dependencies
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
- vi.mock("./client.js", () => ({
12
- sendMessage: vi.fn().mockResolvedValue(true),
13
- sendFileUrl: vi.fn().mockResolvedValue(true),
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
- vi.mock("./webhook-handler.js", () => ({
17
- createWebhookHandler: vi.fn(() => vi.fn()),
18
- }));
22
+ function makeSecurityAccount(
23
+ overrides: Partial<ResolvedSynologyChatAccount> = {},
24
+ ): ResolvedSynologyChatAccount {
25
+ return { ...securityAccountDefaults, ...overrides };
26
+ }
19
27
 
20
- vi.mock("./runtime.js", () => ({
21
- getSynologyRuntime: vi.fn(() => ({
22
- config: { loadConfig: vi.fn().mockResolvedValue({}) },
23
- channel: {
24
- reply: {
25
- dispatchReplyWithBufferedBlockDispatcher: vi.fn().mockResolvedValue({
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("zod", () => ({
34
- z: {
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
- it("returns a plugin object with all required sections", () => {
45
- const plugin = createSynologyChatPlugin();
46
- expect(plugin.id).toBe("synology-chat");
47
- expect(plugin.meta).toBeDefined();
48
- expect(plugin.capabilities).toBeDefined();
49
- expect(plugin.config).toBeDefined();
50
- expect(plugin.security).toBeDefined();
51
- expect(plugin.outbound).toBeDefined();
52
- expect(plugin.gateway).toBeDefined();
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 delegates to accounts module", () => {
75
+ it("listAccountIds includes default and named accounts when configured", () => {
75
76
  const plugin = createSynologyChatPlugin();
76
- const result = plugin.config.listAccountIds({});
77
- expect(Array.isArray(result)).toBe(true);
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 returns account config", () => {
81
- const cfg = { channels: { "synology-chat": { token: "t1" } } };
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, "default");
84
- expect(account.accountId).toBe("default");
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(typeof result.normalizeEntry).toBe("function");
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("has notifyApproval and normalizeAllowEntry", () => {
198
+ it("normalizes entries and notifies approved users", async () => {
119
199
  const plugin = createSynologyChatPlugin();
120
200
  expect(plugin.pairing.idLabel).toBe("synologyChatUserId");
121
- expect(typeof plugin.pairing.normalizeAllowEntry).toBe("function");
122
- expect(plugin.pairing.normalizeAllowEntry(" USER1 ")).toBe("user1");
123
- expect(typeof plugin.pairing.notifyApproval).toBe("function");
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
- accountId: "default",
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
- accountId: "default",
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
- accountId: "default",
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("returns no warnings for fully configured account", () => {
292
+ it("warns when dmPolicy is open and allowedUserIds is empty", () => {
186
293
  const plugin = createSynologyChatPlugin();
187
- const account = {
188
- accountId: "default",
189
- enabled: true,
190
- token: "t",
191
- incomingUrl: "https://nas/incoming",
192
- nasHost: "h",
193
- webhookPath: "/w",
194
- dmPolicy: "allowlist" as const,
195
- allowedUserIds: ["user1"],
196
- rateLimitPerMinute: 30,
197
- botName: "Bot",
198
- allowInsecureSsl: false,
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 warnings = plugin.security.collectWarnings({ account });
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
  });
@@ -206,6 +345,8 @@ describe("createSynologyChatPlugin", () => {
206
345
  it("normalizeTarget strips prefix and trims", () => {
207
346
  const plugin = createSynologyChatPlugin();
208
347
  expect(plugin.messaging.normalizeTarget("synology-chat:123")).toBe("123");
348
+ expect(plugin.messaging.normalizeTarget("synology_chat:123")).toBe("123");
349
+ expect(plugin.messaging.normalizeTarget("synology:123")).toBe("123");
209
350
  expect(plugin.messaging.normalizeTarget(" 456 ")).toBe("456");
210
351
  expect(plugin.messaging.normalizeTarget("")).toBeUndefined();
211
352
  });
@@ -214,6 +355,8 @@ describe("createSynologyChatPlugin", () => {
214
355
  const plugin = createSynologyChatPlugin();
215
356
  expect(plugin.messaging.targetResolver.looksLikeId("12345")).toBe(true);
216
357
  expect(plugin.messaging.targetResolver.looksLikeId("synology-chat:99")).toBe(true);
358
+ expect(plugin.messaging.targetResolver.looksLikeId("synology_chat:99")).toBe(true);
359
+ expect(plugin.messaging.targetResolver.looksLikeId("synology:99")).toBe(true);
217
360
  expect(plugin.messaging.targetResolver.looksLikeId("notanumber")).toBe(false);
218
361
  expect(plugin.messaging.targetResolver.looksLikeId("")).toBe(false);
219
362
  });
@@ -222,9 +365,10 @@ describe("createSynologyChatPlugin", () => {
222
365
  describe("directory", () => {
223
366
  it("returns empty stubs", async () => {
224
367
  const plugin = createSynologyChatPlugin();
225
- expect(await plugin.directory.self()).toBeNull();
226
- expect(await plugin.directory.listPeers()).toEqual([]);
227
- expect(await plugin.directory.listGroups()).toEqual([]);
368
+ const params = { cfg: {}, runtime: {} as never };
369
+ expect(await plugin.directory.self?.(params)).toBeNull();
370
+ expect(await plugin.directory.listPeers?.(params)).toEqual([]);
371
+ expect(await plugin.directory.listGroups?.(params)).toEqual([]);
228
372
  });
229
373
  });
230
374
 
@@ -232,9 +376,9 @@ describe("createSynologyChatPlugin", () => {
232
376
  it("returns formatting hints", () => {
233
377
  const plugin = createSynologyChatPlugin();
234
378
  const hints = plugin.agentPrompt.messageToolHints();
235
- expect(Array.isArray(hints)).toBe(true);
236
- expect(hints.length).toBeGreaterThan(5);
237
- expect(hints.some((h: string) => h.includes("<URL|display text>"))).toBe(true);
379
+ expect(hints).toContain("### Synology Chat Formatting");
380
+ expect(hints).toContain("**Links**: Use `<URL|display text>` to create clickable links.");
381
+ expect(hints).toContain("- No buttons, cards, or interactive elements");
238
382
  });
239
383
  });
240
384
 
@@ -243,18 +387,10 @@ describe("createSynologyChatPlugin", () => {
243
387
  const plugin = createSynologyChatPlugin();
244
388
  await expect(
245
389
  plugin.outbound.sendText({
246
- account: {
247
- accountId: "default",
248
- enabled: true,
249
- token: "t",
250
- incomingUrl: "",
251
- nasHost: "h",
252
- webhookPath: "/w",
253
- dmPolicy: "open",
254
- allowedUserIds: [],
255
- rateLimitPerMinute: 30,
256
- botName: "Bot",
257
- allowInsecureSsl: true,
390
+ cfg: {
391
+ channels: {
392
+ "synology-chat": { enabled: true, token: "t", incomingUrl: "" },
393
+ },
258
394
  },
259
395
  text: "hello",
260
396
  to: "user1",
@@ -265,43 +401,34 @@ describe("createSynologyChatPlugin", () => {
265
401
  it("sendText returns OutboundDeliveryResult on success", async () => {
266
402
  const plugin = createSynologyChatPlugin();
267
403
  const result = await plugin.outbound.sendText({
268
- account: {
269
- accountId: "default",
270
- enabled: true,
271
- token: "t",
272
- incomingUrl: "https://nas/incoming",
273
- nasHost: "h",
274
- webhookPath: "/w",
275
- dmPolicy: "open",
276
- allowedUserIds: [],
277
- rateLimitPerMinute: 30,
278
- botName: "Bot",
279
- allowInsecureSsl: true,
404
+ cfg: {
405
+ channels: {
406
+ "synology-chat": {
407
+ enabled: true,
408
+ token: "t",
409
+ incomingUrl: "https://nas/incoming",
410
+ allowInsecureSsl: true,
411
+ },
412
+ },
280
413
  },
281
414
  text: "hello",
282
415
  to: "user1",
283
416
  });
284
- expect(result.channel).toBe("synology-chat");
285
- expect(result.messageId).toBeDefined();
286
- expect(result.chatId).toBe("user1");
417
+ expect(result).toMatchObject({
418
+ channel: "synology-chat",
419
+ chatId: "user1",
420
+ });
421
+ expect(result.messageId).toMatch(/^sc-\d+$/);
287
422
  });
288
423
 
289
424
  it("sendMedia throws when missing incomingUrl", async () => {
290
425
  const plugin = createSynologyChatPlugin();
291
426
  await expect(
292
427
  plugin.outbound.sendMedia({
293
- account: {
294
- accountId: "default",
295
- enabled: true,
296
- token: "t",
297
- incomingUrl: "",
298
- nasHost: "h",
299
- webhookPath: "/w",
300
- dmPolicy: "open",
301
- allowedUserIds: [],
302
- rateLimitPerMinute: 30,
303
- botName: "Bot",
304
- allowInsecureSsl: true,
428
+ cfg: {
429
+ channels: {
430
+ "synology-chat": { enabled: true, token: "t", incomingUrl: "" },
431
+ },
305
432
  },
306
433
  mediaUrl: "https://example.com/img.png",
307
434
  to: "user1",
@@ -311,30 +438,200 @@ describe("createSynologyChatPlugin", () => {
311
438
  });
312
439
 
313
440
  describe("gateway", () => {
314
- it("startAccount returns stop function for disabled account", async () => {
315
- const plugin = createSynologyChatPlugin();
316
- const ctx = {
317
- cfg: {
318
- channels: { "synology-chat": { enabled: false } },
441
+ function makeStartAccountCtx(
442
+ accountConfig: Record<string, unknown>,
443
+ abortController = new AbortController(),
444
+ ) {
445
+ return {
446
+ abortController,
447
+ ctx: {
448
+ cfg: {
449
+ channels: { "synology-chat": accountConfig },
450
+ },
451
+ accountId: "default",
452
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
453
+ abortSignal: abortController.signal,
454
+ },
455
+ };
456
+ }
457
+
458
+ function makeNamedStartAccountCtx(
459
+ accountOverrides: Record<string, unknown>,
460
+ abortController = new AbortController(),
461
+ ) {
462
+ return {
463
+ abortController,
464
+ ctx: {
465
+ cfg: {
466
+ channels: {
467
+ "synology-chat": {
468
+ enabled: true,
469
+ token: "default-token",
470
+ incomingUrl: "https://nas/default",
471
+ webhookPath: "/webhook/synology-shared",
472
+ dmPolicy: "allowlist",
473
+ allowedUserIds: ["123"],
474
+ accounts: {
475
+ alerts: {
476
+ enabled: true,
477
+ token: "alerts-token",
478
+ incomingUrl: "https://nas/alerts",
479
+ ...accountOverrides,
480
+ },
481
+ },
482
+ },
483
+ },
484
+ },
485
+ accountId: "alerts",
486
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
487
+ abortSignal: abortController.signal,
319
488
  },
320
- accountId: "default",
321
- log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
322
489
  };
323
- const result = await plugin.gateway.startAccount(ctx);
324
- expect(typeof result.stop).toBe("function");
490
+ }
491
+
492
+ async function expectPendingStartAccountPromise(
493
+ result: Promise<unknown>,
494
+ abortController: AbortController,
495
+ ) {
496
+ expect(result).toBeInstanceOf(Promise);
497
+ let settled = false;
498
+ void result.then(
499
+ () => {
500
+ settled = true;
501
+ },
502
+ () => {
503
+ settled = true;
504
+ },
505
+ );
506
+ await Promise.resolve();
507
+ expect(settled).toBe(false);
508
+ abortController.abort();
509
+ await result;
510
+ }
511
+
512
+ async function expectPendingStartAccount(accountConfig: Record<string, unknown>) {
513
+ const plugin = createSynologyChatPlugin();
514
+ const { ctx, abortController } = makeStartAccountCtx(accountConfig);
515
+ const result = plugin.gateway.startAccount(ctx);
516
+ await expectPendingStartAccountPromise(result, abortController);
517
+ }
518
+
519
+ it("startAccount returns pending promise for disabled account", async () => {
520
+ await expectPendingStartAccount({ enabled: false });
521
+ });
522
+
523
+ it("startAccount returns pending promise for account without token", async () => {
524
+ await expectPendingStartAccount({ enabled: true });
325
525
  });
326
526
 
327
- it("startAccount returns stop function for account without token", async () => {
527
+ it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
528
+ const registerMock = registerSynologyWebhookRouteMock;
529
+ registerMock.mockClear();
328
530
  const plugin = createSynologyChatPlugin();
329
- const ctx = {
531
+ const { ctx, abortController } = makeStartAccountCtx({
532
+ enabled: true,
533
+ token: "t",
534
+ incomingUrl: "https://nas/incoming",
535
+ dmPolicy: "allowlist",
536
+ allowedUserIds: [],
537
+ });
538
+
539
+ const result = plugin.gateway.startAccount(ctx);
540
+ await expectPendingStartAccountPromise(result, abortController);
541
+ expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
542
+ expect(registerMock).not.toHaveBeenCalled();
543
+ });
544
+
545
+ it("startAccount refuses open accounts with empty allowedUserIds", async () => {
546
+ const registerMock = registerSynologyWebhookRouteMock;
547
+ registerMock.mockClear();
548
+ const plugin = createSynologyChatPlugin();
549
+ const { ctx, abortController } = makeStartAccountCtx({
550
+ enabled: true,
551
+ token: "t",
552
+ incomingUrl: "https://nas/incoming",
553
+ dmPolicy: "open",
554
+ allowedUserIds: [],
555
+ });
556
+
557
+ const result = plugin.gateway.startAccount(ctx);
558
+ await expectPendingStartAccountPromise(result, abortController);
559
+ expect(ctx.log.warn).toHaveBeenCalledWith(
560
+ expect.stringContaining("dmPolicy=open but empty allowedUserIds"),
561
+ );
562
+ expect(registerMock).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
566
+ const registerMock = registerSynologyWebhookRouteMock;
567
+ const plugin = createSynologyChatPlugin();
568
+ const { ctx, abortController } = makeNamedStartAccountCtx({
569
+ dmPolicy: "allowlist",
570
+ allowedUserIds: ["123"],
571
+ });
572
+
573
+ const result = plugin.gateway.startAccount(ctx);
574
+ await expectPendingStartAccountPromise(result, abortController);
575
+ expect(ctx.log.warn).toHaveBeenCalledWith(
576
+ expect.stringContaining("must set an explicit webhookPath"),
577
+ );
578
+ expect(registerMock).not.toHaveBeenCalled();
579
+ });
580
+
581
+ it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
582
+ const registerMock = registerSynologyWebhookRouteMock;
583
+ const plugin = createSynologyChatPlugin();
584
+ const { ctx, abortController } = makeNamedStartAccountCtx({
585
+ webhookPath: "/webhook/synology-shared",
586
+ dmPolicy: "open",
587
+ allowedUserIds: ["*"],
588
+ });
589
+
590
+ const result = plugin.gateway.startAccount(ctx);
591
+ await expectPendingStartAccountPromise(result, abortController);
592
+ expect(ctx.log.warn).toHaveBeenCalledWith(
593
+ expect.stringContaining("conflicts on webhookPath"),
594
+ );
595
+ expect(registerMock).not.toHaveBeenCalled();
596
+ });
597
+
598
+ it("re-registers same account/path through the route registrar", async () => {
599
+ const unregisterFirst = vi.fn();
600
+ const unregisterSecond = vi.fn();
601
+ const registerMock = registerSynologyWebhookRouteMock;
602
+ registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
603
+
604
+ const plugin = createSynologyChatPlugin();
605
+ const abortFirst = new AbortController();
606
+ const abortSecond = new AbortController();
607
+ const makeCtx = (abortCtrl: AbortController) => ({
330
608
  cfg: {
331
- channels: { "synology-chat": { enabled: true } },
609
+ channels: {
610
+ "synology-chat": {
611
+ enabled: true,
612
+ token: "t",
613
+ incomingUrl: "https://nas/incoming",
614
+ webhookPath: "/webhook/synology",
615
+ dmPolicy: "allowlist",
616
+ allowedUserIds: ["123"],
617
+ },
618
+ },
332
619
  },
333
620
  accountId: "default",
334
621
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
335
- };
336
- const result = await plugin.gateway.startAccount(ctx);
337
- expect(typeof result.stop).toBe("function");
622
+ abortSignal: abortCtrl.signal,
623
+ });
624
+
625
+ const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
626
+ const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
627
+
628
+ expect(registerMock).toHaveBeenCalledTimes(2);
629
+ expect(unregisterFirst).not.toHaveBeenCalled();
630
+ expect(unregisterSecond).not.toHaveBeenCalled();
631
+
632
+ abortFirst.abort();
633
+ abortSecond.abort();
634
+ await Promise.allSettled([firstPromise, secondPromise]);
338
635
  });
339
636
  });
340
637
  });