@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.
@@ -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
  });
@@ -222,9 +361,10 @@ describe("createSynologyChatPlugin", () => {
222
361
  describe("directory", () => {
223
362
  it("returns empty stubs", async () => {
224
363
  const plugin = createSynologyChatPlugin();
225
- expect(await plugin.directory.self()).toBeNull();
226
- expect(await plugin.directory.listPeers()).toEqual([]);
227
- expect(await plugin.directory.listGroups()).toEqual([]);
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(Array.isArray(hints)).toBe(true);
236
- expect(hints.length).toBeGreaterThan(5);
237
- expect(hints.some((h: string) => h.includes("<URL|display text>"))).toBe(true);
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
- 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,
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
- 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,
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.channel).toBe("synology-chat");
285
- expect(result.messageId).toBeDefined();
286
- expect(result.chatId).toBe("user1");
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
- 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,
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
- it("startAccount returns stop function for disabled account", async () => {
315
- const plugin = createSynologyChatPlugin();
316
- const ctx = {
317
- cfg: {
318
- channels: { "synology-chat": { enabled: false } },
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
- const result = await plugin.gateway.startAccount(ctx);
324
- expect(typeof result.stop).toBe("function");
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 returns stop function for account without token", async () => {
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: { "synology-chat": { enabled: true } },
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
- const result = await plugin.gateway.startAccount(ctx);
337
- expect(typeof result.stop).toBe("function");
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
  });