@weave_protocol/mund 0.2.3 → 0.3.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.
Files changed (157) hide show
  1. package/README.md +135 -1
  2. package/dist/analyzers/code-analyzer.d.ts.map +1 -1
  3. package/dist/analyzers/code-analyzer.js +2 -1
  4. package/dist/analyzers/code-analyzer.js.map +1 -1
  5. package/dist/analyzers/exfiltration-detector.d.ts.map +1 -1
  6. package/dist/analyzers/exfiltration-detector.js +2 -1
  7. package/dist/analyzers/exfiltration-detector.js.map +1 -1
  8. package/dist/analyzers/index.d.ts.map +1 -1
  9. package/dist/analyzers/index.js +4 -2
  10. package/dist/analyzers/index.js.map +1 -1
  11. package/dist/analyzers/injection-detector.d.ts.map +1 -1
  12. package/dist/analyzers/injection-detector.js +2 -1
  13. package/dist/analyzers/injection-detector.js.map +1 -1
  14. package/dist/analyzers/mcp-server-analyzer.d.ts +2 -0
  15. package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
  16. package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
  17. package/dist/analyzers/pii-detector.d.ts.map +1 -1
  18. package/dist/analyzers/pii-detector.js +2 -1
  19. package/dist/analyzers/pii-detector.js.map +1 -1
  20. package/dist/analyzers/regex.d.ts +10 -0
  21. package/dist/analyzers/regex.d.ts.map +1 -0
  22. package/dist/analyzers/regex.js +17 -0
  23. package/dist/analyzers/regex.js.map +1 -0
  24. package/dist/analyzers/secret-scanner.d.ts.map +1 -1
  25. package/dist/analyzers/secret-scanner.js +2 -1
  26. package/dist/analyzers/secret-scanner.js.map +1 -1
  27. package/dist/canary.d.ts +48 -0
  28. package/dist/canary.d.ts.map +1 -0
  29. package/dist/canary.js +149 -0
  30. package/dist/canary.js.map +1 -0
  31. package/dist/cli/scan.d.ts +50 -0
  32. package/dist/cli/scan.d.ts.map +1 -0
  33. package/dist/cli/scan.js +235 -0
  34. package/dist/cli/scan.js.map +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +12 -4
  37. package/dist/index.js.map +1 -1
  38. package/dist/notifications/email.d.ts +21 -8
  39. package/dist/notifications/email.d.ts.map +1 -1
  40. package/dist/notifications/email.js +32 -178
  41. package/dist/notifications/email.js.map +1 -1
  42. package/dist/osv.d.ts +94 -0
  43. package/dist/osv.d.ts.map +1 -0
  44. package/dist/osv.js +239 -0
  45. package/dist/osv.js.map +1 -0
  46. package/dist/rugpull.d.ts +73 -0
  47. package/dist/rugpull.d.ts.map +1 -0
  48. package/dist/rugpull.js +177 -0
  49. package/dist/rugpull.js.map +1 -0
  50. package/dist/sarif.d.ts +20 -0
  51. package/dist/sarif.d.ts.map +1 -0
  52. package/dist/sarif.js +122 -0
  53. package/dist/sarif.js.map +1 -0
  54. package/dist/scoring.d.ts +26 -0
  55. package/dist/scoring.d.ts.map +1 -0
  56. package/dist/scoring.js +49 -0
  57. package/dist/scoring.js.map +1 -0
  58. package/dist/threat-intel-manager.d.ts.map +1 -1
  59. package/dist/threat-intel-manager.js +2 -1
  60. package/dist/threat-intel-manager.js.map +1 -1
  61. package/dist/tools/canary-rugpull.d.ts +7 -0
  62. package/dist/tools/canary-rugpull.d.ts.map +1 -0
  63. package/dist/tools/canary-rugpull.js +50 -0
  64. package/dist/tools/canary-rugpull.js.map +1 -0
  65. package/dist/tools/configuration.d.ts.map +1 -1
  66. package/dist/tools/configuration.js +2 -1
  67. package/dist/tools/configuration.js.map +1 -1
  68. package/dist/tools/index.d.ts +1 -0
  69. package/dist/tools/index.d.ts.map +1 -1
  70. package/dist/tools/index.js +4 -0
  71. package/dist/tools/index.js.map +1 -1
  72. package/dist/tools/mcp-scanning.d.ts.map +1 -1
  73. package/dist/tools/mcp-scanning.js +4 -0
  74. package/dist/tools/mcp-scanning.js.map +1 -1
  75. package/dist/tools/monitoring.d.ts.map +1 -1
  76. package/dist/tools/monitoring.js +22 -1
  77. package/dist/tools/monitoring.js.map +1 -1
  78. package/dist/version.d.ts +6 -0
  79. package/dist/version.d.ts.map +1 -0
  80. package/dist/version.js +19 -0
  81. package/dist/version.js.map +1 -0
  82. package/package.json +2 -4
  83. package/src/analyzers/code-analyzer.ts +2 -1
  84. package/src/analyzers/exfiltration-detector.ts +2 -1
  85. package/src/analyzers/index.ts +4 -2
  86. package/src/analyzers/injection-detector.ts +2 -1
  87. package/src/analyzers/mcp-server-analyzer.ts +2 -0
  88. package/src/analyzers/pii-detector.ts +2 -1
  89. package/src/analyzers/regex.ts +17 -0
  90. package/src/analyzers/secret-scanner.ts +2 -1
  91. package/src/canary.ts +165 -0
  92. package/src/cli/scan.ts +260 -0
  93. package/src/index.ts +13 -4
  94. package/src/notifications/email.ts +38 -194
  95. package/src/osv.ts +310 -0
  96. package/src/rugpull.ts +233 -0
  97. package/src/sarif.ts +146 -0
  98. package/src/scoring.ts +64 -0
  99. package/src/threat-intel-manager.ts +2 -1
  100. package/src/tools/canary-rugpull.ts +75 -0
  101. package/src/tools/configuration.ts +2 -1
  102. package/src/tools/index.ts +5 -0
  103. package/src/tools/mcp-scanning.ts +5 -0
  104. package/src/tools/monitoring.ts +23 -1
  105. package/src/version.ts +20 -0
  106. package/tests/analyzers/engine.test.ts +60 -0
  107. package/tests/analyzers/injection-and-code.test.ts +80 -0
  108. package/tests/analyzers/mcp-server-analyzer.test.ts +82 -0
  109. package/tests/analyzers/regex.test.ts +35 -0
  110. package/tests/analyzers/rules.test.ts +54 -0
  111. package/tests/analyzers/secret-scanner.test.ts +80 -0
  112. package/tests/canary.test.ts +79 -0
  113. package/tests/cli/scan.test.ts +92 -0
  114. package/tests/fixtures/canary/base64.json +9 -0
  115. package/tests/fixtures/canary/clean-output.json +8 -0
  116. package/tests/fixtures/canary/exact-leak.json +9 -0
  117. package/tests/fixtures/canary/lowercased.json +9 -0
  118. package/tests/fixtures/canary/other-token.json +8 -0
  119. package/tests/fixtures/canary/prefix-only.json +8 -0
  120. package/tests/fixtures/canary/reversed.json +9 -0
  121. package/tests/fixtures/canary/spaced-out.json +9 -0
  122. package/tests/fixtures/canary/zero-width.json +9 -0
  123. package/tests/fixtures/rugpull/description-changed/baseline.json +36 -0
  124. package/tests/fixtures/rugpull/description-changed/current.json +36 -0
  125. package/tests/fixtures/rugpull/description-changed/expected.json +11 -0
  126. package/tests/fixtures/rugpull/gains-execution/baseline.json +36 -0
  127. package/tests/fixtures/rugpull/gains-execution/current.json +36 -0
  128. package/tests/fixtures/rugpull/gains-execution/expected.json +18 -0
  129. package/tests/fixtures/rugpull/schema-changed/baseline.json +36 -0
  130. package/tests/fixtures/rugpull/schema-changed/current.json +39 -0
  131. package/tests/fixtures/rugpull/schema-changed/expected.json +11 -0
  132. package/tests/fixtures/rugpull/schema-key-reorder/baseline.json +36 -0
  133. package/tests/fixtures/rugpull/schema-key-reorder/current.json +36 -0
  134. package/tests/fixtures/rugpull/schema-key-reorder/expected.json +5 -0
  135. package/tests/fixtures/rugpull/server-renamed/baseline.json +36 -0
  136. package/tests/fixtures/rugpull/server-renamed/current.json +36 -0
  137. package/tests/fixtures/rugpull/server-renamed/expected.json +11 -0
  138. package/tests/fixtures/rugpull/tool-added/baseline.json +36 -0
  139. package/tests/fixtures/rugpull/tool-added/current.json +44 -0
  140. package/tests/fixtures/rugpull/tool-added/expected.json +11 -0
  141. package/tests/fixtures/rugpull/tool-removed/baseline.json +36 -0
  142. package/tests/fixtures/rugpull/tool-removed/current.json +21 -0
  143. package/tests/fixtures/rugpull/tool-removed/expected.json +11 -0
  144. package/tests/fixtures/rugpull/unchanged/baseline.json +36 -0
  145. package/tests/fixtures/rugpull/unchanged/current.json +36 -0
  146. package/tests/fixtures/rugpull/unchanged/expected.json +5 -0
  147. package/tests/fixtures.test.ts +40 -0
  148. package/tests/helpers.ts +51 -0
  149. package/tests/notifications/email.test.ts +68 -0
  150. package/tests/notifications/hub.test.ts +160 -0
  151. package/tests/osv.test.ts +117 -0
  152. package/tests/rugpull.test.ts +80 -0
  153. package/tests/sarif.test.ts +59 -0
  154. package/tests/scoring.test.ts +49 -0
  155. package/tests/tools/canary-rugpull.test.ts +57 -0
  156. package/tests/tools/monitoring.test.ts +95 -0
  157. package/vitest.config.ts +8 -0
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "required": [
10
+ "city"
11
+ ],
12
+ "properties": {
13
+ "city": {
14
+ "type": "string"
15
+ }
16
+ },
17
+ "type": "object"
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "description": "Same schema, keys in a different order",
3
+ "status": "unchanged",
4
+ "changes": []
5
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes-pro",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "Server renamed after approval",
3
+ "status": "changed",
4
+ "changes": [
5
+ {
6
+ "kind": "server_renamed",
7
+ "tool": "*",
8
+ "severity": "high"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ },
35
+ {
36
+ "name": "list_notes",
37
+ "description": "List the titles of saved notes.",
38
+ "inputSchema": {
39
+ "type": "object",
40
+ "properties": {}
41
+ }
42
+ }
43
+ ]
44
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "A new tool appears",
3
+ "status": "changed",
4
+ "changes": [
5
+ {
6
+ "kind": "tool_added",
7
+ "tool": "list_notes",
8
+ "severity": "medium"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "A tool disappears",
3
+ "status": "changed",
4
+ "changes": [
5
+ {
6
+ "kind": "tool_removed",
7
+ "tool": "read_note",
8
+ "severity": "low"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "description": "Same manifest re-checked",
3
+ "status": "unchanged",
4
+ "changes": []
5
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Fixture library: one fixture per case, each with its expected verdict.
3
+ * Add a file/folder under tests/fixtures/ and it is picked up here.
4
+ */
5
+ import { readdirSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { describe, expect, it } from 'vitest';
8
+ import { CanaryRegistry } from '../src/canary.js';
9
+ import { diffManifest, fingerprintManifest } from '../src/rugpull.js';
10
+
11
+ const dir = (p: string) => join(__dirname, 'fixtures', p);
12
+ const json = (p: string) => JSON.parse(readFileSync(p, 'utf8'));
13
+
14
+ describe('canary fixtures', () => {
15
+ for (const file of readdirSync(dir('canary'))) {
16
+ const fx = json(join(dir('canary'), file));
17
+ it(`${file}: ${fx.description}`, () => {
18
+ const registry = new CanaryRegistry();
19
+ registry.register(fx.canary);
20
+ const hits = registry.find(fx.content);
21
+ expect(hits.length > 0).toBe(fx.expected.detected);
22
+ if (fx.expected.kind) expect(hits[0].kind).toBe(fx.expected.kind);
23
+ });
24
+ }
25
+ });
26
+
27
+ describe('rug-pull fixtures', () => {
28
+ for (const name of readdirSync(dir('rugpull'))) {
29
+ const base = join(dir('rugpull'), name);
30
+ const expected = json(join(base, 'expected.json'));
31
+ it(`${name}: ${expected.description}`, async () => {
32
+ const baseline = fingerprintManifest('fixture', json(join(base, 'baseline.json')));
33
+ const changes = await diffManifest(baseline, json(join(base, 'current.json')));
34
+ expect(changes.length > 0 ? 'changed' : 'unchanged').toBe(expected.status);
35
+ expect(changes.map(c => ({ kind: c.kind, tool: c.tool, severity: c.severity, ...(c.escalated ? { escalated: true } : {}) })))
36
+ .toEqual(expect.arrayContaining(expected.changes));
37
+ expect(changes).toHaveLength(expected.changes.length);
38
+ });
39
+ }
40
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared test fixtures for Mund.
3
+ */
4
+
5
+ import { DEFAULT_CONFIG } from '../src/constants.js';
6
+ import type { MundConfig, SecurityEvent } from '../src/types.js';
7
+
8
+ export function makeConfig(overrides: Partial<MundConfig> = {}): MundConfig {
9
+ return { ...DEFAULT_CONFIG, notifications: {}, ...overrides };
10
+ }
11
+
12
+ export function makeEvent(overrides: Partial<SecurityEvent> = {}): SecurityEvent {
13
+ return {
14
+ id: 'evt-1',
15
+ timestamp: new Date('2026-01-01T00:00:00Z'),
16
+ rule_id: 'test_rule',
17
+ rule_name: 'Test Rule',
18
+ severity: 'high',
19
+ type: 'secret',
20
+ action_taken: 'alert',
21
+ content_snippet: 'AKIA****',
22
+ full_content_hash: 'deadbeef',
23
+ context: {},
24
+ acknowledged: false,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ type Handler = (params: any) => Promise<any>;
30
+
31
+ /**
32
+ * Minimal stand-in for McpServer that captures tool handlers so they can be
33
+ * invoked directly, without a transport.
34
+ */
35
+ export class FakeMcpServer {
36
+ handlers = new Map<string, Handler>();
37
+
38
+ registerTool(name: string, _def: unknown, handler: Handler): void {
39
+ this.handlers.set(name, handler);
40
+ }
41
+
42
+ tool(name: string, ...rest: unknown[]): void {
43
+ this.handlers.set(name, rest[rest.length - 1] as Handler);
44
+ }
45
+
46
+ async call(name: string, params: Record<string, unknown>): Promise<any> {
47
+ const handler = this.handlers.get(name);
48
+ if (!handler) throw new Error(`Tool not registered: ${name}`);
49
+ return handler(params);
50
+ }
51
+ }
@@ -0,0 +1,68 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { EmailNotifier } from '../../src/notifications/email.js';
3
+ import type { EmailConfig } from '../../src/types.js';
4
+ import { makeConfig, makeEvent } from '../helpers.js';
5
+
6
+ const emailConfig: EmailConfig = {
7
+ smtp_host: 'smtp.example.test',
8
+ smtp_port: 587,
9
+ smtp_secure: true,
10
+ from_address: 'mund@example.test',
11
+ to_addresses: ['ops@example.test'],
12
+ };
13
+
14
+ const payload = () => ({
15
+ event: makeEvent(),
16
+ config: makeConfig(),
17
+ formatted_message: '[HIGH] Test Rule: AKIA****',
18
+ });
19
+
20
+ describe('EmailNotifier (stub)', () => {
21
+ let warnSpy: ReturnType<typeof vi.spyOn>;
22
+ let errorSpy: ReturnType<typeof vi.spyOn>;
23
+
24
+ beforeEach(() => {
25
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
26
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it('warns at construction that email is not implemented', () => {
34
+ new EmailNotifier(emailConfig);
35
+ expect(warnSpy).toHaveBeenCalledTimes(1);
36
+ expect(String(warnSpy.mock.calls[0][0])).toMatch(/NOT IMPLEMENTED/);
37
+ });
38
+
39
+ it('keeps the config and notifier name', () => {
40
+ const notifier = new EmailNotifier(emailConfig);
41
+ expect(notifier.name).toBe('EmailNotifier');
42
+ expect(notifier.config).toBe(emailConfig);
43
+ });
44
+
45
+ it('send() always returns false', async () => {
46
+ const notifier = new EmailNotifier(emailConfig);
47
+ await expect(notifier.send(payload())).resolves.toBe(false);
48
+ await expect(notifier.send(payload())).resolves.toBe(false);
49
+ });
50
+
51
+ it('logs the send() error only once per instance', async () => {
52
+ const notifier = new EmailNotifier(emailConfig);
53
+ await notifier.send(payload());
54
+ await notifier.send(payload());
55
+ await notifier.send(payload());
56
+ expect(errorSpy).toHaveBeenCalledTimes(1);
57
+ expect(String(errorSpy.mock.calls[0][0])).toMatch(/Alert dropped/);
58
+ });
59
+
60
+ it('tracks the one-time warning per instance, not globally', async () => {
61
+ const a = new EmailNotifier(emailConfig);
62
+ const b = new EmailNotifier(emailConfig);
63
+ await a.send(payload());
64
+ await a.send(payload());
65
+ await b.send(payload());
66
+ expect(errorSpy).toHaveBeenCalledTimes(2);
67
+ });
68
+ });