odac 0.9.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 (213) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/auto-pr-description.yml +49 -0
  3. package/.github/workflows/release.yml +32 -0
  4. package/.github/workflows/test-coverage.yml +58 -0
  5. package/.husky/pre-commit +2 -0
  6. package/.kiro/steering/code-style.md +56 -0
  7. package/.kiro/steering/product.md +20 -0
  8. package/.kiro/steering/structure.md +77 -0
  9. package/.kiro/steering/tech.md +87 -0
  10. package/.prettierrc +10 -0
  11. package/.releaserc.js +134 -0
  12. package/AGENTS.md +84 -0
  13. package/CHANGELOG.md +181 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +63 -0
  16. package/LICENSE +661 -0
  17. package/README.md +57 -0
  18. package/SECURITY.md +26 -0
  19. package/bin/candy +10 -0
  20. package/bin/candypack +10 -0
  21. package/cli/index.js +3 -0
  22. package/cli/src/Cli.js +348 -0
  23. package/cli/src/Connector.js +93 -0
  24. package/cli/src/Monitor.js +416 -0
  25. package/core/Candy.js +87 -0
  26. package/core/Commands.js +239 -0
  27. package/core/Config.js +1094 -0
  28. package/core/Lang.js +52 -0
  29. package/core/Log.js +43 -0
  30. package/core/Process.js +26 -0
  31. package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
  32. package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
  33. package/docs/backend/01-overview/03-development-server.md +79 -0
  34. package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
  35. package/docs/backend/03-config/00-configuration-overview.md +214 -0
  36. package/docs/backend/03-config/01-database-connection.md +60 -0
  37. package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
  38. package/docs/backend/03-config/03-request-timeout.md +11 -0
  39. package/docs/backend/03-config/04-environment-variables.md +227 -0
  40. package/docs/backend/03-config/05-early-hints.md +352 -0
  41. package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
  42. package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
  43. package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
  44. package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
  45. package/docs/backend/04-routing/05-advanced-routing.md +14 -0
  46. package/docs/backend/04-routing/06-error-pages.md +101 -0
  47. package/docs/backend/04-routing/07-cron-jobs.md +149 -0
  48. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
  49. package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
  50. package/docs/backend/05-controllers/03-controller-classes.md +93 -0
  51. package/docs/backend/05-forms/01-custom-forms.md +395 -0
  52. package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
  53. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
  54. package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
  55. package/docs/backend/07-views/01-the-view-directory.md +73 -0
  56. package/docs/backend/07-views/02-rendering-a-view.md +179 -0
  57. package/docs/backend/07-views/03-template-syntax.md +181 -0
  58. package/docs/backend/07-views/03-variables.md +328 -0
  59. package/docs/backend/07-views/04-request-data.md +231 -0
  60. package/docs/backend/07-views/05-conditionals.md +290 -0
  61. package/docs/backend/07-views/06-loops.md +353 -0
  62. package/docs/backend/07-views/07-translations.md +358 -0
  63. package/docs/backend/07-views/08-backend-javascript.md +398 -0
  64. package/docs/backend/07-views/09-comments.md +297 -0
  65. package/docs/backend/08-database/01-database-connection.md +99 -0
  66. package/docs/backend/08-database/02-using-mysql.md +322 -0
  67. package/docs/backend/09-validation/01-the-validator-service.md +424 -0
  68. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
  69. package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
  70. package/docs/backend/10-authentication/03-register.md +134 -0
  71. package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
  72. package/docs/backend/10-authentication/05-session-management.md +159 -0
  73. package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
  74. package/docs/backend/11-mail/01-the-mail-service.md +42 -0
  75. package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
  76. package/docs/backend/13-utilities/01-candy-var.md +504 -0
  77. package/docs/frontend/01-overview/01-introduction.md +146 -0
  78. package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
  79. package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
  80. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
  81. package/docs/frontend/03-forms/01-form-handling.md +420 -0
  82. package/docs/frontend/04-api-requests/01-get-post.md +443 -0
  83. package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
  84. package/docs/index.json +452 -0
  85. package/docs/server/01-installation/01-quick-install.md +19 -0
  86. package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
  87. package/docs/server/02-get-started/01-core-concepts.md +7 -0
  88. package/docs/server/02-get-started/02-basic-commands.md +57 -0
  89. package/docs/server/02-get-started/03-cli-reference.md +276 -0
  90. package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
  91. package/docs/server/03-service/01-start-a-new-service.md +57 -0
  92. package/docs/server/03-service/02-delete-a-service.md +48 -0
  93. package/docs/server/04-web/01-create-a-website.md +36 -0
  94. package/docs/server/04-web/02-list-websites.md +9 -0
  95. package/docs/server/04-web/03-delete-a-website.md +29 -0
  96. package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
  97. package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
  98. package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
  99. package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
  100. package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
  101. package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
  102. package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
  103. package/docs/server/07-mail/04-change-account-password.md +23 -0
  104. package/eslint.config.mjs +120 -0
  105. package/framework/index.js +4 -0
  106. package/framework/src/Auth.js +309 -0
  107. package/framework/src/Candy.js +81 -0
  108. package/framework/src/Config.js +79 -0
  109. package/framework/src/Env.js +60 -0
  110. package/framework/src/Lang.js +57 -0
  111. package/framework/src/Mail.js +83 -0
  112. package/framework/src/Mysql.js +575 -0
  113. package/framework/src/Request.js +301 -0
  114. package/framework/src/Route/Cron.js +128 -0
  115. package/framework/src/Route/Internal.js +439 -0
  116. package/framework/src/Route.js +455 -0
  117. package/framework/src/Server.js +15 -0
  118. package/framework/src/Stream.js +163 -0
  119. package/framework/src/Token.js +37 -0
  120. package/framework/src/Validator.js +271 -0
  121. package/framework/src/Var.js +211 -0
  122. package/framework/src/View/EarlyHints.js +190 -0
  123. package/framework/src/View/Form.js +600 -0
  124. package/framework/src/View.js +513 -0
  125. package/framework/web/candy.js +838 -0
  126. package/jest.config.js +22 -0
  127. package/locale/de-DE.json +80 -0
  128. package/locale/en-US.json +79 -0
  129. package/locale/es-ES.json +80 -0
  130. package/locale/fr-FR.json +80 -0
  131. package/locale/pt-BR.json +80 -0
  132. package/locale/ru-RU.json +80 -0
  133. package/locale/tr-TR.json +85 -0
  134. package/locale/zh-CN.json +80 -0
  135. package/package.json +86 -0
  136. package/server/index.js +5 -0
  137. package/server/src/Api.js +88 -0
  138. package/server/src/DNS.js +940 -0
  139. package/server/src/Hub.js +535 -0
  140. package/server/src/Mail.js +571 -0
  141. package/server/src/SSL.js +180 -0
  142. package/server/src/Server.js +27 -0
  143. package/server/src/Service.js +248 -0
  144. package/server/src/Subdomain.js +64 -0
  145. package/server/src/Web/Firewall.js +170 -0
  146. package/server/src/Web/Proxy.js +134 -0
  147. package/server/src/Web.js +451 -0
  148. package/server/src/mail/imap.js +1091 -0
  149. package/server/src/mail/server.js +32 -0
  150. package/server/src/mail/smtp.js +786 -0
  151. package/test/cli/Cli.test.js +36 -0
  152. package/test/core/Candy.test.js +234 -0
  153. package/test/core/Commands.test.js +538 -0
  154. package/test/core/Config.test.js +1435 -0
  155. package/test/core/Lang.test.js +250 -0
  156. package/test/core/Process.test.js +156 -0
  157. package/test/framework/Route.test.js +239 -0
  158. package/test/framework/View/EarlyHints.test.js +282 -0
  159. package/test/scripts/check-coverage.js +132 -0
  160. package/test/server/Api.test.js +647 -0
  161. package/test/server/Client.test.js +338 -0
  162. package/test/server/DNS.test.js +2050 -0
  163. package/test/server/DNS.test.js.bak +2084 -0
  164. package/test/server/Log.test.js +73 -0
  165. package/test/server/Mail.account.test_.js +460 -0
  166. package/test/server/Mail.init.test_.js +411 -0
  167. package/test/server/Mail.test_.js +1340 -0
  168. package/test/server/SSL.test_.js +1491 -0
  169. package/test/server/Server.test.js +765 -0
  170. package/test/server/Service.test_.js +1127 -0
  171. package/test/server/Subdomain.test.js +440 -0
  172. package/test/server/Web/Firewall.test.js +175 -0
  173. package/test/server/Web.test_.js +1562 -0
  174. package/test/server/__mocks__/acme-client.js +17 -0
  175. package/test/server/__mocks__/bcrypt.js +50 -0
  176. package/test/server/__mocks__/child_process.js +389 -0
  177. package/test/server/__mocks__/crypto.js +432 -0
  178. package/test/server/__mocks__/fs.js +450 -0
  179. package/test/server/__mocks__/globalCandy.js +227 -0
  180. package/test/server/__mocks__/http-proxy.js +105 -0
  181. package/test/server/__mocks__/http.js +575 -0
  182. package/test/server/__mocks__/https.js +272 -0
  183. package/test/server/__mocks__/index.js +249 -0
  184. package/test/server/__mocks__/mail/server.js +100 -0
  185. package/test/server/__mocks__/mail/smtp.js +31 -0
  186. package/test/server/__mocks__/mailparser.js +81 -0
  187. package/test/server/__mocks__/net.js +369 -0
  188. package/test/server/__mocks__/node-forge.js +328 -0
  189. package/test/server/__mocks__/os.js +320 -0
  190. package/test/server/__mocks__/path.js +291 -0
  191. package/test/server/__mocks__/selfsigned.js +8 -0
  192. package/test/server/__mocks__/server/src/mail/server.js +100 -0
  193. package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
  194. package/test/server/__mocks__/smtp-server.js +106 -0
  195. package/test/server/__mocks__/sqlite3.js +394 -0
  196. package/test/server/__mocks__/testFactories.js +299 -0
  197. package/test/server/__mocks__/testHelpers.js +363 -0
  198. package/test/server/__mocks__/tls.js +229 -0
  199. package/watchdog/index.js +3 -0
  200. package/watchdog/src/Watchdog.js +156 -0
  201. package/web/config.json +5 -0
  202. package/web/controller/page/about.js +27 -0
  203. package/web/controller/page/index.js +34 -0
  204. package/web/package.json +18 -0
  205. package/web/public/assets/css/style.css +1835 -0
  206. package/web/public/assets/js/app.js +96 -0
  207. package/web/route/www.js +19 -0
  208. package/web/skeleton/main.html +22 -0
  209. package/web/view/content/about.html +65 -0
  210. package/web/view/content/home.html +205 -0
  211. package/web/view/footer/main.html +11 -0
  212. package/web/view/head/main.html +5 -0
  213. package/web/view/header/main.html +14 -0
@@ -0,0 +1,1340 @@
1
+ /**
2
+ * Comprehensive unit tests for the Mail.js module
3
+ * Tests DKIM key generation, mail processing, and SMTP authentication
4
+ */
5
+
6
+ const {setupGlobalMocks, cleanupGlobalMocks, createMockEventEmitter, waitFor} = require('./__mocks__/testHelpers')
7
+ const {createMockMailAccount, createMockEmailMessage, createMockWebsiteConfig} = require('./__mocks__/testFactories')
8
+
9
+ // Mock external dependencies
10
+ jest.mock('bcrypt')
11
+ jest.mock('smtp-server')
12
+ jest.mock('mailparser')
13
+ jest.mock('sqlite3')
14
+ jest.mock('node-forge')
15
+ jest.mock('fs')
16
+ jest.mock('os')
17
+ jest.mock('../../server/src/mail/server', () => require('./__mocks__/server/src/mail/server'))
18
+ jest.mock('../../server/src/mail/smtp', () => require('./__mocks__/server/src/mail/smtp'))
19
+ jest.mock('tls')
20
+
21
+ const bcrypt = require('bcrypt')
22
+ const {SMTPServer} = require('smtp-server')
23
+ const {simpleParser} = require('mailparser')
24
+ const sqlite3 = require('sqlite3')
25
+ const forge = require('node-forge')
26
+ const fs = require('fs')
27
+ const os = require('os')
28
+ const mailServer = require('../../server/src/mail/server')
29
+ const smtp = require('../../server/src/mail/smtp')
30
+ const tls = require('tls')
31
+
32
+ describe('Mail Module', () => {
33
+ let Mail
34
+ let mockDb
35
+ let mockConfig
36
+ let mockDNS
37
+ let mockApi
38
+
39
+ beforeEach(() => {
40
+ setupGlobalMocks()
41
+
42
+ // Setup mock database
43
+ mockDb = {
44
+ serialize: jest.fn(callback => callback && callback()),
45
+ run: jest.fn((sql, params, callback) => {
46
+ if (typeof params === 'function') {
47
+ callback = params
48
+ }
49
+ if (callback) callback(null)
50
+ }),
51
+ get: jest.fn((sql, params, callback) => {
52
+ if (typeof params === 'function') {
53
+ callback = params
54
+ params = []
55
+ }
56
+ if (callback) callback(null, null)
57
+ }),
58
+ all: jest.fn((sql, params, callback) => {
59
+ if (typeof params === 'function') {
60
+ callback = params
61
+ params = []
62
+ }
63
+ if (callback) callback(null, [])
64
+ }),
65
+ each: jest.fn((sql, params, rowCallback, completeCallback) => {
66
+ if (typeof params === 'function') {
67
+ completeCallback = rowCallback
68
+ rowCallback = params
69
+ params = []
70
+ }
71
+ if (completeCallback) completeCallback(null, 0)
72
+ }),
73
+ prepare: jest.fn(() => ({
74
+ run: jest.fn(),
75
+ finalize: jest.fn()
76
+ }))
77
+ }
78
+
79
+ // Setup sqlite3 mock
80
+ sqlite3.verbose.mockReturnValue({
81
+ Database: jest.fn().mockImplementation((path, callback) => {
82
+ if (callback) callback(null)
83
+ return mockDb
84
+ })
85
+ })
86
+
87
+ // Setup mock config
88
+ mockConfig = {
89
+ config: {
90
+ websites: {
91
+ 'example.com': createMockWebsiteConfig('example.com', {
92
+ DNS: {
93
+ MX: [{name: 'example.com', value: 'mail.example.com', priority: 10}]
94
+ },
95
+ cert: false
96
+ })
97
+ }
98
+ }
99
+ }
100
+
101
+ // Setup mock DNS service
102
+ mockDNS = {
103
+ record: jest.fn()
104
+ }
105
+
106
+ // Setup mock API service
107
+ mockApi = {
108
+ result: jest.fn((success, data) => ({success, data}))
109
+ }
110
+
111
+ // Setup global Candy mocks
112
+ global.Candy.setMock('core', 'Config', mockConfig)
113
+ global.Candy.setMock('server', 'DNS', mockDNS)
114
+ global.Candy.setMock('server', 'Api', mockApi)
115
+ global.Candy.setMock('server', 'Log', {
116
+ init: jest.fn().mockReturnValue({
117
+ log: jest.fn(),
118
+ error: jest.fn()
119
+ })
120
+ })
121
+
122
+ // Setup Candy.core mock
123
+ jest.spyOn(global.Candy, 'core').mockImplementation(name => {
124
+ if (name === 'Config') return mockConfig
125
+ return {init: jest.fn()}
126
+ })
127
+
128
+ // Setup os mock
129
+ os.homedir.mockReturnValue('/home/user')
130
+
131
+ // Setup fs mock
132
+ fs.existsSync.mockReturnValue(true)
133
+ fs.mkdirSync.mockImplementation(() => {})
134
+ fs.writeFileSync.mockImplementation(() => {})
135
+
136
+ // Setup node-forge mock
137
+ forge.pki = {
138
+ rsa: {
139
+ generateKeyPair: jest.fn().mockReturnValue({
140
+ privateKey: 'mock-private-key',
141
+ publicKey: 'mock-public-key'
142
+ })
143
+ },
144
+ privateKeyToPem: jest.fn().mockReturnValue('-----BEGIN PRIVATE KEY-----\nmock-private-key\n-----END PRIVATE KEY-----'),
145
+ publicKeyToPem: jest.fn().mockReturnValue('-----BEGIN PUBLIC KEY-----\nmock-public-key\n-----END PUBLIC KEY-----')
146
+ }
147
+
148
+ // Setup bcrypt mock
149
+ bcrypt.hash.mockImplementation((password, rounds, callback) => {
150
+ callback(null, '$2b$10$hashedpassword')
151
+ })
152
+ bcrypt.compare.mockImplementation((password, hash, callback) => {
153
+ if (callback) {
154
+ callback(null, password === 'correctpassword')
155
+ } else {
156
+ return Promise.resolve(password === 'correctpassword')
157
+ }
158
+ })
159
+
160
+ // Setup SMTP server mock
161
+ const mockSMTPServer = createMockEventEmitter()
162
+ mockSMTPServer.listen = jest.fn()
163
+ SMTPServer.mockImplementation(() => mockSMTPServer)
164
+
165
+ // Setup mail server mock
166
+ const mockMailServer = createMockEventEmitter()
167
+ mockMailServer.listen = jest.fn()
168
+ mailServer.mockImplementation(() => mockMailServer)
169
+
170
+ // Setup mail parser mock
171
+ simpleParser.mockImplementation((stream, options, callback) => {
172
+ if (typeof options === 'function') {
173
+ callback = options
174
+ }
175
+ const mockParsedMail = createMockEmailMessage()
176
+ callback(null, mockParsedMail)
177
+ })
178
+
179
+ // Setup TLS mock
180
+ tls.createSecureContext.mockReturnValue({})
181
+
182
+ // Clear module cache and require fresh instance
183
+ jest.resetModules()
184
+ Mail = require('../../server/src/Mail')
185
+
186
+ // Reset the Mail module's internal state by creating a new instance
187
+ // Since Mail is a singleton, we need to reset its state
188
+ if (Mail._resetForTesting) {
189
+ Mail._resetForTesting()
190
+ }
191
+ })
192
+
193
+ afterEach(() => {
194
+ cleanupGlobalMocks()
195
+ jest.clearAllMocks()
196
+ })
197
+
198
+ describe('DKIM Key Generation', () => {
199
+ beforeEach(() => {
200
+ // Ensure domain has MX record but no DKIM cert
201
+ mockConfig.config.websites['example.com'].cert = {}
202
+
203
+ // Initialize Mail module first
204
+ Mail.init()
205
+ })
206
+
207
+ test('should generate DKIM key pair for domain with MX records', () => {
208
+ // Ensure domain has MX record but no DKIM cert
209
+ mockConfig.config.websites['example.com'].cert = {}
210
+
211
+ // Act
212
+ Mail.check()
213
+
214
+ // Assert
215
+ expect(forge.pki.rsa.generateKeyPair).toHaveBeenCalledWith(1024)
216
+ expect(forge.pki.privateKeyToPem).toHaveBeenCalledWith('mock-private-key')
217
+ expect(forge.pki.publicKeyToPem).toHaveBeenCalledWith('mock-public-key')
218
+ })
219
+
220
+ test('should create DKIM directory if it does not exist', () => {
221
+ // Arrange
222
+ fs.existsSync.mockReturnValue(false)
223
+
224
+ // Act
225
+ Mail.check()
226
+
227
+ // Assert
228
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/home/user/.candypack/cert/dkim', {recursive: true})
229
+ })
230
+
231
+ test('should write DKIM private and public keys to files', () => {
232
+ // Act
233
+ Mail.check()
234
+
235
+ // Assert
236
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
237
+ '/home/user/.candypack/cert/dkim/example.com.key',
238
+ '-----BEGIN PRIVATE KEY-----\nmock-private-key\n-----END PRIVATE KEY-----'
239
+ )
240
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
241
+ '/home/user/.candypack/cert/dkim/example.com.pub',
242
+ '-----BEGIN PUBLIC KEY-----\nmock-public-key\n-----END PUBLIC KEY-----'
243
+ )
244
+ })
245
+
246
+ test('should update website configuration with DKIM certificate paths', () => {
247
+ // Act
248
+ Mail.check()
249
+
250
+ // Assert
251
+ expect(mockConfig.config.websites['example.com'].cert).toEqual({
252
+ dkim: {
253
+ private: '/home/user/.candypack/cert/dkim/example.com.key',
254
+ public: '/home/user/.candypack/cert/dkim/example.com.pub'
255
+ }
256
+ })
257
+ })
258
+
259
+ test('should create DNS TXT record for DKIM public key', () => {
260
+ // Act
261
+ Mail.check()
262
+
263
+ // Assert
264
+ expect(mockDNS.record).toHaveBeenCalledWith({
265
+ type: 'TXT',
266
+ name: 'default._domainkey.example.com',
267
+ value: expect.stringContaining('v=DKIM1; k=rsa; p=')
268
+ })
269
+ })
270
+
271
+ test('should not generate DKIM keys if already exists', () => {
272
+ // Arrange
273
+ mockConfig.config.websites['example.com'].cert = {
274
+ dkim: {
275
+ private: '/existing/path/key',
276
+ public: '/existing/path/pub'
277
+ }
278
+ }
279
+
280
+ // Act
281
+ Mail.check()
282
+
283
+ // Assert
284
+ expect(forge.pki.rsa.generateKeyPair).not.toHaveBeenCalled()
285
+ })
286
+
287
+ test('should not generate DKIM keys for domains without MX records', () => {
288
+ // Arrange
289
+ delete mockConfig.config.websites['example.com'].DNS.MX
290
+
291
+ // Act
292
+ Mail.check()
293
+
294
+ // Assert
295
+ expect(forge.pki.rsa.generateKeyPair).not.toHaveBeenCalled()
296
+ })
297
+
298
+ test('should handle multiple domains with MX records', () => {
299
+ // Arrange
300
+ mockConfig.config.websites['test.com'] = createMockWebsiteConfig('test.com', {
301
+ DNS: {
302
+ MX: [{name: 'test.com', value: 'mail.test.com', priority: 10}]
303
+ },
304
+ cert: false
305
+ })
306
+
307
+ // Act
308
+ Mail.check()
309
+
310
+ // Assert
311
+ expect(forge.pki.rsa.generateKeyPair).toHaveBeenCalledTimes(2)
312
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/user/.candypack/cert/dkim/example.com.key', expect.any(String))
313
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/user/.candypack/cert/dkim/test.com.key', expect.any(String))
314
+ })
315
+ })
316
+
317
+ describe('Mail Parsing and Storage', () => {
318
+ let mockParsedMail
319
+
320
+ beforeEach(() => {
321
+ mockParsedMail = createMockEmailMessage('sender@example.com', 'recipient@example.com')
322
+ simpleParser.mockImplementation((stream, options, callback) => {
323
+ if (typeof options === 'function') {
324
+ callback = options
325
+ }
326
+ callback(null, mockParsedMail)
327
+ })
328
+
329
+ // Initialize Mail module
330
+ Mail.init()
331
+ })
332
+
333
+ test('should parse incoming mail messages', async () => {
334
+ // Arrange
335
+ const mockStream = 'mock-email-stream'
336
+
337
+ // Act
338
+ await new Promise(resolve => {
339
+ simpleParser(mockStream, {}, (err, parsed) => {
340
+ resolve()
341
+ })
342
+ })
343
+
344
+ // Assert
345
+ expect(simpleParser).toHaveBeenCalledWith(mockStream, {}, expect.any(Function))
346
+ })
347
+
348
+ test('should store parsed mail in database with correct structure', async () => {
349
+ // Arrange
350
+ const mockEmail = 'test@example.com'
351
+ const mockMailbox = 'INBOX'
352
+ const mockFlags = '[]'
353
+
354
+ // Mock the private store method by calling it through mail processing
355
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
356
+
357
+ // Act
358
+ await mockSMTPOptions.onData('mock-stream', {user: mockEmail}, jest.fn())
359
+
360
+ // Assert
361
+ expect(mockDb.run).toHaveBeenCalledWith(
362
+ expect.stringContaining('INSERT INTO mail_received'),
363
+ expect.arrayContaining([
364
+ expect.any(Number), // uid
365
+ mockEmail,
366
+ 'INBOX',
367
+ expect.any(String), // attachments JSON
368
+ expect.any(String), // headers JSON
369
+ expect.any(String), // headerLines JSON
370
+ expect.any(String), // html
371
+ expect.any(String), // text
372
+ expect.any(String), // textAsHtml
373
+ expect.any(String), // subject
374
+ expect.any(String), // to JSON
375
+ expect.any(String), // from JSON
376
+ expect.any(String), // messageId
377
+ expect.any(String) // flags
378
+ ]),
379
+ expect.any(Function)
380
+ )
381
+ })
382
+
383
+ test('should handle mail parsing errors gracefully', async () => {
384
+ // Arrange
385
+ const parseError = new Error('Parse error')
386
+ simpleParser.mockImplementation((stream, options, callback) => {
387
+ if (typeof options === 'function') {
388
+ callback = options
389
+ }
390
+ callback(parseError)
391
+ })
392
+
393
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
394
+ const mockCallback = jest.fn()
395
+
396
+ // Act
397
+ await mockSMTPOptions.onData('mock-stream', {user: 'test@example.com'}, mockCallback)
398
+
399
+ // Assert
400
+ expect(mockCallback).toHaveBeenCalledWith(parseError)
401
+ })
402
+
403
+ test('should set seen flag for sent mail', async () => {
404
+ // Arrange
405
+ mockParsedMail.from.value[0].address = 'sender@example.com'
406
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
407
+
408
+ // Act
409
+ await mockSMTPOptions.onData('mock-stream', {user: 'sender@example.com'}, jest.fn())
410
+
411
+ // Assert
412
+ expect(mockDb.run).toHaveBeenCalledWith(
413
+ expect.stringContaining('INSERT INTO mail_received'),
414
+ expect.arrayContaining([
415
+ expect.any(Number),
416
+ 'sender@example.com',
417
+ 'Sent', // Should be Sent mailbox for sender
418
+ expect.any(String),
419
+ expect.any(String),
420
+ expect.any(String),
421
+ expect.any(String),
422
+ expect.any(String),
423
+ expect.any(String),
424
+ expect.any(String),
425
+ expect.any(String),
426
+ expect.any(String),
427
+ expect.any(String),
428
+ '["seen"]' // Should have seen flag
429
+ ]),
430
+ expect.any(Function)
431
+ )
432
+ })
433
+
434
+ test('should increment UID counter for each stored message', async () => {
435
+ // Arrange
436
+ const mockEmail = 'test@example.com'
437
+ mockDb.get.mockImplementation((sql, params, callback) => {
438
+ callback(null, {count: 5})
439
+ })
440
+
441
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
442
+
443
+ // Act
444
+ await mockSMTPOptions.onData('mock-stream', {user: mockEmail}, jest.fn())
445
+
446
+ // Assert
447
+ expect(mockDb.run).toHaveBeenCalledWith(
448
+ expect.stringContaining('INSERT INTO mail_received'),
449
+ expect.arrayContaining([
450
+ 6, // Should be count + 1
451
+ expect.any(String),
452
+ expect.any(String),
453
+ expect.any(String),
454
+ expect.any(String),
455
+ expect.any(String),
456
+ expect.any(String),
457
+ expect.any(String),
458
+ expect.any(String),
459
+ expect.any(String),
460
+ expect.any(String),
461
+ expect.any(String),
462
+ expect.any(String),
463
+ expect.any(String)
464
+ ]),
465
+ expect.any(Function)
466
+ )
467
+ })
468
+ })
469
+
470
+ describe('SMTP Authentication', () => {
471
+ let mockSMTPOptions
472
+
473
+ beforeEach(() => {
474
+ Mail.init()
475
+ mockSMTPOptions = SMTPServer.mock.calls[0][0]
476
+ })
477
+
478
+ test('should authenticate valid mail account credentials', async () => {
479
+ // Arrange
480
+ const mockAuth = {
481
+ username: 'test@example.com',
482
+ password: 'correctpassword'
483
+ }
484
+ const mockSession = {
485
+ remoteAddress: '127.0.0.1'
486
+ }
487
+ const mockCallback = jest.fn()
488
+
489
+ // Mock exists method to return user
490
+ mockDb.get.mockImplementation((sql, params, callback) => {
491
+ callback(null, {email: 'test@example.com', password: '$2b$10$hashedpassword'})
492
+ })
493
+
494
+ // Act
495
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, mockCallback)
496
+
497
+ // Assert
498
+ expect(bcrypt.compare).toHaveBeenCalledWith('correctpassword', '$2b$10$hashedpassword')
499
+ expect(mockCallback).toHaveBeenCalledWith(null, {user: 'test@example.com'})
500
+ })
501
+
502
+ test('should reject invalid credentials', async () => {
503
+ // Arrange
504
+ const mockAuth = {
505
+ username: 'test@example.com',
506
+ password: 'wrongpassword'
507
+ }
508
+ const mockSession = {
509
+ remoteAddress: '127.0.0.1'
510
+ }
511
+ const mockCallback = jest.fn()
512
+
513
+ // Mock exists method to return user
514
+ mockDb.get.mockImplementation((sql, params, callback) => {
515
+ callback(null, {email: 'test@example.com', password: '$2b$10$hashedpassword'})
516
+ })
517
+
518
+ // Act
519
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, mockCallback)
520
+
521
+ // Assert
522
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
523
+ expect(mockCallback.mock.calls[0][0].message).toBe('Invalid username or password')
524
+ })
525
+
526
+ test('should reject invalid email format', async () => {
527
+ // Arrange
528
+ const mockAuth = {
529
+ username: 'invalid-email',
530
+ password: 'password'
531
+ }
532
+ const mockSession = {
533
+ remoteAddress: '127.0.0.1'
534
+ }
535
+ const mockCallback = jest.fn()
536
+
537
+ // Act
538
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, mockCallback)
539
+
540
+ // Assert
541
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
542
+ expect(mockCallback.mock.calls[0][0].message).toBe('Invalid username or password')
543
+ })
544
+
545
+ test('should implement rate limiting for failed attempts', async () => {
546
+ // Arrange
547
+ const mockAuth = {
548
+ username: 'test@example.com',
549
+ password: 'wrongpassword'
550
+ }
551
+ const mockSession = {
552
+ remoteAddress: '192.168.1.100'
553
+ }
554
+ const mockCallback = jest.fn()
555
+
556
+ // Mock exists method to return user
557
+ mockDb.get.mockImplementation((sql, params, callback) => {
558
+ callback(null, {email: 'test@example.com', password: '$2b$10$hashedpassword'})
559
+ })
560
+
561
+ // First failed attempt
562
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, jest.fn())
563
+
564
+ // Second failed attempt (should trigger rate limiting)
565
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, mockCallback)
566
+
567
+ // Assert
568
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
569
+ expect(mockCallback.mock.calls[0][0].message).toContain('Too many attempts')
570
+ })
571
+
572
+ test('should reset rate limiting after timeout', async () => {
573
+ // Arrange
574
+ const mockAuth = {
575
+ username: 'test@example.com',
576
+ password: 'wrongpassword'
577
+ }
578
+ const mockSession = {
579
+ remoteAddress: '192.168.1.100'
580
+ }
581
+
582
+ // Mock exists method to return user
583
+ mockDb.get.mockImplementation((sql, params, callback) => {
584
+ callback(null, {email: 'test@example.com', password: '$2b$10$hashedpassword'})
585
+ })
586
+
587
+ // Simulate old failed attempt (more than 1 hour ago)
588
+ const oldTimestamp = Date.now() - 1000 * 60 * 60 * 2 // 2 hours ago
589
+ jest.spyOn(Date, 'now').mockReturnValue(oldTimestamp)
590
+
591
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, jest.fn())
592
+
593
+ // Reset Date.now to current time
594
+ Date.now.mockRestore()
595
+
596
+ const mockCallback = jest.fn()
597
+
598
+ // Act - should not be rate limited
599
+ await mockSMTPOptions.onAuth(mockAuth, mockSession, mockCallback)
600
+
601
+ // Assert
602
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
603
+ expect(mockCallback.mock.calls[0][0].message).toBe('Invalid username or password')
604
+ })
605
+ })
606
+
607
+ describe('Mail Sending Functionality', () => {
608
+ beforeEach(() => {
609
+ Mail.init()
610
+ })
611
+
612
+ test('should trigger SMTP send for authenticated user mail', async () => {
613
+ // Arrange
614
+ const mockParsedMail = createMockEmailMessage('sender@example.com', 'recipient@external.com')
615
+ const mockSession = {user: 'sender@example.com'}
616
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
617
+
618
+ simpleParser.mockImplementation((stream, options, callback) => {
619
+ if (typeof options === 'function') {
620
+ callback = options
621
+ }
622
+ callback(null, mockParsedMail)
623
+ })
624
+
625
+ // Act
626
+ await mockSMTPOptions.onData('mock-stream', mockSession, jest.fn())
627
+
628
+ // Assert
629
+ expect(smtp.send).toHaveBeenCalledWith(mockParsedMail)
630
+ })
631
+
632
+ test('should not trigger SMTP send for received mail', async () => {
633
+ // Arrange
634
+ const mockParsedMail = createMockEmailMessage('external@other.com', 'recipient@example.com')
635
+ const mockSession = {user: null}
636
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
637
+
638
+ simpleParser.mockImplementation((stream, options, callback) => {
639
+ if (typeof options === 'function') {
640
+ callback = options
641
+ }
642
+ callback(null, mockParsedMail)
643
+ })
644
+
645
+ // Mock exists method to return recipient
646
+ mockDb.get.mockImplementation((sql, params, callback) => {
647
+ if (params[0] === 'recipient@example.com') {
648
+ callback(null, {email: 'recipient@example.com'})
649
+ } else {
650
+ callback(null, null)
651
+ }
652
+ })
653
+
654
+ // Act
655
+ await mockSMTPOptions.onData('mock-stream', mockSession, jest.fn())
656
+
657
+ // Assert
658
+ expect(smtp.send).not.toHaveBeenCalled()
659
+ })
660
+
661
+ test('should validate sender matches authenticated user', async () => {
662
+ // Arrange
663
+ const mockParsedMail = createMockEmailMessage('different@example.com', 'recipient@external.com')
664
+ const mockSession = {user: 'sender@example.com'}
665
+ const mockCallback = jest.fn()
666
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
667
+
668
+ simpleParser.mockImplementation((stream, options, callback) => {
669
+ if (typeof options === 'function') {
670
+ callback = options
671
+ }
672
+ callback(null, mockParsedMail)
673
+ })
674
+
675
+ // Mock exists method to return sender
676
+ mockDb.get.mockImplementation((sql, params, callback) => {
677
+ callback(null, {email: 'different@example.com'})
678
+ })
679
+
680
+ // Act
681
+ await mockSMTPOptions.onData('mock-stream', mockSession, mockCallback)
682
+
683
+ // Assert
684
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
685
+ expect(mockCallback.mock.calls[0][0].message).toBe('Unexpected sender')
686
+ })
687
+
688
+ test('should validate recipient exists for external senders', async () => {
689
+ // Arrange
690
+ const mockParsedMail = createMockEmailMessage('external@other.com', 'nonexistent@example.com')
691
+ const mockSession = {user: null}
692
+ const mockCallback = jest.fn()
693
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
694
+
695
+ simpleParser.mockImplementation((stream, options, callback) => {
696
+ if (typeof options === 'function') {
697
+ callback = options
698
+ }
699
+ callback(null, mockParsedMail)
700
+ })
701
+
702
+ // Mock exists method to return null for both sender and recipient
703
+ mockDb.get.mockImplementation((sql, params, callback) => {
704
+ callback(null, null)
705
+ })
706
+
707
+ // Act
708
+ await mockSMTPOptions.onData('mock-stream', mockSession, mockCallback)
709
+
710
+ // Assert
711
+ expect(mockCallback).toHaveBeenCalledWith(expect.any(Error))
712
+ expect(mockCallback.mock.calls[0][0].message).toBe('Unexpected recipient')
713
+ })
714
+
715
+ test('should allow mail to hostmaster and postmaster accounts', async () => {
716
+ // Arrange
717
+ const mockParsedMail = createMockEmailMessage('external@other.com', 'hostmaster@example.com')
718
+ const mockSession = {user: null}
719
+ const mockCallback = jest.fn()
720
+ const mockSMTPOptions = SMTPServer.mock.calls[0][0]
721
+
722
+ simpleParser.mockImplementation((stream, options, callback) => {
723
+ if (typeof options === 'function') {
724
+ callback = options
725
+ }
726
+ callback(null, mockParsedMail)
727
+ })
728
+
729
+ // Mock exists method to return null (no account exists)
730
+ mockDb.get.mockImplementation((sql, params, callback) => {
731
+ callback(null, null)
732
+ })
733
+
734
+ // Act
735
+ await mockSMTPOptions.onData('mock-stream', mockSession, mockCallback)
736
+
737
+ // Assert
738
+ expect(mockCallback).toHaveBeenCalledWith() // No error
739
+ })
740
+ })
741
+
742
+ describe('Server Initialization and Database Setup', () => {
743
+ beforeEach(() => {
744
+ // Reset mocks before each test
745
+ jest.clearAllMocks()
746
+
747
+ // Ensure Mail module is properly initialized for these tests
748
+ if (Mail._resetForTesting) {
749
+ Mail._resetForTesting()
750
+ }
751
+ })
752
+
753
+ test('should initialize SMTP server on port 25', () => {
754
+ // Act
755
+ Mail.init()
756
+
757
+ // Assert
758
+ expect(SMTPServer).toHaveBeenCalledWith(
759
+ expect.objectContaining({
760
+ logger: true,
761
+ secure: false,
762
+ banner: 'CandyPack',
763
+ size: 1024 * 1024 * 10,
764
+ authOptional: true
765
+ })
766
+ )
767
+
768
+ const smtpInstance = SMTPServer.mock.results[0].value
769
+ expect(smtpInstance.listen).toHaveBeenCalledWith(25)
770
+ })
771
+
772
+ test('should initialize secure SMTP server on port 465', () => {
773
+ // Act
774
+ Mail.init()
775
+
776
+ // Assert
777
+ expect(SMTPServer).toHaveBeenCalledTimes(2)
778
+
779
+ const secureSmtpInstance = SMTPServer.mock.results[1].value
780
+ expect(secureSmtpInstance.listen).toHaveBeenCalledWith(465)
781
+ })
782
+
783
+ test('should initialize IMAP server on port 143', () => {
784
+ // Act
785
+ Mail.init()
786
+
787
+ // Assert
788
+ expect(mailServer).toHaveBeenCalledWith(
789
+ expect.objectContaining({
790
+ logger: true,
791
+ secure: false,
792
+ banner: 'CandyPack'
793
+ })
794
+ )
795
+
796
+ const imapInstance = mailServer.mock.results[0].value
797
+ expect(imapInstance.listen).toHaveBeenCalledWith(143)
798
+ })
799
+
800
+ test('should initialize secure IMAP server on port 993', () => {
801
+ // Act
802
+ Mail.init()
803
+
804
+ // Assert
805
+ expect(mailServer).toHaveBeenCalledTimes(2)
806
+
807
+ const secureImapInstance = mailServer.mock.results[1].value
808
+ expect(secureImapInstance.listen).toHaveBeenCalledWith(993)
809
+ })
810
+
811
+ test('should create SQLite database with correct path', () => {
812
+ // Act
813
+ Mail.init()
814
+
815
+ // Assert
816
+ expect(sqlite3.verbose().Database).toHaveBeenCalledWith('/home/user/.candypack/db/mail', expect.any(Function))
817
+ })
818
+
819
+ test('should create mail database tables on initialization', () => {
820
+ // Act
821
+ Mail.init()
822
+
823
+ // Assert
824
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS mail_received'))
825
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS mail_account'))
826
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS mail_box'))
827
+ })
828
+
829
+ test('should create database indexes on initialization', () => {
830
+ // Act
831
+ Mail.init()
832
+
833
+ // Assert
834
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE INDEX IF NOT EXISTS idx_email ON mail_account'))
835
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE INDEX IF NOT EXISTS idx_domain ON mail_account'))
836
+ expect(mockDb.run).toHaveBeenCalledWith(expect.stringContaining('CREATE INDEX IF NOT EXISTS idx_uid ON mail_received'))
837
+ })
838
+
839
+ test('should setup SSL/TLS configuration with SNI callback', () => {
840
+ // Arrange
841
+ mockConfig.config.ssl = {
842
+ key: '/etc/ssl/private/default.key',
843
+ cert: '/etc/ssl/certs/default.crt'
844
+ }
845
+ fs.existsSync.mockReturnValue(true)
846
+ fs.readFileSync.mockReturnValue('mock-cert-content')
847
+
848
+ // Act
849
+ Mail.init()
850
+
851
+ // Assert
852
+ const smtpOptions = SMTPServer.mock.calls[1][0] // Second call is for secure SMTP
853
+ expect(smtpOptions).toHaveProperty('SNICallback')
854
+ expect(smtpOptions.secure).toBe(true)
855
+ expect(tls.createSecureContext).toHaveBeenCalled()
856
+ })
857
+
858
+ test('should handle database connection errors', () => {
859
+ // Arrange
860
+ const dbError = new Error('Database connection failed')
861
+ sqlite3.verbose.mockReturnValue({
862
+ Database: jest.fn().mockImplementation((path, callback) => {
863
+ callback(dbError)
864
+ return mockDb
865
+ })
866
+ })
867
+
868
+ // Mock error logging
869
+ const mockError = jest.fn()
870
+ global.Candy.setMock('server', 'Log', {
871
+ init: jest.fn().mockReturnValue({
872
+ log: jest.fn(),
873
+ error: mockError
874
+ })
875
+ })
876
+
877
+ // Clear module cache and require fresh instance
878
+ jest.resetModules()
879
+ const FreshMail = require('../../server/src/Mail')
880
+
881
+ // Act
882
+ FreshMail.init()
883
+
884
+ // Assert
885
+ expect(mockError).toHaveBeenCalledWith(dbError.message)
886
+ })
887
+
888
+ test('should create database directory if it does not exist', () => {
889
+ // Arrange
890
+ fs.existsSync.mockReturnValue(false)
891
+
892
+ // Act
893
+ Mail.init()
894
+
895
+ // Assert
896
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/home/user/.candypack/db', {recursive: true})
897
+ })
898
+
899
+ test('should not initialize if no domains have MX records', () => {
900
+ // Arrange
901
+ mockConfig.config.websites = {
902
+ 'example.com': createMockWebsiteConfig('example.com', {
903
+ DNS: {
904
+ A: [{name: 'example.com', value: '127.0.0.1'}]
905
+ // No MX records
906
+ }
907
+ })
908
+ }
909
+
910
+ // Act
911
+ Mail.init()
912
+
913
+ // Assert
914
+ expect(SMTPServer).not.toHaveBeenCalled()
915
+ expect(mailServer).not.toHaveBeenCalled()
916
+ })
917
+
918
+ test('should handle SMTP server errors', () => {
919
+ // Arrange
920
+ const mockError = jest.fn()
921
+ global.Candy.setMock('server', 'Log', {
922
+ init: jest.fn().mockReturnValue({
923
+ log: mockError,
924
+ error: jest.fn()
925
+ })
926
+ })
927
+
928
+ // Act
929
+ Mail.init()
930
+
931
+ // Simulate SMTP server error
932
+ const smtpInstance = SMTPServer.mock.results[0].value
933
+ const errorHandler = smtpInstance.on.mock.calls.find(call => call[0] === 'error')[1]
934
+ const testError = new Error('SMTP Server Error')
935
+ errorHandler(testError)
936
+
937
+ // Assert
938
+ expect(mockError).toHaveBeenCalledWith('SMTP Server Error: ', testError)
939
+ })
940
+
941
+ test('should verify SSL certificate paths exist for SNI callback', () => {
942
+ // Arrange
943
+ const mockWebsite = createMockWebsiteConfig('test.com')
944
+ mockConfig.config.websites['test.com'] = mockWebsite
945
+ mockConfig.config.ssl = {
946
+ key: '/etc/ssl/private/default.key',
947
+ cert: '/etc/ssl/certs/default.crt'
948
+ }
949
+
950
+ fs.existsSync.mockImplementation(path => {
951
+ return path.includes('test.com') // Only test.com certs exist
952
+ })
953
+ fs.readFileSync.mockReturnValue('mock-cert-content')
954
+
955
+ // Act
956
+ Mail.init()
957
+
958
+ // Get the SNI callback
959
+ const smtpOptions = SMTPServer.mock.calls[1][0]
960
+ const sniCallback = smtpOptions.SNICallback
961
+ const mockCallback = jest.fn()
962
+
963
+ // Test SNI callback with existing cert
964
+ sniCallback('test.com', mockCallback)
965
+
966
+ // Assert
967
+ expect(fs.existsSync).toHaveBeenCalledWith(mockWebsite.cert.ssl.key)
968
+ expect(fs.existsSync).toHaveBeenCalledWith(mockWebsite.cert.ssl.cert)
969
+ expect(fs.readFileSync).toHaveBeenCalledWith(mockWebsite.cert.ssl.key)
970
+ expect(fs.readFileSync).toHaveBeenCalledWith(mockWebsite.cert.ssl.cert)
971
+ expect(tls.createSecureContext).toHaveBeenCalledWith({
972
+ key: 'mock-cert-content',
973
+ cert: 'mock-cert-content'
974
+ })
975
+ expect(mockCallback).toHaveBeenCalledWith(null, expect.any(Object))
976
+ })
977
+ })
978
+
979
+ describe('Mail Account Management Operations', () => {
980
+ beforeEach(() => {
981
+ Mail.init()
982
+ })
983
+
984
+ describe('Account Creation', () => {
985
+ test('should create mail account with valid email and password', async () => {
986
+ // Arrange
987
+ const email = 'newuser@example.com'
988
+ const password = 'testpassword'
989
+ const retype = 'testpassword'
990
+
991
+ mockDb.get.mockImplementation((sql, params, callback) => {
992
+ callback(null, null) // Account doesn't exist
993
+ })
994
+
995
+ // Act
996
+ const result = await Mail.create(email, password, retype)
997
+
998
+ // Assert
999
+ expect(bcrypt.hash).toHaveBeenCalledWith(password, 10, expect.any(Function))
1000
+ expect(mockDb.prepare).toHaveBeenCalledWith("INSERT INTO mail_account ('email', 'password', 'domain') VALUES (?, ?, ?)")
1001
+ expect(mockApi.result).toHaveBeenCalledWith(true, expect.stringContaining('created successfully'))
1002
+ })
1003
+
1004
+ test('should validate email format during account creation', async () => {
1005
+ // Arrange
1006
+ const invalidEmail = 'invalid-email'
1007
+ const password = 'testpassword'
1008
+ const retype = 'testpassword'
1009
+
1010
+ // Act
1011
+ const result = await Mail.create(invalidEmail, password, retype)
1012
+
1013
+ // Assert
1014
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Invalid email address'))
1015
+ expect(bcrypt.hash).not.toHaveBeenCalled()
1016
+ })
1017
+
1018
+ test('should reject account creation if passwords do not match', async () => {
1019
+ // Arrange
1020
+ const email = 'test@example.com'
1021
+ const password = 'password1'
1022
+ const retype = 'password2'
1023
+
1024
+ // Act
1025
+ const result = await Mail.create(email, password, retype)
1026
+
1027
+ // Assert
1028
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Passwords do not match'))
1029
+ expect(bcrypt.hash).not.toHaveBeenCalled()
1030
+ })
1031
+
1032
+ test('should reject account creation if required fields are missing', async () => {
1033
+ // Act
1034
+ const result = await Mail.create('', 'password', 'password')
1035
+
1036
+ // Assert
1037
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('All fields are required'))
1038
+ })
1039
+
1040
+ test('should reject account creation if account already exists', async () => {
1041
+ // Arrange
1042
+ const email = 'existing@example.com'
1043
+ const password = 'testpassword'
1044
+ const retype = 'testpassword'
1045
+
1046
+ mockDb.get.mockImplementation((sql, params, callback) => {
1047
+ callback(null, {email: email, password: '$2b$10$hashedpassword'})
1048
+ })
1049
+
1050
+ // Act
1051
+ const result = await Mail.create(email, password, retype)
1052
+
1053
+ // Assert
1054
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('already exists'))
1055
+ })
1056
+
1057
+ test('should reject account creation for unknown domain', async () => {
1058
+ // Arrange
1059
+ const email = 'test@unknown.com'
1060
+ const password = 'testpassword'
1061
+ const retype = 'testpassword'
1062
+
1063
+ mockDb.get.mockImplementation((sql, params, callback) => {
1064
+ callback(null, null) // Account doesn't exist
1065
+ })
1066
+
1067
+ // Act
1068
+ const result = await Mail.create(email, password, retype)
1069
+
1070
+ // Assert
1071
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Domain unknown.com not found'))
1072
+ })
1073
+
1074
+ test('should hash password with bcrypt during account creation', async () => {
1075
+ // Arrange
1076
+ const email = 'test@example.com'
1077
+ const password = 'plainpassword'
1078
+ const retype = 'plainpassword'
1079
+
1080
+ mockDb.get.mockImplementation((sql, params, callback) => {
1081
+ callback(null, null) // Account doesn't exist
1082
+ })
1083
+
1084
+ // Act
1085
+ await Mail.create(email, password, retype)
1086
+
1087
+ // Assert
1088
+ expect(bcrypt.hash).toHaveBeenCalledWith(password, 10, expect.any(Function))
1089
+
1090
+ const preparedStatement = mockDb.prepare.mock.results[0].value
1091
+ expect(preparedStatement.run).toHaveBeenCalledWith(email, '$2b$10$hashedpassword', 'example.com')
1092
+ })
1093
+ })
1094
+
1095
+ describe('Account Deletion', () => {
1096
+ test('should delete existing mail account', async () => {
1097
+ // Arrange
1098
+ const email = 'delete@example.com'
1099
+
1100
+ mockDb.get.mockImplementation((sql, params, callback) => {
1101
+ callback(null, {email: email, password: '$2b$10$hashedpassword'})
1102
+ })
1103
+
1104
+ // Act
1105
+ const result = await Mail.delete(email)
1106
+
1107
+ // Assert
1108
+ expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM mail_account WHERE email = ?')
1109
+ const preparedStatement = mockDb.prepare.mock.results[0].value
1110
+ expect(preparedStatement.run).toHaveBeenCalledWith(email)
1111
+ expect(mockApi.result).toHaveBeenCalledWith(true, expect.stringContaining('deleted successfully'))
1112
+ })
1113
+
1114
+ test('should validate email format during account deletion', async () => {
1115
+ // Arrange
1116
+ const invalidEmail = 'invalid-email'
1117
+
1118
+ // Act
1119
+ const result = await Mail.delete(invalidEmail)
1120
+
1121
+ // Assert
1122
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Invalid email address'))
1123
+ expect(mockDb.prepare).not.toHaveBeenCalled()
1124
+ })
1125
+
1126
+ test('should reject deletion if email is required but not provided', async () => {
1127
+ // Act
1128
+ const result = await Mail.delete('')
1129
+
1130
+ // Assert
1131
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Email address is required'))
1132
+ })
1133
+
1134
+ test('should reject deletion if account does not exist', async () => {
1135
+ // Arrange
1136
+ const email = 'nonexistent@example.com'
1137
+
1138
+ mockDb.get.mockImplementation((sql, params, callback) => {
1139
+ callback(null, null) // Account doesn't exist
1140
+ })
1141
+
1142
+ // Act
1143
+ const result = await Mail.delete(email)
1144
+
1145
+ // Assert
1146
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('not found'))
1147
+ expect(mockDb.prepare).not.toHaveBeenCalled()
1148
+ })
1149
+ })
1150
+
1151
+ describe('Account Existence Checking', () => {
1152
+ test('should return account data if account exists', async () => {
1153
+ // Arrange
1154
+ const email = 'existing@example.com'
1155
+ const mockAccount = {email: email, password: '$2b$10$hashedpassword'}
1156
+
1157
+ mockDb.get.mockImplementation((sql, params, callback) => {
1158
+ callback(null, mockAccount)
1159
+ })
1160
+
1161
+ // Act
1162
+ const result = await Mail.exists(email)
1163
+
1164
+ // Assert
1165
+ expect(result).toEqual(mockAccount)
1166
+ expect(mockDb.get).toHaveBeenCalledWith('SELECT * FROM mail_account WHERE email = ?', [email], expect.any(Function))
1167
+ })
1168
+
1169
+ test('should return false if account does not exist', async () => {
1170
+ // Arrange
1171
+ const email = 'nonexistent@example.com'
1172
+
1173
+ mockDb.get.mockImplementation((sql, params, callback) => {
1174
+ callback(null, null)
1175
+ })
1176
+
1177
+ // Act
1178
+ const result = await Mail.exists(email)
1179
+
1180
+ // Assert
1181
+ expect(result).toBe(false)
1182
+ })
1183
+
1184
+ test('should handle database errors during existence check', async () => {
1185
+ // Arrange
1186
+ const email = 'test@example.com'
1187
+ const dbError = new Error('Database error')
1188
+
1189
+ mockDb.get.mockImplementation((sql, params, callback) => {
1190
+ callback(dbError, null)
1191
+ })
1192
+
1193
+ // Act
1194
+ const result = await Mail.exists(email)
1195
+
1196
+ // Assert
1197
+ expect(result).toBe(false)
1198
+ })
1199
+ })
1200
+
1201
+ describe('Password Update', () => {
1202
+ test('should update password for existing account', async () => {
1203
+ // Arrange
1204
+ const email = 'test@example.com'
1205
+ const password = 'newpassword'
1206
+ const retype = 'newpassword'
1207
+
1208
+ mockDb.get.mockImplementation((sql, params, callback) => {
1209
+ callback(null, {email: email, password: '$2b$10$oldhashedpassword'})
1210
+ })
1211
+
1212
+ // Act
1213
+ const result = await Mail.password(email, password, retype)
1214
+
1215
+ // Assert
1216
+ expect(bcrypt.hash).toHaveBeenCalledWith(password, 10, expect.any(Function))
1217
+ expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE mail_account SET password = ? WHERE email = ?')
1218
+ const preparedStatement = mockDb.prepare.mock.results[0].value
1219
+ expect(preparedStatement.run).toHaveBeenCalledWith('$2b$10$hashedpassword', email)
1220
+ expect(mockApi.result).toHaveBeenCalledWith(true, expect.stringContaining('password updated successfully'))
1221
+ })
1222
+
1223
+ test('should validate email format during password update', async () => {
1224
+ // Arrange
1225
+ const invalidEmail = 'invalid-email'
1226
+ const password = 'newpassword'
1227
+ const retype = 'newpassword'
1228
+
1229
+ // Act
1230
+ const result = await Mail.password(invalidEmail, password, retype)
1231
+
1232
+ // Assert
1233
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Invalid email address'))
1234
+ expect(bcrypt.hash).not.toHaveBeenCalled()
1235
+ })
1236
+
1237
+ test('should reject password update if passwords do not match', async () => {
1238
+ // Arrange
1239
+ const email = 'test@example.com'
1240
+ const password = 'password1'
1241
+ const retype = 'password2'
1242
+
1243
+ // Act
1244
+ const result = await Mail.password(email, password, retype)
1245
+
1246
+ // Assert
1247
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Passwords do not match'))
1248
+ expect(bcrypt.hash).not.toHaveBeenCalled()
1249
+ })
1250
+
1251
+ test('should reject password update if required fields are missing', async () => {
1252
+ // Act
1253
+ const result = await Mail.password('', 'password', 'password')
1254
+
1255
+ // Assert
1256
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('All fields are required'))
1257
+ })
1258
+
1259
+ test('should reject password update if account does not exist', async () => {
1260
+ // Arrange
1261
+ const email = 'nonexistent@example.com'
1262
+ const password = 'newpassword'
1263
+ const retype = 'newpassword'
1264
+
1265
+ mockDb.get.mockImplementation((sql, params, callback) => {
1266
+ callback(null, null) // Account doesn't exist
1267
+ })
1268
+
1269
+ // Act
1270
+ const result = await Mail.password(email, password, retype)
1271
+
1272
+ // Assert
1273
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('not found'))
1274
+ expect(mockDb.prepare).not.toHaveBeenCalled()
1275
+ })
1276
+ })
1277
+
1278
+ describe('Account Listing', () => {
1279
+ test('should list all accounts for a domain', async () => {
1280
+ // Arrange
1281
+ const domain = 'example.com'
1282
+ const mockAccounts = [{email: 'user1@example.com'}, {email: 'user2@example.com'}, {email: 'user3@example.com'}]
1283
+
1284
+ mockDb.each.mockImplementation((sql, params, rowCallback, completeCallback) => {
1285
+ mockAccounts.forEach(account => rowCallback(null, account))
1286
+ completeCallback(null, mockAccounts.length)
1287
+ })
1288
+
1289
+ // Act
1290
+ const result = await Mail.list(domain)
1291
+
1292
+ // Assert
1293
+ expect(mockDb.each).toHaveBeenCalledWith(
1294
+ 'SELECT * FROM mail_account WHERE domain = ?',
1295
+ [domain],
1296
+ expect.any(Function),
1297
+ expect.any(Function)
1298
+ )
1299
+ expect(mockApi.result).toHaveBeenCalledWith(
1300
+ true,
1301
+ expect.stringContaining('user1@example.com\nuser2@example.com\nuser3@example.com')
1302
+ )
1303
+ })
1304
+
1305
+ test('should reject listing if domain is not provided', async () => {
1306
+ // Act
1307
+ const result = await Mail.list('')
1308
+
1309
+ // Assert
1310
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Domain is required'))
1311
+ })
1312
+
1313
+ test('should reject listing for unknown domain', async () => {
1314
+ // Arrange
1315
+ const domain = 'unknown.com'
1316
+
1317
+ // Act
1318
+ const result = await Mail.list(domain)
1319
+
1320
+ // Assert
1321
+ expect(mockApi.result).toHaveBeenCalledWith(false, expect.stringContaining('Domain unknown.com not found'))
1322
+ })
1323
+
1324
+ test('should handle empty account list for domain', async () => {
1325
+ // Arrange
1326
+ const domain = 'example.com'
1327
+
1328
+ mockDb.each.mockImplementation((sql, params, rowCallback, completeCallback) => {
1329
+ completeCallback(null, 0)
1330
+ })
1331
+
1332
+ // Act
1333
+ const result = await Mail.list(domain)
1334
+
1335
+ // Assert
1336
+ expect(mockApi.result).toHaveBeenCalledWith(true, expect.stringContaining('Mail accounts for domain example.com'))
1337
+ })
1338
+ })
1339
+ })
1340
+ })