memory-journal-mcp 4.3.0 → 4.4.0

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 (109) hide show
  1. package/.dockerignore +131 -122
  2. package/.gitattributes +29 -0
  3. package/.github/workflows/docker-publish.yml +1 -1
  4. package/.github/workflows/lint-and-test.yml +1 -2
  5. package/.github/workflows/secrets-scanning.yml +0 -1
  6. package/.github/workflows/security-update.yml +6 -6
  7. package/.vscode/settings.json +17 -15
  8. package/CHANGELOG.md +1065 -11
  9. package/DOCKER_README.md +51 -33
  10. package/Dockerfile +14 -12
  11. package/README.md +68 -33
  12. package/SECURITY.md +225 -220
  13. package/dist/cli.js +7 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/constants/ServerInstructions.d.ts +1 -1
  16. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  17. package/dist/constants/ServerInstructions.js +70 -26
  18. package/dist/constants/ServerInstructions.js.map +1 -1
  19. package/dist/constants/icons.d.ts +2 -0
  20. package/dist/constants/icons.d.ts.map +1 -1
  21. package/dist/constants/icons.js +6 -0
  22. package/dist/constants/icons.js.map +1 -1
  23. package/dist/database/SqliteAdapter.d.ts +51 -10
  24. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  25. package/dist/database/SqliteAdapter.js +143 -43
  26. package/dist/database/SqliteAdapter.js.map +1 -1
  27. package/dist/filtering/ToolFilter.d.ts +1 -1
  28. package/dist/filtering/ToolFilter.d.ts.map +1 -1
  29. package/dist/filtering/ToolFilter.js +7 -1
  30. package/dist/filtering/ToolFilter.js.map +1 -1
  31. package/dist/github/GitHubIntegration.d.ts +74 -2
  32. package/dist/github/GitHubIntegration.d.ts.map +1 -1
  33. package/dist/github/GitHubIntegration.js +508 -7
  34. package/dist/github/GitHubIntegration.js.map +1 -1
  35. package/dist/handlers/prompts/index.js +1 -0
  36. package/dist/handlers/prompts/index.js.map +1 -1
  37. package/dist/handlers/resources/index.d.ts.map +1 -1
  38. package/dist/handlers/resources/index.js +257 -13
  39. package/dist/handlers/resources/index.js.map +1 -1
  40. package/dist/handlers/tools/index.d.ts.map +1 -1
  41. package/dist/handlers/tools/index.js +595 -8
  42. package/dist/handlers/tools/index.js.map +1 -1
  43. package/dist/server/McpServer.d.ts +2 -0
  44. package/dist/server/McpServer.d.ts.map +1 -1
  45. package/dist/server/McpServer.js +69 -26
  46. package/dist/server/McpServer.js.map +1 -1
  47. package/dist/types/index.d.ts +97 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/utils/logger.d.ts +1 -0
  51. package/dist/utils/logger.d.ts.map +1 -1
  52. package/dist/utils/logger.js +8 -1
  53. package/dist/utils/logger.js.map +1 -1
  54. package/dist/utils/progress-utils.d.ts +18 -3
  55. package/dist/utils/progress-utils.d.ts.map +1 -1
  56. package/dist/utils/progress-utils.js.map +1 -1
  57. package/dist/utils/security-utils.d.ts +91 -0
  58. package/dist/utils/security-utils.d.ts.map +1 -0
  59. package/dist/utils/security-utils.js +184 -0
  60. package/dist/utils/security-utils.js.map +1 -0
  61. package/dist/vector/VectorSearchManager.d.ts +2 -1
  62. package/dist/vector/VectorSearchManager.d.ts.map +1 -1
  63. package/dist/vector/VectorSearchManager.js +100 -34
  64. package/dist/vector/VectorSearchManager.js.map +1 -1
  65. package/docker-compose.yml +46 -37
  66. package/mcp-config-example.json +0 -2
  67. package/package.json +21 -14
  68. package/releases/v4.3.1.md +69 -0
  69. package/releases/v4.4.0.md +120 -0
  70. package/server.json +3 -3
  71. package/src/cli.ts +11 -0
  72. package/src/constants/ServerInstructions.ts +70 -26
  73. package/src/constants/icons.ts +7 -0
  74. package/src/database/SqliteAdapter.ts +165 -44
  75. package/src/filtering/ToolFilter.ts +7 -1
  76. package/src/github/GitHubIntegration.ts +588 -8
  77. package/src/handlers/prompts/index.ts +1 -0
  78. package/src/handlers/resources/index.ts +318 -12
  79. package/src/handlers/tools/index.ts +686 -13
  80. package/src/server/McpServer.ts +79 -37
  81. package/src/types/index.ts +98 -0
  82. package/src/utils/logger.ts +10 -1
  83. package/src/utils/progress-utils.ts +17 -6
  84. package/src/utils/security-utils.ts +205 -0
  85. package/src/vector/VectorSearchManager.ts +110 -39
  86. package/tests/constants/icons.test.ts +102 -0
  87. package/tests/constants/server-instructions.test.ts +549 -0
  88. package/tests/database/sqlite-adapter.bench.ts +63 -0
  89. package/tests/database/sqlite-adapter.test.ts +555 -0
  90. package/tests/filtering/tool-filter.test.ts +266 -0
  91. package/tests/github/github-integration.test.ts +1024 -0
  92. package/tests/handlers/github-resource-handlers.test.ts +473 -0
  93. package/tests/handlers/github-tool-handlers.test.ts +556 -0
  94. package/tests/handlers/prompt-handlers.test.ts +91 -0
  95. package/tests/handlers/resource-handlers.test.ts +339 -0
  96. package/tests/handlers/tool-handlers.test.ts +497 -0
  97. package/tests/handlers/vector-tool-handlers.test.ts +238 -0
  98. package/tests/security/sql-injection.test.ts +347 -0
  99. package/tests/server/mcp-server.bench.ts +55 -0
  100. package/tests/server/mcp-server.test.ts +675 -0
  101. package/tests/utils/logger.test.ts +180 -0
  102. package/tests/utils/mcp-logger.test.ts +212 -0
  103. package/tests/utils/progress-utils.test.ts +156 -0
  104. package/tests/utils/security-utils.test.ts +82 -0
  105. package/tests/vector/vector-search-manager.test.ts +335 -0
  106. package/tests/vector/vector-search.bench.ts +53 -0
  107. package/vitest.config.ts +15 -0
  108. package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
  109. package/.github/workflows/dependabot-auto-merge.yml +0 -42
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Logger Tests
3
+ *
4
+ * Tests the stderr Logger class: level filtering, formatting, sanitization.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
+ import { logger } from '../../src/utils/logger.js'
9
+
10
+ describe('Logger', () => {
11
+ let errorSpy: ReturnType<typeof vi.spyOn>
12
+
13
+ beforeEach(() => {
14
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
15
+ })
16
+
17
+ afterEach(() => {
18
+ errorSpy.mockRestore()
19
+ // Reset to default level
20
+ logger.setLevel('info')
21
+ })
22
+
23
+ // ========================================================================
24
+ // Level filtering
25
+ // ========================================================================
26
+
27
+ describe('level filtering', () => {
28
+ it('should log messages at or below the configured level', () => {
29
+ logger.setLevel('info')
30
+ logger.info('info message')
31
+ logger.error('error message')
32
+
33
+ expect(errorSpy).toHaveBeenCalledTimes(2)
34
+ })
35
+
36
+ it('should suppress messages above the configured level', () => {
37
+ logger.setLevel('error')
38
+ logger.debug('debug message')
39
+ logger.info('info message')
40
+ logger.notice('notice message')
41
+ logger.warning('warning message')
42
+
43
+ expect(errorSpy).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('should allow debug messages at debug level', () => {
47
+ logger.setLevel('debug')
48
+ logger.debug('debug message')
49
+
50
+ expect(errorSpy).toHaveBeenCalledTimes(1)
51
+ })
52
+
53
+ it('should log critical messages at any level', () => {
54
+ logger.setLevel('critical')
55
+ logger.critical('critical failure')
56
+
57
+ expect(errorSpy).toHaveBeenCalledTimes(1)
58
+ })
59
+ })
60
+
61
+ // ========================================================================
62
+ // Formatting
63
+ // ========================================================================
64
+
65
+ describe('formatting', () => {
66
+ it('should include timestamp, level, and message', () => {
67
+ logger.info('test message')
68
+ const output = errorSpy.mock.calls[0]?.[0] as string
69
+
70
+ // [timestamp] [LEVEL ] message
71
+ expect(output).toMatch(/^\[.+\] \[INFO\s+\]/)
72
+ expect(output).toContain('test message')
73
+ })
74
+
75
+ it('should include module when provided', () => {
76
+ logger.info('test', { module: 'TestModule' })
77
+ const output = errorSpy.mock.calls[0]?.[0] as string
78
+
79
+ expect(output).toContain('[TestModule]')
80
+ })
81
+
82
+ it('should include operation when provided', () => {
83
+ logger.info('test', { operation: 'doSomething' })
84
+ const output = errorSpy.mock.calls[0]?.[0] as string
85
+
86
+ expect(output).toContain('[doSomething]')
87
+ })
88
+
89
+ it('should include extra context fields as JSON', () => {
90
+ logger.info('test', { module: 'M', entityId: 42 })
91
+ const output = errorSpy.mock.calls[0]?.[0] as string
92
+
93
+ expect(output).toContain('"entityId":42')
94
+ })
95
+
96
+ it('should omit extras JSON when no extra fields', () => {
97
+ logger.info('plain message')
98
+ const output = errorSpy.mock.calls[0]?.[0] as string
99
+
100
+ // Should not contain JSON braces (from extras)
101
+ expect(output).not.toContain('{')
102
+ })
103
+ })
104
+
105
+ // ========================================================================
106
+ // Sanitization
107
+ // ========================================================================
108
+
109
+ describe('sanitization', () => {
110
+ it('should sanitize error field containing tokens', () => {
111
+ const token = 'ghp_' + 'X'.repeat(36)
112
+ logger.error('Request failed', {
113
+ module: 'GitHub',
114
+ error: `Token ${token} expired`,
115
+ })
116
+ const output = errorSpy.mock.calls[0]?.[0] as string
117
+
118
+ expect(output).not.toContain(token)
119
+ expect(output).toContain('[REDACTED]')
120
+ })
121
+
122
+ it('should not sanitize non-string error fields', () => {
123
+ logger.error('Error occurred', {
124
+ module: 'DB',
125
+ error: 500,
126
+ })
127
+ const output = errorSpy.mock.calls[0]?.[0] as string
128
+
129
+ expect(output).toContain('"error":500')
130
+ })
131
+ })
132
+
133
+ // ========================================================================
134
+ // setLevel
135
+ // ========================================================================
136
+
137
+ describe('setLevel', () => {
138
+ it('should change the minimum log level', () => {
139
+ logger.setLevel('critical')
140
+ logger.error('should not appear')
141
+ expect(errorSpy).not.toHaveBeenCalled()
142
+
143
+ logger.setLevel('error')
144
+ logger.error('should appear')
145
+ expect(errorSpy).toHaveBeenCalledTimes(1)
146
+ })
147
+ })
148
+
149
+ // ========================================================================
150
+ // Convenience methods
151
+ // ========================================================================
152
+
153
+ describe('convenience methods', () => {
154
+ it('should log via debug()', () => {
155
+ logger.setLevel('debug')
156
+ logger.debug('debug msg')
157
+ const output = errorSpy.mock.calls[0]?.[0] as string
158
+ expect(output).toContain('DEBUG')
159
+ })
160
+
161
+ it('should log via notice()', () => {
162
+ logger.notice('notice msg')
163
+ const output = errorSpy.mock.calls[0]?.[0] as string
164
+ expect(output).toContain('NOTICE')
165
+ })
166
+
167
+ it('should log via warning()', () => {
168
+ logger.warning('warning msg')
169
+ const output = errorSpy.mock.calls[0]?.[0] as string
170
+ expect(output).toContain('WARNING')
171
+ })
172
+
173
+ it('should log via critical()', () => {
174
+ logger.setLevel('critical')
175
+ logger.critical('critical msg')
176
+ const output = errorSpy.mock.calls[0]?.[0] as string
177
+ expect(output).toContain('CRITICAL')
178
+ })
179
+ })
180
+ })
@@ -0,0 +1,212 @@
1
+ /**
2
+ * McpLogger Tests
3
+ *
4
+ * Tests the MCP Protocol Logger: level filtering, stderr fallback, MCP send.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
+ import { McpLogger, mcpLogger } from '../../src/utils/McpLogger.js'
9
+
10
+ describe('McpLogger', () => {
11
+ let mcpLog: McpLogger
12
+ let errorSpy: ReturnType<typeof vi.spyOn>
13
+
14
+ beforeEach(() => {
15
+ mcpLog = new McpLogger()
16
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
17
+ })
18
+
19
+ afterEach(() => {
20
+ errorSpy.mockRestore()
21
+ })
22
+
23
+ // ========================================================================
24
+ // Level management
25
+ // ========================================================================
26
+
27
+ describe('setLevel / getLevel', () => {
28
+ it('should default to info level', () => {
29
+ expect(mcpLog.getLevel()).toBe('info')
30
+ })
31
+
32
+ it('should set and get level correctly', () => {
33
+ mcpLog.setLevel('debug')
34
+ expect(mcpLog.getLevel()).toBe('debug')
35
+ })
36
+
37
+ it('should accept all valid MCP log levels', () => {
38
+ const levels = [
39
+ 'debug',
40
+ 'info',
41
+ 'notice',
42
+ 'warning',
43
+ 'error',
44
+ 'critical',
45
+ 'alert',
46
+ 'emergency',
47
+ ] as const
48
+
49
+ for (const level of levels) {
50
+ mcpLog.setLevel(level)
51
+ expect(mcpLog.getLevel()).toBe(level)
52
+ }
53
+ })
54
+ })
55
+
56
+ // ========================================================================
57
+ // Level filtering
58
+ // ========================================================================
59
+
60
+ describe('level filtering', () => {
61
+ it('should log messages at or below configured severity', () => {
62
+ mcpLog.setLevel('info')
63
+ mcpLog.log('info', 'test', { message: 'info msg' })
64
+ mcpLog.log('error', 'test', { message: 'error msg' })
65
+
66
+ expect(errorSpy).toHaveBeenCalledTimes(2)
67
+ })
68
+
69
+ it('should suppress messages above configured severity', () => {
70
+ mcpLog.setLevel('error')
71
+ mcpLog.log('info', 'test', { message: 'should be suppressed' })
72
+ mcpLog.log('debug', 'test', { message: 'also suppressed' })
73
+
74
+ expect(errorSpy).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it('should log emergency at any level', () => {
78
+ mcpLog.setLevel('emergency')
79
+ mcpLog.log('emergency', 'test', { message: 'critical system failure' })
80
+
81
+ expect(errorSpy).toHaveBeenCalledTimes(1)
82
+ })
83
+ })
84
+
85
+ // ========================================================================
86
+ // stderr fallback (no server)
87
+ // ========================================================================
88
+
89
+ describe('stderr fallback', () => {
90
+ it('should format output with timestamp, level, and logger name', () => {
91
+ mcpLog.log('info', 'MyModule', { message: 'hello world' })
92
+ const output = errorSpy.mock.calls[0]?.[0] as string
93
+
94
+ expect(output).toMatch(/^\[.+\] \[INFO\s+\]/)
95
+ expect(output).toContain('[MyModule]')
96
+ expect(output).toContain('hello world')
97
+ })
98
+
99
+ it('should include extra context in stderr output', () => {
100
+ mcpLog.log('warning', 'DB', {
101
+ message: 'Slow query',
102
+ operation: 'SELECT',
103
+ duration: 2500,
104
+ })
105
+ const output = errorSpy.mock.calls[0]?.[0] as string
106
+
107
+ expect(output).toContain('"operation":"SELECT"')
108
+ expect(output).toContain('"duration":2500')
109
+ })
110
+ })
111
+
112
+ // ========================================================================
113
+ // MCP server integration
114
+ // ========================================================================
115
+
116
+ describe('MCP server send', () => {
117
+ it('should send via MCP protocol when server is set', () => {
118
+ const mockServer = {
119
+ sendLoggingMessage: vi.fn(),
120
+ }
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ mcpLog.setServer(mockServer as any)
123
+
124
+ mcpLog.log('info', 'test', { message: 'via MCP' })
125
+
126
+ expect(mockServer.sendLoggingMessage).toHaveBeenCalledWith({
127
+ level: 'info',
128
+ logger: 'test',
129
+ data: { message: 'via MCP' },
130
+ })
131
+ // Should also log to stderr
132
+ expect(errorSpy).toHaveBeenCalled()
133
+ })
134
+
135
+ it('should fall back to stderr if MCP send throws', () => {
136
+ const mockServer = {
137
+ sendLoggingMessage: vi.fn(() => {
138
+ throw new Error('Transport error')
139
+ }),
140
+ }
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ mcpLog.setServer(mockServer as any)
143
+
144
+ // Should not throw
145
+ mcpLog.log('error', 'test', { message: 'fallback test' })
146
+
147
+ // Should still log to stderr (fallback + always-log)
148
+ expect(errorSpy).toHaveBeenCalled()
149
+ })
150
+ })
151
+
152
+ // ========================================================================
153
+ // Convenience methods
154
+ // ========================================================================
155
+
156
+ describe('convenience methods', () => {
157
+ it('should log via debug()', () => {
158
+ mcpLog.setLevel('debug')
159
+ mcpLog.debug('mod', 'debug message')
160
+ const output = errorSpy.mock.calls[0]?.[0] as string
161
+ expect(output).toContain('DEBUG')
162
+ expect(output).toContain('debug message')
163
+ })
164
+
165
+ it('should log via info()', () => {
166
+ mcpLog.info('mod', 'info message')
167
+ const output = errorSpy.mock.calls[0]?.[0] as string
168
+ expect(output).toContain('INFO')
169
+ })
170
+
171
+ it('should log via notice()', () => {
172
+ mcpLog.notice('mod', 'notice message')
173
+ const output = errorSpy.mock.calls[0]?.[0] as string
174
+ expect(output).toContain('NOTICE')
175
+ })
176
+
177
+ it('should log via warning()', () => {
178
+ mcpLog.warning('mod', 'warning message')
179
+ const output = errorSpy.mock.calls[0]?.[0] as string
180
+ expect(output).toContain('WARNING')
181
+ })
182
+
183
+ it('should log via error()', () => {
184
+ mcpLog.error('mod', 'error message')
185
+ const output = errorSpy.mock.calls[0]?.[0] as string
186
+ expect(output).toContain('ERROR')
187
+ })
188
+
189
+ it('should log via critical()', () => {
190
+ mcpLog.setLevel('critical')
191
+ mcpLog.critical('mod', 'critical message')
192
+ const output = errorSpy.mock.calls[0]?.[0] as string
193
+ expect(output).toContain('CRITICAL')
194
+ })
195
+
196
+ it('should pass context through convenience methods', () => {
197
+ mcpLog.info('mod', 'msg', { extra: 'data' })
198
+ const output = errorSpy.mock.calls[0]?.[0] as string
199
+ expect(output).toContain('"extra":"data"')
200
+ })
201
+ })
202
+
203
+ // ========================================================================
204
+ // Singleton
205
+ // ========================================================================
206
+
207
+ describe('singleton', () => {
208
+ it('should export mcpLogger as a McpLogger instance', () => {
209
+ expect(mcpLogger).toBeInstanceOf(McpLogger)
210
+ })
211
+ })
212
+ })
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Progress Utilities Tests
3
+ *
4
+ * Tests sendProgress and createBatchProgressReporter with mock NotificationSender.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { sendProgress, createBatchProgressReporter } from '../../src/utils/progress-utils.js'
9
+ import type { ProgressContext } from '../../src/utils/progress-utils.js'
10
+
11
+ /** Create a mock ProgressContext */
12
+ function createMockContext(token?: string | number): {
13
+ ctx: ProgressContext
14
+ notifications: { progress: number; total?: number; message?: string }[]
15
+ } {
16
+ const notifications: { progress: number; total?: number; message?: string }[] = []
17
+ const ctx: ProgressContext = {
18
+ server: {
19
+ notification: vi.fn(async (n) => {
20
+ notifications.push(n.params)
21
+ }),
22
+ },
23
+ progressToken: token,
24
+ }
25
+ return { ctx, notifications }
26
+ }
27
+
28
+ // ============================================================================
29
+ // sendProgress
30
+ // ============================================================================
31
+
32
+ describe('sendProgress', () => {
33
+ it('should no-op when context is undefined', async () => {
34
+ // Should not throw
35
+ await sendProgress(undefined, 5, 10, 'working')
36
+ })
37
+
38
+ it('should no-op when progressToken is undefined', async () => {
39
+ const { ctx } = createMockContext(undefined)
40
+ await sendProgress(ctx, 5, 10, 'working')
41
+ expect(ctx.server.notification).not.toHaveBeenCalled()
42
+ })
43
+
44
+ it('should send notification with correct shape', async () => {
45
+ const { ctx, notifications } = createMockContext('token-1')
46
+ await sendProgress(ctx, 3, 10, 'Processing')
47
+
48
+ expect(notifications).toHaveLength(1)
49
+ expect(notifications[0]).toEqual({
50
+ progressToken: 'token-1',
51
+ progress: 3,
52
+ total: 10,
53
+ message: 'Processing',
54
+ })
55
+ })
56
+
57
+ it('should omit total when undefined', async () => {
58
+ const { ctx, notifications } = createMockContext('t2')
59
+ await sendProgress(ctx, 5, undefined, 'step')
60
+
61
+ expect(notifications[0]).not.toHaveProperty('total')
62
+ expect(notifications[0]?.progress).toBe(5)
63
+ })
64
+
65
+ it('should omit message when undefined', async () => {
66
+ const { ctx, notifications } = createMockContext('t3')
67
+ await sendProgress(ctx, 7, 10)
68
+
69
+ expect(notifications[0]).not.toHaveProperty('message')
70
+ })
71
+
72
+ it('should omit message when empty string', async () => {
73
+ const { ctx, notifications } = createMockContext('t4')
74
+ await sendProgress(ctx, 1, 5, '')
75
+
76
+ expect(notifications[0]).not.toHaveProperty('message')
77
+ })
78
+
79
+ it('should accept numeric progressToken', async () => {
80
+ const { ctx, notifications } = createMockContext(42)
81
+ await sendProgress(ctx, 1, 1, 'done')
82
+
83
+ expect(notifications[0]?.progressToken).toBe(42)
84
+ })
85
+
86
+ it('should silently handle notification errors', async () => {
87
+ const ctx: ProgressContext = {
88
+ server: {
89
+ notification: vi.fn(async () => {
90
+ throw new Error('Transport closed')
91
+ }),
92
+ },
93
+ progressToken: 'token',
94
+ }
95
+
96
+ // Should not throw
97
+ await sendProgress(ctx, 1, 10, 'test')
98
+ })
99
+ })
100
+
101
+ // ============================================================================
102
+ // createBatchProgressReporter
103
+ // ============================================================================
104
+
105
+ describe('createBatchProgressReporter', () => {
106
+ it('should report at throttle intervals', async () => {
107
+ const { ctx, notifications } = createMockContext('batch-1')
108
+ const report = createBatchProgressReporter(ctx, 100, 10)
109
+
110
+ // Report items 1 through 30
111
+ for (let i = 1; i <= 30; i++) {
112
+ await report(i, `Item ${i}`)
113
+ }
114
+
115
+ // Should have reported at 10, 20, 30
116
+ expect(notifications).toHaveLength(3)
117
+ expect(notifications[0]?.progress).toBe(10)
118
+ expect(notifications[1]?.progress).toBe(20)
119
+ expect(notifications[2]?.progress).toBe(30)
120
+ })
121
+
122
+ it('should always report on completion', async () => {
123
+ const { ctx, notifications } = createMockContext('batch-2')
124
+ const total = 15
125
+ const report = createBatchProgressReporter(ctx, total, 10)
126
+
127
+ // Report items 1 through 15 (total)
128
+ for (let i = 1; i <= total; i++) {
129
+ await report(i)
130
+ }
131
+
132
+ // Should report at 10 and 15 (completion)
133
+ expect(notifications).toHaveLength(2)
134
+ expect(notifications[1]?.progress).toBe(15)
135
+ })
136
+
137
+ it('should skip intermediate items below throttle', async () => {
138
+ const { ctx, notifications } = createMockContext('batch-3')
139
+ const report = createBatchProgressReporter(ctx, 100, 20)
140
+
141
+ await report(5)
142
+ await report(10)
143
+ await report(15)
144
+
145
+ expect(notifications).toHaveLength(0)
146
+ })
147
+
148
+ it('should work with undefined context', async () => {
149
+ const report = createBatchProgressReporter(undefined, 50, 10)
150
+
151
+ // Should not throw
152
+ for (let i = 1; i <= 50; i++) {
153
+ await report(i)
154
+ }
155
+ })
156
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Security Utilities Tests - sanitizeErrorForLogging
3
+ *
4
+ * Covers the token scrubbing function not tested by sql-injection.test.ts.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import { sanitizeErrorForLogging } from '../../src/utils/security-utils.js'
9
+
10
+ describe('sanitizeErrorForLogging', () => {
11
+ it('should redact GitHub classic PATs (ghp_)', () => {
12
+ const token = 'ghp_' + 'A'.repeat(36)
13
+ const message = `Request failed: token ${token} is invalid`
14
+ const result = sanitizeErrorForLogging(message)
15
+
16
+ expect(result).not.toContain(token)
17
+ expect(result).toContain('[REDACTED]')
18
+ expect(result).toBe('Request failed: token [REDACTED] is invalid')
19
+ })
20
+
21
+ it('should redact GitHub fine-grained PATs (github_pat_)', () => {
22
+ const token = 'github_pat_' + 'B'.repeat(82)
23
+ const message = `Auth error with ${token}`
24
+ const result = sanitizeErrorForLogging(message)
25
+
26
+ expect(result).not.toContain(token)
27
+ expect(result).toContain('[REDACTED]')
28
+ })
29
+
30
+ it('should redact Authorization headers with token', () => {
31
+ const message =
32
+ 'Failed request with Authorization: token ghp_secret123456789012345678901234567890'
33
+ const result = sanitizeErrorForLogging(message)
34
+
35
+ expect(result).toContain('[REDACTED]')
36
+ expect(result).not.toContain('ghp_secret')
37
+ })
38
+
39
+ it('should redact Authorization headers with Bearer', () => {
40
+ const message = 'Error: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig'
41
+ const result = sanitizeErrorForLogging(message)
42
+
43
+ expect(result).toContain('[REDACTED]')
44
+ expect(result).not.toContain('eyJhbGciOiJIUzI1NiJ9')
45
+ })
46
+
47
+ it('should redact generic Bearer tokens', () => {
48
+ const message = 'Token expired: Bearer abc123.def456.ghi789+/=='
49
+ const result = sanitizeErrorForLogging(message)
50
+
51
+ expect(result).toContain('[REDACTED]')
52
+ expect(result).not.toContain('abc123.def456')
53
+ })
54
+
55
+ it('should pass through clean messages unchanged', () => {
56
+ const message = 'Database connection failed at localhost:5432'
57
+ expect(sanitizeErrorForLogging(message)).toBe(message)
58
+ })
59
+
60
+ it('should pass through empty string', () => {
61
+ expect(sanitizeErrorForLogging('')).toBe('')
62
+ })
63
+
64
+ it('should handle multiple tokens in one message', () => {
65
+ const token1 = 'ghp_' + 'C'.repeat(36)
66
+ const token2 = 'ghp_' + 'D'.repeat(36)
67
+ const message = `Token1: ${token1}, Token2: ${token2}`
68
+ const result = sanitizeErrorForLogging(message)
69
+
70
+ expect(result).not.toContain(token1)
71
+ expect(result).not.toContain(token2)
72
+ expect(result).toBe('Token1: [REDACTED], Token2: [REDACTED]')
73
+ })
74
+
75
+ it('should be idempotent - calling twice yields same result', () => {
76
+ const token = 'ghp_' + 'E'.repeat(36)
77
+ const message = `Error with ${token}`
78
+ const first = sanitizeErrorForLogging(message)
79
+ const second = sanitizeErrorForLogging(first)
80
+ expect(first).toBe(second)
81
+ })
82
+ })