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.
- package/.editorconfig +21 -0
- package/.github/workflows/auto-pr-description.yml +49 -0
- package/.github/workflows/release.yml +32 -0
- package/.github/workflows/test-coverage.yml +58 -0
- package/.husky/pre-commit +2 -0
- package/.kiro/steering/code-style.md +56 -0
- package/.kiro/steering/product.md +20 -0
- package/.kiro/steering/structure.md +77 -0
- package/.kiro/steering/tech.md +87 -0
- package/.prettierrc +10 -0
- package/.releaserc.js +134 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +181 -0
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +661 -0
- package/README.md +57 -0
- package/SECURITY.md +26 -0
- package/bin/candy +10 -0
- package/bin/candypack +10 -0
- package/cli/index.js +3 -0
- package/cli/src/Cli.js +348 -0
- package/cli/src/Connector.js +93 -0
- package/cli/src/Monitor.js +416 -0
- package/core/Candy.js +87 -0
- package/core/Commands.js +239 -0
- package/core/Config.js +1094 -0
- package/core/Lang.js +52 -0
- package/core/Log.js +43 -0
- package/core/Process.js +26 -0
- package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
- package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
- package/docs/backend/01-overview/03-development-server.md +79 -0
- package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
- package/docs/backend/03-config/00-configuration-overview.md +214 -0
- package/docs/backend/03-config/01-database-connection.md +60 -0
- package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
- package/docs/backend/03-config/03-request-timeout.md +11 -0
- package/docs/backend/03-config/04-environment-variables.md +227 -0
- package/docs/backend/03-config/05-early-hints.md +352 -0
- package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
- package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
- package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
- package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
- package/docs/backend/04-routing/05-advanced-routing.md +14 -0
- package/docs/backend/04-routing/06-error-pages.md +101 -0
- package/docs/backend/04-routing/07-cron-jobs.md +149 -0
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
- package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
- package/docs/backend/05-controllers/03-controller-classes.md +93 -0
- package/docs/backend/05-forms/01-custom-forms.md +395 -0
- package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
- package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
- package/docs/backend/07-views/01-the-view-directory.md +73 -0
- package/docs/backend/07-views/02-rendering-a-view.md +179 -0
- package/docs/backend/07-views/03-template-syntax.md +181 -0
- package/docs/backend/07-views/03-variables.md +328 -0
- package/docs/backend/07-views/04-request-data.md +231 -0
- package/docs/backend/07-views/05-conditionals.md +290 -0
- package/docs/backend/07-views/06-loops.md +353 -0
- package/docs/backend/07-views/07-translations.md +358 -0
- package/docs/backend/07-views/08-backend-javascript.md +398 -0
- package/docs/backend/07-views/09-comments.md +297 -0
- package/docs/backend/08-database/01-database-connection.md +99 -0
- package/docs/backend/08-database/02-using-mysql.md +322 -0
- package/docs/backend/09-validation/01-the-validator-service.md +424 -0
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
- package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
- package/docs/backend/10-authentication/03-register.md +134 -0
- package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
- package/docs/backend/10-authentication/05-session-management.md +159 -0
- package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
- package/docs/backend/11-mail/01-the-mail-service.md +42 -0
- package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
- package/docs/backend/13-utilities/01-candy-var.md +504 -0
- package/docs/frontend/01-overview/01-introduction.md +146 -0
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
- package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
- package/docs/frontend/03-forms/01-form-handling.md +420 -0
- package/docs/frontend/04-api-requests/01-get-post.md +443 -0
- package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
- package/docs/index.json +452 -0
- package/docs/server/01-installation/01-quick-install.md +19 -0
- package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
- package/docs/server/02-get-started/01-core-concepts.md +7 -0
- package/docs/server/02-get-started/02-basic-commands.md +57 -0
- package/docs/server/02-get-started/03-cli-reference.md +276 -0
- package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
- package/docs/server/03-service/01-start-a-new-service.md +57 -0
- package/docs/server/03-service/02-delete-a-service.md +48 -0
- package/docs/server/04-web/01-create-a-website.md +36 -0
- package/docs/server/04-web/02-list-websites.md +9 -0
- package/docs/server/04-web/03-delete-a-website.md +29 -0
- package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
- package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
- package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
- package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
- package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
- package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
- package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
- package/docs/server/07-mail/04-change-account-password.md +23 -0
- package/eslint.config.mjs +120 -0
- package/framework/index.js +4 -0
- package/framework/src/Auth.js +309 -0
- package/framework/src/Candy.js +81 -0
- package/framework/src/Config.js +79 -0
- package/framework/src/Env.js +60 -0
- package/framework/src/Lang.js +57 -0
- package/framework/src/Mail.js +83 -0
- package/framework/src/Mysql.js +575 -0
- package/framework/src/Request.js +301 -0
- package/framework/src/Route/Cron.js +128 -0
- package/framework/src/Route/Internal.js +439 -0
- package/framework/src/Route.js +455 -0
- package/framework/src/Server.js +15 -0
- package/framework/src/Stream.js +163 -0
- package/framework/src/Token.js +37 -0
- package/framework/src/Validator.js +271 -0
- package/framework/src/Var.js +211 -0
- package/framework/src/View/EarlyHints.js +190 -0
- package/framework/src/View/Form.js +600 -0
- package/framework/src/View.js +513 -0
- package/framework/web/candy.js +838 -0
- package/jest.config.js +22 -0
- package/locale/de-DE.json +80 -0
- package/locale/en-US.json +79 -0
- package/locale/es-ES.json +80 -0
- package/locale/fr-FR.json +80 -0
- package/locale/pt-BR.json +80 -0
- package/locale/ru-RU.json +80 -0
- package/locale/tr-TR.json +85 -0
- package/locale/zh-CN.json +80 -0
- package/package.json +86 -0
- package/server/index.js +5 -0
- package/server/src/Api.js +88 -0
- package/server/src/DNS.js +940 -0
- package/server/src/Hub.js +535 -0
- package/server/src/Mail.js +571 -0
- package/server/src/SSL.js +180 -0
- package/server/src/Server.js +27 -0
- package/server/src/Service.js +248 -0
- package/server/src/Subdomain.js +64 -0
- package/server/src/Web/Firewall.js +170 -0
- package/server/src/Web/Proxy.js +134 -0
- package/server/src/Web.js +451 -0
- package/server/src/mail/imap.js +1091 -0
- package/server/src/mail/server.js +32 -0
- package/server/src/mail/smtp.js +786 -0
- package/test/cli/Cli.test.js +36 -0
- package/test/core/Candy.test.js +234 -0
- package/test/core/Commands.test.js +538 -0
- package/test/core/Config.test.js +1435 -0
- package/test/core/Lang.test.js +250 -0
- package/test/core/Process.test.js +156 -0
- package/test/framework/Route.test.js +239 -0
- package/test/framework/View/EarlyHints.test.js +282 -0
- package/test/scripts/check-coverage.js +132 -0
- package/test/server/Api.test.js +647 -0
- package/test/server/Client.test.js +338 -0
- package/test/server/DNS.test.js +2050 -0
- package/test/server/DNS.test.js.bak +2084 -0
- package/test/server/Log.test.js +73 -0
- package/test/server/Mail.account.test_.js +460 -0
- package/test/server/Mail.init.test_.js +411 -0
- package/test/server/Mail.test_.js +1340 -0
- package/test/server/SSL.test_.js +1491 -0
- package/test/server/Server.test.js +765 -0
- package/test/server/Service.test_.js +1127 -0
- package/test/server/Subdomain.test.js +440 -0
- package/test/server/Web/Firewall.test.js +175 -0
- package/test/server/Web.test_.js +1562 -0
- package/test/server/__mocks__/acme-client.js +17 -0
- package/test/server/__mocks__/bcrypt.js +50 -0
- package/test/server/__mocks__/child_process.js +389 -0
- package/test/server/__mocks__/crypto.js +432 -0
- package/test/server/__mocks__/fs.js +450 -0
- package/test/server/__mocks__/globalCandy.js +227 -0
- package/test/server/__mocks__/http-proxy.js +105 -0
- package/test/server/__mocks__/http.js +575 -0
- package/test/server/__mocks__/https.js +272 -0
- package/test/server/__mocks__/index.js +249 -0
- package/test/server/__mocks__/mail/server.js +100 -0
- package/test/server/__mocks__/mail/smtp.js +31 -0
- package/test/server/__mocks__/mailparser.js +81 -0
- package/test/server/__mocks__/net.js +369 -0
- package/test/server/__mocks__/node-forge.js +328 -0
- package/test/server/__mocks__/os.js +320 -0
- package/test/server/__mocks__/path.js +291 -0
- package/test/server/__mocks__/selfsigned.js +8 -0
- package/test/server/__mocks__/server/src/mail/server.js +100 -0
- package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
- package/test/server/__mocks__/smtp-server.js +106 -0
- package/test/server/__mocks__/sqlite3.js +394 -0
- package/test/server/__mocks__/testFactories.js +299 -0
- package/test/server/__mocks__/testHelpers.js +363 -0
- package/test/server/__mocks__/tls.js +229 -0
- package/watchdog/index.js +3 -0
- package/watchdog/src/Watchdog.js +156 -0
- package/web/config.json +5 -0
- package/web/controller/page/about.js +27 -0
- package/web/controller/page/index.js +34 -0
- package/web/package.json +18 -0
- package/web/public/assets/css/style.css +1835 -0
- package/web/public/assets/js/app.js +96 -0
- package/web/route/www.js +19 -0
- package/web/skeleton/main.html +22 -0
- package/web/view/content/about.html +65 -0
- package/web/view/content/home.html +205 -0
- package/web/view/footer/main.html +11 -0
- package/web/view/head/main.html +5 -0
- 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
|
+
})
|