@kodelyth/synology-chat 2026.5.42 → 2026.6.2

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